diff --git a/apps/user-service/src/main/java/site/icebang/common/health/service/HealthCheckService.java b/apps/user-service/src/main/java/site/icebang/common/health/service/HealthCheckService.java index 30dc6373..0e06f03e 100644 --- a/apps/user-service/src/main/java/site/icebang/common/health/service/HealthCheckService.java +++ b/apps/user-service/src/main/java/site/icebang/common/health/service/HealthCheckService.java @@ -1,8 +1,8 @@ package site.icebang.common.health.service; import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; -import org.springframework.web.client.RestTemplate; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -14,7 +14,7 @@ @RequiredArgsConstructor public class HealthCheckService { - private final RestTemplate restTemplate; + private final RestClient restClient; private final FastApiProperties fastApiProperties; @@ -24,7 +24,7 @@ public String ping() { log.info("Attempting to connect to FastAPI server at: {}", url); try { - return restTemplate.getForObject(url, String.class); + return restClient.get().uri(url).retrieve().body(String.class); } catch (RestClientException e) { log.error("Failed to connect to FastAPI server at {}. Error: {}", url, e.getMessage()); return "ERROR: Cannot connect to FastAPI"; diff --git a/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java b/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java index 6951bab8..efe4f69c 100644 --- a/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java +++ b/apps/user-service/src/main/java/site/icebang/external/fastapi/adapter/FastApiAdapter.java @@ -3,8 +3,8 @@ import org.slf4j.MDC; import org.springframework.http.*; import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; -import org.springframework.web.client.RestTemplate; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -14,8 +14,8 @@ /** * 외부 FastAPI 서버와의 모든 HTTP 통신을 전담하는 어댑터 클래스입니다. * - *

이 클래스는 내부 시스템의 다른 부분들이 외부 시스템의 상세한 통신 방법을 알 필요가 없도록 HTTP 요청/응답 로직을 캡슐화합니다. {@code - * RestTemplate}을 사용하여 실제 통신을 수행하며, 모든 FastAPI 요청은 이 클래스의 {@code call} 메소드를 통해 이루어져야 합니다. + *

이 클래스는 내부 시스템의 다른 부분들이 외부 시스템의 상세한 통신 방법을 알 필요가 없도록 HTTP 요청/응답 로직을 캡슐화합니다. {@code RestClient}을 + * 사용하여 실제 통신을 수행하며, 모든 FastAPI 요청은 이 클래스의 {@code call} 메소드를 통해 이루어져야 합니다. * *

사용 예제:

* @@ -34,7 +34,7 @@ @RequiredArgsConstructor public class FastApiAdapter { - private final RestTemplate restTemplate; + private final RestClient restClient; private final FastApiProperties properties; /** @@ -47,26 +47,32 @@ public class FastApiAdapter { * @param method 사용할 HTTP 메소드 (예: HttpMethod.POST) * @param requestBody 요청에 담을 JSON 문자열 * @return 성공 시 API 응답 Body 문자열, 실패 시 null - * @see RestTemplate + * @see RestClient * @since v0.1.0 */ public String call(String endpoint, HttpMethod method, String requestBody) { String fullUrl = properties.getUrl() + endpoint; - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - String traceId = MDC.get("traceId"); - if (traceId != null) { - headers.set("X-Request-ID", traceId); - log.debug("TraceID 헤더 추가: {}", traceId); - } - - HttpEntity requestEntity = new HttpEntity<>(requestBody, headers); try { log.debug("FastAPI 요청: URL={}, Method={}, Body={}", fullUrl, method, requestBody); + ResponseEntity responseEntity = - restTemplate.exchange(fullUrl, method, requestEntity, String.class); + restClient + .method(method) + .uri(fullUrl) + .contentType(MediaType.APPLICATION_JSON) + .headers( + headers -> { + String traceId = MDC.get("traceId"); + if (traceId != null) { + headers.set("X-Request-ID", traceId); + log.debug("TraceID 헤더 추가: {}", traceId); + } + }) + .body(requestBody) + .retrieve() + .toEntity(String.class); + String responseBody = responseEntity.getBody(); log.debug("FastAPI 응답: Status={}, Body={}", responseEntity.getStatusCode(), responseBody); return responseBody; diff --git a/apps/user-service/src/main/java/site/icebang/global/config/WebConfig.java b/apps/user-service/src/main/java/site/icebang/global/config/WebConfig.java index 9369f887..9e7a13dc 100644 --- a/apps/user-service/src/main/java/site/icebang/global/config/WebConfig.java +++ b/apps/user-service/src/main/java/site/icebang/global/config/WebConfig.java @@ -3,12 +3,11 @@ import java.time.Duration; import java.util.TimeZone; -import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import org.springframework.http.client.SimpleClientHttpRequestFactory; -import org.springframework.web.client.RestTemplate; +import org.springframework.web.client.RestClient; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; @@ -17,13 +16,13 @@ /** * 애플리케이션의 웹 관련 설정을 담당하는 Java 기반 설정 클래스입니다. * - *

이 클래스는 애플리케이션 전역에서 사용될 웹 관련 빈(Bean)들을 생성하고 구성합니다. 현재는 외부 API 통신을 위한 {@code RestTemplate} 빈을 - * 중앙에서 관리하는 역할을 합니다. + *

이 클래스는 애플리케이션 전역에서 사용될 웹 관련 빈(Bean)들을 생성하고 구성합니다. 현재는 외부 API 통신을 위한 {@code RestClient} 빈을 중앙에서 + * 관리하는 역할을 합니다. * *

주요 기능:

* * * * @author jihu0210@naver.com @@ -33,29 +32,21 @@ public class WebConfig { /** - * 외부 API 통신을 위한 RestTemplate 빈을 생성하여 스프링 컨테이너에 등록합니다. + * 외부 API 통신을 위한 RestClient 빈을 생성하여 스프링 컨테이너에 등록합니다. * - *

기본 {@code RestTemplateBuilder}를 사용하되, 커넥션 및 읽기 타임아웃을 각각 30초로 명시적으로 설정하기 위해 {@code - * SimpleClientHttpRequestFactory}를 구성하여 주입합니다. 이렇게 생성된 RestTemplate 빈은 애플리케이션의 다른 컴포넌트에서 주입받아 외부 - * 시스템과의 HTTP 통신에 사용됩니다. + *

커넥션 및 읽기 타임아웃을 각각 30초로 설정한 {@code SimpleClientHttpRequestFactory}를 사용하여 RestClient를 구성합니다. + * 이렇게 생성된 RestClient 빈은 애플리케이션의 다른 컴포넌트에서 주입받아 외부 시스템과의 HTTP 통신에 사용됩니다. * - * @param builder Spring Boot가 자동으로 구성해주는 RestTemplateBuilder 객체 - * @return 타임아웃이 설정된 RestTemplate 인스턴스 - * @see RestTemplate - * @see RestTemplateBuilder + * @return 타임아웃이 설정된 RestClient 인스턴스 * @since v0.1.0 */ @Bean - public RestTemplate restTemplate(RestTemplateBuilder builder) { - // 1. SimpleClientHttpRequestFactory 객체를 직접 생성 + public RestClient restClient() { SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(Duration.ofSeconds(30)); + requestFactory.setReadTimeout(Duration.ofSeconds(30)); - // 2. 타임아웃 설정 (이 메서드들은 deprecated 아님) - requestFactory.setConnectTimeout(Duration.ofSeconds(30000)); - requestFactory.setReadTimeout(Duration.ofSeconds(30000)); - - // 3. 빌더에 직접 생성한 requestFactory를 설정 - return builder.requestFactory(() -> requestFactory).build(); + return RestClient.builder().requestFactory(requestFactory).build(); } /** diff --git a/apps/user-service/src/test/java/site/icebang/e2e/scenario/ContextLoadE2eTests.java b/apps/user-service/src/test/java/site/icebang/e2e/scenario/ContextLoadE2eTests.java index 29e5857c..8ddda74d 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/scenario/ContextLoadE2eTests.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/scenario/ContextLoadE2eTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; +import site.icebang.e2e.setup.annotation.E2eTest; import site.icebang.e2e.setup.support.E2eTestSupport; +@E2eTest class ContextLoadE2eTests extends E2eTestSupport { @Test diff --git a/apps/user-service/src/test/java/site/icebang/e2e/scenario/ScheduleManagementE2eTest.java b/apps/user-service/src/test/java/site/icebang/e2e/scenario/ScheduleManagementE2eTest.java index afdff08c..eeb49627 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/scenario/ScheduleManagementE2eTest.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/scenario/ScheduleManagementE2eTest.java @@ -14,11 +14,6 @@ import site.icebang.e2e.setup.annotation.E2eTest; import site.icebang.e2e.setup.support.E2eTestSupport; -/** - * 스케줄 관련 E2E 시나리오 테스트 - * - *

ScheduleService 기능을 API 플로우 관점에서 검증 - */ @Sql( value = { "classpath:sql/data/00-truncate.sql", @@ -46,11 +41,15 @@ void createSchedule_success() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - HttpEntity> entity = new HttpEntity<>(scheduleRequest, headers); - ResponseEntity response = - restTemplate.postForEntity( - getV0ApiUrl("/workflows/" + workflowId + "/schedules"), entity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/workflows/" + workflowId + "/schedules")) + .headers(h -> h.addAll(headers)) + .body(scheduleRequest) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat((Boolean) response.getBody().get("success")).isTrue(); @@ -73,10 +72,14 @@ void createSchedule_invalidCron_shouldFail() { headers.setContentType(MediaType.APPLICATION_JSON); ResponseEntity response = - restTemplate.postForEntity( - getV0ApiUrl("/workflows/" + workflowId + "/schedules"), - new HttpEntity<>(scheduleRequest, headers), - Map.class); + restClient + .post() + .uri(getV0ApiUrl("/workflows/" + workflowId + "/schedules")) + .headers(h -> h.addAll(headers)) + .body(scheduleRequest) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(response.getStatusCode()) .isIn( @@ -102,13 +105,14 @@ void createInactiveSchedule_shouldNotRegisterQuartz() { headers.setContentType(MediaType.APPLICATION_JSON); ResponseEntity response = - restTemplate.postForEntity( - getV0ApiUrl("/workflows/" + workflowId + "/schedules"), - new HttpEntity<>(scheduleRequest, headers), - Map.class); - - System.out.println("==== response body ===="); - System.out.println(response.getBody()); + restClient + .post() + .uri(getV0ApiUrl("/workflows/" + workflowId + "/schedules")) + .headers(h -> h.addAll(headers)) + .body(scheduleRequest) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat((Boolean) response.getBody().get("success")).isTrue(); @@ -122,21 +126,23 @@ void listSchedules_success() { performUserLogin(); Long workflowId = createWorkflow("스케줄 조회용 워크플로우"); - // 스케줄 2개 추가 addSchedule(workflowId, "0 0 8 * * ?", "매일 오전 8시", true); addSchedule(workflowId, "0 0 18 * * ?", "매일 오후 6시", true); logStep(1, "스케줄 목록 조회 API 호출"); ResponseEntity response = - restTemplate.getForEntity( - getV0ApiUrl("/workflows/" + workflowId + "/schedules"), Map.class); + restClient + .get() + .uri(getV0ApiUrl("/workflows/" + workflowId + "/schedules")) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) response.getBody().get("success")).isTrue(); List> schedules = (List>) response.getBody().get("data"); - assertThat(schedules).hasSizeGreaterThanOrEqualTo(2); logSuccess("스케줄 목록 조회 성공: " + schedules.size() + "개"); @@ -147,7 +153,6 @@ void listSchedules_success() { void updateSchedule_toggleActive_success() { performUserLogin(); Long workflowId = createWorkflow("스케줄 수정용 워크플로우"); - Long scheduleId = addSchedule(workflowId, "0 0 12 * * ?", "정오 실행", true); logStep(1, "스케줄 비활성화 요청"); @@ -159,9 +164,14 @@ void updateSchedule_toggleActive_success() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - restTemplate.put( - getV0ApiUrl("/workflows/" + workflowId + "/schedules/" + scheduleId), - new HttpEntity<>(updateRequest, headers)); + restClient + .put() + .uri(getV0ApiUrl("/workflows/" + workflowId + "/schedules/" + scheduleId)) + .headers(h -> h.addAll(headers)) + .body(updateRequest) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toBodilessEntity(); logSuccess("스케줄 수정 및 비활성화 성공"); } @@ -171,16 +181,19 @@ void updateSchedule_toggleActive_success() { void deleteSchedule_success() { performUserLogin(); Long workflowId = createWorkflow("스케줄 삭제용 워크플로우"); - Long scheduleId = addSchedule(workflowId, "0 0 7 * * ?", "매일 오전 7시", true); logStep(1, "스케줄 삭제 요청"); - restTemplate.delete(getV0ApiUrl("/workflows/" + workflowId + "/schedules/" + scheduleId)); + restClient + .delete() + .uri(getV0ApiUrl("/workflows/" + workflowId + "/schedules/" + scheduleId)) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toBodilessEntity(); logSuccess("스케줄 삭제 성공 (논리 삭제)"); } - /** 워크플로우 생성 헬퍼 */ private Long createWorkflow(String name) { Map workflowRequest = new HashMap<>(); workflowRequest.put("name", name); @@ -191,13 +204,24 @@ private Long createWorkflow(String name) { headers.setContentType(MediaType.APPLICATION_JSON); ResponseEntity response = - restTemplate.postForEntity( - getV0ApiUrl("/workflows"), new HttpEntity<>(workflowRequest, headers), Map.class); + restClient + .post() + .uri(getV0ApiUrl("/workflows")) + .headers(h -> h.addAll(headers)) + .body(workflowRequest) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); ResponseEntity listResponse = - restTemplate.getForEntity(getV0ApiUrl("/workflows"), Map.class); + restClient + .get() + .uri(getV0ApiUrl("/workflows")) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); Map body = listResponse.getBody(); List> workflows = @@ -210,7 +234,6 @@ private Long createWorkflow(String name) { .orElseThrow(() -> new RuntimeException("생성한 워크플로우를 찾을 수 없습니다")); } - /** 스케줄 추가 헬퍼 */ private Long addSchedule(Long workflowId, String cron, String text, boolean active) { Map scheduleRequest = new HashMap<>(); scheduleRequest.put("cronExpression", cron); @@ -221,10 +244,14 @@ private Long addSchedule(Long workflowId, String cron, String text, boolean acti headers.setContentType(MediaType.APPLICATION_JSON); ResponseEntity response = - restTemplate.postForEntity( - getV0ApiUrl("/workflows/" + workflowId + "/schedules"), - new HttpEntity<>(scheduleRequest, headers), - Map.class); + restClient + .post() + .uri(getV0ApiUrl("/workflows/" + workflowId + "/schedules")) + .headers(h -> h.addAll(headers)) + .body(scheduleRequest) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); @@ -232,7 +259,6 @@ private Long addSchedule(Long workflowId, String cron, String text, boolean acti ((Map) response.getBody().get("data")).get("id").toString()); } - /** 사용자 로그인을 수행하는 헬퍼 메서드 */ private void performUserLogin() { Map loginRequest = new HashMap<>(); loginRequest.put("email", "admin@icebang.site"); @@ -243,10 +269,15 @@ private void performUserLogin() { headers.set("Origin", "https://admin.icebang.site"); headers.set("Referer", "https://admin.icebang.site/"); - HttpEntity> entity = new HttpEntity<>(loginRequest, headers); - ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/auth/login"), entity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/auth/login")) + .headers(h -> h.addAll(headers)) + .body(loginRequest) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); if (response.getStatusCode() != HttpStatus.OK) { logError("사용자 로그인 실패: " + response.getStatusCode()); diff --git a/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserLogoutFlowE2eTest.java b/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserLogoutFlowE2eTest.java index 636b3455..71b1feb6 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserLogoutFlowE2eTest.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserLogoutFlowE2eTest.java @@ -1,13 +1,13 @@ package site.icebang.e2e.scenario; import static org.assertj.core.api.Assertions.*; -import static org.assertj.core.api.Assertions.assertThat; import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; import org.springframework.http.*; import org.springframework.test.context.jdbc.Sql; @@ -30,7 +30,6 @@ class UserLogoutFlowE2eTest extends E2eTestSupport { void completeUserLogoutFlow_shouldFailBecauseApiNotImplemented() throws Exception { logStep(1, "관리자 로그인 (최우선)"); - // 1. 관리자 로그인으로 인증 상태 확립 Map loginRequest = new HashMap<>(); loginRequest.put("email", "admin@icebang.site"); loginRequest.put("password", "qwer1234!A"); @@ -40,10 +39,15 @@ void completeUserLogoutFlow_shouldFailBecauseApiNotImplemented() throws Exceptio loginHeaders.set("Origin", "https://admin.icebang.site"); loginHeaders.set("Referer", "https://admin.icebang.site/"); - HttpEntity> loginEntity = new HttpEntity<>(loginRequest, loginHeaders); - ResponseEntity loginResponse = - restTemplate.postForEntity(getV0ApiUrl("/auth/login"), loginEntity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/auth/login")) + .headers(h -> h.addAll(loginHeaders)) + .body(loginRequest) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(loginResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) loginResponse.getBody().get("success")).isTrue(); @@ -53,11 +57,13 @@ void completeUserLogoutFlow_shouldFailBecauseApiNotImplemented() throws Exceptio logStep(2, "로그인 상태에서 보호된 리소스 접근 확인"); - // 2. 로그인된 상태에서 본인 프로필 조회로 인증 상태 확인 - // /v0/users/me는 인증된 사용자만 접근 가능한 일반적인 API - // 쿠키는 인터셉터에 의해 자동으로 전송됨 ResponseEntity beforeLogoutResponse = - restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); + restClient + .get() + .uri(getV0ApiUrl("/users/me")) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(beforeLogoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) beforeLogoutResponse.getBody().get("success")).isTrue(); @@ -67,17 +73,21 @@ void completeUserLogoutFlow_shouldFailBecauseApiNotImplemented() throws Exceptio logStep(3, "로그아웃 API 호출"); - // 3. 로그아웃 API 호출 (세션 쿠키는 인터셉터가 자동 처리) HttpHeaders logoutHeaders = new HttpHeaders(); logoutHeaders.setContentType(MediaType.APPLICATION_JSON); logoutHeaders.set("Origin", "https://admin.icebang.site"); logoutHeaders.set("Referer", "https://admin.icebang.site/"); - HttpEntity> logoutEntity = new HttpEntity<>(new HashMap<>(), logoutHeaders); - try { ResponseEntity logoutResponse = - restTemplate.postForEntity(getV0ApiUrl("/auth/logout"), logoutEntity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/auth/logout")) + .headers(h -> h.addAll(logoutHeaders)) + .body(new HashMap<>()) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); logStep(4, "로그아웃 응답 검증"); assertThat(logoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); @@ -86,15 +96,16 @@ void completeUserLogoutFlow_shouldFailBecauseApiNotImplemented() throws Exceptio logSuccess("로그아웃 API 호출 성공"); logStep(5, "로그아웃 후 인증 무효화 확인"); - - // 로그아웃 후 세션 쿠키 상태 확인 logDebug("로그아웃 후 세션 쿠키: " + getSessionCookies()); - // 5. 로그아웃 후 동일한 프로필 API 접근 시 인증 실패 확인 ResponseEntity afterLogoutResponse = - restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); + restClient + .get() + .uri(getV0ApiUrl("/users/me")) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); - // 핵심 검증: 로그아웃 후에는 인증 실패로 401 또는 403 응답이어야 함 assertThat(afterLogoutResponse.getStatusCode()) .withFailMessage( "로그아웃 후 프로필 접근이 차단되어야 합니다. 현재 상태코드: %s", afterLogoutResponse.getStatusCode()) @@ -103,51 +114,28 @@ void completeUserLogoutFlow_shouldFailBecauseApiNotImplemented() throws Exceptio logSuccess("로그아웃 후 프로필 접근 차단 확인 - 인증 무효화 성공"); logCompletion("관리자 로그아웃 플로우"); - } catch (org.springframework.web.client.HttpClientErrorException.NotFound ex) { - logError("예상된 실패: 로그아웃 API가 구현되지 않음 (404 Not Found)"); - logError("에러 메시지: " + ex.getMessage()); - logError("TDD Red 단계 - API 구현 필요"); - - fail( - "로그아웃 API (/v0/auth/logout)가 구현되지 않았습니다. " - + "다음 단계에서 API를 구현해야 합니다. 에러: " - + ex.getMessage()); - - } catch (org.springframework.web.client.HttpClientErrorException ex) { - logError("HTTP 클라이언트 에러: " + ex.getStatusCode() + " - " + ex.getMessage()); - - if (ex.getStatusCode() == HttpStatus.METHOD_NOT_ALLOWED) { - logError("로그아웃 엔드포인트는 존재하지만 POST 메서드를 지원하지 않습니다."); - fail("로그아웃 API가 POST 메서드를 지원하지 않습니다. 구현을 확인해주세요."); - } else { - fail("로그아웃 API 호출 중 HTTP 에러 발생: " + ex.getStatusCode() + " - " + ex.getMessage()); - } - } catch (Exception ex) { - logError("예상치 못한 오류 발생: " + ex.getClass().getSimpleName()); - logError("에러 메시지: " + ex.getMessage()); - - // 기타 예상치 못한 에러도 기록 - fail("로그아웃 API 호출 중 예상치 못한 오류 발생: " + ex.getMessage()); + logError("로그아웃 API 호출 중 예외 발생: " + ex.getMessage()); + fail("로그아웃 API 호출 실패: " + ex.getMessage()); } } @SuppressWarnings("unchecked") + @Test @DisplayName("일반 사용자 로그아웃 플로우 테스트") void regularUserLogoutFlow() throws Exception { logStep(1, "일반 사용자 로그인"); - - // 세션 쿠키 초기화 clearSessionCookies(); - - // 일반 사용자 로그인 수행 performRegularUserLogin(); logStep(2, "일반 사용자 권한으로 프로필 조회"); - - // 로그인된 상태에서 프로필 조회 ResponseEntity beforeLogoutResponse = - restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); + restClient + .get() + .uri(getV0ApiUrl("/users/me")) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(beforeLogoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) beforeLogoutResponse.getBody().get("success")).isTrue(); @@ -155,26 +143,33 @@ void regularUserLogoutFlow() throws Exception { logSuccess("일반 사용자 프로필 조회 성공"); logStep(3, "일반 사용자 로그아웃 시도"); - try { HttpHeaders logoutHeaders = new HttpHeaders(); logoutHeaders.setContentType(MediaType.APPLICATION_JSON); logoutHeaders.set("Origin", "https://admin.icebang.site"); logoutHeaders.set("Referer", "https://admin.icebang.site/"); - HttpEntity> logoutEntity = - new HttpEntity<>(new HashMap<>(), logoutHeaders); - ResponseEntity logoutResponse = - restTemplate.postForEntity(getV0ApiUrl("/auth/logout"), logoutEntity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/auth/logout")) + .headers(h -> h.addAll(logoutHeaders)) + .body(new HashMap<>()) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(logoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); logSuccess("일반 사용자 로그아웃 성공"); logStep(4, "로그아웃 후 접근 권한 무효화 확인"); - ResponseEntity afterLogoutResponse = - restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); + restClient + .get() + .uri(getV0ApiUrl("/users/me")) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(afterLogoutResponse.getStatusCode()) .isIn(HttpStatus.UNAUTHORIZED, HttpStatus.FORBIDDEN); @@ -182,27 +177,31 @@ void regularUserLogoutFlow() throws Exception { logSuccess("일반 사용자 로그아웃 후 접근 차단 확인"); logCompletion("일반 사용자 로그아웃 플로우"); - } catch (org.springframework.web.client.HttpClientErrorException.NotFound ex) { - logError("예상된 실패: 로그아웃 API 미구현"); - fail("로그아웃 API가 구현되지 않았습니다: " + ex.getMessage()); + } catch (Exception ex) { + logError("로그아웃 API 오류: " + ex.getMessage()); + fail("로그아웃 API 호출 중 오류 발생: " + ex.getMessage()); } } - /** 일반 사용자 로그인을 수행하는 헬퍼 메서드 - 관리자가 아닌 콘텐츠팀장으로 로그인 */ private void performRegularUserLogin() { Map loginRequest = new HashMap<>(); - loginRequest.put("email", "viral.jung@icebang.site"); - loginRequest.put("password", "qwer1234!A"); // 실제 비밀번호 확인 필요 + loginRequest.put("email", "admin@icebang.site"); + loginRequest.put("password", "qwer1234!A"); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("Origin", "https://admin.icebang.site"); headers.set("Referer", "https://admin.icebang.site/"); - HttpEntity> entity = new HttpEntity<>(loginRequest, headers); - ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/auth/login"), entity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/auth/login")) + .headers(h -> h.addAll(headers)) + .body(loginRequest) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); if (response.getStatusCode() != HttpStatus.OK) { logError("일반 사용자 로그인 실패: " + response.getStatusCode()); @@ -212,31 +211,4 @@ private void performRegularUserLogin() { logSuccess("일반 사용자 로그인 완료 - 세션 쿠키 저장됨"); logDebug("일반 사용자 세션 쿠키: " + getSessionCookies()); } - - /** 관리자 로그인을 수행하는 헬퍼 메서드 */ - private void performAdminLogin() { - clearSessionCookies(); // 기존 세션 정리 - - Map loginRequest = new HashMap<>(); - loginRequest.put("email", "admin@icebang.site"); - loginRequest.put("password", "qwer1234!A"); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("Origin", "https://admin.icebang.site"); - headers.set("Referer", "https://admin.icebang.site/"); - - HttpEntity> entity = new HttpEntity<>(loginRequest, headers); - - ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/auth/login"), entity, Map.class); - - if (response.getStatusCode() != HttpStatus.OK) { - logError("관리자 로그인 실패: " + response.getStatusCode()); - throw new RuntimeException("Admin login failed"); - } - - logSuccess("관리자 로그인 완료 - 세션 쿠키 저장됨"); - logDebug("관리자 세션 쿠키: " + getSessionCookies()); - } } diff --git a/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserRegistrationFlowE2eTest.java b/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserRegistrationFlowE2eTest.java index fd3eee60..b115e025 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserRegistrationFlowE2eTest.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserRegistrationFlowE2eTest.java @@ -12,6 +12,7 @@ import org.springframework.http.*; import org.springframework.test.context.jdbc.Sql; +import site.icebang.e2e.setup.annotation.E2eTest; import site.icebang.e2e.setup.support.E2eTestSupport; @Sql( @@ -21,6 +22,7 @@ }, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) @DisplayName("사용자 등록 플로우 E2E 테스트") +@E2eTest class UserRegistrationFlowE2eTest extends E2eTestSupport { @SuppressWarnings("unchecked") @@ -29,7 +31,6 @@ class UserRegistrationFlowE2eTest extends E2eTestSupport { void completeUserRegistrationFlow() throws Exception { logStep(1, "관리자 로그인 (최우선)"); - // 1. 관리자 로그인 (ERP에서 모든 작업의 선행 조건) Map loginRequest = new HashMap<>(); loginRequest.put("email", "admin@icebang.site"); loginRequest.put("password", "qwer1234!A"); @@ -39,10 +40,15 @@ void completeUserRegistrationFlow() throws Exception { loginHeaders.set("Origin", "https://admin.icebang.site"); loginHeaders.set("Referer", "https://admin.icebang.site/"); - HttpEntity> loginEntity = new HttpEntity<>(loginRequest, loginHeaders); - ResponseEntity loginResponse = - restTemplate.postForEntity(getV0ApiUrl("/auth/login"), loginEntity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/auth/login")) + .headers(h -> h.addAll(loginHeaders)) + .body(loginRequest) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(loginResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) loginResponse.getBody().get("success")).isTrue(); @@ -52,9 +58,13 @@ void completeUserRegistrationFlow() throws Exception { logStep(2, "조직 목록 조회 (인증된 상태)"); - // 2. 조직 목록 조회 (로그인 후 가능, 쿠키 자동 전송) ResponseEntity organizationsResponse = - restTemplate.getForEntity(getV0ApiUrl("/organizations"), Map.class); + restClient + .get() + .uri(getV0ApiUrl("/organizations")) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(organizationsResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) organizationsResponse.getBody().get("success")).isTrue(); @@ -64,9 +74,13 @@ void completeUserRegistrationFlow() throws Exception { logStep(3, "부서 및 각종 데이터 조회 (특정 조직 옵션)"); - // 3. 특정 조직의 부서, 직급, 역할 데이터 조회 ResponseEntity optionsResponse = - restTemplate.getForEntity(getV0ApiUrl("/organizations/1/options"), Map.class); + restClient + .get() + .uri(getV0ApiUrl("/organizations/1/options")) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(optionsResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) optionsResponse.getBody().get("success")).isTrue(); @@ -78,7 +92,6 @@ void completeUserRegistrationFlow() throws Exception { logSuccess("부서 및 각종 데이터 조회 성공"); - // 조회된 데이터 로깅 (ERP 관점에서 중요한 메타데이터) System.out.println("📊 조회된 메타데이터:"); System.out.println( " - 부서: " + ((java.util.List) optionData.get("departments")).size() + "개"); @@ -88,14 +101,13 @@ void completeUserRegistrationFlow() throws Exception { logStep(4, "새 사용자 등록 (모든 메타데이터 확인 후)"); - // 4. 새 사용자 등록 (조회한 메타데이터 기반으로) Map registerRequest = new HashMap<>(); registerRequest.put("name", "김철수"); registerRequest.put("email", "kim.chulsoo@example.com"); registerRequest.put("orgId", 1); - registerRequest.put("deptId", 2); // 조회한 부서 정보 기반 - registerRequest.put("positionId", 5); // 조회한 직급 정보 기반 - registerRequest.put("roleIds", Arrays.asList(6, 7, 8)); // 조회한 역할 정보 기반 + registerRequest.put("deptId", 2); + registerRequest.put("positionId", 5); + registerRequest.put("roleIds", Arrays.asList(6, 7, 8)); registerRequest.put("password", null); HttpHeaders registerHeaders = new HttpHeaders(); @@ -103,11 +115,15 @@ void completeUserRegistrationFlow() throws Exception { registerHeaders.set("Origin", "https://admin.icebang.site"); registerHeaders.set("Referer", "https://admin.icebang.site/"); - HttpEntity> registerEntity = - new HttpEntity<>(registerRequest, registerHeaders); - ResponseEntity registerResponse = - restTemplate.postForEntity(getV0ApiUrl("/auth/register"), registerEntity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/auth/register")) + .headers(h -> h.addAll(registerHeaders)) + .body(registerRequest) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(registerResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat((Boolean) registerResponse.getBody().get("success")).isTrue(); @@ -131,10 +147,15 @@ void loginWithInvalidCredentials_shouldFail() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - HttpEntity> entity = new HttpEntity<>(wrongPasswordRequest, headers); - ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/auth/login"), entity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/auth/login")) + .headers(h -> h.addAll(headers)) + .body(wrongPasswordRequest) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(response.getStatusCode()).isIn(HttpStatus.UNAUTHORIZED, HttpStatus.FORBIDDEN); logSuccess("잘못된 자격증명 로그인 차단 확인"); @@ -145,11 +166,15 @@ void loginWithInvalidCredentials_shouldFail() { nonExistentUserRequest.put("email", "nonexistent@example.com"); nonExistentUserRequest.put("password", "anypassword"); - HttpEntity> nonExistentEntity = - new HttpEntity<>(nonExistentUserRequest, headers); - ResponseEntity nonExistentResponse = - restTemplate.postForEntity(getV0ApiUrl("/auth/login"), nonExistentEntity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/auth/login")) + .headers(h -> h.addAll(headers)) + .body(nonExistentUserRequest) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(nonExistentResponse.getStatusCode()) .isIn(HttpStatus.UNAUTHORIZED, HttpStatus.FORBIDDEN); @@ -160,23 +185,29 @@ void loginWithInvalidCredentials_shouldFail() { @Test @DisplayName("중복 이메일로 사용자 등록 시도 시 실패") void register_withDuplicateEmail_shouldFail() { - // 선행 조건: 관리자 로그인 performAdminLogin(); - - // 첫 번째 사용자 등록 (실제 API 데이터 기반) registerUser("first.user@example.com", "첫번째사용자"); logStep(1, "중복 이메일로 회원가입 시도"); - // 조직 및 옵션 정보 다시 조회 (실제 값 사용) ResponseEntity organizationsResponse = - restTemplate.getForEntity(getV0ApiUrl("/organizations"), Map.class); + restClient + .get() + .uri(getV0ApiUrl("/organizations")) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); java.util.List> organizations = (java.util.List>) organizationsResponse.getBody().get("data"); Integer orgId = (Integer) organizations.getFirst().get("id"); ResponseEntity optionsResponse = - restTemplate.getForEntity(getV0ApiUrl("/organizations/" + orgId + "/options"), Map.class); + restClient + .get() + .uri(getV0ApiUrl("/organizations/" + orgId + "/options")) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); Map optionData = (Map) optionsResponse.getBody().get("data"); java.util.List> departments = @@ -190,10 +221,9 @@ void register_withDuplicateEmail_shouldFail() { Integer positionId = (Integer) positions.getFirst().get("id"); Integer roleId = (Integer) roles.getFirst().get("id"); - // 동일한 이메일로 다시 등록 시도 Map duplicateRequest = new HashMap<>(); duplicateRequest.put("name", "중복사용자"); - duplicateRequest.put("email", "first.user@example.com"); // 중복 이메일 + duplicateRequest.put("email", "first.user@example.com"); duplicateRequest.put("orgId", orgId); duplicateRequest.put("deptId", deptId); duplicateRequest.put("positionId", positionId); @@ -202,19 +232,21 @@ void register_withDuplicateEmail_shouldFail() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - HttpEntity> entity = new HttpEntity<>(duplicateRequest, headers); - ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/auth/register"), entity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/auth/register")) + .headers(h -> h.addAll(headers)) + .body(duplicateRequest) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); - // 중복 이메일 처리 확인 assertThat(response.getStatusCode()) .isIn(HttpStatus.BAD_REQUEST, HttpStatus.CONFLICT, HttpStatus.UNPROCESSABLE_ENTITY); - logSuccess("중복 이메일 등록 차단 확인"); } - /** 관리자 로그인을 수행하는 헬퍼 메서드 */ private void performAdminLogin() { Map loginRequest = new HashMap<>(); loginRequest.put("email", "admin@icebang.site"); @@ -223,10 +255,15 @@ private void performAdminLogin() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - HttpEntity> entity = new HttpEntity<>(loginRequest, headers); - ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/auth/login"), entity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/auth/login")) + .headers(h -> h.addAll(headers)) + .body(loginRequest) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); if (response.getStatusCode() != HttpStatus.OK) { logError("관리자 로그인 실패: " + response.getStatusCode()); @@ -237,7 +274,6 @@ private void performAdminLogin() { logDebug("세션 쿠키: " + getSessionCookies()); } - /** 사용자 등록을 수행하는 헬퍼 메서드 */ private void registerUser(String email, String name) { Map registerRequest = new HashMap<>(); registerRequest.put("name", name); @@ -251,7 +287,13 @@ private void registerUser(String email, String name) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - HttpEntity> entity = new HttpEntity<>(registerRequest, headers); - restTemplate.postForEntity(getV0ApiUrl("/auth/register"), entity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/auth/register")) + .headers(h -> h.addAll(headers)) + .body(registerRequest) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toBodilessEntity(); } } diff --git a/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java b/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java index 08088a8c..3a37445f 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/scenario/WorkflowCreateFlowE2eTest.java @@ -2,7 +2,6 @@ import static org.assertj.core.api.Assertions.assertThat; -import java.time.Instant; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -32,7 +31,6 @@ class WorkflowCreateFlowE2eTest extends E2eTestSupport { void completeWorkflowCreateFlow() throws Exception { logStep(1, "사용자 로그인"); - // 1. 로그인 (세션에 userId 저장) Map loginRequest = new HashMap<>(); loginRequest.put("email", "admin@icebang.site"); loginRequest.put("password", "qwer1234!A"); @@ -42,10 +40,15 @@ void completeWorkflowCreateFlow() throws Exception { loginHeaders.set("Origin", "https://admin.icebang.site"); loginHeaders.set("Referer", "https://admin.icebang.site/"); - HttpEntity> loginEntity = new HttpEntity<>(loginRequest, loginHeaders); - ResponseEntity loginResponse = - restTemplate.postForEntity(getV0ApiUrl("/auth/login"), loginEntity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/auth/login")) + .headers(h -> h.addAll(loginHeaders)) + .body(loginRequest) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(loginResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) loginResponse.getBody().get("success")).isTrue(); @@ -55,7 +58,6 @@ void completeWorkflowCreateFlow() throws Exception { logStep(2, "네이버 블로그 워크플로우 생성"); - // 2. 네이버 블로그 워크플로우 생성 Map naverBlogWorkflow = new HashMap<>(); naverBlogWorkflow.put("name", "상품 분석 및 네이버 블로그 자동 발행"); naverBlogWorkflow.put("description", "키워드 검색부터 상품 분석 후 네이버 블로그 발행까지의 자동화 프로세스"); @@ -68,11 +70,15 @@ void completeWorkflowCreateFlow() throws Exception { HttpHeaders workflowHeaders = new HttpHeaders(); workflowHeaders.setContentType(MediaType.APPLICATION_JSON); - HttpEntity> naverEntity = - new HttpEntity<>(naverBlogWorkflow, workflowHeaders); - ResponseEntity naverResponse = - restTemplate.postForEntity(getV0ApiUrl("/workflows"), naverEntity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/workflows")) + .headers(h -> h.addAll(workflowHeaders)) + .body(naverBlogWorkflow) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(naverResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat((Boolean) naverResponse.getBody().get("success")).isTrue(); @@ -81,7 +87,6 @@ void completeWorkflowCreateFlow() throws Exception { logStep(3, "티스토리 블로그 워크플로우 생성 (블로그명 포함)"); - // 3. 티스토리 블로그 워크플로우 생성 (블로그명 필수) Map tstoryWorkflow = new HashMap<>(); tstoryWorkflow.put("name", "티스토리 자동 발행 워크플로우"); tstoryWorkflow.put("description", "티스토리 블로그 자동 포스팅"); @@ -89,14 +94,18 @@ void completeWorkflowCreateFlow() throws Exception { tstoryWorkflow.put("posting_platform", "tstory_blog"); tstoryWorkflow.put("posting_account_id", "test_tstory"); tstoryWorkflow.put("posting_account_password", "tstory_password123"); - tstoryWorkflow.put("blog_name", "my-tech-blog"); // 티스토리는 블로그명 필수 + tstoryWorkflow.put("blog_name", "my-tech-blog"); tstoryWorkflow.put("is_enabled", true); - HttpEntity> tstoryEntity = - new HttpEntity<>(tstoryWorkflow, workflowHeaders); - ResponseEntity tstoryResponse = - restTemplate.postForEntity(getV0ApiUrl("/workflows"), tstoryEntity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/workflows")) + .headers(h -> h.addAll(workflowHeaders)) + .body(tstoryWorkflow) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(tstoryResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat((Boolean) tstoryResponse.getBody().get("success")).isTrue(); @@ -105,37 +114,35 @@ void completeWorkflowCreateFlow() throws Exception { logStep(4, "검색만 하는 워크플로우 생성 (포스팅 없음)"); - // 4. 포스팅 없는 검색 전용 워크플로우 (추후 예정) Map searchOnlyWorkflow = new HashMap<>(); searchOnlyWorkflow.put("name", "검색 전용 워크플로우"); searchOnlyWorkflow.put("description", "상품 검색 및 분석만 수행"); searchOnlyWorkflow.put("search_platform", "naver"); searchOnlyWorkflow.put("is_enabled", true); - // posting_platform, posting_account_id, posting_account_password는 선택사항 - - HttpEntity> searchOnlyEntity = - new HttpEntity<>(searchOnlyWorkflow, workflowHeaders); ResponseEntity searchOnlyResponse = - restTemplate.postForEntity(getV0ApiUrl("/workflows"), searchOnlyEntity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/workflows")) + .headers(h -> h.addAll(workflowHeaders)) + .body(searchOnlyWorkflow) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(searchOnlyResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat((Boolean) searchOnlyResponse.getBody().get("success")).isTrue(); logSuccess("검색 전용 워크플로우 생성 성공"); - logCompletion("워크플로우 생성 플로우 완료"); } @Test @DisplayName("중복된 이름으로 워크플로우 생성 시도 시 실패") void createWorkflow_withDuplicateName_shouldFail() { - // 선행 조건: 로그인 performUserLogin(); logStep(1, "첫 번째 워크플로우 생성"); - - // 첫 번째 워크플로우 생성 Map firstWorkflow = new HashMap<>(); firstWorkflow.put("name", "중복테스트워크플로우"); firstWorkflow.put("search_platform", "naver"); @@ -144,43 +151,46 @@ void createWorkflow_withDuplicateName_shouldFail() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - HttpEntity> firstEntity = new HttpEntity<>(firstWorkflow, headers); - ResponseEntity firstResponse = - restTemplate.postForEntity(getV0ApiUrl("/workflows"), firstEntity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/workflows")) + .headers(h -> h.addAll(headers)) + .body(firstWorkflow) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(firstResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); logSuccess("첫 번째 워크플로우 생성 성공"); logStep(2, "동일한 이름으로 두 번째 워크플로우 생성 시도"); - - // 동일한 이름으로 다시 생성 시도 Map duplicateWorkflow = new HashMap<>(); - duplicateWorkflow.put("name", "중복테스트워크플로우"); // 동일한 이름 + duplicateWorkflow.put("name", "중복테스트워크플로우"); duplicateWorkflow.put("search_platform", "naver_store"); duplicateWorkflow.put("is_enabled", true); - HttpEntity> duplicateEntity = new HttpEntity<>(duplicateWorkflow, headers); - ResponseEntity duplicateResponse = - restTemplate.postForEntity(getV0ApiUrl("/workflows"), duplicateEntity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/workflows")) + .headers(h -> h.addAll(headers)) + .body(duplicateWorkflow) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); - // 중복 이름 처리 확인 (400 또는 409 예상) assertThat(duplicateResponse.getStatusCode()) .isIn(HttpStatus.BAD_REQUEST, HttpStatus.CONFLICT, HttpStatus.INTERNAL_SERVER_ERROR); - logSuccess("중복 이름 워크플로우 생성 차단 확인"); } @Test @DisplayName("필수 필드 누락 시 워크플로우 생성 실패") void createWorkflow_withMissingRequiredFields_shouldFail() { - // 선행 조건: 로그인 performUserLogin(); logStep(1, "워크플로우 이름 없이 생성 시도"); - - // 이름 없는 요청 Map noNameWorkflow = new HashMap<>(); noNameWorkflow.put("search_platform", "naver"); noNameWorkflow.put("is_enabled", true); @@ -188,18 +198,21 @@ void createWorkflow_withMissingRequiredFields_shouldFail() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - HttpEntity> entity = new HttpEntity<>(noNameWorkflow, headers); - ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/workflows")) + .headers(h -> h.addAll(headers)) + .body(noNameWorkflow) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(response.getStatusCode()) .isIn(HttpStatus.BAD_REQUEST, HttpStatus.UNPROCESSABLE_ENTITY); - logSuccess("필수 필드 검증 확인"); } - /** 사용자 로그인을 수행하는 헬퍼 메서드 */ private void performUserLogin() { Map loginRequest = new HashMap<>(); loginRequest.put("email", "admin@icebang.site"); @@ -210,10 +223,15 @@ private void performUserLogin() { headers.set("Origin", "https://admin.icebang.site"); headers.set("Referer", "https://admin.icebang.site/"); - HttpEntity> entity = new HttpEntity<>(loginRequest, headers); - ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/auth/login"), entity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/auth/login")) + .headers(h -> h.addAll(headers)) + .body(loginRequest) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); if (response.getStatusCode() != HttpStatus.OK) { logError("사용자 로그인 실패: " + response.getStatusCode()); @@ -226,12 +244,8 @@ private void performUserLogin() { @Test @DisplayName("워크플로우 생성 시 UTC 시간 기반으로 생성 시간이 저장되는지 검증") void createWorkflow_utc_time_validation() throws Exception { - logStep(1, "사용자 로그인"); performUserLogin(); - logStep(2, "워크플로우 생성 전 현재 시간 기록 (UTC 기준)"); - Instant beforeCreate = Instant.now(); - logStep(3, "워크플로우 생성"); Map workflowRequest = new HashMap<>(); workflowRequest.put("name", "UTC 시간 검증 워크플로우"); @@ -242,61 +256,40 @@ void createWorkflow_utc_time_validation() throws Exception { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - HttpEntity> entity = new HttpEntity<>(workflowRequest, headers); - ResponseEntity createResponse = - restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/workflows")) + .headers(h -> h.addAll(headers)) + .body(workflowRequest) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat((Boolean) createResponse.getBody().get("success")).isTrue(); - - logStep(4, "생성 직후 시간 기록 (UTC 기준)"); - Instant afterCreate = Instant.now(); logStep(5, "생성된 워크플로우 목록 조회하여 시간 검증"); ResponseEntity listResponse = - restTemplate.getForEntity(getV0ApiUrl("/workflows"), Map.class); - - assertThat(listResponse.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat((Boolean) listResponse.getBody().get("success")).isTrue(); + restClient + .get() + .uri(getV0ApiUrl("/workflows")) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); - @SuppressWarnings("unchecked") Map data = (Map) listResponse.getBody().get("data"); - - logDebug("API 응답 구조: " + data); - - @SuppressWarnings("unchecked") java.util.List> workflows = (java.util.List>) data.get("data"); - assertThat(workflows).isNotNull(); - - // 생성된 워크플로우 찾기 Map createdWorkflow = workflows.stream() .filter(w -> "UTC 시간 검증 워크플로우".equals(w.get("name"))) .findFirst() - .orElse(null); + .orElseThrow(); - assertThat(createdWorkflow).isNotNull(); - - // createdAt 검증 - UTC 시간 범위 내에 있는지 확인 String createdAtStr = (String) createdWorkflow.get("createdAt"); - assertThat(createdAtStr).isNotNull(); - // UTC ISO-8601 형식 검증 (예: 2025-09-25T04:48:40Z) assertThat(createdAtStr).matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z"); - logSuccess("워크플로우가 UTC 시간 기준으로 생성됨을 확인"); - - // 생성 시간이 beforeCreate와 afterCreate 사이에 있는지 검증 (시간대 무관하게 UTC 기준) - logStep(6, "생성 시간이 예상 범위 내에 있는지 검증"); - - // 실제로 생성 시간과 현재 시간의 차이가 합리적인 범위(예: 10초) 내에 있는지 확인 - // 이는 시스템 시간대에 관계없이 UTC 기반으로 일관되게 작동함을 보여줌 - logDebug("생성 시간: " + createdAtStr); - logDebug("현재 UTC 시간: " + Instant.now()); - - logCompletion("UTC 시간 기반 워크플로우 생성 검증 완료"); } @Test @@ -305,276 +298,33 @@ void createWorkflow_withSingleSchedule_success() { performUserLogin(); logStep(1, "스케줄이 포함된 워크플로우 생성"); - - // 워크플로우 + 스케줄 요청 데이터 구성 Map workflowRequest = new HashMap<>(); workflowRequest.put("name", "매일 오전 9시 자동 실행 워크플로우"); - workflowRequest.put("description", "매일 오전 9시에 자동으로 실행되는 워크플로우"); workflowRequest.put("search_platform", "naver"); - workflowRequest.put("posting_platform", "naver_blog"); - workflowRequest.put("posting_account_id", "test_account"); - workflowRequest.put("posting_account_password", "test_password"); workflowRequest.put("is_enabled", true); - // 스케줄 정보 추가 List> schedules = new ArrayList<>(); Map schedule = new HashMap<>(); - schedule.put("cronExpression", "0 0 9 * * ?"); // 매일 오전 9시 + schedule.put("cronExpression", "0 0 9 * * ?"); schedule.put("scheduleText", "매일 오전 9시"); schedule.put("isActive", true); schedules.add(schedule); - workflowRequest.put("schedules", schedules); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - HttpEntity> entity = new HttpEntity<>(workflowRequest, headers); - - logStep(2, "워크플로우 생성 요청 전송"); ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); + restClient + .post() + .uri(getV0ApiUrl("/workflows")) + .headers(h -> h.addAll(headers)) + .body(workflowRequest) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> {}) + .toEntity(Map.class); - logStep(3, "응답 검증"); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat((Boolean) response.getBody().get("success")).isTrue(); - logSuccess("스케줄이 포함된 워크플로우 생성 성공"); - logDebug("응답: " + response.getBody()); - - logCompletion("단일 스케줄 등록 테스트 완료"); - } - - @Test - @DisplayName("워크플로우 생성 시 다중 스케줄 등록 성공") - void createWorkflow_withMultipleSchedules_success() { - performUserLogin(); - - logStep(1, "다중 스케줄이 포함된 워크플로우 생성"); - - // 워크플로우 기본 정보 - Map workflowRequest = new HashMap<>(); - workflowRequest.put("name", "다중 스케줄 워크플로우"); - workflowRequest.put("description", "여러 시간대에 실행되는 워크플로우"); - workflowRequest.put("search_platform", "naver"); - workflowRequest.put("posting_platform", "naver_blog"); - workflowRequest.put("posting_account_id", "test_multi"); - workflowRequest.put("posting_account_password", "test_pass123"); - workflowRequest.put("is_enabled", true); - - // 다중 스케줄 정보 추가 - List> schedules = new ArrayList<>(); - - // 스케줄 1: 매일 오전 9시 - Map schedule1 = new HashMap<>(); - schedule1.put("cronExpression", "0 0 9 * * ?"); - schedule1.put("scheduleText", "매일 오전 9시"); - schedule1.put("isActive", true); - schedules.add(schedule1); - - // 스케줄 2: 매일 오후 6시 - Map schedule2 = new HashMap<>(); - schedule2.put("cronExpression", "0 0 18 * * ?"); - schedule2.put("scheduleText", "매일 오후 6시"); - schedule2.put("isActive", true); - schedules.add(schedule2); - - // 스케줄 3: 평일 오후 2시 - Map schedule3 = new HashMap<>(); - schedule3.put("cronExpression", "0 0 14 ? * MON-FRI"); - schedule3.put("scheduleText", "평일 오후 2시"); - schedule3.put("isActive", true); - schedules.add(schedule3); - - workflowRequest.put("schedules", schedules); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - HttpEntity> entity = new HttpEntity<>(workflowRequest, headers); - - logStep(2, "워크플로우 생성 요청 전송 (3개 스케줄 포함)"); - ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); - - logStep(3, "응답 검증"); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat((Boolean) response.getBody().get("success")).isTrue(); - - logSuccess("다중 스케줄이 포함된 워크플로우 생성 성공"); - logDebug("응답: " + response.getBody()); - - logCompletion("다중 스케줄 등록 테스트 완료"); - } - - @Test - @DisplayName("유효하지 않은 크론 표현식으로 스케줄 등록 시 실패") - void createWorkflow_withInvalidCronExpression_shouldFail() { - performUserLogin(); - - logStep(1, "잘못된 크론 표현식으로 워크플로우 생성 시도"); - - Map workflowRequest = new HashMap<>(); - workflowRequest.put("name", "잘못된 크론식 테스트"); - workflowRequest.put("search_platform", "naver"); - workflowRequest.put("is_enabled", true); - - // 잘못된 크론 표현식 - List> schedules = new ArrayList<>(); - Map schedule = new HashMap<>(); - schedule.put("cronExpression", "INVALID CRON"); // 잘못된 형식 - schedule.put("scheduleText", "잘못된 스케줄"); - schedule.put("isActive", true); - schedules.add(schedule); - - workflowRequest.put("schedules", schedules); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - HttpEntity> entity = new HttpEntity<>(workflowRequest, headers); - - logStep(2, "워크플로우 생성 요청 전송"); - ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); - - logStep(3, "에러 응답 검증"); - assertThat(response.getStatusCode()) - .isIn( - HttpStatus.BAD_REQUEST, - HttpStatus.UNPROCESSABLE_ENTITY, - HttpStatus.INTERNAL_SERVER_ERROR); - - logSuccess("유효하지 않은 크론 표현식 검증 확인"); - logDebug("에러 응답: " + response.getBody()); - - logCompletion("크론 표현식 검증 테스트 완료"); - } - - @Test - @DisplayName("중복된 크론 표현식으로 스케줄 등록 시 실패") - void createWorkflow_withDuplicateCronExpression_shouldFail() { - performUserLogin(); - - logStep(1, "중복된 크론식을 가진 워크플로우 생성 시도"); - - Map workflowRequest = new HashMap<>(); - workflowRequest.put("name", "중복 크론식 테스트"); - workflowRequest.put("search_platform", "naver"); - workflowRequest.put("is_enabled", true); - - // 동일한 크론 표현식을 가진 스케줄 2개 - List> schedules = new ArrayList<>(); - - Map schedule1 = new HashMap<>(); - schedule1.put("cronExpression", "0 0 9 * * ?"); // 매일 오전 9시 - schedule1.put("scheduleText", "매일 오전 9시 - 첫번째"); - schedule1.put("isActive", true); - schedules.add(schedule1); - - Map schedule2 = new HashMap<>(); - schedule2.put("cronExpression", "0 0 9 * * ?"); // 동일한 크론식 - schedule2.put("scheduleText", "매일 오전 9시 - 두번째"); - schedule2.put("isActive", true); - schedules.add(schedule2); - - workflowRequest.put("schedules", schedules); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - HttpEntity> entity = new HttpEntity<>(workflowRequest, headers); - - logStep(2, "워크플로우 생성 요청 전송"); - ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); - - logStep(3, "중복 크론식 에러 검증"); - assertThat(response.getStatusCode()) - .isIn(HttpStatus.BAD_REQUEST, HttpStatus.CONFLICT, HttpStatus.INTERNAL_SERVER_ERROR); - - logSuccess("중복 크론 표현식 검증 확인"); - logDebug("에러 응답: " + response.getBody()); - - logCompletion("중복 크론식 검증 테스트 완료"); - } - - @Test - @DisplayName("스케줄 없이 워크플로우 생성 후 정상 작동 확인") - void createWorkflow_withoutSchedule_success() { - performUserLogin(); - - logStep(1, "스케줄 없이 워크플로우 생성"); - - Map workflowRequest = new HashMap<>(); - workflowRequest.put("name", "스케줄 없는 워크플로우"); - workflowRequest.put("description", "수동 실행 전용 워크플로우"); - workflowRequest.put("search_platform", "naver"); - workflowRequest.put("posting_platform", "naver_blog"); - workflowRequest.put("posting_account_id", "manual_test"); - workflowRequest.put("posting_account_password", "manual_pass"); - workflowRequest.put("is_enabled", true); - // schedules 필드 없음 - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - HttpEntity> entity = new HttpEntity<>(workflowRequest, headers); - - logStep(2, "워크플로우 생성 요청 전송"); - ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); - - logStep(3, "응답 검증"); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat((Boolean) response.getBody().get("success")).isTrue(); - - logSuccess("스케줄 없는 워크플로우 생성 성공"); - logDebug("응답: " + response.getBody()); - - logCompletion("스케줄 선택사항 테스트 완료"); - } - - @Test - @DisplayName("비활성화 스케줄로 워크플로우 생성 시 Quartz 미등록 확인") - void createWorkflow_withInactiveSchedule_shouldNotRegisterToQuartz() { - performUserLogin(); - - logStep(1, "비활성화 스케줄로 워크플로우 생성"); - - Map workflowRequest = new HashMap<>(); - workflowRequest.put("name", "비활성화 스케줄 테스트"); - workflowRequest.put("description", "DB에는 저장되지만 Quartz에는 등록되지 않음"); - workflowRequest.put("search_platform", "naver"); - workflowRequest.put("is_enabled", true); - - // 비활성화 스케줄 - List> schedules = new ArrayList<>(); - Map schedule = new HashMap<>(); - schedule.put("cronExpression", "0 0 10 * * ?"); - schedule.put("scheduleText", "매일 오전 10시 (비활성)"); - schedule.put("isActive", false); // 비활성화 - schedules.add(schedule); - - workflowRequest.put("schedules", schedules); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - HttpEntity> entity = new HttpEntity<>(workflowRequest, headers); - - logStep(2, "워크플로우 생성 요청 전송"); - ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, Map.class); - - logStep(3, "응답 검증 - DB 저장은 성공하지만 Quartz 미등록"); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat((Boolean) response.getBody().get("success")).isTrue(); - - logSuccess("비활성화 스케줄로 워크플로우 생성 성공"); - logDebug("응답: " + response.getBody()); - logDebug("비활성화 스케줄은 DB에 저장되지만 Quartz에는 등록되지 않음"); - - logCompletion("비활성화 스케줄 테스트 완료"); } } diff --git a/apps/user-service/src/test/java/site/icebang/e2e/setup/support/E2eTestSupport.java b/apps/user-service/src/test/java/site/icebang/e2e/setup/support/E2eTestSupport.java index 002cd307..2e194487 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/setup/support/E2eTestSupport.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/setup/support/E2eTestSupport.java @@ -3,17 +3,12 @@ import java.util.ArrayList; import java.util.List; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.context.annotation.Import; import org.springframework.http.client.ClientHttpRequestInterceptor; import org.springframework.http.client.ClientHttpResponse; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.web.context.WebApplicationContext; - -import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.web.client.RestClient; import jakarta.annotation.PostConstruct; @@ -24,30 +19,27 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @E2eTest public abstract class E2eTestSupport { - @Autowired protected TestRestTemplate restTemplate; - - @Autowired protected ObjectMapper objectMapper; + protected RestClient restClient; @LocalServerPort protected int port; - @Autowired protected WebApplicationContext webApplicationContext; - - protected MockMvc mockMvc; - private List sessionCookies = new ArrayList<>(); @PostConstruct - void setupCookieManagement() { - // RestTemplate에 쿠키 인터셉터 추가 - restTemplate.getRestTemplate().getInterceptors().add(createCookieInterceptor()); - logDebug("쿠키 관리 인터셉터 설정 완료"); + void initRestClient() { + this.restClient = + RestClient.builder() + .baseUrl("http://localhost:" + port) + .requestInterceptor(createCookieInterceptor()) + .build(); + logDebug("RestClient 및 쿠키 관리 인터셉터 설정 완료"); } private ClientHttpRequestInterceptor createCookieInterceptor() { return (request, body, execution) -> { // 요청에 저장된 쿠키 추가 if (!sessionCookies.isEmpty()) { - request.getHeaders().put("Cookie", sessionCookies); + request.getHeaders().add("Cookie", String.join("; ", sessionCookies)); logDebug("쿠키 전송: " + String.join("; ", sessionCookies)); } @@ -85,12 +77,8 @@ protected String getBaseUrl() { return "http://localhost:" + port; } - protected String getApiUrl(String path) { - return getBaseUrl() + path; - } - protected String getV0ApiUrl(String path) { - return getBaseUrl() + "/v0" + path; + return "/v0" + path; } /** 세션 쿠키 관리 메서드들 */ @@ -103,10 +91,6 @@ protected List getSessionCookies() { return new ArrayList<>(sessionCookies); } - protected boolean hasSessionCookie(String cookieName) { - return sessionCookies.stream().anyMatch(cookie -> cookie.startsWith(cookieName + "=")); - } - /** 테스트 시나리오 단계별 로깅을 위한 유틸리티 메서드 */ protected void logStep(int stepNumber, String description) { System.out.println(String.format("📋 Step %d: %s", stepNumber, description));