diff --git a/scripts/after.js b/scripts/after.js new file mode 100644 index 0000000..10581de --- /dev/null +++ b/scripts/after.js @@ -0,0 +1,53 @@ +import http from 'k6/http'; +import { sleep, check } from 'k6'; +import { uuidv4 } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js'; + +export const options = { + stages: [ + { duration: '30s', target: 3000 }, + { duration: '2m30s', target: 3000 }, + { duration: '10s', target: 0 }, + ], +}; + +const PROGRAM_ID = '550e8400-e29b-41d4-a716-446655440000'; +const BASE_URL = 'http://localhost:8085'; + +const userIdsByVU = {}; + +export default function () { + if (!userIdsByVU[__VU]) { + userIdsByVU[__VU] = uuidv4(); + + http.post( + `${BASE_URL}/api/v1/queues/programs/${PROGRAM_ID}`, + null, + { headers: { 'X-User-Id': userIdsByVU[__VU] }, tags: { name: 'enqueue' } } + ); + } + + const userId = userIdsByVU[__VU]; + + const res = http.get( + `${BASE_URL}/api/v1/queues/programs/${PROGRAM_ID}`, + { headers: { 'X-User-Id': userId }, tags: { name: 'polling' } } + ); + + check(res, { 'polling 200': (r) => r.status === 200 }); + + // After: retryAfterMs 따라 동적 sleep + let sleepSec = 3; + if (res.status === 200) { + try { + const body = JSON.parse(res.body); + const retryAfterMs = body.data?.retryAfterMs; + if (retryAfterMs != null) { + sleepSec = retryAfterMs / 1000; + } + } catch (e) { + // 파싱 실패 시 기본값 + } + } + + sleep(sleepSec); +} diff --git a/scripts/before.js b/scripts/before.js new file mode 100644 index 0000000..5554b82 --- /dev/null +++ b/scripts/before.js @@ -0,0 +1,43 @@ +import http from 'k6/http'; +import { sleep, check } from 'k6'; +import { uuidv4 } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js'; + +export const options = { + stages: [ + { duration: '30s', target: 3000 }, + { duration: '2m30s', target: 3000 }, + { duration: '10s', target: 0 }, + ], +}; + +const PROGRAM_ID = '550e8400-e29b-41d4-a716-446655440000'; +const BASE_URL = 'http://localhost:8085'; + +// 각 VU 마다 고유 userId 유지 +const userIdsByVU = {}; + +export default function () { + // 이 VU 의 userId (없으면 새로 만들고 enqueue) + if (!userIdsByVU[__VU]) { + userIdsByVU[__VU] = uuidv4(); + + // enqueue (한 번만) + http.post( + `${BASE_URL}/api/v1/queues/programs/${PROGRAM_ID}`, + null, + { headers: { 'X-User-Id': userIdsByVU[__VU] }, tags: { name: 'enqueue' } } + ); + } + + const userId = userIdsByVU[__VU]; + + // Polling + const res = http.get( + `${BASE_URL}/api/v1/queues/programs/${PROGRAM_ID}`, + { headers: { 'X-User-Id': userId }, tags: { name: 'polling' } } + ); + + check(res, { 'polling 200': (r) => r.status === 200 }); + + sleep(3); // Before: 3초 고정 +} diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index 813c805..c13f5aa 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -25,11 +25,41 @@ "tokenId": "...", "status": "WAITING", "issuedAt": "...", - "position": 50 + "position": 50, + "retryAfterMs": 1000 } } ---- +=== Adaptive Polling (`retryAfterMs`) + +대기 정보 응답에 포함되는 `retryAfterMs` 필드는 클라이언트가 다음 폴링까지 대기해야 할 시간(ms)을 서버가 권장하는 값이다. + +순번에 따라 차등 적용된다: + +[cols="1,1,2", options="header"] +|=== +| 순번 구간 | 권장 간격 | 비고 + +| ≤ 100 (임박) +| 1,000ms +| Jitter 없음 (빠른 알림 우선) + +| ≤ 1,000 (중간) +| 5,000ms ± 300ms +| Jitter 적용 + +| ≤ 10,000 (멀리) +| 15,000ms ± 500ms +| Jitter 적용 + +| > 10,000 (매우 멀리) +| 30,000ms ± 4,000ms +| Jitter 적용 +|=== + +ADMITTED 등 큐에서 빠진 상태의 토큰 조회 시 `retryAfterMs` 는 응답에서 제외된다 (폴링 종료 신호). + == 공통 에러 응답 === 인증 실패 (401) @@ -58,7 +88,7 @@ operation::queue-token-get[snippets='http-request,path-parameters,request-header === ADMITTED 상태 응답 -큐에서 빠진 후 (입장 가능) 응답. +큐에서 빠진 후 (입장 가능) 응답. `retryAfterMs` 는 포함되지 않으며 클라이언트는 폴링을 중단하고 `entryToken` 으로 입장한다. operation::queue-token-get-admitted[snippets='http-response,response-fields'] diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/presentation/PollingIntervalPolicy.java b/src/main/java/com/firstticket/queueservice/queuetoken/presentation/PollingIntervalPolicy.java new file mode 100644 index 0000000..f53852c --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/presentation/PollingIntervalPolicy.java @@ -0,0 +1,73 @@ +package com.firstticket.queueservice.queuetoken.presentation; + +import org.springframework.stereotype.Component; +import java.util.concurrent.ThreadLocalRandom; + +/** + * 사용자 순번에 따른 폴링 간격 결정 정책. + * + *

Server-driven Adaptive Polling 의 핵심 컴포넌트. + * 응답에 포함될 {@code retryAfterMs} 값을 계산한다. + * + *

정책

+ * + * + *

Jitter 적용으로 같은 구간 사용자들의 동시 요청 분산 (Thundering Herd 방지). + */ +@Component +public class PollingIntervalPolicy { + + private static final int IMMINENT_THRESHOLD = 100; + private static final int NEAR_THRESHOLD = 1000; + private static final int FAR_THRESHOLD = 10000; + + private static final int IMMINENT_INTERVAL_MS = 1000; + private static final int NEAR_INTERVAL_MS = 5000; + private static final int FAR_INTERVAL_MS = 15000; + private static final int VERY_FAR_INTERVAL_MS = 30000; + + private static final int NEAR_JITTER_MS = 300; + private static final int FAR_JITTER_MS = 500; + private static final int VERY_FAR_JITTER_MS = 4000; + + /** + * 사용자 순번에 따른 다음 폴링 간격을 계산한다. + * + * @param position 1-based 순번. {@code null} 이면 큐에서 빠진 상태 (ADMITTED 등). + * @return 다음 폴링까지 대기 시간 (ms). {@code null} 이면 폴링 불필요. + */ + public Integer nextRetryAfterMs(Long position) { + if (position == null) { + return null; + } + + int interval; + int jitter; + + if (position <= IMMINENT_THRESHOLD) { + interval = IMMINENT_INTERVAL_MS; + jitter = 0; + } else if (position <= NEAR_THRESHOLD) { + interval = NEAR_INTERVAL_MS; + jitter = NEAR_JITTER_MS; + } else if (position <= FAR_THRESHOLD) { + interval = FAR_INTERVAL_MS; + jitter = FAR_JITTER_MS; + } else { + interval = VERY_FAR_INTERVAL_MS; + jitter = VERY_FAR_JITTER_MS; + } + + int jitterValue = jitter > 0 + ? ThreadLocalRandom.current().nextInt(-jitter, jitter + 1) + : 0; + + return interval + jitterValue; + } + +} diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/presentation/QueueTokenController.java b/src/main/java/com/firstticket/queueservice/queuetoken/presentation/QueueTokenController.java index 5854d48..2b06889 100644 --- a/src/main/java/com/firstticket/queueservice/queuetoken/presentation/QueueTokenController.java +++ b/src/main/java/com/firstticket/queueservice/queuetoken/presentation/QueueTokenController.java @@ -23,6 +23,7 @@ public class QueueTokenController { private final QueueTokenService queueTokenService; + private final PollingIntervalPolicy pollingIntervalPolicy; /** * 대기열 진입. @@ -38,9 +39,10 @@ public ResponseEntity> issueToken( programId ); QueueTokenResult result = queueTokenService.issueToken(command); + Integer retryAfterMs = pollingIntervalPolicy.nextRetryAfterMs(result.position()); return ApiResponse.success( QueueSuccessCode.QUEUE_TOKEN_ISSUED, - QueueTokenResponse.from(result) + QueueTokenResponse.from(result, retryAfterMs) ); } @@ -55,9 +57,11 @@ public ResponseEntity> getToken( ) { GetQueueTokenQuery query = GetQueueTokenQuery.of(AuthContext.getUserId(), programId); QueueTokenResult result = queueTokenService.getToken(query); + Integer retryAfterMs = pollingIntervalPolicy.nextRetryAfterMs(result.position()); return ApiResponse.success( QueueSuccessCode.QUEUE_TOKEN_FOUND, - QueueTokenResponse.from(result)); + QueueTokenResponse.from(result, retryAfterMs) + ); } /** diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/presentation/dto/QueueTokenResponse.java b/src/main/java/com/firstticket/queueservice/queuetoken/presentation/dto/QueueTokenResponse.java index 1f33b7d..db27aa0 100644 --- a/src/main/java/com/firstticket/queueservice/queuetoken/presentation/dto/QueueTokenResponse.java +++ b/src/main/java/com/firstticket/queueservice/queuetoken/presentation/dto/QueueTokenResponse.java @@ -11,15 +11,17 @@ public record QueueTokenResponse( String status, LocalDateTime issuedAt, Long position, - String entryToken + String entryToken, + Integer retryAfterMs ) { - public static QueueTokenResponse from(QueueTokenResult result) { + public static QueueTokenResponse from(QueueTokenResult result, Integer retryAfterMs) { return new QueueTokenResponse( result.tokenId().asString(), result.status().name(), result.issuedAt().value(), result.position(), - result.entryToken() + result.entryToken(), + retryAfterMs ); } } diff --git a/src/test/java/com/firstticket/queueservice/queuetoken/presentation/QueueTokenControllerTest.java b/src/test/java/com/firstticket/queueservice/queuetoken/presentation/QueueTokenControllerTest.java index f6565fe..bf6d360 100644 --- a/src/test/java/com/firstticket/queueservice/queuetoken/presentation/QueueTokenControllerTest.java +++ b/src/test/java/com/firstticket/queueservice/queuetoken/presentation/QueueTokenControllerTest.java @@ -12,7 +12,6 @@ import com.firstticket.queueservice.queuetoken.domain.exception.TokenNotFoundException; import com.firstticket.queueservice.queuetoken.domain.vo.ProgramId; import com.firstticket.queueservice.queuetoken.domain.vo.UserId; -import com.firstticket.queueservice.queuetoken.presentation.QueueTokenController; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; @@ -47,29 +46,6 @@ import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -/** - * 대기열 API 통합 테스트. - * - *

WebMvcTest 슬라이스로 Controller 만 로드하고 Service 는 mock 한다. - * 테스트 통과 시 REST Docs snippet 이 자동 생성되며, - * AsciiDoc 빌드를 거쳐 build/docs/asciidoc/index.html 로 문서화된다. - * - *

인증 처리: - *

- * - *

주요 검증: - *

- */ @WebMvcTest(QueueTokenController.class) @AutoConfigureRestDocs @ActiveProfiles("test") @@ -85,6 +61,9 @@ class QueueTokenControllerTest { @MockitoBean private QueueTokenService queueTokenService; + @MockitoBean + private PollingIntervalPolicy pollingIntervalPolicy; // ← 추가 + // ===== 성공 케이스 ===== @Test @@ -97,38 +76,39 @@ class QueueTokenControllerTest { QueueTokenResult result = QueueTokenResult.of(token, 1L); when(queueTokenService.issueToken(any())).thenReturn(result); + when(pollingIntervalPolicy.nextRetryAfterMs(1L)).thenReturn(1000); // ← 추가 try (MockedStatic mocked = mockStatic(AuthContext.class)) { mocked.when(AuthContext::getUserId).thenReturn(userId); - // when & then mockMvc.perform(post("/api/v1/queues/programs/{programId}", programId) - .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) - .andExpect(status().isCreated()) - .andDo(document("queue-token-issue", - preprocessRequest( - prettyPrint(), - modifyHeaders().remove("Content-Type") - ), - preprocessResponse(prettyPrint()), - pathParameters( - parameterWithName("programId").description("프로그램 ID") - ), - requestHeaders( - headerWithName("Authorization") - .description("Bearer access token (Keycloak 발급)") - ), - responseFields( - fieldWithPath("success").description("요청 성공 여부"), - fieldWithPath("code").description("응답 코드"), - fieldWithPath("message").description("응답 메시지"), - fieldWithPath("timestamp").description("응답 시각"), - fieldWithPath("data.tokenId").description("발급된 토큰 ID"), - fieldWithPath("data.status").description("토큰 상태 (WAITING)"), - fieldWithPath("data.issuedAt").description("발급 시각"), - fieldWithPath("data.position").description("현재 순번") - ) - )); + .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) + .andExpect(status().isCreated()) + .andDo(document("queue-token-issue", + preprocessRequest( + prettyPrint(), + modifyHeaders().remove("Content-Type") + ), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("programId").description("프로그램 ID") + ), + requestHeaders( + headerWithName("Authorization") + .description("Bearer access token (Keycloak 발급)") + ), + responseFields( + fieldWithPath("success").description("요청 성공 여부"), + fieldWithPath("code").description("응답 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("timestamp").description("응답 시각"), + fieldWithPath("data.tokenId").description("발급된 토큰 ID"), + fieldWithPath("data.status").description("토큰 상태 (WAITING)"), + fieldWithPath("data.issuedAt").description("발급 시각"), + fieldWithPath("data.position").description("현재 순번"), + fieldWithPath("data.retryAfterMs").description("다음 폴링까지 권장 대기 시간 (ms). 순번에 따라 차등 적용") + ) + )); } } @@ -142,35 +122,36 @@ class QueueTokenControllerTest { QueueTokenResult result = QueueTokenResult.of(token, 50L); when(queueTokenService.getToken(any())).thenReturn(result); + when(pollingIntervalPolicy.nextRetryAfterMs(50L)).thenReturn(1000); // ← 추가 try (MockedStatic mocked = mockStatic(AuthContext.class)) { mocked.when(AuthContext::getUserId).thenReturn(userId); - // when & then mockMvc.perform(get("/api/v1/queues/programs/{programId}", programId) - .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) - .andExpect(status().isOk()) - .andDo(document("queue-token-get", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - pathParameters( - parameterWithName("programId").description("프로그램 ID") - ), - requestHeaders( - headerWithName("Authorization") - .description("Bearer access token (Keycloak 발급)") - ), - responseFields( - fieldWithPath("success").description("요청 성공 여부"), - fieldWithPath("code").description("응답 코드"), - fieldWithPath("message").description("응답 메시지"), - fieldWithPath("timestamp").description("응답 시각"), - fieldWithPath("data.tokenId").description("토큰 ID"), - fieldWithPath("data.status").description("토큰 상태 (WAITING / ADMITTED / EXPIRED)"), - fieldWithPath("data.issuedAt").description("발급 시각"), - fieldWithPath("data.position").description("현재 순번. ADMITTED 등 큐에서 빠진 상태면 null").optional() - ) - )); + .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) + .andExpect(status().isOk()) + .andDo(document("queue-token-get", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("programId").description("프로그램 ID") + ), + requestHeaders( + headerWithName("Authorization") + .description("Bearer access token (Keycloak 발급)") + ), + responseFields( + fieldWithPath("success").description("요청 성공 여부"), + fieldWithPath("code").description("응답 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("timestamp").description("응답 시각"), + fieldWithPath("data.tokenId").description("토큰 ID"), + fieldWithPath("data.status").description("토큰 상태 (WAITING / ADMITTED / EXPIRED)"), + fieldWithPath("data.issuedAt").description("발급 시각"), + fieldWithPath("data.position").description("현재 순번. ADMITTED 등 큐에서 빠진 상태면 null").optional(), + fieldWithPath("data.retryAfterMs").description("다음 폴링까지 권장 대기 시간 (ms). ADMITTED 상태면 null").optional() + ) + )); } } @@ -186,31 +167,31 @@ class QueueTokenControllerTest { try (MockedStatic mocked = mockStatic(AuthContext.class)) { mocked.when(AuthContext::getUserId).thenReturn(userId); - // when & then mockMvc.perform(delete("/api/v1/queues/programs/{programId}", programId) - .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) - .andExpect(status().isOk()) - .andDo(document("queue-token-cancel", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - pathParameters( - parameterWithName("programId").description("프로그램 ID") - ), - requestHeaders( - headerWithName("Authorization") - .description("Bearer access token (Keycloak 발급)") - ), - responseFields( - fieldWithPath("success").description("요청 성공 여부"), - fieldWithPath("code").description("응답 코드 (QUEUE_TOKEN_CANCELLED)"), - fieldWithPath("message").description("응답 메시지"), - fieldWithPath("timestamp").description("응답 시각") - ) - )); + .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) + .andExpect(status().isOk()) + .andDo(document("queue-token-cancel", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("programId").description("프로그램 ID") + ), + requestHeaders( + headerWithName("Authorization") + .description("Bearer access token (Keycloak 발급)") + ), + responseFields( + fieldWithPath("success").description("요청 성공 여부"), + fieldWithPath("code").description("응답 코드 (QUEUE_TOKEN_CANCELLED)"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("timestamp").description("응답 시각") + ) + )); } } // ===== 에러 케이스 ===== + // (변경 없음 - 에러 응답엔 data 필드 없으니 그대로) @Test @DisplayName("인증 실패 시 401 Unauthorized") @@ -220,31 +201,29 @@ class QueueTokenControllerTest { try (MockedStatic mocked = mockStatic(AuthContext.class)) { mocked.when(AuthContext::getUserId) - .thenThrow(new BusinessException(CommonErrorCode.UNAUTHORIZED)); + .thenThrow(new BusinessException(CommonErrorCode.UNAUTHORIZED)); - // when & then mockMvc.perform(post("/api/v1/queues/programs/{programId}", programId)) - .andExpect(status().isUnauthorized()) - .andDo(document("queue-token-unauthorized", - preprocessRequest( - prettyPrint(), - modifyHeaders().remove("Content-Type") - ), - preprocessResponse(prettyPrint()), - responseFields( - fieldWithPath("success").description("요청 성공 여부 (false)"), - fieldWithPath("code").description("에러 코드 (UNAUTHORIZED)"), - fieldWithPath("message").description("에러 메시지"), - fieldWithPath("timestamp").description("응답 시각") - ) - )); + .andExpect(status().isUnauthorized()) + .andDo(document("queue-token-unauthorized", + preprocessRequest( + prettyPrint(), + modifyHeaders().remove("Content-Type") + ), + preprocessResponse(prettyPrint()), + responseFields( + fieldWithPath("success").description("요청 성공 여부 (false)"), + fieldWithPath("code").description("에러 코드 (UNAUTHORIZED)"), + fieldWithPath("message").description("에러 메시지"), + fieldWithPath("timestamp").description("응답 시각") + ) + )); } } @Test @DisplayName("동시 진입 시 race — 409 Conflict") void 중복_진입_409() throws Exception { - // given UUID userId = UUID.randomUUID(); UUID programId = UUID.randomUUID(); @@ -254,28 +233,27 @@ class QueueTokenControllerTest { mocked.when(AuthContext::getUserId).thenReturn(userId); mockMvc.perform(post("/api/v1/queues/programs/{programId}", programId) - .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) - .andExpect(status().isConflict()) - .andDo(document("queue-token-duplicate", - preprocessRequest( - prettyPrint(), - modifyHeaders().remove("Content-Type") - ), - preprocessResponse(prettyPrint()), - responseFields( - fieldWithPath("success").description("요청 성공 여부 (false)"), - fieldWithPath("code").description("에러 코드 (DUPLICATE_TOKEN)"), - fieldWithPath("message").description("에러 메시지"), - fieldWithPath("timestamp").description("응답 시각") - ) - )); + .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) + .andExpect(status().isConflict()) + .andDo(document("queue-token-duplicate", + preprocessRequest( + prettyPrint(), + modifyHeaders().remove("Content-Type") + ), + preprocessResponse(prettyPrint()), + responseFields( + fieldWithPath("success").description("요청 성공 여부 (false)"), + fieldWithPath("code").description("에러 코드 (DUPLICATE_TOKEN)"), + fieldWithPath("message").description("에러 메시지"), + fieldWithPath("timestamp").description("응답 시각") + ) + )); } } @Test @DisplayName("토큰 없음 — 조회 시 404") void 토큰_없음_조회_404() throws Exception { - // given UUID userId = UUID.randomUUID(); UUID programId = UUID.randomUUID(); @@ -284,27 +262,25 @@ class QueueTokenControllerTest { try (MockedStatic mocked = mockStatic(AuthContext.class)) { mocked.when(AuthContext::getUserId).thenReturn(userId); - // when & then mockMvc.perform(get("/api/v1/queues/programs/{programId}", programId) - .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) - .andExpect(status().isNotFound()) - .andDo(document("queue-token-get-not-found", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - responseFields( - fieldWithPath("success").description("요청 성공 여부 (false)"), - fieldWithPath("code").description("에러 코드 (TOKEN_NOT_FOUND)"), - fieldWithPath("message").description("에러 메시지"), - fieldWithPath("timestamp").description("응답 시각") - ) - )); + .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) + .andExpect(status().isNotFound()) + .andDo(document("queue-token-get-not-found", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + responseFields( + fieldWithPath("success").description("요청 성공 여부 (false)"), + fieldWithPath("code").description("에러 코드 (TOKEN_NOT_FOUND)"), + fieldWithPath("message").description("에러 메시지"), + fieldWithPath("timestamp").description("응답 시각") + ) + )); } } @Test @DisplayName("토큰 없음 — 취소 시 404") void 토큰_없음_취소_404() throws Exception { - // given UUID userId = UUID.randomUUID(); UUID programId = UUID.randomUUID(); @@ -313,27 +289,25 @@ class QueueTokenControllerTest { try (MockedStatic mocked = mockStatic(AuthContext.class)) { mocked.when(AuthContext::getUserId).thenReturn(userId); - // when & then mockMvc.perform(delete("/api/v1/queues/programs/{programId}", programId) - .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) - .andExpect(status().isNotFound()) - .andDo(document("queue-token-cancel-not-found", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - responseFields( - fieldWithPath("success").description("요청 성공 여부 (false)"), - fieldWithPath("code").description("에러 코드 (TOKEN_NOT_FOUND)"), - fieldWithPath("message").description("에러 메시지"), - fieldWithPath("timestamp").description("응답 시각") - ) - )); + .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) + .andExpect(status().isNotFound()) + .andDo(document("queue-token-cancel-not-found", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + responseFields( + fieldWithPath("success").description("요청 성공 여부 (false)"), + fieldWithPath("code").description("에러 코드 (TOKEN_NOT_FOUND)"), + fieldWithPath("message").description("에러 메시지"), + fieldWithPath("timestamp").description("응답 시각") + ) + )); } } @Test @DisplayName("WAITING 이 아닌 상태 취소 시도 — 400") void 취소_불가_상태_400() throws Exception { - // given UUID userId = UUID.randomUUID(); UUID programId = UUID.randomUUID(); @@ -342,20 +316,19 @@ class QueueTokenControllerTest { try (MockedStatic mocked = mockStatic(AuthContext.class)) { mocked.when(AuthContext::getUserId).thenReturn(userId); - // when & then mockMvc.perform(delete("/api/v1/queues/programs/{programId}", programId) - .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) - .andExpect(status().isBadRequest()) - .andDo(document("queue-token-cancel-invalid-state", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - responseFields( - fieldWithPath("success").description("요청 성공 여부 (false)"), - fieldWithPath("code").description("에러 코드 (INVALID_TOKEN_STATE)"), - fieldWithPath("message").description("에러 메시지"), - fieldWithPath("timestamp").description("응답 시각") - ) - )); + .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) + .andExpect(status().isBadRequest()) + .andDo(document("queue-token-cancel-invalid-state", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + responseFields( + fieldWithPath("success").description("요청 성공 여부 (false)"), + fieldWithPath("code").description("에러 코드 (INVALID_TOKEN_STATE)"), + fieldWithPath("message").description("에러 메시지"), + fieldWithPath("timestamp").description("응답 시각") + ) + )); } } @@ -371,35 +344,36 @@ class QueueTokenControllerTest { QueueTokenResult result = QueueTokenResult.of(token, null); // position null when(queueTokenService.getToken(any())).thenReturn(result); + when(pollingIntervalPolicy.nextRetryAfterMs(null)).thenReturn(null); // ADMITTED니까 null try (MockedStatic mocked = mockStatic(AuthContext.class)) { mocked.when(AuthContext::getUserId).thenReturn(userId); - // when & then mockMvc.perform(get("/api/v1/queues/programs/{programId}", programId) - .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) - .andExpect(status().isOk()) - .andDo(document("queue-token-get-admitted", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - pathParameters( - parameterWithName("programId").description("프로그램 ID") - ), - requestHeaders( - headerWithName("Authorization") - .description("Bearer access token (Keycloak 발급)") - ), - responseFields( - fieldWithPath("success").description("요청 성공 여부"), - fieldWithPath("code").description("응답 코드"), - fieldWithPath("message").description("응답 메시지"), - fieldWithPath("timestamp").description("응답 시각"), - fieldWithPath("data.tokenId").description("토큰 ID"), - fieldWithPath("data.status").description("토큰 상태 (ADMITTED)"), - fieldWithPath("data.issuedAt").description("발급 시각"), - fieldWithPath("data.entryToken").description("입장 토큰 (JWT) — ADMITTED 상태일 때만 포함") - ) - )); + .header(AUTHORIZATION_HEADER, DUMMY_BEARER_TOKEN)) + .andExpect(status().isOk()) + .andDo(document("queue-token-get-admitted", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("programId").description("프로그램 ID") + ), + requestHeaders( + headerWithName("Authorization") + .description("Bearer access token (Keycloak 발급)") + ), + responseFields( + fieldWithPath("success").description("요청 성공 여부"), + fieldWithPath("code").description("응답 코드"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("timestamp").description("응답 시각"), + fieldWithPath("data.tokenId").description("토큰 ID"), + fieldWithPath("data.status").description("토큰 상태 (ADMITTED)"), + fieldWithPath("data.issuedAt").description("발급 시각"), + fieldWithPath("data.entryToken").description("입장 토큰 (JWT) — ADMITTED 상태일 때만 포함") + // retryAfterMs 는 null 이라 응답에 없음 → 명세 안 함 + ) + )); } } }