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} 값을 계산한다.
+ *
+ *
정책
+ *
+ * - 임박 (≤100명): 1초 (jitter 없음, 빠른 알림 우선)
+ * - 중간 (≤1000명): 5초 ± 300ms
+ * - 멀리 (≤10000명): 15초 ± 500ms
+ * - 매우 멀리 (그 이상): 30초 ± 4초
+ *
+ *
+ * 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 로 문서화된다.
- *
- *
인증 처리:
- *
- * - 실제 운영 환경에선 Gateway 가 Authorization Bearer 토큰 검증 후
- * 사용자 ID 를 Filter 통해 ThreadLocal (AuthContext) 에 주입한다.
- * - 테스트에선 AuthContext.getUserId() 를 mockStatic 으로 직접 mock 하므로
- * 실제 헤더는 의미 없다. 단, REST Docs 문서화를 위해 외부 클라이언트
- * 시각의 Authorization 헤더를 dummy 값으로 보낸다.
- *
- *
- * 주요 검증:
- *
- * - HTTP 메서드별 정상 동작 (POST 201, GET 200, DELETE 200)
- * - 인증 실패 시 401
- * - 도메인 예외 → HTTP status 매핑 (404, 400, 409)
- *
- */
@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 이라 응답에 없음 → 명세 안 함
+ )
+ ));
}
}
}