chore: update

This commit is contained in:
TakahashiNg
2026-05-07 07:00:40 +00:00
parent c619f407ab
commit 7b1738dd3a
10 changed files with 155 additions and 436 deletions
+2 -7
View File
@@ -52,9 +52,8 @@
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.quarkiverse.wiremock</groupId>
<artifactId>quarkus-wiremock-test</artifactId>
<version>1.6.1</version>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5-mockito</artifactId>
<scope>test</scope>
</dependency>
<dependency>
@@ -66,10 +65,6 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-messaging-kafka</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-redis-client</artifactId>
@@ -1,174 +0,0 @@
package com.drinkool.controllers;
import static io.smallrye.graphql.client.core.Argument.arg;
import static io.smallrye.graphql.client.core.Document.document;
import static io.smallrye.graphql.client.core.Field.field;
import static io.smallrye.graphql.client.core.Operation.operation;
import com.drinkool.lib.dtos.CartItemRequest;
import com.drinkool.lib.dtos.event.MenuItemValidation;
import io.quarkus.redis.datasource.ReactiveRedisDataSource;
import io.quarkus.redis.datasource.hash.ReactiveHashCommands;
import io.quarkus.redis.datasource.keys.ReactiveKeyCommands;
import io.quarkus.redis.datasource.value.ReactiveValueCommands;
import io.smallrye.graphql.client.GraphQLClient;
import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient;
import io.smallrye.mutiny.Uni;
import jakarta.inject.Inject;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.UUID;
import org.eclipse.microprofile.reactive.messaging.Channel;
import org.eclipse.microprofile.reactive.messaging.Emitter;
import org.eclipse.microprofile.reactive.messaging.Incoming;
@Path("/")
public class CartController {
@Inject
@GraphQLClient("eatery-client")
DynamicGraphQLClient dynamicClient;
@Inject
ReactiveRedisDataSource redisDataSource;
@Inject
@Channel("menu-validation-out")
Emitter<MenuItemValidation> validationEmitter;
private ReactiveValueCommands<String, String> valueCommands;
private ReactiveHashCommands<String, String, String> hashCommands;
private final ReactiveKeyCommands<String> keyCommands;
@Inject
public CartController(ReactiveRedisDataSource ds) {
this.redisDataSource = ds;
this.valueCommands = ds.value(String.class);
this.hashCommands = ds.hash(String.class);
this.keyCommands = ds.key();
}
@POST
public Uni<Response> createCart(@QueryParam("eateryId") UUID eateryId) {
if (eateryId == null) {
return Uni.createFrom().item(
Response.status(Response.Status.BAD_REQUEST)
.entity("EmptyEateryId")
.build()
);
}
String eateryCacheKey = "eatery:exists:" + eateryId;
return valueCommands
.get(eateryCacheKey)
.<Boolean>chain(existsInCache -> {
if (
existsInCache != null && Boolean.TRUE.equals(existsInCache)
) return Uni.createFrom().item(Boolean.TRUE);
var query = document(
operation(
field(
"eateryById",
List.of(arg("id", eateryId.toString())),
field("id")
)
)
);
return dynamicClient
.executeAsync(query)
.map(response -> !response.hasError() && response.getData() != null);
})
.chain(isValid -> {
if (Boolean.TRUE.equals(isValid)) {
String cartId = UUID.randomUUID().toString();
return valueCommands
.setex("cart:" + cartId, 86400, eateryId.toString())
.replaceWith(Response.ok(cartId).build());
}
return Uni.createFrom().item(
Response.status(Response.Status.NOT_FOUND)
.entity("InvalidEateryId")
.build()
);
});
}
@POST
@Path("/{cartId}")
public Uni<Response> addItem(
@PathParam("cartId") String cartId,
CartItemRequest request
) {
String cartKey = "cart:" + cartId;
String cartItemsKey = cartKey + ":items";
String menuKey = "menu:item:" + request.menuItemId;
return valueCommands
.get(cartKey)
.chain(cart -> {
if (cart == null) {
return Uni.createFrom().item(
Response.status(Response.Status.NOT_FOUND)
.entity("CartNotFound")
.build()
);
}
UUID eateryId = UUID.fromString(cart);
return valueCommands
.get(menuKey)
.chain(menuData ->
hashCommands
.hincrby(
cartItemsKey,
request.menuItemId.toString(),
request.quantity
)
.call(_ -> keyCommands.expire(cartItemsKey, 86400L))
.replaceWith(menuData)
)
.map(menuData -> {
if (menuData != null) {
return Response.ok("ItemAdded").build();
}
MenuItemValidation validation = new MenuItemValidation();
validation.menuItemId = request.menuItemId;
validation.eateryId = eateryId;
validationEmitter.send(validation);
return Response.status(Response.Status.ACCEPTED)
.entity("PendingVerification")
.build();
});
});
}
// @Incoming("menu-validation-in") // Tên channel trong application.properties
// public Uni<Void> processValidationResult(MenuItemStatusE event) {
// // Giả sử event trả về có: menuItemId, eateryId, exists (boolean)
// if (!event.exists) {
// // Nếu món ăn không tồn tại, ta xóa nó khỏi Redis
// // Lưu ý: Vì ta không biết món này nằm ở cartId nào (do bạn yêu cầu bỏ cartId)
// // Bạn sẽ cần một chiến lược tìm kiếm hoặc quản lý key hiệu quả.
// // Cách 1: Nếu bạn có danh sách Cart của Eatery đó, duyệt qua và xóa:
// // String cartItemsKey = "cart:" + event.cartId + ":items";
// // return hashCommands.hdel(cartItemsKey, event.menuItemId.toString()).replaceWithVoid();
// // Cách 2: Nếu xóa cache thông tin món ăn để các request sau cũng fail:
// // return keyCommands.del("menu:item:" + event.menuItemId).replaceWithVoid();
// }
// return Uni.createFrom().voidItem();
// }
}
@@ -0,0 +1,17 @@
package com.drinkool.controllers;
import io.smallrye.mutiny.Uni;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.core.Response;
import java.util.UUID;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
@RegisterRestClient(configKey = "eatery")
@Path("/")
public interface EateryController {
@GET
@Path("/{id}")
Uni<Response> getEateryById(@PathParam("id") UUID id);
}
@@ -1,15 +1,17 @@
package com.drinkool.services;
import com.drinkool.InternalValue;
import com.drinkool.controllers.EateryController;
import com.drinkool.dtos.Cart;
import com.drinkool.dtos.CartItem;
import com.drinkool.lib.dtos.event.MenuItemValidation;
import io.quarkus.redis.datasource.RedisDataSource;
import io.quarkus.redis.datasource.hash.HashCommands;
import io.quarkus.redis.datasource.value.ValueCommands;
import io.smallrye.mutiny.Uni;
import io.vertx.core.http.HttpServerRequest;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.core.Response;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
@@ -20,9 +22,7 @@ import org.eclipse.microprofile.graphql.GraphQLException;
import org.eclipse.microprofile.graphql.Mutation;
import org.eclipse.microprofile.graphql.Name;
import org.eclipse.microprofile.graphql.Query;
import org.eclipse.microprofile.reactive.messaging.Channel;
import org.eclipse.microprofile.reactive.messaging.Emitter;
import org.eclipse.microprofile.reactive.messaging.Incoming;
import org.eclipse.microprofile.rest.client.inject.RestClient;
@ApplicationScoped
@GraphQLApi
@@ -32,8 +32,8 @@ public class CartService {
HttpServerRequest request;
@Inject
@Channel("menu-validation-out")
Emitter<MenuItemValidation> validationEmitter;
@RestClient
EateryController eateryController;
private final HashCommands<String, String, String> hashCommands;
private final ValueCommands<String, String> valueCommands;
@@ -45,25 +45,43 @@ public class CartService {
}
@Mutation("createCart")
public String createCart(@Name("eateryId") UUID eateryId) {
public Uni<String> createCart(@Name("eateryId") UUID eateryId) {
String userId = request.getHeader(InternalValue.userId);
String cartId = UUID.randomUUID().toString();
String redisKey = "cart:" + cartId;
return eateryController
.getEateryById(eateryId)
.onItem()
.transform(response -> {
if (response.getStatus() != Response.Status.OK.getStatusCode()) {
throw new RuntimeException("NotFoundEatery");
}
hashCommands.hset(
redisKey,
Map.of(
"_metadata_owner",
userId != null ? userId : "GUEST",
"_metadata_eatery",
eateryId.toString()
)
);
String cartId = UUID.randomUUID().toString();
String redisKey = "cart:" + cartId;
valueCommands.getDataSource().key().expire(redisKey, Duration.ofDays(30));
hashCommands.hset(
redisKey,
Map.of(
"_metadata_owner",
userId != null ? userId : "GUEST",
"_metadata_eatery",
eateryId.toString()
)
);
return cartId;
valueCommands
.getDataSource()
.key()
.expire(redisKey, Duration.ofDays(30));
return cartId;
})
.onFailure()
.transform(f ->
new GraphQLException(
"Lỗi kết nối đến dịch vụ nhà hàng: " + f.getMessage()
)
);
}
@Query("getCart")
@@ -102,17 +120,11 @@ public class CartService {
UUID eateryId = UUID.fromString(hashCommands.hget(key, "_metadata_eatery"));
validationEmitter.send(new MenuItemValidation(menuItemId, eateryId));
// validationEmitter.send(new MenuItemValidation(menuItemId, eateryId));
hashCommands.hincrby(key, menuItemId.toString(), quantity);
hashCommands.getDataSource().key().expire(key, Duration.ofDays(30));
return getCart(cartId);
}
@Incoming("menuItem-deleted-in")
public void removeItem(UUID cartId, String menuItemId) {
String key = "cart:" + cartId;
hashCommands.hdel(key, menuItemId);
}
}
@@ -5,18 +5,5 @@ quarkus.index-dependency.lib.artifact-id=lib
# Redis
quarkus.redis.hosts=redis://${REDIS_SERVICE_SERVICE_HOST:localhost}:${REDIS_SERVICE_SERVICE_PORT:6379}
# GraphQl
quarkus.smallrye-graphql-client.eatery-client.url=http://eatery-service/graphql
# Kafka
%prod.mp.messaging.connector.smallrye-kafka.bootstrap.servers=${KAFKA_SERVICE_SERVICE_HOST:host.docker.internal}:9092
quarkus.messaging.kafka.serializer-generation.enabled=true
## Create menu validation topic
mp.messaging.outgoing.menu-validation-out.connector=smallrye-kafka
mp.messaging.outgoing.menu-validation-out.topic=menu-validation
mp.messaging.outgoing.menu-validation-out.value.serializer=io.quarkus.kafka.client.serialization.ObjectMapperSerializer
# ### In
# mp.messaging.incoming.menu-validation-in.connector=smallrye-kafka
# mp.messaging.incoming.menu-validation-in.topic=menu-validation
# mp.messaging.incoming.menu-validation-in.value.serializer=io.quarkus.kafka.client.serialization.ObjectMapperSerializer
# Rest Client
quarkus.rest-client.eatery.url=http://eatery-service
@@ -1,182 +0,0 @@
package com.drinkool.controllers;
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.post;
import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.jupiter.api.Assertions.assertEquals;
import com.drinkool.dtos.CartItemRequest;
import com.drinkool.dtos.event.MenuItemValidation;
import io.quarkiverse.wiremock.devservice.ConnectWireMock;
import io.quarkus.redis.datasource.ReactiveRedisDataSource;
import io.quarkus.test.common.http.TestHTTPEndpoint;
import io.quarkus.test.junit.QuarkusTest;
import io.smallrye.reactive.messaging.memory.InMemoryConnector;
import io.smallrye.reactive.messaging.memory.InMemorySink;
import jakarta.enterprise.inject.Any;
import jakarta.inject.Inject;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@QuarkusTest
@TestHTTPEndpoint(CartController.class)
@ConnectWireMock
public class CartControllerTest {
@Inject
ReactiveRedisDataSource redisDataSource;
@Inject
@Any
InMemoryConnector connector;
@BeforeEach
void setup() {
redisDataSource.flushall().await().indefinitely();
stubFor(
post(urlEqualTo("/graphql")).willReturn(
aResponse()
.withHeader("Content-Type", "application/json")
.withBody(
"{\"data\":{\"eateryById\":{\"id\":\"" + UUID.randomUUID() + "\"}}}"
)
)
);
InMemorySink<MenuItemValidation> results = connector.sink(
"menu-validation-out"
);
results.clear();
}
@Test
void testCreateCart_Success_WithRealRedis() {
UUID eateryId = UUID.randomUUID();
// 2. Gọi API tạo giỏ hàng
String cartId = given()
.queryParam("eateryId", eateryId.toString())
.when()
.post()
.then()
.statusCode(200)
.extract()
.asString();
// 3. Kiểm tra Redis thật xem dữ liệu đã được lưu chưa
String savedEateryId = redisDataSource
.value(String.class)
.get("cart:" + cartId)
.await()
.indefinitely();
assert savedEateryId.equals(eateryId.toString());
}
@Test
void testAddItem_PendingVerification_WithKafkaMock() {
InMemorySink<MenuItemValidation> results = connector.sink(
"menu-validation-out"
);
// 1. Chuẩn bị dữ liệu: Tạo một giỏ hàng sẵn trong Redis
String cartId = UUID.randomUUID().toString();
UUID eateryId = UUID.randomUUID();
redisDataSource
.value(String.class)
.set("cart:" + cartId, eateryId.toString())
.await()
.indefinitely();
// 2. Gửi request thêm món ăn KHÔNG có trong cache menu (menuData null)
CartItemRequest request = new CartItemRequest();
request.menuItemId = UUID.randomUUID();
request.quantity = 2;
given()
.pathParam("cartId", cartId)
.contentType("application/json")
.body(request)
.when()
.post("/{cartId}")
.then()
.statusCode(202) // ACCEPTED
.body(is("PendingVerification"));
// 3. Kiểm tra Redis: hincrby có hoạt động không?
String quantityStr = redisDataSource
.hash(String.class)
.hget("cart:" + cartId + ":items", request.menuItemId.toString())
.await()
.indefinitely();
assert quantityStr.equals("2");
assertEquals(1, results.received().size());
MenuItemValidation payload = results.received().get(0).getPayload();
assertEquals(request.menuItemId, payload.menuItemId);
}
@Test
void testAddItem_Success_WhenMenuInCache() {
InMemorySink<MenuItemValidation> results = connector.sink(
"menu-validation-out"
);
// 1. Chuẩn bị: Cart và Menu đã có sẵn trong Redis cache
String cartId = UUID.randomUUID().toString();
UUID eateryId = UUID.randomUUID();
UUID menuItemId = UUID.randomUUID();
redisDataSource
.value(String.class)
.set("cart:" + cartId, eateryId.toString())
.await()
.indefinitely();
// Giả lập menu đã verify (có data trong cache)
redisDataSource
.value(String.class)
.set("menu:item:" + menuItemId, "{\"name\":\"Pizza\"}")
.await()
.indefinitely();
CartItemRequest request = new CartItemRequest();
request.menuItemId = menuItemId;
request.quantity = 1;
// 2. Thực thi
given()
.pathParam("cartId", cartId)
.contentType("application/json")
.body(request)
.when()
.post("/{cartId}")
.then()
.statusCode(200)
.body(is("ItemAdded"));
// 3. Kiểm tra: Không được gửi Kafka vì đã có trong cache
assertEquals(0, results.received().size());
}
@Test
void testAddItem_CartNotFound() {
CartItemRequest request = new CartItemRequest();
request.menuItemId = UUID.randomUUID();
request.quantity = 1;
given()
.pathParam("cartId", "invalid-cart-id")
.contentType("application/json")
.body(request)
.when()
.post("/{cartId}")
.then()
.statusCode(404)
.body(is("CartNotFound"));
}
}
@@ -0,0 +1,76 @@
package com.drinkool.services;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.drinkool.controllers.EateryController;
import io.quarkus.redis.datasource.RedisDataSource;
import io.quarkus.redis.datasource.hash.HashCommands;
import io.quarkus.redis.datasource.keys.KeyCommands;
import io.quarkus.redis.datasource.value.ValueCommands;
import io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.helpers.test.UniAssertSubscriber;
import jakarta.inject.Inject;
import jakarta.ws.rs.core.Response;
import java.util.UUID;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@QuarkusTest
public class CartServiceTest {
@Inject
CartService cartService;
@InjectMock
@RestClient
EateryController eateryController;
@InjectMock
RedisDataSource redisDataSource;
HashCommands<String, String, String> hashCommands;
ValueCommands<String, String> valueCommands;
@BeforeEach
void setup() {
hashCommands = mock(HashCommands.class);
valueCommands = mock(ValueCommands.class);
// Mock các chain gọi của RedisDataSource
when(redisDataSource.hash(String.class)).thenReturn(hashCommands);
when(redisDataSource.value(String.class)).thenReturn(valueCommands);
// Mock phần expire key
var keyCommands = mock(KeyCommands.class);
when(valueCommands.getDataSource()).thenReturn(redisDataSource);
when(redisDataSource.key()).thenReturn(keyCommands);
}
@Test
void testCreateCart_Success() {
UUID eateryId = UUID.randomUUID();
when(eateryController.getEateryById(eateryId)).thenReturn(
Uni.createFrom().item(Response.ok().build())
);
String cartId = cartService
.createCart(eateryId)
.subscribe()
.withSubscriber(UniAssertSubscriber.create())
.awaitItem()
.getItem();
assertNotNull(cartId);
verify(hashCommands, times(1)).hset(anyString(), anyMap());
}
}
@@ -1,12 +0,0 @@
# Redis
quarkus.smallrye-graphql-client.eatery-client.url=http://localhost:8089/graphql
# WireMock
quarkus.wiremock.devservices.host=0.0.0.0
quarkus.wiremock.devservices.port=8089
# Kafka
quarkus.kafka.devservices.enabled=false
mp.messaging.outgoing.menu-validation-out.connector=smallrye-in-memory
mp.messaging.incoming.menu-validation-in.connector=smallrye-in-memory
@@ -13,6 +13,10 @@ import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.UUID;
import org.eclipse.microprofile.graphql.GraphQLApi;
@@ -23,6 +27,7 @@ import org.eclipse.microprofile.reactive.messaging.Incoming;
@ApplicationScoped
@GraphQLApi
@Path("/")
public class EateryService {
@Inject
@@ -31,6 +36,20 @@ public class EateryService {
@Inject
HttpServerRequest request;
@GET
@Path("/{id}")
public Response checkEateryExists(@PathParam("id") UUID id) {
EateryEntity eatery = EateryEntity.findById(id);
if (eatery == null) {
return Response.status(Response.Status.NOT_FOUND)
.entity("NotFoundEatery")
.build();
}
return Response.ok().build();
}
@Incoming("manager-in")
@Transactional
public void consumeManagerSignup(ManagerCreate input) {
@@ -1,19 +0,0 @@
package com.drinkool.lib.dtos.event;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@RegisterForReflection
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class MenuItemValidation {
public UUID menuItemId;
public UUID eateryId;
}