diff --git a/.github/workflows/test-server-ci.yml b/.github/workflows/test-server-ci.yml index ed2b5349..14655ca7 100644 --- a/.github/workflows/test-server-ci.yml +++ b/.github/workflows/test-server-ci.yml @@ -23,6 +23,20 @@ jobs: env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + + # CI 작업이 실행되는 동안 RabbitMQ 서비스 컨테이너를 함께 실행 + services: + rabbitmq: + image: rabbitmq:3-management + ports: + - 5672:5672 + # RabbitMQ가 완전히 준비될 때까지 기다리는 상태 확인 옵션 + options: >- + --health-cmd "rabbitmq-diagnostics check_running" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: # 1. 소스 코드 체크아웃 - name: Checkout source code @@ -74,6 +88,12 @@ jobs: # 7. Gradle 테스트 실행 - name: Test with Gradle + # 테스트 단계에서 RabbitMQ 연결을 위한 환경 변수 설정 + env: + SPRING_RABBITMQ_HOST: localhost + SPRING_RABBITMQ_PORT: 5672 + SPRING_RABBITMQ_USERNAME: guest + SPRING_RABBITMQ_PASSWORD: guest run: ./gradlew test # 8. 테스트 결과 요약 출력 diff --git a/build.gradle b/build.gradle index 8ec30a42..b135ff17 100644 --- a/build.gradle +++ b/build.gradle @@ -116,8 +116,12 @@ dependencies { // Redis (Spring starter) implementation 'org.springframework.boot:spring-boot-starter-data-redis' - // RabbitMQ + // RabbitMQ (Spring starter) implementation 'org.springframework.boot:spring-boot-starter-amqp' + testImplementation 'org.springframework.amqp:spring-rabbit-test' + + // Awaitility (비동기 테스트 지원) + testImplementation 'org.awaitility:awaitility:4.2.0' } dependencyManagement { diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/ApiV1DashboardController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/ApiV1DashboardController.java index 9e12f49c..45e59df1 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/ApiV1DashboardController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/ApiV1DashboardController.java @@ -33,18 +33,18 @@ public class ApiV1DashboardController { */ @PutMapping("/{dashboardId}/graph") @Operation(summary = "React-flow 데이터 저장(갱신)") - public ResponseEntity> updateGraph( + public ResponseEntity> queueGraphUpdate( @PathVariable Integer dashboardId, @RequestBody String requestBody, @RequestHeader("Liveblocks-Signature") String signature ) { - dashboardService.verifyAndUpdateGraph(dashboardId, requestBody, signature); + dashboardService.queueGraphUpdate(dashboardId, requestBody, signature); return ResponseEntity - .status(HttpStatus.OK) + .status(HttpStatus.ACCEPTED) .body(new RsData<>( - "200", - "React-flow 데이터를 저장했습니다.", + "202", + "데이터 업데이트 요청이 성공적으로 접수되었습니다.", null )); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/GraphUpdateMessage.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/GraphUpdateMessage.java new file mode 100644 index 00000000..6ea10ede --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/GraphUpdateMessage.java @@ -0,0 +1,7 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.dto; + +public record GraphUpdateMessage( + Integer dashboardId, + String requestBody +){ +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Graph.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Graph.java index 7f70b105..7715d2d6 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Graph.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Graph.java @@ -3,6 +3,7 @@ import jakarta.persistence.CascadeType; import jakarta.persistence.Entity; import jakarta.persistence.OneToMany; +import jakarta.persistence.Version; import lombok.Getter; import lombok.Setter; import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; @@ -14,6 +15,10 @@ @Setter @Entity public class Graph extends BaseEntity { + + @Version + private Long version; + @OneToMany(mappedBy = "graph", cascade = CascadeType.ALL, orphanRemoval = true) private List nodes = new ArrayList<>(); diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/extraComponent/GraphUpdateConsumer.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/extraComponent/GraphUpdateConsumer.java new file mode 100644 index 00000000..fffdb834 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/extraComponent/GraphUpdateConsumer.java @@ -0,0 +1,39 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.extraComponent; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Component; +import org.tuna.zoopzoop.backend.domain.dashboard.dto.BodyForReactFlow; +import org.tuna.zoopzoop.backend.domain.dashboard.dto.GraphUpdateMessage; +import org.tuna.zoopzoop.backend.domain.dashboard.service.DashboardService; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GraphUpdateConsumer { + private final DashboardService dashboardService; + private final ObjectMapper objectMapper; + + @RabbitListener(queues = "graph.update.queue") + public void handleGraphUpdate(GraphUpdateMessage message) { + log.info("Received graph update message for dashboardId: {}", message.dashboardId()); + try { + BodyForReactFlow dto = objectMapper.readValue(message.requestBody(), BodyForReactFlow.class); + dashboardService.updateGraph(message.dashboardId(), dto); + log.info("Successfully updated graph for dashboardId: {}", message.dashboardId()); + } catch (ObjectOptimisticLockingFailureException e) { + // Optimistic Lock 충돌 발생! + // 내가 처리하려던 메시지는 이미 구버전 데이터에 대한 요청이었음. + // 따라서 이 메시지는 무시하고 정상 처리된 것으로 간주. + log.warn("Stale update attempt for dashboardId: {}. A newer version already exists. Discarding message.", message.dashboardId()); + // 예외를 다시 던지지 않으므로, 메시지는 큐에서 정상적으로 제거(ACK)됩니다. + } catch (Exception e) { + // 실제 운영에서는 메시지를 재시도하거나, 실패 큐(Dead Letter Queue)로 보내는 등의 + // 정교한 에러 처리 로직이 필요합니다. + log.error("Failed to process graph update for dashboardId: {}", message.dashboardId(), e); + } + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/DashboardService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/DashboardService.java index b6ab567c..4771b6f4 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/DashboardService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/DashboardService.java @@ -3,9 +3,13 @@ import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.persistence.NoResultException; import lombok.RequiredArgsConstructor; +import org.apache.commons.codec.binary.Hex; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.tuna.zoopzoop.backend.domain.dashboard.dto.BodyForReactFlow; +import org.tuna.zoopzoop.backend.domain.dashboard.dto.GraphUpdateMessage; import org.tuna.zoopzoop.backend.domain.dashboard.entity.Dashboard; import org.tuna.zoopzoop.backend.domain.dashboard.entity.Edge; import org.tuna.zoopzoop.backend.domain.dashboard.entity.Graph; @@ -25,6 +29,7 @@ public class DashboardService { private final MembershipService membershipService; private final ObjectMapper objectMapper; private final SignatureService signatureService; + private final RabbitTemplate rabbitTemplate; @@ -103,6 +108,33 @@ public void verifyAccessPermission(Member member, Integer dashboardId) throws Ac } } + // =========================== message 관리 메서드 =========================== + + /** + * Graph 업데이트 요청을 RabbitMQ 큐에 비동기적으로 발행하는 메서드 + * @param dashboardId 대시보드 ID + * @param requestBody 요청 바디 + * @param signatureHeader 서명 헤더 + */ + public void queueGraphUpdate(Integer dashboardId, String requestBody, String signatureHeader){ + // 서명 검증은 동기적으로 즉시 처리 + if (!signatureService.isValidSignature(requestBody, signatureHeader)) { + throw new SecurityException("Invalid webhook signature."); + } + + // 대시보드 존재 여부 확인 + if (!dashboardRepository.existsById(dashboardId)) { + throw new NoResultException(dashboardId + " ID를 가진 대시보드를 찾을 수 없습니다."); + } + + // 큐에 보낼 메시지 생성 + GraphUpdateMessage message = new GraphUpdateMessage(dashboardId, requestBody); + + // RabbitMQ에 메시지 발행 + rabbitTemplate.convertAndSend("zoopzoop.exchange", "graph.update.rk", message); + } + + diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/SignatureService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/SignatureService.java index 0c06021e..242e925b 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/SignatureService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/SignatureService.java @@ -26,6 +26,11 @@ public class SignatureService { * @return 서명이 유효하면 true, 그렇지 않으면 false */ public boolean isValidSignature(String requestBody, String signatureHeader) { + // [임시 코드] 로컬 테스트를 위해 무조건 true 반환 +// if ("true".equals(System.getProperty("local.test.skip.signature"))) { +// return true; +// } + try { // 1. 헤더 파싱 String[] parts = signatureHeader.split(","); diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/config/mq/RabbitMQConfig.java b/src/main/java/org/tuna/zoopzoop/backend/global/config/mq/RabbitMQConfig.java new file mode 100644 index 00000000..9c2ff21c --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/config/mq/RabbitMQConfig.java @@ -0,0 +1,69 @@ +package org.tuna.zoopzoop.backend.global.config.mq; + +import org.springframework.amqp.core.*; +import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RabbitMQConfig { + private static final String EXCHANGE_NAME = "zoopzoop.exchange"; + private static final String QUEUE_NAME = "graph.update.queue"; + private static final String ROUTING_KEY = "graph.update.#"; + + private static final String DLQ_EXCHANGE_NAME = EXCHANGE_NAME + ".dlx"; + private static final String DLQ_QUEUE_NAME = QUEUE_NAME + ".dlq"; + private static final String DLQ_ROUTING_KEY = "graph.update.dlq"; + + @Bean + public TopicExchange exchange() { + return new TopicExchange(EXCHANGE_NAME); + } + + @Bean + public Queue queue() { + return QueueBuilder.durable(QUEUE_NAME) + .withArgument("x-dead-letter-exchange", DLQ_EXCHANGE_NAME) // 실패 시 메시지를 보낼 Exchange + .withArgument("x-dead-letter-routing-key", DLQ_ROUTING_KEY) // 실패 시 사용할 라우팅 키 + .build(); + } + + @Bean + public Binding binding(Queue queue, TopicExchange exchange) { + return BindingBuilder.bind(queue).to(exchange).with(ROUTING_KEY); + } + + // ================= DLQ 인프라 구성 추가 ================= // + + @Bean + public TopicExchange dlqExchange() { + return new TopicExchange(DLQ_EXCHANGE_NAME); + } + + @Bean + public Queue dlqQueue() { + return new Queue(DLQ_QUEUE_NAME); + } + + @Bean + public Binding dlqBinding(Queue dlqQueue, TopicExchange dlqExchange) { + return BindingBuilder.bind(dlqQueue).to(dlqExchange).with(DLQ_ROUTING_KEY); + } + + // ================= DLQ 인프라 구성 추가 ================= // + @Bean + public MessageConverter messageConverter() { + // 메시지를 JSON 형식으로 직렬화/역직렬화하는 컨버터 + return new Jackson2JsonMessageConverter(); + } + + @Bean + public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, MessageConverter messageConverter) { + RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory); + rabbitTemplate.setMessageConverter(messageConverter); + return rabbitTemplate; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7b594c9f..b730aec3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -40,6 +40,23 @@ spring: port: 5672 username: ${SPRING_RABBITMQ_USERNAME:guest} password: ${SPRING_RABBITMQ_PASSWORD:guest} + listener: + simple: + retry: + enabled: true + initial-interval: 2000 + max-attempts: 3 + data: #RedisTemplate 등을 사용하기 위한 직접 연결용 + redis: + host: localhost + port: 6379 + timeout: 6000 + cache: #Spring Cache를 사용하기 위한 Redis + type: redis + redis: + time-to-live: 300000 + cache-null-values: false + key-prefix: springdoc: default-produces-media-type: application/json;charset=UTF-8 @@ -49,6 +66,7 @@ logging: org.hibernate.orm.jdbc.extract: TRACE org.springframework.transaction.interceptor: TRACE com.back: DEBUG + org.springframework.retry: DEBUG server: port: 8080 diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/DashboardControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/DashboardControllerTest.java index 5263a2e0..2e51822a 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/DashboardControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/DashboardControllerTest.java @@ -4,23 +4,24 @@ import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.TestExecutionEvent; import org.springframework.security.test.context.support.WithUserDetails; import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import org.tuna.zoopzoop.backend.domain.dashboard.entity.Graph; -import org.tuna.zoopzoop.backend.domain.dashboard.repository.GraphRepository; import org.tuna.zoopzoop.backend.domain.member.enums.Provider; +import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; import org.tuna.zoopzoop.backend.domain.member.service.MemberService; import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; +import org.tuna.zoopzoop.backend.domain.space.membership.repository.MembershipRepository; import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService; import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; +import org.tuna.zoopzoop.backend.domain.space.space.repository.SpaceRepository; import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService; import org.tuna.zoopzoop.backend.testSupport.ControllerTestSupport; @@ -29,31 +30,34 @@ import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; +import java.util.concurrent.TimeUnit; -import static org.hamcrest.Matchers.hasSize; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.awaitility.Awaitility.await; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) @Transactional @TestInstance(TestInstance.Lifecycle.PER_CLASS) class DashboardControllerTest extends ControllerTestSupport { - @Autowired - private SpaceService spaceService; + @Autowired private SpaceService spaceService; + @Autowired private MemberService memberService; + @Autowired private MembershipService membershipService; - @Autowired - private MemberService memberService; + @Autowired SpaceRepository spaceRepository; + @Autowired MemberRepository memberRepository; + @Autowired MembershipRepository membershipRepository; - @Autowired - private MembershipService membershipService; + @Autowired private TransactionTemplate transactionTemplate; - private Integer authorizedDashboardId; private Integer unauthorizedDashboardId; + private Integer authorizedDashboardId; + + private String authorizedSpaceName = "TestSpace1_forDashboardControllerTest"; + private String unauthorizedSpaceName = "TestSpace2_forDashboardControllerTest"; @Value("${liveblocks.secret-key}") private String testSecretKey; @@ -65,8 +69,8 @@ void setUp() { memberService.createMember("tester2_forDashboardControllerTest", "url", "dc2222", Provider.KAKAO); // 2. 스페이스 생성 (생성과 동시에 대시보드도 생성됨) - Space space1 = spaceService.createSpace("TestSpace1_forDashboardControllerTest", "thumb1"); - Space space2 = spaceService.createSpace("TestSpace2_forDashboardControllerTest", "thumb2"); + Space space1 = spaceService.createSpace(authorizedSpaceName, "thumb1"); + Space space2 = spaceService.createSpace(unauthorizedSpaceName, "thumb2"); // 테스트에서 사용할 대시보드 ID 저장 this.authorizedDashboardId = space1.getDashboard().getId(); @@ -87,6 +91,14 @@ void setUp() { ); } + @AfterAll + void tearDown() { + // 멤버십, 스페이스, 멤버 모두 삭제 + membershipRepository.deleteAll(); + spaceRepository.deleteAll(); + memberRepository.deleteAll(); + } + // ============================= GET GRAPH ============================= // @Test @@ -149,35 +161,34 @@ void getGraph_Fail_NotFound() throws Exception { @Test @WithUserDetails(value = "KAKAO:dc1111", setupBefore = TestExecutionEvent.TEST_METHOD) - @DisplayName("대시보드 그래프 데이터 저장 - 성공") + @DisplayName("대시보드 그래프 데이터 저장 요청 - 성공") void updateGraph_Success() throws Exception { // Given String url = String.format("/api/v1/dashboard/%d/graph", authorizedDashboardId); String requestBody = createReactFlowJsonBody(); String validSignature = generateLiveblocksSignature(requestBody); - // When: 데이터 저장 + // When: 데이터 저장 요청 (메세지 발행) ResultActions updateResult = mvc.perform(put(url) .contentType(MediaType.APPLICATION_JSON) .header("Liveblocks-Signature", validSignature) // ★ 서명 헤더 추가 .content(requestBody)); - // Then: 저장 성공 응답 확인 - expectOk( - updateResult, - "React-flow 데이터를 저장했습니다." - ); - - // When: 데이터 재조회하여 검증 - ResultActions getResult = performGet(url); - - // Then: 재조회 결과가 수정한 데이터와 일치하는지 확인 - getResult - .andExpect(jsonPath("$.data.nodes", hasSize(2))) - .andExpect(jsonPath("$.data.edges", hasSize(1))) - .andExpect(jsonPath("$.data.nodes[0].id").value("1")) - .andExpect(jsonPath("$.data.nodes[0].data.title").value("노드1")) - .andExpect(jsonPath("$.data.edges[0].id").value("e1-2")); + // Then: 요청 접수 성공 응답 확인 + expectAccepted(updateResult, "데이터 업데이트 요청이 성공적으로 접수되었습니다."); + + // Then: (비동기 검증) 최종적으로 DB에 데이터가 반영될 때까지 최대 5초간 기다립니다. + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + // [수정] transactionTemplate을 사용하여 트랜잭션 내에서 검증 로직을 실행 + transactionTemplate.execute(status -> { + Space space = spaceService.findByName(authorizedSpaceName); + Graph updatedGraph = space.getDashboard().getGraph(); + assertThat(updatedGraph.getNodes()).hasSize(2); + assertThat(updatedGraph.getEdges()).hasSize(1); + assertThat(updatedGraph.getNodes().get(0).getData().get("title")).isEqualTo("노드1"); + return null; // execute 메서드는 반환값이 필요 + }); + }); } @Test diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/dashboard/extraComponent/GraphUpdateConsumerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/dashboard/extraComponent/GraphUpdateConsumerTest.java new file mode 100644 index 00000000..4774022c --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/dashboard/extraComponent/GraphUpdateConsumerTest.java @@ -0,0 +1,96 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.extraComponent; + +import org.junit.jupiter.api.*; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import org.tuna.zoopzoop.backend.domain.dashboard.dto.GraphUpdateMessage; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Graph; +import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; +import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService; +import org.tuna.zoopzoop.backend.testSupport.ControllerTestSupport; + +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.awaitility.Awaitility.await; + +@SpringBootTest +@Transactional +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class GraphUpdateConsumerTest extends ControllerTestSupport { + @Autowired private RabbitTemplate rabbitTemplate; + @Autowired private TransactionTemplate transactionTemplate; + @Autowired private SpaceService spaceService; + + // 테스트에 사용할 dashboardId (실제 DB에 존재하는 ID) + private Integer existingDashboardId; + + private String existingSpaceName = "TestSpace1_forGraphUpdateConsumerTest"; + + @BeforeAll + void setUp(){ + spaceService.createSpace(existingSpaceName, "thumb1"); + } + + + @Test + @DisplayName("큐에 업데이트 메시지가 들어오면 컨슈머가 DB를 성공적으로 업데이트한다") + void handleGraphUpdate_Success() throws Exception { + // Given + // ControllerTestSupport의 setUp에서 생성된 대시보드 ID를 가져옵니다. + existingDashboardId = spaceService.findByName(existingSpaceName).getDashboard().getId(); + + String requestBody = createReactFlowJsonBody(); // 테스트용 JSON 데이터 + GraphUpdateMessage message = new GraphUpdateMessage(existingDashboardId, requestBody); + + // When: 테스트에서 직접 RabbitMQ에 메시지를 발행합니다. + rabbitTemplate.convertAndSend("zoopzoop.exchange", "graph.update.rk", message); + + // Then: 컨슈머가 메시지를 처리하여 DB가 변경될 때까지 최대 5초간 기다립니다. + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + transactionTemplate.execute(status -> { + Space space = spaceService.findByName(existingSpaceName); + Graph updatedGraph = space.getDashboard().getGraph(); + assertThat(updatedGraph.getNodes()).hasSize(2); + assertThat(updatedGraph.getEdges()).hasSize(1); + assertThat(updatedGraph.getNodes().get(0).getData().get("title")).isEqualTo("노드1"); + return null; + }); + }); + } + + private String createReactFlowJsonBody() { + return """ + { + "nodes": [ + { + "id": "1", + "type": "CUSTOM", + "data": { "title": "노드1", "description": "설명1" }, + "position": { "x": 100, "y": 200 } + }, + { + "id": "2", + "type": "CUSTOM", + "data": { "title": "노드2" }, + "position": { "x": 300, "y": 400 } + } + ], + "edges": [ + { + "id": "e1-2", + "source": "1", + "target": "2", + "type": "SMOOTHSTEP", + "animated": true, + "style": { "stroke": "#999", "strokeWidth": 2.0 } + } + ] + } + """; + } + +} \ No newline at end of file diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/dashboard/service/GraphServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/dashboard/service/GraphServiceTest.java index 3a2cbe62..7e9af41c 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/dashboard/service/GraphServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/dashboard/service/GraphServiceTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; import org.tuna.zoopzoop.backend.domain.dashboard.entity.Edge; diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipServiceTest.java index 01eac5e1..8d74ea52 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipServiceTest.java @@ -5,9 +5,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.security.test.context.support.TestExecutionEvent; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.security.test.context.support.WithUserDetails; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; import org.tuna.zoopzoop.backend.domain.member.entity.Member; diff --git a/src/test/java/org/tuna/zoopzoop/backend/testSupport/ControllerTestSupport.java b/src/test/java/org/tuna/zoopzoop/backend/testSupport/ControllerTestSupport.java index f0d64e8a..d2e74e9f 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/testSupport/ControllerTestSupport.java +++ b/src/test/java/org/tuna/zoopzoop/backend/testSupport/ControllerTestSupport.java @@ -152,6 +152,18 @@ protected void expectCreated(ResultActions resultActions, String msg) throws Exc .andExpect(jsonPath("$.msg").value(msg)); } + /** + * 202 Accepted 응답을 기대하는 헬퍼 메서드 + * @param resultActions - MockMvc의 ResultActions 객체 + * @param msg - 기대하는 메시지 + * @throws Exception - 예외 발생 시 던짐 + */ + protected void expectAccepted(ResultActions resultActions, String msg) throws Exception { + resultActions.andExpect(status().isAccepted()) + .andExpect(jsonPath("$.status").value("202")) + .andExpect(jsonPath("$.msg").value(msg)); + } + /** * 400 Bad Request 응답을 기대하는 헬퍼 메서드 * @param resultActions - MockMvc의 ResultActions 객체