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..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 @@ -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,57 @@ class TournamentIntegrationTest : IntegrationTestSupport() { .contentType(MediaType.APPLICATION_JSON) .content("{}"), ) + val tournament = tournamentJpaRepository.findByIdAndDeletedAtIsNull(tournamentId)!! + tournament.expirePlayLink() + tournamentJpaRepository.save(tournament) + + val requestedAt = LocalDateTime.now() + 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() + + // 규정 기간만큼 나왔는지는 저장된 값으로 본다. "미래이기만 하면 통과" 로 두면 재발급 기간이 하루로 + // 줄어도 안 깨진다. 응답 문자열이 아니라 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) 주최자의 참여 행이 지워지고 플레이 링크가 무효화된다. + // 이 무효화는 자연 만료와 컬럼 값이 똑같이 "과거 시각" 이지만, 재발급 대상이면 안 된다 — 주최자가 더 + // 이상 이 토너먼트의 참여자가 아니므로 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) + val invalidatedExpiresAt = tournamentJpaRepository.findByIdAndDeletedAtIsNull(tournamentId)!!.playLinkExpiresAt mockMvc .perform( @@ -3385,7 +3469,79 @@ class TournamentIntegrationTest : IntegrationTestSupport() { .header(HttpHeaders.AUTHORIZATION, authHeader(userId)) .contentType(MediaType.APPLICATION_JSON) .content("{}"), - ).andExpect(status().isConflict) + ).andExpect(status().isForbidden) + + // 403 만 보면 "거부는 했는데 그 전에 값은 갱신해 버린" 회귀를 못 잡는다. 무효화 시각이 그대로여야 + // 주최자가 의도적으로 끊은 링크가 되살아나지 않았다고 말할 수 있다. + assertEquals(invalidatedExpiresAt, tournamentJpaRepository.findByIdAndDeletedAtIsNull(tournamentId)!!.playLinkExpiresAt) + } + + // 복제 토너먼트에서는 공유 자체가 불가하다 — 아이템 추가 금지(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