Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -690,19 +690,31 @@ interface TournamentApi {
): ApiResponseBody<RecordMatchResponse>

@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(
Expand All @@ -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))],
),
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -665,7 +676,6 @@ class TournamentApiExamples(
)
add(TournamentException.notFoundTournament(), name = "토너먼트를 찾을 수 없음")
add(TournamentException.notCompletedTournament(), name = "완료되지 않은 토너먼트")
add(TournamentException.playLinkAlreadyCreated(), name = "플레이 링크 이미 생성됨")
}

handlerMethod.binds(TournamentController::getPlayLinkInfo) ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, "플레이 링크가 만료됐어요."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -3378,14 +3411,137 @@ 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)
Comment thread
sevineleven marked this conversation as resolved.
.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(
post("/api/v1/tournaments/$tournamentId/play-link")
.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
Expand Down
Loading