From 22f661329006307fbb25044b03841b81906b063c Mon Sep 17 00:00:00 2001 From: sevineleven Date: Tue, 15 Sep 2026 16:58:43 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=99=84=EB=A3=8C=EB=90=9C=20=ED=86=A0?= =?UTF-8?q?=EB=84=88=EB=A8=BC=ED=8A=B8=EC=97=90=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=85=EC=9E=A5=20(#1013)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 완료된 토너먼트를 남에게 넘기는 방법이 링크 하나뿐이었다. 6자리 코드는 생성 시점에 발급돼 바뀌지 않는데 대기실 합류에만 쓰여, 완료 후에는 죽은 값이 됐다. 오프라인에서 불러주거나 손으로 입력하는 자리를 지원하지 못한다. POST /tournaments/from-play-code 를 추가한다. 경로에 id 를 두지 않는다 - 코드로 들어오는 사람은 토너먼트 id 를 모르고, 그게 이 경로의 요점이다. 코드는 주소일 뿐이고 무엇이 열려 있는지는 상태가 정한다. 완료 상태의 게이트를 playLinkExpiresAt 으로 둬 링크와 같은 수명을 공유하게 했다. 그래서 공유를 켜지 않은 토너먼트는 코드만으로 뚫리지 않고, 주최자 탈퇴로 무효화된 경우도 함께 막히며, 만료 후 재발급(#980)하면 코드도 살아난다. 정원 8명을 링크 경로에도 걸었다. 코드만 막으면 "링크를 달라고 하면 들어가지는" 상태가 되어 제한이 이름뿐이 된다. prod 실측상 공유된 토너먼트 37개의 입장자가 최대 4명이라 기존 사용은 깨지지 않는다. 미리보기도 입장과 같은 게이트를 쓰게 했다. 예전엔 checkJoinable(null) 하나라 완주한 토너먼트가 409 로 막혀, 코드로 들어오기 전에 무엇을 보게 될지 알 수 없었다. 작업 중 "완료" 판정이 옮겨져 있던 것을 발견했다 - Phase 3(#1044) 이후 진행 상태가 참여 행으로 가서 tournaments.status 는 주최자의 완주를 뜻하지 않는다. createPlayLink 와 같은 기준(주최자 참여 행)을 쓰지 않으면 "링크는 만들어졌는데 코드로는 못 들어가는" 어긋남이 생긴다. #1027 Phase 1-3 덕분에 클론 재공유와 source 평탄화는 이 이슈에서 빠졌다. 클론 개념이 없어져 누가 어느 주소로 들어오든 참여 행이 하나 생긴다. --- .../tournament/controller/TournamentApi.kt | 55 +++++- .../controller/TournamentApiExamples.kt | 28 ++- .../controller/TournamentController.kt | 12 ++ .../dto/CreateFromPlayCodeRequest.kt | 18 ++ .../tournament/service/TournamentService.kt | 86 ++++++++- .../controller/TournamentIntegrationTest.kt | 179 ++++++++++++++++++ 6 files changed, 371 insertions(+), 7 deletions(-) create mode 100644 src/main/kotlin/com/depromeet/piki/tournament/controller/dto/CreateFromPlayCodeRequest.kt 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 47064cf7e..72f7fba9b 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt @@ -2,6 +2,7 @@ package com.depromeet.piki.tournament.controller import com.depromeet.piki.auth.web.ClientType import com.depromeet.piki.common.response.ApiResponseBody +import com.depromeet.piki.tournament.controller.dto.CreateFromPlayCodeRequest import com.depromeet.piki.tournament.controller.dto.CreateTournamentRequest import com.depromeet.piki.tournament.controller.dto.CreateTournamentResponse import com.depromeet.piki.tournament.controller.dto.GroupResultResponse @@ -843,7 +844,7 @@ interface TournamentApi { ), ApiResponse( responseCode = "409", - description = "상태 충돌 (플레이 링크 만료)", + description = "상태 충돌 (플레이 링크 만료 `TOURNAMENT-027` · 참여 인원 초과 `TOURNAMENT-030`)", content = [Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = Schema(implementation = ApiResponseBody::class))], ), ], @@ -853,6 +854,58 @@ interface TournamentApi { @Parameter(description = "원본 토너먼트 ID", example = "1") sourceTournamentId: Long, ): ApiResponseBody + @Operation( + summary = "완료된 토너먼트에 코드로 입장", + description = + "완료된 토너먼트의 6자리 코드로 입장해 내 플레이를 만든다. `from-play-link` 와 같은 일을 하고 **주소만 다르다** " + + "- 링크를 열 수 없는 자리(오프라인에서 코드를 불러주는 등)를 위한 경로다.\n\n" + + "**코드는 초대 코드와 같은 값이다.** 토너먼트 생성 시점에 발급돼 바뀌지 않으며, 상태에 따라 다른 문을 연다.\n\n" + + "| 대상 상태 | 이 API | 게이트 |\n" + + "|---|---|---|\n" + + "| PENDING | **409** (`TOURNAMENT-023`) — `POST /{id}/join` 을 쓴다 | - |\n" + + "| COMPLETED | 플레이 생성 | 공유 링크가 유효해야 한다 (14일) |\n\n" + + "**공유를 켜지 않은 토너먼트는 코드로도 열리지 않는다** - 주최자가 공유 링크를 만들지 않았으면 404(`TOURNAMENT-026`). " + + "주최자가 나가 링크가 무효화된 경우도 같은 게이트에 걸린다. 만료 후 재발급하면 코드도 함께 살아난다.\n\n" + + "**멱등하다.** 이미 참여 중이면 기존 토너먼트 id 를 그대로 200 으로 돌려준다(링크 만료와 무관). " + + "그래서 201 이 아니라 200 이다.\n\n" + + "**정원은 8명이다**(주최자 포함, 참여 행 기준). 초대·링크·코드가 같은 제한을 공유한다.", + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "입장 성공 또는 기존 참여 반환 (플레이할 토너먼트 id)", + content = [Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = Schema(implementation = ApiResponseBody::class))], + ), + ApiResponse( + responseCode = "400", + description = "코드 형식이 아님 (영문 대문자 3 + 숫자 3) · 해당 코드의 토너먼트 없음(`TOURNAMENT-020`)", + content = [Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = Schema(implementation = ApiResponseBody::class))], + ), + ApiResponse( + responseCode = "401", + description = "미인증 (JWT 토큰 없음 또는 유효하지 않음)", + content = [Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = Schema(implementation = ApiResponseBody::class))], + ), + ApiResponse( + responseCode = "404", + description = "공유 링크가 만들어지지 않은 토너먼트 (`TOURNAMENT-026`)", + content = [Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = Schema(implementation = ApiResponseBody::class))], + ), + ApiResponse( + responseCode = "409", + description = + "완료되지 않은 토너먼트(`TOURNAMENT-023`) · 공유 링크 만료(`TOURNAMENT-027`) · " + + "참여 인원 초과(`TOURNAMENT-030`)", + content = [Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = Schema(implementation = ApiResponseBody::class))], + ), + ], + ) + fun createFromPlayCode( + @Parameter(hidden = true) userId: UUID, + request: CreateFromPlayCodeRequest, + ): ApiResponseBody + @Operation( summary = "친구 초대 마감 시각 수정", description = "PENDING 상태 토너먼트의 초대 마감 시각을 재설정한다. 주최자만 가능. 현재 시각 이후, 최대 24시간 이내의 절대 시각을 입력한다.", 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 be8dd21ea..9cf3216da 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt @@ -712,13 +712,39 @@ class TournamentApiExamples( operation.examples(openApiObjectMapper.delegate) { add( status = HttpStatus.OK, - name = "복제 토너먼트 생성 또는 기존 본인 클론 반환", + name = "참여 생성 또는 기존 참여 반환", payload = ApiResponseBody.ok(42L), ) unauthorized() add(TournamentException.notFoundTournament(), name = "토너먼트를 찾을 수 없음") add(TournamentException.playLinkNotCreated(), name = "플레이 링크가 생성되지 않은 토너먼트") add(TournamentException.playLinkExpired(), name = "플레이 링크 만료") + // 정원을 링크 경로에도 걸었다(#1013) — 코드 경로만 막으면 제한이 이름뿐이 된다. + add(TournamentException.participantLimitExceeded(), name = "참여 인원 초과") + } + + handlerMethod.binds(TournamentController::createFromPlayCode) -> + operation.examples(openApiObjectMapper.delegate) { + add( + status = HttpStatus.OK, + name = "참여 생성 또는 기존 참여 반환", + payload = ApiResponseBody.ok(42L), + ) + add( + status = HttpStatus.BAD_REQUEST, + name = "코드 형식 오류", + payload = + ApiResponseBody.fail( + CommonErrorCode.INVALID_INPUT, + detail = JoinTournamentAsGuestRequest.INVITE_CODE_PATTERN_MESSAGE, + ), + ) + add(TournamentException.invalidInviteCode(), name = "해당 코드의 토너먼트 없음") + unauthorized() + add(TournamentException.notCompletedTournament(), name = "완료되지 않은 토너먼트 (대기실은 join 을 쓴다)") + add(TournamentException.playLinkNotCreated(), name = "공유를 켜지 않은 토너먼트") + add(TournamentException.playLinkExpired(), name = "공유 링크 만료") + add(TournamentException.participantLimitExceeded(), name = "참여 인원 초과") } handlerMethod.binds(TournamentController::getGroupResult) -> diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentController.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentController.kt index 63395b054..280360f21 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentController.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentController.kt @@ -1,6 +1,7 @@ package com.depromeet.piki.tournament.controller import com.depromeet.piki.common.response.ApiResponseBody +import com.depromeet.piki.tournament.controller.dto.CreateFromPlayCodeRequest import com.depromeet.piki.tournament.controller.dto.CreateTournamentRequest import com.depromeet.piki.tournament.controller.dto.CreateTournamentResponse import com.depromeet.piki.tournament.controller.dto.GroupResultResponse @@ -178,6 +179,17 @@ class TournamentController( return ApiResponseBody.ok(tournamentId) } + // 경로에 id 를 두지 않는다 - 코드로 들어오는 사람은 토너먼트 id 를 모른다. 그게 이 경로의 요점이다. + @PostMapping("/from-play-code") + override fun createFromPlayCode( + @AuthenticationPrincipal userId: UUID, + @Valid @RequestBody request: CreateFromPlayCodeRequest, + ): ApiResponseBody { + // from-play-link 와 같은 idempotent get-or-create 라 201 이 아니라 200 이다. + val tournamentId = tournamentService.createFromPlayCode(userId, request.code) + return ApiResponseBody.ok(tournamentId) + } + @PatchMapping("/{tournamentId}/invite") override fun updateInviteExpiry( @AuthenticationPrincipal userId: UUID, diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/CreateFromPlayCodeRequest.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/CreateFromPlayCodeRequest.kt new file mode 100644 index 000000000..212fa4c3f --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/CreateFromPlayCodeRequest.kt @@ -0,0 +1,18 @@ +package com.depromeet.piki.tournament.controller.dto + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern + +@Schema(description = "완료된 토너먼트에 코드로 입장 (#1013)") +data class CreateFromPlayCodeRequest( + // 초대 코드와 같은 값이다. 완료 상태에서는 그 코드가 플레이 입장 주소로도 쓰인다 - 코드는 + // 생성 시점에 발급돼 바뀌지 않으므로 새로 발급할 것이 없다. + // + // 형식 검증을 여기 두면 오타가 서비스에 닿기 전에 걸리고, 로그에도 "형식은 맞는데 없는 코드" + // 와 "형식부터 틀린 값" 이 구분돼 남는다. + @field:NotBlank(message = JoinTournamentAsGuestRequest.INVITE_CODE_PATTERN_MESSAGE) + @field:Pattern(regexp = "[A-Z]{3}\\d{3}", message = JoinTournamentAsGuestRequest.INVITE_CODE_PATTERN_MESSAGE) + @field:Schema(description = "6자리 코드 (영문 대문자 3 + 숫자 3)", example = "ABC123") + val code: String, +) 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 7bdac1c5f..3bc00360d 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt @@ -993,7 +993,17 @@ class TournamentService( val tournament = tournamentRepository.findTournamentByInviteCode(code) ?: throw TournamentException.invalidInviteCode() - tournament.checkJoinable(null) + // 코드 하나가 상태에 따라 다른 문을 연다(#1013). 대기실이면 합류, 주최자가 완주했으면 플레이 + // 생성이다. 그래서 미리보기도 상태별로 게이트가 갈린다 - 예전엔 checkJoinable(null) 하나라 + // 완주한 토너먼트가 409 로 막혀, 코드로 들어온 사람이 무엇을 보게 될지 미리 볼 수 없었다. + // + // 입장 게이트(createFromPlayCode)와 같은 조건을 써야 "보이는데 못 들어가는" 어긋남이 안 생긴다. + if (isOwnerCompleted(tournament)) { + tournament.playLinkExpiresAt ?: throw TournamentException.playLinkNotCreated() + if (!tournament.isPlayLinkValid()) throw TournamentException.playLinkExpired() + } else { + tournament.checkJoinable(null) + } val itemCount = tournamentItemRepository.countByTournamentId(tournament.getId()) val participantCount = tournamentUserRepository.countByTournamentId(tournament.getId()) val joined = @@ -1076,11 +1086,76 @@ class TournamentService( root.playLinkExpiresAt ?: throw TournamentException.playLinkNotCreated() if (!root.isPlayLinkValid()) throw TournamentException.playLinkExpired() + return joinAsPlayer(root.getId(), userId) + } + + // 완료된 토너먼트에 **코드로** 입장한다(#1013). + // + // 링크 경로(createFromPlayLink)와 같은 일을 하고 주소만 다르다. 코드는 생성 시점에 발급돼 + // 바뀌지 않으므로 새 컬럼도 새 발급도 필요 없고, uk_tournaments_active_invite_code 가 활성 + // 토너먼트 전체에서 코드 유일성을 보장하므로 "코드 하나가 토너먼트 하나를 가리킨다" 가 이미 + // 성립한다(삭제행은 generated 컬럼이 NULL 이라 코드가 재활용된다). + // + // create 와 달리 회원 게이트를 두지 않는다 - 여기서 만들어지는 것은 참여 행이고, 참여자는 + // 아이템 추가가 막혀 있어 추출·LLM 비용을 만들 수 없다(링크 경로와 같은 판단). + @Transactional + fun createFromPlayCode( + userId: UUID, + code: String, + ): Long { + val tournament = + tournamentRepository.findTournamentByInviteCode(code) + ?: throw TournamentException.invalidInviteCode() + val root = tournamentRepository.findTournamentByIdForUpdate(tournament.getId()) + ?: throw TournamentException.notFoundTournament() + + // 이미 참여 행이 있으면 그대로 이어서 진행한다. 링크 경로와 같은 재진입 동작이다. + tournamentUserRepository.findByTournamentIdAndUserId(root.getId(), userId)?.let { return root.getId() } + + // 상태가 게이트를 정한다. 아직 공유할 단계가 아니면 초대 합류 경로(join)를 쓰라는 뜻으로 + // 막는다 - 그쪽은 정원·중복 검사와 발행 이벤트가 다르다. + // + // **"완료" 는 전역 status 가 아니라 주최자 참여 행으로 판정한다(#1027 Phase 3).** 진행 상태가 + // 참여 행으로 옮겨져 tournaments.status 는 더 이상 주최자의 완주를 뜻하지 않는다. + // createPlayLink 가 공유 가능 여부를 판정하는 기준과 같아야, "링크는 만들어졌는데 코드로는 + // 아직 못 들어가는" 어긋남이 안 생긴다. + if (!isOwnerCompleted(root)) throw TournamentException.notCompletedTournament() + root.playLinkExpiresAt ?: throw TournamentException.playLinkNotCreated() + if (!root.isPlayLinkValid()) throw TournamentException.playLinkExpired() + + return joinAsPlayer(root.getId(), userId) + } + + // 주최자가 자기 판을 완주했는지 — 곧 "공유 가능한 상태인지" 다. + // + // **전역 status 로 판정하지 않는다(#1027 Phase 3).** 진행 상태가 참여 행으로 옮겨져 + // tournaments.status 는 더 이상 주최자의 완주를 뜻하지 않는다. createPlayLink 가 쓰는 기준과 + // 같은 것을 써야 "링크는 만들어졌는데 코드로는 못 들어가는" 어긋남이 안 생긴다. + private fun isOwnerCompleted(tournament: Tournament): Boolean = + tournamentUserRepository + .findByIds(setOf(tournament.ownerTournamentUserId)) + .firstOrNull() + ?.isCompleted() ?: false + + // 링크·코드가 공유하는 참여 행 생성. 두 경로가 같은 일을 하므로 정원 검사와 알림도 같아야 한다. + // + // **정원을 링크 경로에도 건다(#1013).** 예전엔 초대 합류만 8명 제한이고 플레이 링크는 무제한이라, + // 코드 경로만 막으면 "코드로는 막히는데 링크를 달라고 하면 들어가지는" 상태가 되어 제한이 이름뿐이 + // 된다. 같은 동작이므로 주소와 무관하게 같은 제한을 건다. + // + // 실측상 기존 사용은 깨지지 않는다 - 공유된 토너먼트 37개의 입장자가 최대 4명이었다. + private fun joinAsPlayer( + rootId: Long, + userId: UUID, + ): Long { + if (tournamentUserRepository.countByTournamentId(rootId) >= TOURNAMENT_MAX_PARTICIPANT_COUNT) { + throw TournamentException.participantLimitExceeded() + } // 참여 행은 PENDING 으로 붙인다 — 이후 start(startAsMember)가 플레이 시작으로 전이한다. - tournamentUserRepository.save(TournamentUser(root.getId(), userId, nicknameOf(userId))) - // 플레이링크로 새로 참여해 플레이를 시작한 사실을 ROOT 주최자에게 알린다(#473). 신규 생성 분기에서만 발행한다. - eventPublisher.publishEvent(TournamentPlayedFromLink(rootTournamentId = root.getId(), actorId = userId)) - return root.getId() + tournamentUserRepository.save(TournamentUser(rootId, userId, nicknameOf(userId))) + // 새로 참여해 플레이를 시작한 사실을 ROOT 주최자에게 알린다(#473). 신규 생성 분기에서만 발행한다. + eventPublisher.publishEvent(TournamentPlayedFromLink(rootTournamentId = rootId, actorId = userId)) + return rootId } @Transactional(readOnly = true) @@ -1457,3 +1532,4 @@ internal fun Tournament.checkJoinable(inviteCode: String?) { if (!isInviteValid()) throw TournamentException.inviteExpired() inviteCode?.let { if (this.inviteCode != it) throw TournamentException.invalidInviteCode() } } + 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 6f1924211..70f68d8ab 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt @@ -32,6 +32,7 @@ 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.TOURNAMENT_MAX_PARTICIPANT_COUNT import com.depromeet.piki.tournament.service.TournamentErrorCode import com.depromeet.piki.user.domain.IdentityType import com.depromeet.piki.user.domain.User @@ -70,6 +71,8 @@ import java.time.format.DateTimeFormatter import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue @RecordApplicationEvents @@ -4600,6 +4603,182 @@ class TournamentIntegrationTest : IntegrationTestSupport() { assertEquals(1, events.size) } + // ---- #1013 완료된 토너먼트에 코드로 입장 ---------------------------------------------- + + @Test + fun `POST from-play-code 는 완료된 토너먼트의 코드로 입장해 ROOT id 를 돌려준다`() { + val mockMvc = buildMockMvc() + saveUser(otherUserId, "https://cdn.example.com/other.jpg", "다른유저") + val (rootId, _, _) = completeTournamentWith2Items(mockMvc) + val code = inviteCodeOf(rootId) + createPlayLink(mockMvc, rootId) + + val result = mockMvc + .perform( + post("/api/v1/tournaments/from-play-code") + .header(HttpHeaders.AUTHORIZATION, authHeader(otherUserId)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"code":"$code"}"""), + ).andExpect(status().isOk) + .andReturn() + + // 링크 경로와 같은 결과 - 클론이 아니라 ROOT 참여 행이 생긴다(#1027 이후). + assertEquals(rootId, objectMapper.readTree(result.response.contentAsString)["data"].asLong()) + assertNotNull(tournamentUserJpaRepository.findByTournamentIdAndUserIdAndDeletedAtIsNull(rootId, otherUserId)) + } + + // 이 이슈의 핵심 가드다. 코드는 생성 시점에 발급돼 늘 존재하므로, 게이트가 없으면 주최자가 + // 공유를 켜지 않은 토너먼트도 코드만 알면 뚫린다. + @Test + fun `POST from-play-code 는 공유를 켜지 않은 완료 토너먼트를 404 로 막는다`() { + val mockMvc = buildMockMvc() + saveUser(otherUserId, "https://cdn.example.com/other.jpg", "다른유저") + val (rootId, _, _) = completeTournamentWith2Items(mockMvc) + val code = inviteCodeOf(rootId) + // 플레이 링크를 만들지 않는다. + + mockMvc + .perform( + post("/api/v1/tournaments/from-play-code") + .header(HttpHeaders.AUTHORIZATION, authHeader(otherUserId)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"code":"$code"}"""), + ).andExpect(status().isNotFound) + .andExpect(jsonPath("$.code").value("TOURNAMENT-026")) + + assertNull(tournamentUserJpaRepository.findByTournamentIdAndUserIdAndDeletedAtIsNull(rootId, otherUserId)) + } + + // 회귀 가드: 대기실 코드는 종전대로 join 경로를 쓴다. 이 API 로 오면 409 로 돌려보낸다 - + // 두 경로는 정원·중복 검사와 발행 이벤트가 다르다. + @Test + fun `POST from-play-code 는 PENDING 토너먼트를 409 로 막는다`() { + val mockMvc = buildMockMvc() + saveUser(otherUserId, "https://cdn.example.com/other.jpg", "다른유저") + val (_, code) = createTournamentWithInviteCode(mockMvc) + + mockMvc + .perform( + post("/api/v1/tournaments/from-play-code") + .header(HttpHeaders.AUTHORIZATION, authHeader(otherUserId)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"code":"$code"}"""), + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("TOURNAMENT-023")) + } + + @Test + fun `POST from-play-code 는 없는 코드를 400 으로 막는다`() { + val mockMvc = buildMockMvc() + mockMvc + .perform( + post("/api/v1/tournaments/from-play-code") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"code":"ZZZ999"}"""), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("TOURNAMENT-020")) + } + + // 링크 경로와 같은 멱등 동작. 두 번 눌러도 참여 행이 늘지 않는다. + @Test + fun `POST from-play-code 는 이미 참여 중이면 같은 id 를 돌려준다`() { + val mockMvc = buildMockMvc() + saveUser(otherUserId, "https://cdn.example.com/other.jpg", "다른유저") + val (rootId, _, _) = completeTournamentWith2Items(mockMvc) + val code = inviteCodeOf(rootId) + createPlayLink(mockMvc, rootId) + + repeat(2) { + mockMvc + .perform( + post("/api/v1/tournaments/from-play-code") + .header(HttpHeaders.AUTHORIZATION, authHeader(otherUserId)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"code":"$code"}"""), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(rootId)) + } + assertEquals(2, tournamentUserJpaRepository.countByTournamentIdAndDeletedAtIsNull(rootId)) + } + + // 정원을 링크 경로에도 걸었다(#1013). 코드만 막으면 "링크 달라고 하면 들어가지는" 상태가 된다. + @Test + fun `POST from-play-link 는 정원이 차면 409 로 막는다`() { + val mockMvc = buildMockMvc() + val (rootId, _, _) = completeTournamentWith2Items(mockMvc) + createPlayLink(mockMvc, rootId) + + // 주최자 1명 + 7명 = 8명으로 정원을 채운다. + repeat(TOURNAMENT_MAX_PARTICIPANT_COUNT - 1) { i -> + val filler = UUID.randomUUID() + saveUser(filler, "https://cdn.example.com/f$i.jpg", "채움$i") + mockMvc + .perform( + post("/api/v1/tournaments/$rootId/from-play-link") + .header(HttpHeaders.AUTHORIZATION, authHeader(filler)), + ).andExpect(status().isOk) + } + assertEquals( + TOURNAMENT_MAX_PARTICIPANT_COUNT, + tournamentUserJpaRepository.countByTournamentIdAndDeletedAtIsNull(rootId), + ) + + saveUser(otherUserId, "https://cdn.example.com/other.jpg", "초과유저") + mockMvc + .perform( + post("/api/v1/tournaments/$rootId/from-play-link") + .header(HttpHeaders.AUTHORIZATION, authHeader(otherUserId)), + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("TOURNAMENT-030")) + } + + // 미리보기가 상태별로 갈린다. 예전엔 checkJoinable(null) 하나라 COMPLETED 가 409 로 막혀, + // 코드로 들어오기 전에 무엇을 보게 될지 알 수 없었다. + @Test + fun `GET by-invite-code 는 완료된 토너먼트도 미리보기를 내려준다`() { + val mockMvc = buildMockMvc() + val (rootId, _, _) = completeTournamentWith2Items(mockMvc) + val code = inviteCodeOf(rootId) + createPlayLink(mockMvc, rootId) + + mockMvc + .perform(get("/api/v1/tournaments/by-invite-code").param("code", code)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.tournamentId").value(rootId)) + .andExpect(jsonPath("$.data.itemCount").value(2)) + } + + // 공유를 안 켰으면 미리보기도 막힌다 - 입장이 막히는데 미리보기만 열리면 "보이는데 못 들어가는" + // 상태가 된다. + @Test + fun `GET by-invite-code 는 공유를 켜지 않은 완료 토너먼트를 404 로 막는다`() { + val mockMvc = buildMockMvc() + val (rootId, _, _) = completeTournamentWith2Items(mockMvc) + val code = inviteCodeOf(rootId) + + mockMvc + .perform(get("/api/v1/tournaments/by-invite-code").param("code", code)) + .andExpect(status().isNotFound) + .andExpect(jsonPath("$.code").value("TOURNAMENT-026")) + } + + private fun inviteCodeOf(tournamentId: Long): String = + tournamentJpaRepository.findByIdAndDeletedAtIsNull(tournamentId)!!.inviteCode + + private fun createPlayLink( + mockMvc: MockMvc, + tournamentId: Long, + ) { + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/play-link") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content("{}"), + ).andExpect(status().isOk) + } + private fun completeTournamentWith2Items(mockMvc: MockMvc): TournamentStart { val tournamentId = createTournament(mockMvc) val item1Id = saveWishItem(name = "아이템1")