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 07b94c15..789d1d16 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 @@ -853,7 +854,7 @@ interface TournamentApi { ), ApiResponse( responseCode = "409", - description = "상태 충돌 (플레이 링크 만료)", + description = "상태 충돌 (플레이 링크 만료 `TOURNAMENT-027` · 참여 인원 초과 `TOURNAMENT-030`)", content = [Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = Schema(implementation = ApiResponseBody::class))], ), ], @@ -863,6 +864,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 fd16281c..0b8391a2 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt @@ -715,6 +715,33 @@ class TournamentApiExamples( 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 = "참여 시작 또는 기존 참여 이어하기 (항상 원본 토너먼트 id)", + 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 e8a27b83..ac348198 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 00000000..212fa4c3 --- /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 1e1ea2ea..456a31ca 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt @@ -1001,7 +1001,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 = @@ -1084,11 +1094,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) @@ -1469,3 +1544,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 fb925ff3..22ae0ef4 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt @@ -33,6 +33,7 @@ import com.depromeet.piki.tournament.repository.TournamentJpaRepository import com.depromeet.piki.tournament.repository.TournamentUserJpaRepository import com.depromeet.piki.tournament.service.CloneRedirectMetrics 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 @@ -72,6 +73,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 @@ -4645,6 +4648,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")