diff --git a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentHistoryJpaRepository.kt b/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentHistoryJpaRepository.kt index e2348e758..71b68b27e 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentHistoryJpaRepository.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentHistoryJpaRepository.kt @@ -2,6 +2,9 @@ package com.depromeet.piki.tournament.repository import com.depromeet.piki.tournament.domain.TournamentHistory import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.util.UUID interface TournamentHistoryJpaRepository : JpaRepository { fun findAllByTournamentIdAndTournamentUserIdAndDeletedAtIsNullOrderByCurrentRoundAscIdAsc( @@ -10,4 +13,37 @@ interface TournamentHistoryJpaRepository : JpaRepository fun findAllByTournamentIdInAndDeletedAtIsNull(tournamentIds: List): List + + /** + * 이 사용자가 **자기 플레이의 결승에서 고른** 상품들과, 그 우승이 난 토너먼트. 위시 카드의 우승 칩(#1236). + * + * 기준이 참여 행(TournamentUser)인 것이 핵심이다. 그룹 합산 순위가 아니라 "내가 선택한 1등" 이므로, + * 같은 소셜 토너먼트라도 사람마다 답이 다르다. 결승 한 행만 보면 되어 순위 전체를 계산할 필요가 없다 + * (TournamentService.computeRanking 은 2·3위까지 필요할 때만 쓴다). + * + * 삭제된 참여 행은 빠진다 — 토너먼트를 지우면 칩도 사라져야 한다는 기획 요구가 여기서 충족된다. + * 우승을 따로 저장하지 않고 매 조회에 계산하는 이유도 이것이다. 저장하면 삭제와 동기화하는 부채가 생긴다. + * + * 출전(TournamentItem)은 상품을 버전(snapshot)으로 가리키므로 itemId 까지 두 단계를 더 탄다. + * FK·연관관계를 두지 않는 규약이라 암시적 조인으로 엮는다(TournamentItemJpaRepository 의 선례와 같은 형태). + * + * itemIds 로 먼저 좁혀, 한 페이지에 보이는 상품만 본다. 위시가 수백 건이어도 조회 비용이 페이지 크기에 묶인다. + * + * 정렬은 결승 기록 id 역순 — 곧 "우승한 순서의 역순"이다. 참여 행의 완료 시각(completedAt)을 쓰지 않는 이유는 + * 그 값이 완료 판정의 출처가 아니어서(#1027) 레거시 행에 비어 있을 수 있기 때문이다. 기록 id 는 항상 있다. + */ + @Query( + "SELECT s.itemId AS itemId, h.tournamentId AS tournamentId " + + "FROM TournamentHistory h, TournamentUser tu, TournamentItem ti, ItemSnapshot s " + + "WHERE h.tournamentUserId = tu.id AND tu.userId = :userId AND tu.deletedAt IS NULL " + + "AND h.currentRound = :finalRound AND h.deletedAt IS NULL " + + "AND ti.id = h.selectedTournamentItemId AND ti.deletedAt IS NULL " + + "AND s.id = ti.snapshotId AND s.itemId IN :itemIds " + + "ORDER BY h.id DESC", + ) + fun findWinnerItems( + @Param("userId") userId: UUID, + @Param("itemIds") itemIds: Collection, + @Param("finalRound") finalRound: Int, + ): List } diff --git a/src/main/kotlin/com/depromeet/piki/tournament/repository/WinnerItemView.kt b/src/main/kotlin/com/depromeet/piki/tournament/repository/WinnerItemView.kt new file mode 100644 index 000000000..36f91902b --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/tournament/repository/WinnerItemView.kt @@ -0,0 +1,8 @@ +package com.depromeet.piki.tournament.repository + +// 우승한 상품과 그 우승이 난 토너먼트 좌표 — 위시 카드의 우승 칩(#1236)이 역추적에 쓴다. +// 한 상품이 여러 판에서 이길 수 있어 itemId 당 여러 행이 나온다. Spring Data interface projection. +interface WinnerItemView { + val itemId: Long + val tournamentId: Long +} diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentWinnerReader.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentWinnerReader.kt new file mode 100644 index 000000000..f5a63ed3a --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentWinnerReader.kt @@ -0,0 +1,44 @@ +package com.depromeet.piki.tournament.service + +import com.depromeet.piki.tournament.domain.Tournament +import com.depromeet.piki.tournament.repository.TournamentHistoryJpaRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +/** + * "이 사용자가 우승시킨 상품" 을 읽는다. 위시 목록의 우승 칩(#1236)이 쓴다. + * + * 토너먼트 바깥(위시)에서 필요한 질문이지만 판정 규칙은 토너먼트의 것이라 여기 둔다. 결승 라운드 번호가 + * Tournament 의 internal 상수인 것도 같은 이유다 — 그 값을 호출자가 알아야 한다면 경계가 샌 것이다. + * + * TournamentService 에 얹지 않는다. 그쪽은 플레이 진행·결과 집계를 맡는 큰 서비스라, 조회 한 줄을 더하면 + * 읽는 사람이 "우승 칩도 저 안 어딘가" 를 뒤져야 한다. 질문이 작으므로 자리도 작게 둔다. + */ +@Service +class TournamentWinnerReader( + private val tournamentHistoryJpaRepository: TournamentHistoryJpaRepository, +) { + /** + * [itemIds] 중 이 사용자가 **자기 플레이의 결승에서 고른** 상품 → 그 우승이 난 토너먼트들(최신 우승 순). + * + * 호출자가 관심 있는 상품만 넘기고 그 부분집합을 돌려받는다. 한 페이지에 보이는 것만 묻게 되어 + * 위시가 수백 건이어도 비용이 페이지 크기에 묶인다. 결과에 없는 itemId 는 우승 이력이 없다는 뜻이다. + * + * 한 상품이 여러 판에서 이길 수 있어 값이 리스트다. 어느 하나를 서버가 고르지 않고 전부 넘긴다 — + * "이 상품이 이긴 판들" 을 보여줄지 최신 하나만 쓸지는 화면이 정할 일이다. + * + * 빈 입력에 쿼리를 날리지 않는다 — `IN ()` 은 DB 마다 문법이 갈리고, 어차피 답이 빈 맵이다. + */ + @Transactional(readOnly = true) + fun findWinnerTournamentIdsByItem( + userId: UUID, + itemIds: Collection, + ): Map> { + if (itemIds.isEmpty()) return emptyMap() + // 쿼리가 최신 우승 순으로 정렬해 돌려주므로 groupBy 가 그 순서를 그대로 유지한다. + return tournamentHistoryJpaRepository + .findWinnerItems(userId, itemIds, Tournament.FINAL_ROUND_SIZE) + .groupBy({ it.itemId }, { it.tournamentId }) + } +} diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt index 2c6d01c4c..8a1df0e4f 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/WishlistApiExamples.kt @@ -83,7 +83,8 @@ class WishlistApiExamples( name = "조회 성공 (다음 페이지 있음)", payload = ApiResponseBody.ok( - data = listOf(sampleEntry), + // 우승 칩이 붙은 항목과 아닌 항목을 함께 둔다 — 항목마다 갈리는 값임을 드러낸다. + data = listOf(winnerSampleEntry, sampleEntry), pageResponse = PageResponse(nextCursor = "1024", hasNext = true), ), ) @@ -417,6 +418,9 @@ class WishlistApiExamples( // URL 등록 응답 전용(#853) — 등록 경로는 attach 메타(reused·refreshNeeded)가 non-null 로 내려간다. // 새 파싱을 만든 등록: 캐시에 붙지 않았으므로 둘 다 false. + // 우승 칩이 붙는 항목(#1236) — 두 판에서 이긴 상품이라 최신 우승이 앞에 온다. 그 토너먼트를 지우면 id 가 빠진다. + private val winnerSampleEntry = sampleEntry.copy(winnerTournamentIds = listOf(1402, 1397)) + private val pendingRegistrationEntry = pendingSampleEntry.copy(reused = false, refreshNeeded = false) // 파싱 없이 다른 등록의 완성 값(캐시)에 붙은 등록 — 값이 갱신 권고 임계(24h)보다 낡아 refreshNeeded=true. diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishDetailResponse.kt b/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishDetailResponse.kt index 473b06850..6ed0dec89 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishDetailResponse.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishDetailResponse.kt @@ -33,6 +33,11 @@ data class WishDetailResponse( "가격 기록이 하나도 없으면 빈 배열이다(그때는 item.status 로 대기와 실패를 가른다).", ) val priceHistory: List, + @field:Schema( + description = "이 상품이 우승한 내 토너먼트들 — 목록 응답의 같은 필드와 같은 규칙·순서로 내려간다.", + example = "[1402, 1397]", + ) + val winnerTournamentIds: List = emptyList(), ) { companion object { // requesterId 는 editedByMe(수기 버전을 내가 넣었는지) 파생용 — 편집자 식별자(UUID)는 응답에 노출하지 않는다. @@ -45,6 +50,7 @@ data class WishDetailResponse( memo = detail.wish.memo, item = WishItemResponse.ItemView.from(detail.item, detail.snapshot, detail.sourcePlatform), priceHistory = detail.history.map { PriceHistoryEntry.from(it, requesterId) }, + winnerTournamentIds = detail.winnerTournamentIds, ) } diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishItemResponse.kt b/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishItemResponse.kt index 946105510..0ba916718 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishItemResponse.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/controller/dto/WishItemResponse.kt @@ -30,6 +30,14 @@ data class WishItemResponse( nullable = true, ) val refreshNeeded: Boolean? = null, + @field:Schema( + description = + "이 상품이 우승한 내 토너먼트들 — 최신 우승 순이고, 비어 있으면 우승 이력이 없다(위시 카드의 우승 칩). " + + "기준은 그룹 합산 순위가 아니라 내가 플레이한 결과라, 같은 소셜 토너먼트라도 참여자마다 다르다. " + + "한 상품이 여러 판에서 이길 수 있어 배열이며, 그 토너먼트를 삭제하면 해당 id 가 빠진다.", + example = "[1402, 1397]", + ) + val winnerTournamentIds: List = emptyList(), ) { companion object { fun from( @@ -38,6 +46,7 @@ data class WishItemResponse( WishItemResponse( wish = WishView.from(result.wish), item = ItemView.from(result), + winnerTournamentIds = result.winnerTournamentIds, ) // URL 등록 응답 전용 — 공유 attach 메타(#853)까지 싣는다. 등록만 이 오버로드를 쓰고, diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt index f9e7881d8..d061dbda2 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt @@ -12,6 +12,7 @@ import com.depromeet.piki.item.domain.ItemErrorCode import com.depromeet.piki.item.domain.ItemSnapshot import com.depromeet.piki.item.repository.ItemRepository import com.depromeet.piki.item.repository.ItemSnapshotRepository +import com.depromeet.piki.tournament.service.TournamentWinnerReader import com.depromeet.piki.item.service.ItemDisplayService import com.depromeet.piki.item.service.ItemRegistrar import com.depromeet.piki.product.domain.ProductLink @@ -45,6 +46,7 @@ class WishlistService( private val itemQuotaGuard: ItemQuotaGuard, private val userService: UserService, private val sourcePlatformResolver: SourcePlatformResolver, + private val tournamentWinnerReader: TournamentWinnerReader, ) { private fun WishWithItem.withSourcePlatform(): WishWithItem = copy(sourcePlatform = sourcePlatformResolver.resolve(item.link)) @@ -107,6 +109,8 @@ class WishlistService( // 위시는 상품(itemId)을 직접 참조하므로 상품과 표시값을 그 키로 끌어온다. val displayByCard = itemDisplayService.resolveDisplay(pageWishes.map { it.displayCard() }) val itemsById = itemRepository.findByIds(pageWishes.map { it.itemId }).associateBy { it.getId() } + // 우승 칩(#1236) — 이 페이지에 보이는 상품만 한 번에 물어 N+1 을 만들지 않는다(표시값·상품 조회와 같은 결). + val winnersByItem = tournamentWinnerReader.findWinnerTournamentIdsByItem(userId, pageWishes.map { it.itemId }) val entries = pageWishes.map { wish -> // item 은 wish 와 함께 영속화되며 병합 시 함께 옮겨진다(WishItemMergeListener). 없으면 영속화 경로가 깨진 코드 버그다. @@ -115,6 +119,7 @@ class WishlistService( wish = wish, item = item, snapshot = displayByCard.getValue(wish.displayCard()), + winnerTournamentIds = winnersByItem[wish.itemId].orEmpty(), ).withSourcePlatform() } @@ -145,12 +150,15 @@ class WishlistService( val item = itemRepository.findById(wish.itemId) ?: error("wish ${wish.getId()} 의 item ${wish.itemId} 가 없다") // 표시값 파생(#857·#1051) — 목록(getWishlist)과 같은 규칙. val history = itemSnapshotRepository.findPriceHistoryByItemId(wish.itemId, PRICE_HISTORY_LIMIT) + // 우승 칩(#1236) — 목록과 같은 규칙으로 판정한다. 상세는 상품이 하나라 그 하나만 묻는다. + val winners = tournamentWinnerReader.findWinnerTournamentIdsByItem(userId, listOf(wish.itemId)) return WishDetail( wish = wish, item = item, snapshot = itemDisplayService.resolveDisplay(wish.displayCard()), history = history, sourcePlatform = sourcePlatformResolver.resolve(item.link), + winnerTournamentIds = winners[wish.itemId].orEmpty(), ) } diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishDetail.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishDetail.kt index f63d7c84e..fd4a41f02 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishDetail.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishDetail.kt @@ -17,4 +17,6 @@ data class WishDetail( val snapshot: ItemSnapshot, val history: List, val sourcePlatform: String?, + // 이 상품이 우승한 내 토너먼트들(#1236, 최신 우승 순). 목록과 같은 규칙으로 판정해 두 화면이 어긋나지 않게 한다. + val winnerTournamentIds: List = emptyList(), ) diff --git a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishWithItem.kt b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishWithItem.kt index e42fbb0b4..446eba498 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishWithItem.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/dto/WishWithItem.kt @@ -8,6 +8,8 @@ import com.depromeet.piki.wishlist.domain.Wish // 표시값(name/price/image/status)은 snapshot 에서, 정체성(id·sourceUrl=link)은 item 에서 온다. // reused·refreshNeeded 는 공유 등록(#853)의 attach 메타 — 등록 경로만 채우고 그 외(조회·수기 수정 등)는 기본값 false. // sourcePlatform 은 응답 경계(WishlistService)가 링크에서 판정해 채운다. 영속화 계층은 비워 둔다. +// winnerTournamentIds 도 같은 자리에서 채운다 — 토너먼트에서 파생하는 값이라 위시 영속화와 무관하고, +// 등록 직후 응답에는 쓸 일이 없어 기본값이 빈 목록이다(막 담은 상품이 우승했을 리 없다). data class WishWithItem( val wish: Wish, val item: Item, @@ -15,4 +17,5 @@ data class WishWithItem( val reused: Boolean = false, val refreshNeeded: Boolean = false, val sourcePlatform: String? = null, + val winnerTournamentIds: List = emptyList(), ) diff --git a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishWinnerChipIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishWinnerChipIntegrationTest.kt new file mode 100644 index 000000000..6979a40c6 --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishWinnerChipIntegrationTest.kt @@ -0,0 +1,374 @@ +package com.depromeet.piki.wishlist.controller + +import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider +import com.depromeet.piki.item.domain.Item +import com.depromeet.piki.item.domain.ItemSnapshot +import com.depromeet.piki.item.repository.ItemJpaRepository +import com.depromeet.piki.item.repository.ItemSnapshotJpaRepository +import com.depromeet.piki.item.service.ItemParsingService +import com.depromeet.piki.product.service.ProductSnapshot +import com.depromeet.piki.support.IntegrationTestSupport +import com.depromeet.piki.tournament.repository.TournamentItemJpaRepository +import com.depromeet.piki.user.domain.IdentityType +import com.depromeet.piki.user.domain.User +import com.depromeet.piki.user.repository.UserJpaRepository +import com.depromeet.piki.wishlist.domain.Wish +import com.depromeet.piki.wishlist.repository.WishJpaRepository +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.context.WebApplicationContext +import tools.jackson.databind.ObjectMapper +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +// 위시 카드의 우승 칩(#1236) 계약. 판정 기준이 "그룹 합산 순위" 가 아니라 "내가 플레이한 결승에서 내가 고른 것" 이라 +// 사람마다 답이 다르고, 같은 토너먼트에서도 참여자별로 갈린다. 그 갈림을 여기서 고정한다. +// +// 아이템 2개로 시작하면 첫 매치가 곧 결승(round=2)이라, 우승 판정에 필요한 최소 상태를 한 번의 기록으로 만든다. +@Transactional +class WishWinnerChipIntegrationTest : IntegrationTestSupport() { + @Autowired private lateinit var webApplicationContext: WebApplicationContext + + @Autowired private lateinit var objectMapper: ObjectMapper + + @Autowired private lateinit var tournamentItemJpaRepository: TournamentItemJpaRepository + + @Autowired private lateinit var itemSnapshotJpaRepository: ItemSnapshotJpaRepository + + @Autowired private lateinit var itemParsingService: ItemParsingService + + @Autowired private lateinit var itemJpaRepository: ItemJpaRepository + + @Autowired private lateinit var wishJpaRepository: WishJpaRepository + + @Autowired private lateinit var userJpaRepository: UserJpaRepository + + @Autowired private lateinit var jwtProvider: JwtProvider + + private val ownerId: UUID = UUID.fromString("a1111111-2222-3333-4444-555555555555") + + private val guestPlayerId: UUID = UUID.fromString("b1111111-2222-3333-4444-555555555555") + + @Test + fun `내 토너먼트 결승에서 고른 상품은 위시 목록에 우승 칩이 붙는다`() { + val mockMvc = buildMockMvc() + saveUser(ownerId, "주최자") + val winnerItemId = saveWishItem(ownerId, "우승 상품") + val loserItemId = saveWishItem(ownerId, "탈락 상품") + val tournamentId = startTournamentWith(mockMvc, listOf(winnerItemId, loserItemId)) + val cards = tournamentItemIdsOf(tournamentId) + val winnerCard = cardIdOf(cards, winnerItemId) + + playFinal(mockMvc, tournamentId, cards, winner = winnerCard) + + assertEquals(listOf(tournamentId), winnersInList(mockMvc, winnerItemId), "결승에서 고른 상품이 그 토너먼트를 가리키지 않는다") + assertEquals(emptyList(), winnersInList(mockMvc, loserItemId), "탈락한 상품에 칩이 붙었다") + } + + @Test + fun `우승 칩은 위시 상세에서도 목록과 같게 내려온다`() { + val mockMvc = buildMockMvc() + saveUser(ownerId, "주최자") + val winnerItemId = saveWishItem(ownerId, "우승 상품") + val loserItemId = saveWishItem(ownerId, "탈락 상품") + val tournamentId = startTournamentWith(mockMvc, listOf(winnerItemId, loserItemId)) + val cards = tournamentItemIdsOf(tournamentId) + playFinal(mockMvc, tournamentId, cards, winner = cardIdOf(cards, winnerItemId)) + + // 목록과 상세가 같은 규칙을 쓰는지 — 두 화면이 어긋나면 칩이 깜빡이는 것처럼 보인다. + assertEquals(listOf(tournamentId), winnersInDetail(mockMvc, wishIdOf(ownerId, winnerItemId))) + assertEquals(emptyList(), winnersInDetail(mockMvc, wishIdOf(ownerId, loserItemId))) + } + + @Test + fun `한 토너먼트에서 탈락해도 다른 토너먼트에서 우승했으면 칩이 남는다`() { + val mockMvc = buildMockMvc() + saveUser(ownerId, "주최자") + val itemId = saveWishItem(ownerId, "한 번은 우승한 상품") + val rivalA = saveWishItem(ownerId, "상대 A") + val rivalB = saveWishItem(ownerId, "상대 B") + + // 1차: 우승 + val first = startTournamentWith(mockMvc, listOf(itemId, rivalA)) + val firstCards = tournamentItemIdsOf(first) + playFinal(mockMvc, first, firstCards, winner = cardIdOf(firstCards, itemId)) + + // 2차: 같은 상품이 탈락 + val second = startTournamentWith(mockMvc, listOf(itemId, rivalB)) + val secondCards = tournamentItemIdsOf(second) + playFinal(mockMvc, second, secondCards, winner = cardIdOf(secondCards, rivalB)) + + assertEquals(listOf(first), winnersInList(mockMvc, itemId), "뒤 토너먼트의 탈락이 앞의 우승을 지웠다") + assertEquals(listOf(second), winnersInList(mockMvc, rivalB), "2차 우승자에 칩이 없다") + } + + @Test + fun `여러 판에서 이긴 상품은 최신 우승이 앞에 오고 한 판만 지우면 그것만 빠진다`() { + val mockMvc = buildMockMvc() + saveUser(ownerId, "주최자") + val itemId = saveWishItem(ownerId, "두 번 우승한 상품") + val rivalA = saveWishItem(ownerId, "상대 A") + val rivalB = saveWishItem(ownerId, "상대 B") + + val first = startTournamentWith(mockMvc, listOf(itemId, rivalA)) + val firstCards = tournamentItemIdsOf(first) + playFinal(mockMvc, first, firstCards, winner = cardIdOf(firstCards, itemId)) + + val second = startTournamentWith(mockMvc, listOf(itemId, rivalB)) + val secondCards = tournamentItemIdsOf(second) + playFinal(mockMvc, second, secondCards, winner = cardIdOf(secondCards, itemId)) + + // 나중에 이긴 판이 앞 — 칩을 눌러 한 곳으로 보낼 때 가장 최근 승부로 가게 된다. + assertEquals(listOf(second, first), winnersInList(mockMvc, itemId), "최신 우승이 앞에 오지 않는다") + + mockMvc + .perform( + delete("/api/v1/tournaments/$second") + .header(HttpHeaders.AUTHORIZATION, authHeader(ownerId)), + ).andExpect(status().isOk) + + // 지운 판만 빠지고 나머지 우승은 남는다 — 칩 자체는 그대로다. + assertEquals(listOf(first), winnersInList(mockMvc, itemId), "지운 판 외의 우승까지 사라졌다") + } + + @Test + fun `토너먼트를 삭제하면 그 우승으로 붙었던 칩이 사라진다`() { + val mockMvc = buildMockMvc() + saveUser(ownerId, "주최자") + val winnerItemId = saveWishItem(ownerId, "우승 상품") + val loserItemId = saveWishItem(ownerId, "탈락 상품") + val tournamentId = startTournamentWith(mockMvc, listOf(winnerItemId, loserItemId)) + val cards = tournamentItemIdsOf(tournamentId) + playFinal(mockMvc, tournamentId, cards, winner = cardIdOf(cards, winnerItemId)) + assertTrue(winnersInList(mockMvc, winnerItemId).isNotEmpty(), "삭제 전부터 칩이 없어 이 테스트가 의미를 잃는다") + + mockMvc + .perform( + delete("/api/v1/tournaments/$tournamentId") + .header(HttpHeaders.AUTHORIZATION, authHeader(ownerId)), + ).andExpect(status().isOk) + + assertEquals(emptyList(), winnersInList(mockMvc, winnerItemId), "토너먼트를 지웠는데 칩이 남았다") + } + + @Test + fun `같은 토너먼트라도 다른 참여자가 고른 상품은 내 칩이 되지 않는다`() { + val mockMvc = buildMockMvc() + saveUser(ownerId, "주최자") + saveUser(guestPlayerId, "참여자") + val itemA = saveWishItem(ownerId, "주최자가 고를 상품") + val itemB = saveWishItem(ownerId, "참여자가 고를 상품") + // 참여자는 시작 전에 초대로 들어온다 — 시작 뒤에는 합류할 수 없다. + val tournamentId = startTournamentWith(mockMvc, listOf(itemA, itemB), invite = guestPlayerId) + val cards = tournamentItemIdsOf(tournamentId) + + // 같은 판을 둘이 각자 플레이해 서로 다른 결론을 낸다. 참여자는 자기 start 로 플레이를 열어야 한다(#1027). + playFinal(mockMvc, tournamentId, cards, winner = cardIdOf(cards, itemA), player = ownerId) + startPlaying(mockMvc, tournamentId, guestPlayerId) + playFinal(mockMvc, tournamentId, cards, winner = cardIdOf(cards, itemB), player = guestPlayerId) + + // 내 위시 목록은 내 플레이만 반영한다. 참여자가 고른 B 가 섞이면 "내가 선택한 1등" 이 아니게 된다. + assertEquals(listOf(tournamentId), winnersInList(mockMvc, itemA), "내가 고른 상품에 칩이 없다") + assertEquals(emptyList(), winnersInList(mockMvc, itemB), "다른 참여자가 고른 상품이 내 칩으로 새어 들어왔다") + } + + @Test + fun `토너먼트를 한 번도 돌리지 않으면 칩이 없다`() { + val mockMvc = buildMockMvc() + saveUser(ownerId, "주최자") + val itemId = saveWishItem(ownerId, "담기만 한 상품") + + assertEquals(emptyList(), winnersInList(mockMvc, itemId)) + } + + // ── 헬퍼 ─────────────────────────────────────────────────────────────── + + // 목록에서 그 상품이 이긴 토너먼트 id 들(응답 순서 그대로 — 최신 우승이 앞). + private fun winnersInList( + mockMvc: MockMvc, + itemId: Long, + ): List { + val result = + mockMvc + .perform( + get("/api/v1/wishlists") + .header(HttpHeaders.AUTHORIZATION, authHeader(ownerId)), + ).andExpect(status().isOk) + .andReturn() + val entries = objectMapper.readTree(result.response.contentAsString)["data"] + val entry = + entries.firstOrNull { it["item"]["id"].asLong() == itemId } + ?: error("위시 목록에 item $itemId 가 없다") + // JsonNode 자체의 map(Jackson 3)이 Kotlin 의 Iterable.map 보다 먼저 잡혀 원소 변환이 안 된다 — + // 인덱스로 훑는 이 repo 의 선례(OpenApiDocsIntegrationTest 등)를 따른다. + val ids = entry["winnerTournamentIds"] + return (0 until ids.size()).map { ids.path(it).asLong() } + } + + private fun winnersInDetail( + mockMvc: MockMvc, + wishId: Long, + ): List { + val result = + mockMvc + .perform( + get("/api/v1/wishlists/$wishId") + .header(HttpHeaders.AUTHORIZATION, authHeader(ownerId)), + ).andExpect(status().isOk) + .andReturn() + val ids = objectMapper.readTree(result.response.contentAsString)["data"]["winnerTournamentIds"] + return (0 until ids.size()).map { ids.path(it).asLong() } + } + + private fun wishIdOf( + userId: UUID, + itemId: Long, + ): Long = + wishJpaRepository + .findByUserIdAndDeletedAtIsNullOrderByIdDesc( + userId, + org.springframework.data.domain.Limit + .of(100), + ).first { it.itemId == itemId } + .getId() + + // 결승 한 판으로 끝나는 토너먼트를 만들어 시작까지 한다(아이템 2개 = 첫 매치가 곧 결승). + // invite 를 주면 그 사용자를 초대 코드로 합류시킨다 — 시작 전에만 가능하므로 여기서 함께 처리한다. + private fun startTournamentWith( + mockMvc: MockMvc, + itemIds: List, + invite: UUID? = null, + ): Long { + val created = + mockMvc + .perform( + post("/api/v1/tournaments") + .header(HttpHeaders.AUTHORIZATION, authHeader(ownerId)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"name":"우승 칩 테스트"}"""), + ).andReturn() + val data = objectMapper.readTree(created.response.contentAsString)["data"] + val tournamentId = data["tournamentId"].asLong() + invite?.let { guest -> + val code = data["inviteCode"].asText() + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/join") + .header(HttpHeaders.AUTHORIZATION, authHeader(guest)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"inviteCode":"$code"}"""), + ).andExpect(status().isOk) + } + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/items/wish") + .header(HttpHeaders.AUTHORIZATION, authHeader(ownerId)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"itemIds":${itemIds.joinToString(",", "[", "]")}}"""), + ).andExpect(status().isOk) + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/start") + .header(HttpHeaders.AUTHORIZATION, authHeader(ownerId)), + ).andExpect(status().isOk) + return tournamentId + } + + // 참여자의 "시작" 은 주최자의 시작과 다른 일이다 — 자기 참여 행을 플레이 시작으로 전이시킨다(#1027). + // 이걸 빼면 참여 행이 PENDING 에 머물러 매치 기록이 409 로 막힌다. + private fun startPlaying( + mockMvc: MockMvc, + tournamentId: Long, + player: UUID, + ) { + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/start") + .header(HttpHeaders.AUTHORIZATION, authHeader(player)), + ).andExpect(status().isOk) + } + + private fun playFinal( + mockMvc: MockMvc, + tournamentId: Long, + cards: List, + winner: Long, + player: UUID = ownerId, + ) { + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/matches") + .header(HttpHeaders.AUTHORIZATION, authHeader(player)) + .contentType(MediaType.APPLICATION_JSON) + .content( + """{"currentRound":2,"firstTournamentItemId":${cards[0]},""" + + """"secondTournamentItemId":${cards[1]},"selectedTournamentItemId":$winner}""", + ), + ).andExpect(status().isOk) + } + + private fun tournamentItemIdsOf(tournamentId: Long): List = + tournamentItemJpaRepository + .findAllByTournamentIdAndNotDeleted(tournamentId) + .map { it.getId() } + + // 출전 카드는 상품을 버전(snapshot)으로 가리키므로 itemId 로 되찾으려면 그 단계를 거친다. + private fun cardIdOf( + cards: List, + itemId: Long, + ): Long = + cards.first { cardId -> + val card = tournamentItemJpaRepository.findById(cardId).orElseThrow() + itemSnapshotJpaRepository.findById(card.snapshotId).orElseThrow().itemId == itemId + } + + private fun saveWishItem( + userId: UUID, + name: String, + ): Long { + // 필요한 것은 "READY 인 위시 아이템" 이라는 상태뿐이라 등록 API 를 타지 않고 행을 직접 심는다. + val item = itemJpaRepository.save(Item(sourceImageKey = "items/raw/${UUID.randomUUID()}.png")) + val snapshot = itemSnapshotJpaRepository.save(ItemSnapshot.pending(item.getId(), requestedBy = userId)) + wishJpaRepository.save(Wish(userId = userId, waitingSnapshotId = snapshot.getId(), itemId = snapshot.itemId)) + snapshot.markProcessing() + itemParsingService.markExtracted( + snapshot.getId(), + ProductSnapshot(name = name, price = 10_000, currency = "KRW", imageUrl = "https://img.example.com/a.png"), + expectedAttempt = 0, + ) + return item.getId() + } + + private fun saveUser( + id: UUID, + nickname: String, + ): User = + userJpaRepository.save( + User( + id = id, + nickname = nickname, + profileImage = "https://img.example.com/p.png", + identityType = IdentityType.MEMBER, + ), + ) + + private fun buildMockMvc(): MockMvc = + MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .apply(springSecurity()) + .build() + + private fun authHeader(userId: UUID): String = + "Bearer ${jwtProvider.generateAccessToken(userId, IdentityType.MEMBER)}" +}