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 0e06f03e..30dc6373 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 RestClient restClient; + private final RestTemplate restTemplate; private final FastApiProperties fastApiProperties; @@ -24,7 +24,7 @@ public String ping() { log.info("Attempting to connect to FastAPI server at: {}", url); try { - return restClient.get().uri(url).retrieve().body(String.class); + return restTemplate.getForObject(url, 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 efe4f69c..6951bab8 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 RestClient}을 - * 사용하여 실제 통신을 수행하며, 모든 FastAPI 요청은 이 클래스의 {@code call} 메소드를 통해 이루어져야 합니다. + *

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

사용 예제:

* @@ -34,7 +34,7 @@ @RequiredArgsConstructor public class FastApiAdapter { - private final RestClient restClient; + private final RestTemplate restTemplate; private final FastApiProperties properties; /** @@ -47,32 +47,26 @@ public class FastApiAdapter { * @param method 사용할 HTTP 메소드 (예: HttpMethod.POST) * @param requestBody 요청에 담을 JSON 문자열 * @return 성공 시 API 응답 Body 문자열, 실패 시 null - * @see RestClient + * @see RestTemplate * @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 = - 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); - + restTemplate.exchange(fullUrl, method, requestEntity, 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 9e7a13dc..9369f887 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,11 +3,12 @@ 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.RestClient; +import org.springframework.web.client.RestTemplate; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; @@ -16,13 +17,13 @@ /** * 애플리케이션의 웹 관련 설정을 담당하는 Java 기반 설정 클래스입니다. * - *

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

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

주요 기능:

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

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

기본 {@code RestTemplateBuilder}를 사용하되, 커넥션 및 읽기 타임아웃을 각각 30초로 명시적으로 설정하기 위해 {@code + * SimpleClientHttpRequestFactory}를 구성하여 주입합니다. 이렇게 생성된 RestTemplate 빈은 애플리케이션의 다른 컴포넌트에서 주입받아 외부 + * 시스템과의 HTTP 통신에 사용됩니다. * - * @return 타임아웃이 설정된 RestClient 인스턴스 + * @param builder Spring Boot가 자동으로 구성해주는 RestTemplateBuilder 객체 + * @return 타임아웃이 설정된 RestTemplate 인스턴스 + * @see RestTemplate + * @see RestTemplateBuilder * @since v0.1.0 */ @Bean - public RestClient restClient() { + public RestTemplate restTemplate(RestTemplateBuilder builder) { + // 1. SimpleClientHttpRequestFactory 객체를 직접 생성 SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout(Duration.ofSeconds(30)); - requestFactory.setReadTimeout(Duration.ofSeconds(30)); - return RestClient.builder().requestFactory(requestFactory).build(); + // 2. 타임아웃 설정 (이 메서드들은 deprecated 아님) + requestFactory.setConnectTimeout(Duration.ofSeconds(30000)); + requestFactory.setReadTimeout(Duration.ofSeconds(30000)); + + // 3. 빌더에 직접 생성한 requestFactory를 설정 + return 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 8ddda74d..29e5857c 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,10 +2,8 @@ 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 eeb49627..afdff08c 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,6 +14,11 @@ 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", @@ -41,15 +46,11 @@ void createSchedule_success() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(scheduleRequest, headers); + ResponseEntity response = - restClient - .post() - .uri(getV0ApiUrl("/workflows/" + workflowId + "/schedules")) - .headers(h -> h.addAll(headers)) - .body(scheduleRequest) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity( + getV0ApiUrl("/workflows/" + workflowId + "/schedules"), entity, Map.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat((Boolean) response.getBody().get("success")).isTrue(); @@ -72,14 +73,10 @@ void createSchedule_invalidCron_shouldFail() { headers.setContentType(MediaType.APPLICATION_JSON); ResponseEntity response = - restClient - .post() - .uri(getV0ApiUrl("/workflows/" + workflowId + "/schedules")) - .headers(h -> h.addAll(headers)) - .body(scheduleRequest) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity( + getV0ApiUrl("/workflows/" + workflowId + "/schedules"), + new HttpEntity<>(scheduleRequest, headers), + Map.class); assertThat(response.getStatusCode()) .isIn( @@ -105,14 +102,13 @@ void createInactiveSchedule_shouldNotRegisterQuartz() { headers.setContentType(MediaType.APPLICATION_JSON); ResponseEntity response = - restClient - .post() - .uri(getV0ApiUrl("/workflows/" + workflowId + "/schedules")) - .headers(h -> h.addAll(headers)) - .body(scheduleRequest) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity( + getV0ApiUrl("/workflows/" + workflowId + "/schedules"), + new HttpEntity<>(scheduleRequest, headers), + Map.class); + + System.out.println("==== response body ===="); + System.out.println(response.getBody()); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat((Boolean) response.getBody().get("success")).isTrue(); @@ -126,23 +122,21 @@ 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 = - restClient - .get() - .uri(getV0ApiUrl("/workflows/" + workflowId + "/schedules")) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.getForEntity( + getV0ApiUrl("/workflows/" + workflowId + "/schedules"), 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() + "개"); @@ -153,6 +147,7 @@ void listSchedules_success() { void updateSchedule_toggleActive_success() { performUserLogin(); Long workflowId = createWorkflow("스케줄 수정용 워크플로우"); + Long scheduleId = addSchedule(workflowId, "0 0 12 * * ?", "정오 실행", true); logStep(1, "스케줄 비활성화 요청"); @@ -164,14 +159,9 @@ void updateSchedule_toggleActive_success() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - restClient - .put() - .uri(getV0ApiUrl("/workflows/" + workflowId + "/schedules/" + scheduleId)) - .headers(h -> h.addAll(headers)) - .body(updateRequest) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toBodilessEntity(); + restTemplate.put( + getV0ApiUrl("/workflows/" + workflowId + "/schedules/" + scheduleId), + new HttpEntity<>(updateRequest, headers)); logSuccess("스케줄 수정 및 비활성화 성공"); } @@ -181,19 +171,16 @@ void updateSchedule_toggleActive_success() { void deleteSchedule_success() { performUserLogin(); Long workflowId = createWorkflow("스케줄 삭제용 워크플로우"); + Long scheduleId = addSchedule(workflowId, "0 0 7 * * ?", "매일 오전 7시", true); logStep(1, "스케줄 삭제 요청"); - restClient - .delete() - .uri(getV0ApiUrl("/workflows/" + workflowId + "/schedules/" + scheduleId)) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toBodilessEntity(); + restTemplate.delete(getV0ApiUrl("/workflows/" + workflowId + "/schedules/" + scheduleId)); logSuccess("스케줄 삭제 성공 (논리 삭제)"); } + /** 워크플로우 생성 헬퍼 */ private Long createWorkflow(String name) { Map workflowRequest = new HashMap<>(); workflowRequest.put("name", name); @@ -204,24 +191,13 @@ private Long createWorkflow(String name) { headers.setContentType(MediaType.APPLICATION_JSON); ResponseEntity response = - restClient - .post() - .uri(getV0ApiUrl("/workflows")) - .headers(h -> h.addAll(headers)) - .body(workflowRequest) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity( + getV0ApiUrl("/workflows"), new HttpEntity<>(workflowRequest, headers), Map.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); ResponseEntity listResponse = - restClient - .get() - .uri(getV0ApiUrl("/workflows")) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.getForEntity(getV0ApiUrl("/workflows"), Map.class); Map body = listResponse.getBody(); List> workflows = @@ -234,6 +210,7 @@ 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); @@ -244,14 +221,10 @@ private Long addSchedule(Long workflowId, String cron, String text, boolean acti headers.setContentType(MediaType.APPLICATION_JSON); ResponseEntity response = - restClient - .post() - .uri(getV0ApiUrl("/workflows/" + workflowId + "/schedules")) - .headers(h -> h.addAll(headers)) - .body(scheduleRequest) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity( + getV0ApiUrl("/workflows/" + workflowId + "/schedules"), + new HttpEntity<>(scheduleRequest, headers), + Map.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); @@ -259,6 +232,7 @@ 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"); @@ -269,15 +243,10 @@ 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 = - restClient - .post() - .uri(getV0ApiUrl("/auth/login")) - .headers(h -> h.addAll(headers)) - .body(loginRequest) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity(getV0ApiUrl("/auth/login"), entity, 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 71b1feb6..636b3455 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,6 +30,7 @@ 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"); @@ -39,15 +40,10 @@ 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 = - restClient - .post() - .uri(getV0ApiUrl("/auth/login")) - .headers(h -> h.addAll(loginHeaders)) - .body(loginRequest) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity(getV0ApiUrl("/auth/login"), loginEntity, Map.class); assertThat(loginResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) loginResponse.getBody().get("success")).isTrue(); @@ -57,13 +53,11 @@ void completeUserLogoutFlow_shouldFailBecauseApiNotImplemented() throws Exceptio logStep(2, "로그인 상태에서 보호된 리소스 접근 확인"); + // 2. 로그인된 상태에서 본인 프로필 조회로 인증 상태 확인 + // /v0/users/me는 인증된 사용자만 접근 가능한 일반적인 API + // 쿠키는 인터셉터에 의해 자동으로 전송됨 ResponseEntity beforeLogoutResponse = - restClient - .get() - .uri(getV0ApiUrl("/users/me")) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); assertThat(beforeLogoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) beforeLogoutResponse.getBody().get("success")).isTrue(); @@ -73,21 +67,17 @@ 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 = - restClient - .post() - .uri(getV0ApiUrl("/auth/logout")) - .headers(h -> h.addAll(logoutHeaders)) - .body(new HashMap<>()) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity(getV0ApiUrl("/auth/logout"), logoutEntity, Map.class); logStep(4, "로그아웃 응답 검증"); assertThat(logoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); @@ -96,16 +86,15 @@ void completeUserLogoutFlow_shouldFailBecauseApiNotImplemented() throws Exceptio logSuccess("로그아웃 API 호출 성공"); logStep(5, "로그아웃 후 인증 무효화 확인"); + + // 로그아웃 후 세션 쿠키 상태 확인 logDebug("로그아웃 후 세션 쿠키: " + getSessionCookies()); + // 5. 로그아웃 후 동일한 프로필 API 접근 시 인증 실패 확인 ResponseEntity afterLogoutResponse = - restClient - .get() - .uri(getV0ApiUrl("/users/me")) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); + // 핵심 검증: 로그아웃 후에는 인증 실패로 401 또는 403 응답이어야 함 assertThat(afterLogoutResponse.getStatusCode()) .withFailMessage( "로그아웃 후 프로필 접근이 차단되어야 합니다. 현재 상태코드: %s", afterLogoutResponse.getStatusCode()) @@ -114,28 +103,51 @@ 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("로그아웃 API 호출 중 예외 발생: " + ex.getMessage()); - fail("로그아웃 API 호출 실패: " + ex.getMessage()); + logError("예상치 못한 오류 발생: " + ex.getClass().getSimpleName()); + logError("에러 메시지: " + ex.getMessage()); + + // 기타 예상치 못한 에러도 기록 + fail("로그아웃 API 호출 중 예상치 못한 오류 발생: " + ex.getMessage()); } } @SuppressWarnings("unchecked") - @Test @DisplayName("일반 사용자 로그아웃 플로우 테스트") void regularUserLogoutFlow() throws Exception { logStep(1, "일반 사용자 로그인"); + + // 세션 쿠키 초기화 clearSessionCookies(); + + // 일반 사용자 로그인 수행 performRegularUserLogin(); logStep(2, "일반 사용자 권한으로 프로필 조회"); + + // 로그인된 상태에서 프로필 조회 ResponseEntity beforeLogoutResponse = - restClient - .get() - .uri(getV0ApiUrl("/users/me")) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); assertThat(beforeLogoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) beforeLogoutResponse.getBody().get("success")).isTrue(); @@ -143,33 +155,26 @@ 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 = - restClient - .post() - .uri(getV0ApiUrl("/auth/logout")) - .headers(h -> h.addAll(logoutHeaders)) - .body(new HashMap<>()) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity(getV0ApiUrl("/auth/logout"), logoutEntity, Map.class); assertThat(logoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); logSuccess("일반 사용자 로그아웃 성공"); logStep(4, "로그아웃 후 접근 권한 무효화 확인"); + ResponseEntity afterLogoutResponse = - restClient - .get() - .uri(getV0ApiUrl("/users/me")) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); assertThat(afterLogoutResponse.getStatusCode()) .isIn(HttpStatus.UNAUTHORIZED, HttpStatus.FORBIDDEN); @@ -177,31 +182,27 @@ void regularUserLogoutFlow() throws Exception { logSuccess("일반 사용자 로그아웃 후 접근 차단 확인"); logCompletion("일반 사용자 로그아웃 플로우"); - } catch (Exception ex) { - logError("로그아웃 API 오류: " + ex.getMessage()); - fail("로그아웃 API 호출 중 오류 발생: " + ex.getMessage()); + } catch (org.springframework.web.client.HttpClientErrorException.NotFound ex) { + logError("예상된 실패: 로그아웃 API 미구현"); + fail("로그아웃 API가 구현되지 않았습니다: " + ex.getMessage()); } } + /** 일반 사용자 로그인을 수행하는 헬퍼 메서드 - 관리자가 아닌 콘텐츠팀장으로 로그인 */ private void performRegularUserLogin() { Map loginRequest = new HashMap<>(); - loginRequest.put("email", "admin@icebang.site"); - loginRequest.put("password", "qwer1234!A"); + loginRequest.put("email", "viral.jung@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 = - restClient - .post() - .uri(getV0ApiUrl("/auth/login")) - .headers(h -> h.addAll(headers)) - .body(loginRequest) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity(getV0ApiUrl("/auth/login"), entity, Map.class); if (response.getStatusCode() != HttpStatus.OK) { logError("일반 사용자 로그인 실패: " + response.getStatusCode()); @@ -211,4 +212,31 @@ 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 b115e025..fd3eee60 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,7 +12,6 @@ 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( @@ -22,7 +21,6 @@ }, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) @DisplayName("사용자 등록 플로우 E2E 테스트") -@E2eTest class UserRegistrationFlowE2eTest extends E2eTestSupport { @SuppressWarnings("unchecked") @@ -31,6 +29,7 @@ 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"); @@ -40,15 +39,10 @@ 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 = - restClient - .post() - .uri(getV0ApiUrl("/auth/login")) - .headers(h -> h.addAll(loginHeaders)) - .body(loginRequest) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity(getV0ApiUrl("/auth/login"), loginEntity, Map.class); assertThat(loginResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) loginResponse.getBody().get("success")).isTrue(); @@ -58,13 +52,9 @@ void completeUserRegistrationFlow() throws Exception { logStep(2, "조직 목록 조회 (인증된 상태)"); + // 2. 조직 목록 조회 (로그인 후 가능, 쿠키 자동 전송) ResponseEntity organizationsResponse = - restClient - .get() - .uri(getV0ApiUrl("/organizations")) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.getForEntity(getV0ApiUrl("/organizations"), Map.class); assertThat(organizationsResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) organizationsResponse.getBody().get("success")).isTrue(); @@ -74,13 +64,9 @@ void completeUserRegistrationFlow() throws Exception { logStep(3, "부서 및 각종 데이터 조회 (특정 조직 옵션)"); + // 3. 특정 조직의 부서, 직급, 역할 데이터 조회 ResponseEntity optionsResponse = - restClient - .get() - .uri(getV0ApiUrl("/organizations/1/options")) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.getForEntity(getV0ApiUrl("/organizations/1/options"), Map.class); assertThat(optionsResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) optionsResponse.getBody().get("success")).isTrue(); @@ -92,6 +78,7 @@ void completeUserRegistrationFlow() throws Exception { logSuccess("부서 및 각종 데이터 조회 성공"); + // 조회된 데이터 로깅 (ERP 관점에서 중요한 메타데이터) System.out.println("📊 조회된 메타데이터:"); System.out.println( " - 부서: " + ((java.util.List) optionData.get("departments")).size() + "개"); @@ -101,13 +88,14 @@ 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(); @@ -115,15 +103,11 @@ 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 = - restClient - .post() - .uri(getV0ApiUrl("/auth/register")) - .headers(h -> h.addAll(registerHeaders)) - .body(registerRequest) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity(getV0ApiUrl("/auth/register"), registerEntity, Map.class); assertThat(registerResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat((Boolean) registerResponse.getBody().get("success")).isTrue(); @@ -147,15 +131,10 @@ void loginWithInvalidCredentials_shouldFail() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(wrongPasswordRequest, headers); + ResponseEntity response = - restClient - .post() - .uri(getV0ApiUrl("/auth/login")) - .headers(h -> h.addAll(headers)) - .body(wrongPasswordRequest) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity(getV0ApiUrl("/auth/login"), entity, Map.class); assertThat(response.getStatusCode()).isIn(HttpStatus.UNAUTHORIZED, HttpStatus.FORBIDDEN); logSuccess("잘못된 자격증명 로그인 차단 확인"); @@ -166,15 +145,11 @@ void loginWithInvalidCredentials_shouldFail() { nonExistentUserRequest.put("email", "nonexistent@example.com"); nonExistentUserRequest.put("password", "anypassword"); + HttpEntity> nonExistentEntity = + new HttpEntity<>(nonExistentUserRequest, headers); + ResponseEntity nonExistentResponse = - restClient - .post() - .uri(getV0ApiUrl("/auth/login")) - .headers(h -> h.addAll(headers)) - .body(nonExistentUserRequest) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity(getV0ApiUrl("/auth/login"), nonExistentEntity, Map.class); assertThat(nonExistentResponse.getStatusCode()) .isIn(HttpStatus.UNAUTHORIZED, HttpStatus.FORBIDDEN); @@ -185,29 +160,23 @@ void loginWithInvalidCredentials_shouldFail() { @Test @DisplayName("중복 이메일로 사용자 등록 시도 시 실패") void register_withDuplicateEmail_shouldFail() { + // 선행 조건: 관리자 로그인 performAdminLogin(); + + // 첫 번째 사용자 등록 (실제 API 데이터 기반) registerUser("first.user@example.com", "첫번째사용자"); logStep(1, "중복 이메일로 회원가입 시도"); + // 조직 및 옵션 정보 다시 조회 (실제 값 사용) ResponseEntity organizationsResponse = - restClient - .get() - .uri(getV0ApiUrl("/organizations")) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.getForEntity(getV0ApiUrl("/organizations"), Map.class); java.util.List> organizations = (java.util.List>) organizationsResponse.getBody().get("data"); Integer orgId = (Integer) organizations.getFirst().get("id"); ResponseEntity optionsResponse = - restClient - .get() - .uri(getV0ApiUrl("/organizations/" + orgId + "/options")) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.getForEntity(getV0ApiUrl("/organizations/" + orgId + "/options"), Map.class); Map optionData = (Map) optionsResponse.getBody().get("data"); java.util.List> departments = @@ -221,9 +190,10 @@ 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); @@ -232,21 +202,19 @@ void register_withDuplicateEmail_shouldFail() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(duplicateRequest, headers); + ResponseEntity response = - restClient - .post() - .uri(getV0ApiUrl("/auth/register")) - .headers(h -> h.addAll(headers)) - .body(duplicateRequest) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity(getV0ApiUrl("/auth/register"), entity, 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"); @@ -255,15 +223,10 @@ private void performAdminLogin() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(loginRequest, headers); + ResponseEntity response = - restClient - .post() - .uri(getV0ApiUrl("/auth/login")) - .headers(h -> h.addAll(headers)) - .body(loginRequest) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity(getV0ApiUrl("/auth/login"), entity, Map.class); if (response.getStatusCode() != HttpStatus.OK) { logError("관리자 로그인 실패: " + response.getStatusCode()); @@ -274,6 +237,7 @@ private void performAdminLogin() { logDebug("세션 쿠키: " + getSessionCookies()); } + /** 사용자 등록을 수행하는 헬퍼 메서드 */ private void registerUser(String email, String name) { Map registerRequest = new HashMap<>(); registerRequest.put("name", name); @@ -287,13 +251,7 @@ private void registerUser(String email, String name) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - restClient - .post() - .uri(getV0ApiUrl("/auth/register")) - .headers(h -> h.addAll(headers)) - .body(registerRequest) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toBodilessEntity(); + HttpEntity> entity = new HttpEntity<>(registerRequest, headers); + restTemplate.postForEntity(getV0ApiUrl("/auth/register"), entity, Map.class); } } 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 3a37445f..08088a8c 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,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.time.Instant; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -31,6 +32,7 @@ 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"); @@ -40,15 +42,10 @@ 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 = - restClient - .post() - .uri(getV0ApiUrl("/auth/login")) - .headers(h -> h.addAll(loginHeaders)) - .body(loginRequest) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity(getV0ApiUrl("/auth/login"), loginEntity, Map.class); assertThat(loginResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) loginResponse.getBody().get("success")).isTrue(); @@ -58,6 +55,7 @@ void completeWorkflowCreateFlow() throws Exception { logStep(2, "네이버 블로그 워크플로우 생성"); + // 2. 네이버 블로그 워크플로우 생성 Map naverBlogWorkflow = new HashMap<>(); naverBlogWorkflow.put("name", "상품 분석 및 네이버 블로그 자동 발행"); naverBlogWorkflow.put("description", "키워드 검색부터 상품 분석 후 네이버 블로그 발행까지의 자동화 프로세스"); @@ -70,15 +68,11 @@ void completeWorkflowCreateFlow() throws Exception { HttpHeaders workflowHeaders = new HttpHeaders(); workflowHeaders.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> naverEntity = + new HttpEntity<>(naverBlogWorkflow, workflowHeaders); + ResponseEntity naverResponse = - restClient - .post() - .uri(getV0ApiUrl("/workflows")) - .headers(h -> h.addAll(workflowHeaders)) - .body(naverBlogWorkflow) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity(getV0ApiUrl("/workflows"), naverEntity, Map.class); assertThat(naverResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat((Boolean) naverResponse.getBody().get("success")).isTrue(); @@ -87,6 +81,7 @@ void completeWorkflowCreateFlow() throws Exception { logStep(3, "티스토리 블로그 워크플로우 생성 (블로그명 포함)"); + // 3. 티스토리 블로그 워크플로우 생성 (블로그명 필수) Map tstoryWorkflow = new HashMap<>(); tstoryWorkflow.put("name", "티스토리 자동 발행 워크플로우"); tstoryWorkflow.put("description", "티스토리 블로그 자동 포스팅"); @@ -94,18 +89,14 @@ 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 = - restClient - .post() - .uri(getV0ApiUrl("/workflows")) - .headers(h -> h.addAll(workflowHeaders)) - .body(tstoryWorkflow) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity(getV0ApiUrl("/workflows"), tstoryEntity, Map.class); assertThat(tstoryResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat((Boolean) tstoryResponse.getBody().get("success")).isTrue(); @@ -114,35 +105,37 @@ 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 = - restClient - .post() - .uri(getV0ApiUrl("/workflows")) - .headers(h -> h.addAll(workflowHeaders)) - .body(searchOnlyWorkflow) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity(getV0ApiUrl("/workflows"), searchOnlyEntity, 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"); @@ -151,46 +144,43 @@ void createWorkflow_withDuplicateName_shouldFail() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> firstEntity = new HttpEntity<>(firstWorkflow, headers); + ResponseEntity firstResponse = - restClient - .post() - .uri(getV0ApiUrl("/workflows")) - .headers(h -> h.addAll(headers)) - .body(firstWorkflow) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity(getV0ApiUrl("/workflows"), firstEntity, 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 = - restClient - .post() - .uri(getV0ApiUrl("/workflows")) - .headers(h -> h.addAll(headers)) - .body(duplicateWorkflow) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity(getV0ApiUrl("/workflows"), duplicateEntity, 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); @@ -198,21 +188,18 @@ void createWorkflow_withMissingRequiredFields_shouldFail() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(noNameWorkflow, headers); + ResponseEntity response = - restClient - .post() - .uri(getV0ApiUrl("/workflows")) - .headers(h -> h.addAll(headers)) - .body(noNameWorkflow) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, 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"); @@ -223,15 +210,10 @@ 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 = - restClient - .post() - .uri(getV0ApiUrl("/auth/login")) - .headers(h -> h.addAll(headers)) - .body(loginRequest) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity(getV0ApiUrl("/auth/login"), entity, Map.class); if (response.getStatusCode() != HttpStatus.OK) { logError("사용자 로그인 실패: " + response.getStatusCode()); @@ -244,8 +226,12 @@ 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 시간 검증 워크플로우"); @@ -256,40 +242,61 @@ void createWorkflow_utc_time_validation() throws Exception { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> entity = new HttpEntity<>(workflowRequest, headers); + ResponseEntity createResponse = - restClient - .post() - .uri(getV0ApiUrl("/workflows")) - .headers(h -> h.addAll(headers)) - .body(workflowRequest) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.postForEntity(getV0ApiUrl("/workflows"), entity, 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 = - restClient - .get() - .uri(getV0ApiUrl("/workflows")) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + restTemplate.getForEntity(getV0ApiUrl("/workflows"), Map.class); + + assertThat(listResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat((Boolean) listResponse.getBody().get("success")).isTrue(); + @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() - .orElseThrow(); + .orElse(null); + 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 @@ -298,33 +305,276 @@ 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 * * ?"); + schedule.put("cronExpression", "0 0 9 * * ?"); // 매일 오전 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 = - restClient - .post() - .uri(getV0ApiUrl("/workflows")) - .headers(h -> h.addAll(headers)) - .body(workflowRequest) - .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> {}) - .toEntity(Map.class); + 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_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 2e194487..002cd307 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,12 +3,17 @@ 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.web.client.RestClient; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.context.WebApplicationContext; + +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; @@ -19,27 +24,30 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @E2eTest public abstract class E2eTestSupport { - protected RestClient restClient; + @Autowired protected TestRestTemplate restTemplate; + + @Autowired protected ObjectMapper objectMapper; @LocalServerPort protected int port; + @Autowired protected WebApplicationContext webApplicationContext; + + protected MockMvc mockMvc; + private List sessionCookies = new ArrayList<>(); @PostConstruct - void initRestClient() { - this.restClient = - RestClient.builder() - .baseUrl("http://localhost:" + port) - .requestInterceptor(createCookieInterceptor()) - .build(); - logDebug("RestClient 및 쿠키 관리 인터셉터 설정 완료"); + void setupCookieManagement() { + // RestTemplate에 쿠키 인터셉터 추가 + restTemplate.getRestTemplate().getInterceptors().add(createCookieInterceptor()); + logDebug("쿠키 관리 인터셉터 설정 완료"); } private ClientHttpRequestInterceptor createCookieInterceptor() { return (request, body, execution) -> { // 요청에 저장된 쿠키 추가 if (!sessionCookies.isEmpty()) { - request.getHeaders().add("Cookie", String.join("; ", sessionCookies)); + request.getHeaders().put("Cookie", sessionCookies); logDebug("쿠키 전송: " + String.join("; ", sessionCookies)); } @@ -77,8 +85,12 @@ protected String getBaseUrl() { return "http://localhost:" + port; } + protected String getApiUrl(String path) { + return getBaseUrl() + path; + } + protected String getV0ApiUrl(String path) { - return "/v0" + path; + return getBaseUrl() + "/v0" + path; } /** 세션 쿠키 관리 메서드들 */ @@ -91,6 +103,10 @@ 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));