From f4613cb88d3ec78843749d2316c8f88ae304412e Mon Sep 17 00:00:00 2001 From: sevineleven Date: Tue, 25 Aug 2026 09:04:47 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=ED=94=8C=EB=A0=88=EC=9D=B4=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EC=83=9D=EC=84=B1=EC=9D=84=20=EB=A9=B1?= =?UTF-8?q?=EB=93=B1=ED=95=98=EA=B2=8C=20-=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=ED=9B=84=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20=EB=B6=88=EA=B0=80=20?= =?UTF-8?q?=ED=95=B4=EC=86=8C=20(#980)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "공유하기가 두 번째부터 안 된다"는 신고를 따라가 보니 생성 가드와 유효성 검사가 서로 다른 것을 보고 있었다. 생성 가드는 playLinkExpiresAt 이 null 인지만 봤고, 유효성 검사만 시각을 비교했다. 그래서 값이 한번 박히면 만료 여부와 무관하게 재호출이 409 로 막혔다. 이 컬럼을 다시 null 로 되돌리는 코드도 없다. 쓰기는 두 곳뿐이고(createPlayLink 는 미래 시각, expirePlayLink 는 과거 시각) 정리 배치도 없다. 결과적으로 14일이 지나면 그 토너먼트는 영구히 재공유가 불가능한 상태로 굳었다. POST 는 "이미 만들어졌다", GET play-link-info 는 "만료됐다" 로 양쪽이 서로를 부정하며 막혔다. 유효기간이 링크를 죽이는 데만 쓰이고 되살리는 데는 안 쓰인 셈이다. createPlayLink 를 상태에 따라 갈리되 항상 200 인 형태로 바꿨다. - 미생성: 새로 만든다 (기존과 동일) - 유효: 연장하지 않고 기존 만료시각을 그대로 돌려준다. 공유 버튼을 다시 누른 것만으로 노출 기간이 늘면 주최자가 의도하지 않은 노출이 생긴다 - 만료: 새 14일로 갱신한다 이슈에서 실측하라고 못 박아 둔 것을 확인했다. 주최자가 완료된 토너먼트를 나가면 expirePlayLink 로 과거 시각이 박혀 자연 만료와 컬럼 값이 같아지는데, 이 경우가 "만료됐으니 갱신" 분기를 타면 안 된다. 같은 트랜잭션에서 주최자 TournamentUser 행이 소프트 삭제되고 findByTournamentIdAndUserId 가 deletedAt IS NULL 조건으로 위임되므로, 앞단 소유자 확인에서 forbiddenTournament 로 먼저 걸린다. 테스트로 고정했고, 그래서 컬럼 추가나 상태 enum 승격은 하지 않았다. TOURNAMENT-025 는 더 이상 발생하지 않는다. 번호는 재사용하지 않고 결번으로 남긴다(코드가 클라 계약). enum 엔트리는 GUEST_CANNOT_CREATE_TOURNAMENT(036)와 같은 방식으로 사유를 적어 유지했다. API 문서는 계약 자체가 바뀐 자리라 다시 썼다. 세 상태별 동작, 토너먼트당 링크가 하나이고 주소가 tournamentId 로 결정된다는 점(별도 토큰 없음), 참여 인원 제한이 없다는 점, 초대 코드와는 다른 기능이고 동시에 열리는 구간이 없다는 점을 명시했다. 지금까지 어디에도 없어 클라가 서버 코드를 읽어야 알 수 있던 것들이다. 409 example 은 지우고 세 상태의 200 example 로 교체했다. 검증하면서 알게 된 것 하나: 완료 검사가 CLONE 검사보다 앞서므로, TOURNAMENT-024 에 닿으려면 클론을 끝까지 진행시켜야 한다. 그래서 CLONE 테스트는 클론을 완료시킨 뒤 호출한다. 그동안 이 사유는 테스트가 전혀 없었다. 멱등 동작 두 건은 수정 전 코드에서 실제로 깨지는 것을 확인했다(negative control). 두 건만 FAILED 로 떨어졌고, 함께 추가한 403 가드 테스트들은 그대로 통과했다 - 옛 409 경로를 타고 우연히 통과하는 테스트가 아니라는 뜻이다. --- .../tournament/controller/TournamentApi.kt | 26 +++- .../controller/TournamentApiExamples.kt | 14 +- .../tournament/service/TournamentErrorCode.kt | 3 + .../tournament/service/TournamentService.kt | 8 +- .../controller/TournamentIntegrationTest.kt | 145 +++++++++++++++++- 5 files changed, 184 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt index 1c832596..23c4f398 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt @@ -690,19 +690,31 @@ interface TournamentApi { ): ApiResponseBody @Operation( - summary = "플레이 링크 생성", + summary = "플레이 링크 생성 (멱등)", description = """ - 완료된 토너먼트의 플레이 링크를 생성한다. 토너먼트 소유자만 호출 가능. - 플레이 링크를 통해 친구들이 동일한 아이템 구성으로 토너먼트를 진행할 수 있다. - 만료 기간은 생성 시점 + 14일로 고정이며 변경 불가. - 이미 링크가 생성된 경우 409. + 완료된 토너먼트의 플레이 링크를 만들거나 갱신한다. 토너먼트 소유자만 호출 가능. + 플레이 링크를 통해 친구들이 동일한 아이템 구성으로 각자 자기 토너먼트를 진행할 수 있다 + (친구마다 원본을 복제한 새 토너먼트가 생긴다 - `POST /{sourceTournamentId}/from-play-link`). + + **호출 시점의 링크 상태에 따라 동작이 갈리고, 항상 200을 반환한다.** + - 아직 만든 적 없으면: 새로 만든다 (생성 시점 + 14일). + - 유효한 링크가 있으면: **연장하지 않고** 기존 만료시각을 그대로 돌려준다. 공유 버튼을 다시 누른 것만으로 + 노출 기간이 늘어나는 것을 막기 위함이다. + - 만료된 링크가 있으면: 새 14일로 갱신한다. + + 플레이 링크는 **토너먼트당 하나**이고 주소가 `tournamentId` 로 결정된다(별도 토큰 없음) - 같은 + 토너먼트에 여러 개를 만들 수 없다. 링크를 통해 들어오는 인원 제한은 없고, 들어온 사람마다 자기 + 클론이 하나씩 생긴다. + + 초대 코드(시작 전, 참여자로 합류)와는 다른 기능이며 동시에 열리는 구간이 없다 - 초대 코드는 + PENDING 상태에서만, 플레이 링크는 COMPLETED 상태에서만 쓸 수 있다. """, ) @ApiResponses( value = [ ApiResponse( responseCode = "200", - description = "플레이 링크 생성 성공 (playLinkExpiresAt 반환)", + description = "플레이 링크 생성 또는 갱신 성공 (playLinkExpiresAt 반환) - 매 호출이 이 응답이다", content = [Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = Schema(implementation = ApiResponseBody::class))], ), ApiResponse( @@ -722,7 +734,7 @@ interface TournamentApi { ), ApiResponse( responseCode = "409", - description = "상태 충돌 (COMPLETED가 아닌 토너먼트 · 플레이 링크가 이미 생성됨)", + description = "상태 충돌 (COMPLETED가 아닌 토너먼트)", content = [Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = Schema(implementation = ApiResponseBody::class))], ), ], diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt index 7f2dddae..5f4785bb 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt @@ -652,11 +652,22 @@ class TournamentApiExamples( handlerMethod.binds(TournamentController::createPlayLink) -> operation.examples(openApiObjectMapper.delegate) { + // 멱등(#980) — 세 상태(최초 생성 · 유효한 값 그대로 · 만료 후 갱신) 모두 200 이다. add( status = HttpStatus.OK, - name = "플레이 링크 생성 성공", + name = "최초 생성", payload = ApiResponseBody.ok(LocalDateTime.of(2026, 6, 9, 22, 0, 0)), ) + add( + status = HttpStatus.OK, + name = "유효한 링크가 이미 있음 (연장 없이 기존 만료시각 그대로)", + payload = ApiResponseBody.ok(LocalDateTime.of(2026, 6, 9, 22, 0, 0)), + ) + add( + status = HttpStatus.OK, + name = "만료된 링크를 다시 호출 (새 14일로 갱신)", + payload = ApiResponseBody.ok(LocalDateTime.of(2026, 6, 27, 10, 0, 0)), + ) unauthorized() add(TournamentException.forbiddenTournament(), name = "권한 없음 (참여자 아님 · 소유자 아님)") add( @@ -665,7 +676,6 @@ class TournamentApiExamples( ) add(TournamentException.notFoundTournament(), name = "토너먼트를 찾을 수 없음") add(TournamentException.notCompletedTournament(), name = "완료되지 않은 토너먼트") - add(TournamentException.playLinkAlreadyCreated(), name = "플레이 링크 이미 생성됨") } handlerMethod.binds(TournamentController::getPlayLinkInfo) -> diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt index 7655f3ad..7ede2365 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt @@ -40,6 +40,9 @@ enum class TournamentErrorCode( ALREADY_PARTICIPANT("TOURNAMENT-022", ErrorCategory.CONFLICT, "이미 참여 중인 토너먼트예요."), NOT_COMPLETED_TOURNAMENT("TOURNAMENT-023", ErrorCategory.CONFLICT, "완료된 토너먼트에서만 할 수 있어요."), CLONED_TOURNAMENT_CANNOT_SHARE_PLAY_LINK("TOURNAMENT-024", ErrorCategory.FORBIDDEN, "플레이 링크로 참여한 토너먼트는 공유 링크를 만들 수 없어요."), + // 025 는 #980 에서 발생을 멈췄다. createPlayLink 가 멱등해지며(유효하면 기존 값 반환, 만료됐으면 재발급) + // "이미 있으면 무조건 거부" 하던 이 사유가 사라졌다 — 그 거부가 만료 후 영구 재발급 불가로 이어졌었다. + // 번호는 재사용하지 않고 결번으로 남긴다(코드가 클라 계약). GUEST_CANNOT_CREATE_TOURNAMENT(036)와 같은 결. PLAY_LINK_ALREADY_CREATED("TOURNAMENT-025", ErrorCategory.CONFLICT, "이미 플레이 링크가 만들어진 토너먼트예요."), PLAY_LINK_NOT_CREATED("TOURNAMENT-026", ErrorCategory.NOT_FOUND, "아직 플레이 링크가 만들어지지 않은 토너먼트예요."), PLAY_LINK_EXPIRED("TOURNAMENT-027", ErrorCategory.CONFLICT, "플레이 링크가 만료됐어요."), diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt index 79633070..3ff37bd9 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt @@ -937,7 +937,13 @@ class TournamentService( ?: throw TournamentException.forbiddenTournament() if (tournamentUser.getId() != tournament.ownerTournamentUserId) throw TournamentException.forbiddenTournament() if (!tournament.isRoot()) throw TournamentException.clonedTournamentCannotSharePlayLink() - tournament.playLinkExpiresAt?.let { throw TournamentException.playLinkAlreadyCreated() } + // 멱등(#980) — 유효한 링크가 있으면 그 값을 그대로 돌려준다(연장하지 않는다: 공유 버튼을 다시 누른 + // 것만으로 노출 기간이 늘면 주최자가 의도하지 않은 노출이 생긴다). 없거나(최초) 만료됐으면 새로 발급한다. + // 종전엔 "값이 있으면 무조건 거부" 라 만료된 뒤에는 영구히 재발급이 안 됐다 — 유효기간이 링크를 죽이는 + // 데만 쓰이고 되살리는 데는 안 쓰였다. 주최자 탈퇴로 무효화된 경우는 이 지점에 닿지 않는다: softDelete + // 가 주최자 TournamentUser 행을 지우므로 위 findByTournamentIdAndUserId 가 못 찾아 forbiddenTournament + // 로 먼저 걸린다(findByTournamentIdAndUserIdAndDeletedAtIsNull 위임, 실측 확인). + if (tournament.isPlayLinkValid()) return requireNotNull(tournament.playLinkExpiresAt) val expiresAt = LocalDateTime .now() .plusDays(PLAY_LINK_DURATION_DAYS) diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt index 74ab1771..d5d12607 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt @@ -60,6 +60,7 @@ import org.springframework.transaction.annotation.Transactional import org.springframework.web.context.WebApplicationContext import tools.jackson.databind.ObjectMapper import java.time.LocalDateTime +import java.time.OffsetDateTime import java.time.format.DateTimeFormatter import java.util.UUID import kotlin.test.assertEquals @@ -3368,8 +3369,40 @@ class TournamentIntegrationTest : IntegrationTestSupport() { .andExpect(jsonPath("$.data").isString) } + // 멱등(#980) — 유효한 링크가 있는 상태로 다시 호출해도 더 이상 409 가 아니다. 이게 신고된 증상이었다: + // 클라는 성공 직후 로컬 상태를 갱신하지 않아, 같은 세션에서 공유 버튼을 다시 누르면 stale 한 "미생성" + // 상태로 POST 를 재호출했고 그게 409 로 막혀 공유 자체가 중단됐다. @Test - fun `POST play-link 는 이미 생성된 경우 재생성 시 409 를 반환한다`() { + fun `POST play-link 는 유효한 링크가 있으면 연장 없이 기존 만료시각을 그대로 반환한다`() { + val mockMvc = buildMockMvc() + val (tournamentId, _, _) = completeTournamentWith2Items(mockMvc) + val firstResult = mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/play-link") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content("{}"), + ).andExpect(status().isOk) + .andReturn() + val firstExpiresAt = objectMapper.readTree(firstResult.response.contentAsString)["data"].asText() + + // 두 번째 호출도 200 이고, 만료시각이 늘어나지 않는다 — 공유 버튼을 다시 누른 것만으로 + // 노출 기간이 연장되면 주최자가 의도하지 않은 노출이 생긴다. + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/play-link") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content("{}"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(firstExpiresAt)) + } + + // 종전엔 이 상태에서 POST 도 409(이미 생성됨), GET play-link-info 도 409(만료됨) 로 양쪽 다 막혀 + // 그 토너먼트는 영구히 재공유가 불가능했다. 유효기간이 링크를 죽이는 데만 쓰이고 되살리는 데는 + // 안 쓰이던 문제 — 재호출은 새 14일로 갱신돼야 한다. + @Test + fun `POST play-link 는 만료된 링크가 있으면 새 만료시각으로 갱신한다`() { val mockMvc = buildMockMvc() val (tournamentId, _, _) = completeTournamentWith2Items(mockMvc) mockMvc.perform( @@ -3378,6 +3411,46 @@ class TournamentIntegrationTest : IntegrationTestSupport() { .contentType(MediaType.APPLICATION_JSON) .content("{}"), ) + val tournament = tournamentJpaRepository.findByIdAndDeletedAtIsNull(tournamentId)!! + tournament.expirePlayLink() + tournamentJpaRepository.save(tournament) + + val renewed = mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/play-link") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content("{}"), + ).andExpect(status().isOk) + .andReturn() + // 응답은 오프셋이 붙은 형식으로 직렬화된다(예: 2026-09-08T17:58:51.498+09:00). + val renewedExpiresAt = OffsetDateTime.parse(objectMapper.readTree(renewed.response.contentAsString)["data"].asText()) + assertTrue(renewedExpiresAt.isAfter(OffsetDateTime.now())) + + // GET play-link-info 도 더 이상 만료로 막히지 않고 갱신된 값을 준다. + mockMvc + .perform(get("/api/v1/tournaments/$tournamentId/play-link-info")) + .andExpect(status().isOk) + } + + // 주최자가 완료된 토너먼트를 나가면(DELETE) 주최자의 참여 행이 지워지고 플레이 링크가 무효화된다. + // 이 무효화는 자연 만료와 컬럼 값이 똑같이 "과거 시각" 이지만, 재발급 대상이면 안 된다 — 주최자가 더 + // 이상 이 토너먼트의 참여자가 아니므로 createPlayLink 앞단의 소유자 확인에서 먼저 막혀야 한다. + @Test + fun `POST play-link 는 주최자가 나가서 무효화된 토너먼트에서는 403 이고 갱신되지 않는다`() { + val mockMvc = buildMockMvc() + val (tournamentId, _, _) = completeTournamentWith2Items(mockMvc) + mockMvc.perform( + post("/api/v1/tournaments/$tournamentId/play-link") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content("{}"), + ) + mockMvc + .perform( + delete("/api/v1/tournaments/$tournamentId") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), + ).andExpect(status().isOk) mockMvc .perform( @@ -3385,7 +3458,75 @@ class TournamentIntegrationTest : IntegrationTestSupport() { .header(HttpHeaders.AUTHORIZATION, authHeader(userId)) .contentType(MediaType.APPLICATION_JSON) .content("{}"), - ).andExpect(status().isConflict) + ).andExpect(status().isForbidden) + } + + // 복제 토너먼트에서는 공유 자체가 불가하다 — 아이템 추가 금지(TOURNAMENT-032)와 같은 결로, + // 원본이 아닌 판이 또 다른 원본 행세를 해선 안 된다. + // 완료 검사가 CLONE 검사보다 앞서므로, 이 사유에 닿으려면 클론을 끝까지 진행시켜야 한다. + @Test + fun `POST play-link 는 완료된 CLONE 이어도 403 TOURNAMENT-024 를 반환한다`() { + val mockMvc = buildMockMvc() + val (rootId, cloneId, _) = cloneFromCompletedRoot(mockMvc) + mockMvc + .perform( + post("/api/v1/tournaments/$cloneId/start") + .header(HttpHeaders.AUTHORIZATION, authHeader(otherUserId)), + ).andExpect(status().isOk) + // CLONE 은 자기 아이템 행이 없고 ROOT 것을 그대로 쓴다 — 대진도 ROOT 의 tournamentItemId 로 기록한다. + val items = tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(rootId) + mockMvc + .perform( + post("/api/v1/tournaments/$cloneId/matches") + .header(HttpHeaders.AUTHORIZATION, authHeader(otherUserId)) + .contentType(MediaType.APPLICATION_JSON) + .content( + """{"currentRound":2,"firstTournamentItemId":${items[0].getId()},""" + + """"secondTournamentItemId":${items[1].getId()},"selectedTournamentItemId":${items[0].getId()}}""", + ), + ).andExpect(status().isOk) + + mockMvc + .perform( + post("/api/v1/tournaments/$cloneId/play-link") + .header(HttpHeaders.AUTHORIZATION, authHeader(otherUserId)) + .contentType(MediaType.APPLICATION_JSON) + .content("{}"), + ).andExpect(status().isForbidden) + .andExpect(jsonPath("$.code").value("TOURNAMENT-024")) + } + + @Test + fun `POST play-link 는 소유자가 아닌 참여자면 403 을 반환한다`() { + val mockMvc = buildMockMvc() + saveUser(otherUserId, "https://cdn.example.com/other.jpg", "다른유저") + val rootTournamentId = createTournament(mockMvc) + mockMvc.perform( + post("/api/v1/tournaments/$rootTournamentId/join") + .header(HttpHeaders.AUTHORIZATION, authHeader(otherUserId)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"inviteCode":"${tournamentJpaRepository.findByIdAndDeletedAtIsNull(rootTournamentId)!!.inviteCode}"}"""), + ) + addItemsToTournament(mockMvc, rootTournamentId, userId, saveWishItem(name = "아이템1"), saveWishItem(name = "아이템2")) + mockMvc.perform(post("/api/v1/tournaments/$rootTournamentId/start").header(HttpHeaders.AUTHORIZATION, authHeader(userId))) + val items = tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(rootTournamentId) + mockMvc.perform( + post("/api/v1/tournaments/$rootTournamentId/matches") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content( + """{"currentRound":2,"firstTournamentItemId":${items[0].getId()},""" + + """"secondTournamentItemId":${items[1].getId()},"selectedTournamentItemId":${items[0].getId()}}""", + ), + ) + + mockMvc + .perform( + post("/api/v1/tournaments/$rootTournamentId/play-link") + .header(HttpHeaders.AUTHORIZATION, authHeader(otherUserId)) + .contentType(MediaType.APPLICATION_JSON) + .content("{}"), + ).andExpect(status().isForbidden) } @Test From 4bcc27cf6dd6bcfd22ce180e6bc062f7c2a31b7d Mon Sep 17 00:00:00 2001 From: sevineleven Date: Tue, 25 Aug 2026 09:51:41 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test:=20=ED=94=8C=EB=A0=88=EC=9D=B4=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EC=9D=84=20=EC=8B=A4=EC=A7=88=20=EB=8B=A8=EC=96=B8?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A1=B0=EC=9E=84=20(#980)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit 지적대로 세 자리가 이름만 그럴듯하고 실질 검증이 약했다. - 재발급 만료시각을 "미래이기만 하면 통과" 로 봤다. 발급 기간이 한 시간으로 줄어도 안 깨진다 - GET play-link-info 를 200 인지만 봤다. 조회가 재발급과 다른 시각을 줘도 안 잡힌다 - 주최자 탈퇴 후 403 만 봤다. 거부는 했는데 그 전에 만료시각을 갱신해 버리는 회귀를 못 잡는다. 이 테스트의 목적이 "의도적으로 끊은 링크가 되살아나지 않는다" 인데 정작 그걸 확인하지 않고 있었다 셋 다 단언을 추가했다. 탈퇴 케이스는 요청 전후의 저장값이 같은지 비교한다. 기간 검증은 응답 문자열이 아니라 저장값으로 한다. 작성 중 알게 된 것인데, LocalDateTime 응답은 Jackson 이 그 값을 UTC 로 간주해 시스템 존으로 변환하며 오프셋을 붙인다. 그래서 와이어 값의 시각이 서버가 저장한 값과 9시간 어긋난다(예: 저장 09:42 → 응답 18:42+09:00). 이 PR 범위 밖의 앱 전역 특성이라 여기서는 건드리지 않고, 검증만 저장값 기준으로 돌렸다. 조회-재발급 일치는 두 응답이 같은 직렬화를 거치므로 원문 문자열 비교로 확인한다. 기간은 리터럴 14 대신 PLAY_LINK_DURATION_DAYS 상수를 참조한다. 상수를 바꾸면 테스트도 따라 바뀌므로 "값이 14인가" 는 못 잡지만, 겨냥하는 회귀는 그게 아니라 "재발급이 규정 기간만큼 새로 나오는가" 다. 실제로 발급을 한 시간짜리로 바꿔 보니 이 단언이 잡아냈다(negative control). --- .../controller/TournamentIntegrationTest.kt | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt index d5d12607..1a148412 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt @@ -27,6 +27,7 @@ import com.depromeet.piki.tournament.event.TournamentStarted import com.depromeet.piki.tournament.repository.TournamentItemJpaRepository import com.depromeet.piki.tournament.repository.TournamentJpaRepository import com.depromeet.piki.tournament.repository.TournamentUserJpaRepository +import com.depromeet.piki.tournament.service.PLAY_LINK_DURATION_DAYS import com.depromeet.piki.tournament.service.TournamentErrorCode import com.depromeet.piki.user.domain.IdentityType import com.depromeet.piki.user.domain.User @@ -60,7 +61,6 @@ import org.springframework.transaction.annotation.Transactional import org.springframework.web.context.WebApplicationContext import tools.jackson.databind.ObjectMapper import java.time.LocalDateTime -import java.time.OffsetDateTime import java.time.format.DateTimeFormatter import java.util.UUID import kotlin.test.assertEquals @@ -3415,6 +3415,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { tournament.expirePlayLink() tournamentJpaRepository.save(tournament) + val requestedAt = LocalDateTime.now() val renewed = mockMvc .perform( post("/api/v1/tournaments/$tournamentId/play-link") @@ -3423,14 +3424,23 @@ class TournamentIntegrationTest : IntegrationTestSupport() { .content("{}"), ).andExpect(status().isOk) .andReturn() - // 응답은 오프셋이 붙은 형식으로 직렬화된다(예: 2026-09-08T17:58:51.498+09:00). - val renewedExpiresAt = OffsetDateTime.parse(objectMapper.readTree(renewed.response.contentAsString)["data"].asText()) - assertTrue(renewedExpiresAt.isAfter(OffsetDateTime.now())) - // GET play-link-info 도 더 이상 만료로 막히지 않고 갱신된 값을 준다. - mockMvc + // 규정 기간만큼 나왔는지는 저장된 값으로 본다. "미래이기만 하면 통과" 로 두면 재발급 기간이 하루로 + // 줄어도 안 깨진다. 응답 문자열이 아니라 DB 값을 쓰는 이유: LocalDateTime 응답은 Jackson 이 UTC 로 + // 간주해 시스템 존으로 변환하며 오프셋을 붙이므로, 와이어 값의 시각이 서버가 저장한 값과 다르다. + val storedExpiresAt = requireNotNull(tournamentJpaRepository.findByIdAndDeletedAtIsNull(tournamentId)!!.playLinkExpiresAt) + assertTrue(storedExpiresAt.isAfter(requestedAt.plusDays(PLAY_LINK_DURATION_DAYS).minusSeconds(10))) + assertTrue(storedExpiresAt.isBefore(LocalDateTime.now().plusDays(PLAY_LINK_DURATION_DAYS).plusSeconds(10))) + + // GET play-link-info 도 더 이상 만료로 막히지 않고, 재발급된 것과 같은 값을 준다 — 200 만 보면 + // 조회가 엉뚱한 시각을 주는 회귀를 놓친다. 두 응답은 같은 직렬화를 거치므로 원문 비교로 충분하다. + val renewedRaw = objectMapper.readTree(renewed.response.contentAsString)["data"].asText() + val info = mockMvc .perform(get("/api/v1/tournaments/$tournamentId/play-link-info")) .andExpect(status().isOk) + .andReturn() + val infoRaw = objectMapper.readTree(info.response.contentAsString)["data"]["playLinkExpiresAt"].asText() + assertEquals(renewedRaw, infoRaw) } // 주최자가 완료된 토너먼트를 나가면(DELETE) 주최자의 참여 행이 지워지고 플레이 링크가 무효화된다. @@ -3451,6 +3461,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { delete("/api/v1/tournaments/$tournamentId") .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), ).andExpect(status().isOk) + val invalidatedExpiresAt = tournamentJpaRepository.findByIdAndDeletedAtIsNull(tournamentId)!!.playLinkExpiresAt mockMvc .perform( @@ -3459,6 +3470,10 @@ class TournamentIntegrationTest : IntegrationTestSupport() { .contentType(MediaType.APPLICATION_JSON) .content("{}"), ).andExpect(status().isForbidden) + + // 403 만 보면 "거부는 했는데 그 전에 값은 갱신해 버린" 회귀를 못 잡는다. 무효화 시각이 그대로여야 + // 주최자가 의도적으로 끊은 링크가 되살아나지 않았다고 말할 수 있다. + assertEquals(invalidatedExpiresAt, tournamentJpaRepository.findByIdAndDeletedAtIsNull(tournamentId)!!.playLinkExpiresAt) } // 복제 토너먼트에서는 공유 자체가 불가하다 — 아이템 추가 금지(TOURNAMENT-032)와 같은 결로,