From bff0a7111a08e40c4b34c1fd9e87b8077aefe655 Mon Sep 17 00:00:00 2001 From: sevineleven <117634128+sevineleven@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:04:23 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=9C=84=EC=8B=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=97=90=20=EC=9A=B0=EC=8A=B9=20=EC=B9=A9=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 응답에 isWinner 추가 — 목록·상세 모두 - 구현: 참여 행 단위 결승 기록에서 선택된 상품을 우승으로 판정 - 원인: 기준이 그룹 합산 순위가 아니라 내가 플레이한 결과 - 결과: 같은 소셜 토너먼트라도 참여자마다 답이 갈림 - 결승 한 행만 조회. 순위 전체를 계산하지 않음 - 원인: 1위만 필요한데 computeRanking 은 2·3위까지 셈 - 우승을 별도 컬럼에 저장하지 않음 - 원인: 토너먼트를 지우면 칩도 사라져야 한다는 요구가 있어, 저장하면 삭제와 동기화하는 부채가 생김 - 결과: 삭제된 참여 행이 조인에서 빠지며 자연히 칩도 사라짐 - 결과: 마이그레이션·백필 없음 - 페이지에 보이는 상품만 한 번에 조회 - 원인: 위시가 수백 건이라 상품별 조회는 N+1 - 결과: 비용이 페이지 크기에 묶임 - 판정 규칙을 tournament 패키지에 둠 - 원인: 결승 라운드 번호가 Tournament 의 internal 상수. 호출자가 알아야 하면 경계가 샌 것 --- .../TournamentHistoryJpaRepository.kt | 31 ++ .../service/TournamentWinnerReader.kt | 40 +++ .../controller/WishlistApiExamples.kt | 6 +- .../controller/dto/WishDetailResponse.kt | 6 + .../controller/dto/WishItemResponse.kt | 9 + .../piki/wishlist/service/WishlistService.kt | 6 + .../piki/wishlist/service/dto/WishDetail.kt | 2 + .../piki/wishlist/service/dto/WishWithItem.kt | 3 + .../WishWinnerChipIntegrationTest.kt | 305 ++++++++++++++++++ 9 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/com/depromeet/piki/tournament/service/TournamentWinnerReader.kt create mode 100644 src/test/kotlin/com/depromeet/piki/wishlist/controller/WishWinnerChipIntegrationTest.kt 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..6f8531782 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,32 @@ interface TournamentHistoryJpaRepository : JpaRepository fun findAllByTournamentIdInAndDeletedAtIsNull(tournamentIds: List): List + + /** + * 이 사용자가 **자기 플레이의 결승에서 고른** 상품들(itemId). 위시 목록의 우승 칩 판정에 쓴다(#1236). + * + * 기준이 참여 행(TournamentUser)인 것이 핵심이다. 그룹 합산 순위가 아니라 "내가 선택한 1등" 이므로, + * 같은 소셜 토너먼트라도 사람마다 답이 다르다. 결승 한 행만 보면 되어 순위 전체를 계산할 필요가 없다 + * (TournamentService.computeRanking 은 2·3위까지 필요할 때만 쓴다). + * + * 삭제된 참여 행은 빠진다 — 토너먼트를 지우면 칩도 사라져야 한다는 기획 요구가 여기서 충족된다. + * 우승을 따로 저장하지 않고 매 조회에 계산하는 이유도 이것이다. 저장하면 삭제와 동기화하는 부채가 생긴다. + * + * 출전(TournamentItem)은 상품을 버전(snapshot)으로 가리키므로 itemId 까지 두 단계를 더 탄다. + * FK·연관관계를 두지 않는 규약이라 암시적 조인으로 엮는다(TournamentItemJpaRepository 의 선례와 같은 형태). + * + * itemIds 로 먼저 좁혀, 한 페이지에 보이는 상품만 본다. 위시가 수백 건이어도 조회 비용이 페이지 크기에 묶인다. + */ + @Query( + "SELECT DISTINCT s.itemId 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", + ) + fun findWinnerItemIds( + @Param("userId") userId: UUID, + @Param("itemIds") itemIds: Collection, + @Param("finalRound") finalRound: Int, + ): List } 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..195f2fe7d --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentWinnerReader.kt @@ -0,0 +1,40 @@ +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] 중 이 사용자가 **자기 플레이의 결승에서 고른** 상품들. + * + * 호출자가 관심 있는 상품만 넘기고 그 부분집합을 돌려받는다. 한 페이지에 보이는 것만 묻게 되어 + * 위시가 수백 건이어도 비용이 페이지 크기에 묶인다. + * + * 빈 입력에 쿼리를 날리지 않는다 — `IN ()` 은 DB 마다 문법이 갈리고, 어차피 답이 공집합이다. + */ + @Transactional(readOnly = true) + fun findWinnerItemIds( + userId: UUID, + itemIds: Collection, + ): Set { + if (itemIds.isEmpty()) return emptySet() + return tournamentHistoryJpaRepository + .findWinnerItemIds(userId, itemIds, Tournament.FINAL_ROUND_SIZE) + .toSet() + } +} 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..39f882c3c 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), + // 우승 칩이 붙은 항목과 아닌 항목을 함께 둔다 — isWinner 가 항목마다 갈리는 값임을 드러낸다. + 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) — 이 상품을 내 토너먼트 결승에서 골랐다. 그 토너먼트를 지우면 다시 false 가 된다. + private val winnerSampleEntry = sampleEntry.copy(isWinner = true) + 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..b93161aa9 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 = "true", + ) + val isWinner: Boolean = false, ) { 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) }, + isWinner = detail.isWinner, ) } 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..de5eb72d8 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 = + "이 상품이 내 토너먼트 결승에서 뽑힌 적 있는지 — 위시 카드의 우승 칩 표시에 쓴다. " + + "기준은 그룹 합산 순위가 아니라 내가 플레이한 결과이며, 한 번이라도 해당되면 true 다. " + + "그 토너먼트를 삭제하면 다시 false 가 된다.", + example = "true", + ) + val isWinner: Boolean = false, ) { companion object { fun from( @@ -38,6 +46,7 @@ data class WishItemResponse( WishItemResponse( wish = WishView.from(result.wish), item = ItemView.from(result), + isWinner = result.isWinner, ) // 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..395c50255 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 winnerItemIds = tournamentWinnerReader.findWinnerItemIds(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()), + isWinner = wish.itemId in winnerItemIds, ).withSourcePlatform() } @@ -151,6 +156,7 @@ class WishlistService( snapshot = itemDisplayService.resolveDisplay(wish.displayCard()), history = history, sourcePlatform = sourcePlatformResolver.resolve(item.link), + isWinner = tournamentWinnerReader.findWinnerItemIds(userId, listOf(wish.itemId)).isNotEmpty(), ) } 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..d76bfabd5 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 isWinner: Boolean = false, ) 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..6aa45e283 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)가 링크에서 판정해 채운다. 영속화 계층은 비워 둔다. +// isWinner 도 같은 자리에서 채운다 — 토너먼트에서 파생하는 값이라 위시 영속화와 무관하고, +// 등록 직후 응답에는 쓸 일이 없어 기본값 false 다(막 담은 상품이 우승했을 리 없다). 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 isWinner: Boolean = false, ) 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..dcb1366d4 --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishWinnerChipIntegrationTest.kt @@ -0,0 +1,305 @@ +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.jsonPath +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(true, isWinnerInList(mockMvc, winnerItemId), "결승에서 고른 상품에 칩이 없다") + assertEquals(false, isWinnerInList(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(true, isWinnerInDetail(mockMvc, wishIdOf(ownerId, winnerItemId))) + assertEquals(false, isWinnerInDetail(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(true, isWinnerInList(mockMvc, itemId), "뒤 토너먼트의 탈락이 앞의 우승을 지웠다") + assertEquals(true, isWinnerInList(mockMvc, rivalB), "2차 우승자에 칩이 없다") + } + + @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(isWinnerInList(mockMvc, winnerItemId), "삭제 전부터 칩이 없어 이 테스트가 의미를 잃는다") + + mockMvc + .perform( + delete("/api/v1/tournaments/$tournamentId") + .header(HttpHeaders.AUTHORIZATION, authHeader(ownerId)), + ).andExpect(status().isOk) + + assertEquals(false, isWinnerInList(mockMvc, winnerItemId), "토너먼트를 지웠는데 칩이 남았다") + } + + @Test + fun `남의 플레이 결과는 내 위시 칩에 영향을 주지 않는다`() { + val mockMvc = buildMockMvc() + saveUser(ownerId, "주최자") + saveUser(guestPlayerId, "참여자") + val mine = saveWishItem(ownerId, "내 상품") + val rival = saveWishItem(ownerId, "상대 상품") + val tournamentId = startTournamentWith(mockMvc, listOf(mine, rival)) + val cards = tournamentItemIdsOf(tournamentId) + + // 주최자는 rival 을 골랐다 — 내 플레이 기준이면 mine 에 칩이 없어야 한다. + playFinal(mockMvc, tournamentId, cards, winner = cardIdOf(cards, rival)) + + assertEquals(false, isWinnerInList(mockMvc, mine), "내가 고르지 않은 상품에 칩이 붙었다") + assertEquals(true, isWinnerInList(mockMvc, rival)) + } + + @Test + fun `토너먼트를 한 번도 돌리지 않으면 칩이 없다`() { + val mockMvc = buildMockMvc() + saveUser(ownerId, "주최자") + val itemId = saveWishItem(ownerId, "담기만 한 상품") + + assertEquals(false, isWinnerInList(mockMvc, itemId)) + } + + // ── 헬퍼 ─────────────────────────────────────────────────────────────── + + private fun isWinnerInList( + mockMvc: MockMvc, + itemId: Long, + ): Boolean { + 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 가 없다") + return entry["isWinner"].asBoolean() + } + + private fun isWinnerInDetail( + mockMvc: MockMvc, + wishId: Long, + ): Boolean { + val result = + mockMvc + .perform( + get("/api/v1/wishlists/$wishId") + .header(HttpHeaders.AUTHORIZATION, authHeader(ownerId)), + ).andExpect(status().isOk) + .andReturn() + return objectMapper.readTree(result.response.contentAsString)["data"]["isWinner"].asBoolean() + } + + private fun wishIdOf( + userId: UUID, + itemId: Long, + ): Long = + wishJpaRepository + .findByUserIdAndDeletedAtIsNullOrderByIdDesc(userId, org.springframework.data.domain.Limit.of(100)) + .first { it.itemId == itemId } + .getId() + + // 결승 한 판으로 끝나는 토너먼트를 만들어 시작까지 한다(아이템 2개 = 첫 매치가 곧 결승). + private fun startTournamentWith( + mockMvc: MockMvc, + itemIds: List, + ): Long { + val created = + mockMvc + .perform( + post("/api/v1/tournaments") + .header(HttpHeaders.AUTHORIZATION, authHeader(ownerId)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"name":"우승 칩 테스트"}"""), + ).andReturn() + val tournamentId = objectMapper.readTree(created.response.contentAsString)["data"]["tournamentId"].asLong() + 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 + } + + private fun playFinal( + mockMvc: MockMvc, + tournamentId: Long, + cards: List, + winner: Long, + ) { + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/matches") + .header(HttpHeaders.AUTHORIZATION, authHeader(ownerId)) + .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)}" +} From 1b50dab5164218ec2aed73aad53aa9d7200fa285 Mon Sep 17 00:00:00 2001 From: sevineleven <117634128+sevineleven@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:21:29 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=9A=B0=EC=8A=B9=20=EC=B9=A9?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=86=A0=EB=84=88=EB=A8=BC=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=97=AD=EC=B6=94=EC=A0=81=ED=95=A0=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EA=B2=8C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 응답을 불리언에서 토너먼트 id 목록으로 교체 - 이전: isWinner 만 내려 어느 판에서 이겼는지 알 수 없음 - 결과: 칩에서 그 토너먼트로 이동하는 경로가 열림 - 비용: 없음. 쿼리가 이미 그 좌표를 지나고 있어 select 절만 늘어남 - 최신 우승 순 정렬 - 구현: 결승 기록 id 역순 - 원인: 참여 행의 완료 시각은 완료 판정의 출처가 아니어서 레거시 행에 비어 있을 수 있음 - 한 상품이 여러 판에서 이기면 전부 내려줌 - 원인: 어느 하나를 고르는 것은 화면이 정할 일 - 삭제는 그 판만 빠짐 - 결과: 다른 우승이 남아 있으면 칩은 유지 --- .../TournamentHistoryJpaRepository.kt | 15 ++-- .../tournament/repository/WinnerItemView.kt | 8 ++ .../service/TournamentWinnerReader.kt | 20 +++-- .../controller/WishlistApiExamples.kt | 6 +- .../controller/dto/WishDetailResponse.kt | 8 +- .../controller/dto/WishItemResponse.kt | 12 +-- .../piki/wishlist/service/WishlistService.kt | 8 +- .../piki/wishlist/service/dto/WishDetail.kt | 4 +- .../piki/wishlist/service/dto/WishWithItem.kt | 6 +- .../WishWinnerChipIntegrationTest.kt | 79 ++++++++++++++----- 10 files changed, 111 insertions(+), 55 deletions(-) create mode 100644 src/main/kotlin/com/depromeet/piki/tournament/repository/WinnerItemView.kt 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 6f8531782..71b68b27e 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentHistoryJpaRepository.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentHistoryJpaRepository.kt @@ -15,7 +15,7 @@ interface TournamentHistoryJpaRepository : JpaRepository): List /** - * 이 사용자가 **자기 플레이의 결승에서 고른** 상품들(itemId). 위시 목록의 우승 칩 판정에 쓴다(#1236). + * 이 사용자가 **자기 플레이의 결승에서 고른** 상품들과, 그 우승이 난 토너먼트. 위시 카드의 우승 칩(#1236). * * 기준이 참여 행(TournamentUser)인 것이 핵심이다. 그룹 합산 순위가 아니라 "내가 선택한 1등" 이므로, * 같은 소셜 토너먼트라도 사람마다 답이 다르다. 결승 한 행만 보면 되어 순위 전체를 계산할 필요가 없다 @@ -28,17 +28,22 @@ interface TournamentHistoryJpaRepository : JpaRepository, @Param("finalRound") finalRound: Int, - ): List + ): 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 index 195f2fe7d..f5a63ed3a 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentWinnerReader.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentWinnerReader.kt @@ -20,21 +20,25 @@ class TournamentWinnerReader( private val tournamentHistoryJpaRepository: TournamentHistoryJpaRepository, ) { /** - * [itemIds] 중 이 사용자가 **자기 플레이의 결승에서 고른** 상품들. + * [itemIds] 중 이 사용자가 **자기 플레이의 결승에서 고른** 상품 → 그 우승이 난 토너먼트들(최신 우승 순). * * 호출자가 관심 있는 상품만 넘기고 그 부분집합을 돌려받는다. 한 페이지에 보이는 것만 묻게 되어 - * 위시가 수백 건이어도 비용이 페이지 크기에 묶인다. + * 위시가 수백 건이어도 비용이 페이지 크기에 묶인다. 결과에 없는 itemId 는 우승 이력이 없다는 뜻이다. * - * 빈 입력에 쿼리를 날리지 않는다 — `IN ()` 은 DB 마다 문법이 갈리고, 어차피 답이 공집합이다. + * 한 상품이 여러 판에서 이길 수 있어 값이 리스트다. 어느 하나를 서버가 고르지 않고 전부 넘긴다 — + * "이 상품이 이긴 판들" 을 보여줄지 최신 하나만 쓸지는 화면이 정할 일이다. + * + * 빈 입력에 쿼리를 날리지 않는다 — `IN ()` 은 DB 마다 문법이 갈리고, 어차피 답이 빈 맵이다. */ @Transactional(readOnly = true) - fun findWinnerItemIds( + fun findWinnerTournamentIdsByItem( userId: UUID, itemIds: Collection, - ): Set { - if (itemIds.isEmpty()) return emptySet() + ): Map> { + if (itemIds.isEmpty()) return emptyMap() + // 쿼리가 최신 우승 순으로 정렬해 돌려주므로 groupBy 가 그 순서를 그대로 유지한다. return tournamentHistoryJpaRepository - .findWinnerItemIds(userId, itemIds, Tournament.FINAL_ROUND_SIZE) - .toSet() + .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 39f882c3c..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,7 @@ class WishlistApiExamples( name = "조회 성공 (다음 페이지 있음)", payload = ApiResponseBody.ok( - // 우승 칩이 붙은 항목과 아닌 항목을 함께 둔다 — isWinner 가 항목마다 갈리는 값임을 드러낸다. + // 우승 칩이 붙은 항목과 아닌 항목을 함께 둔다 — 항목마다 갈리는 값임을 드러낸다. data = listOf(winnerSampleEntry, sampleEntry), pageResponse = PageResponse(nextCursor = "1024", hasNext = true), ), @@ -418,8 +418,8 @@ class WishlistApiExamples( // URL 등록 응답 전용(#853) — 등록 경로는 attach 메타(reused·refreshNeeded)가 non-null 로 내려간다. // 새 파싱을 만든 등록: 캐시에 붙지 않았으므로 둘 다 false. - // 우승 칩이 붙는 항목(#1236) — 이 상품을 내 토너먼트 결승에서 골랐다. 그 토너먼트를 지우면 다시 false 가 된다. - private val winnerSampleEntry = sampleEntry.copy(isWinner = true) + // 우승 칩이 붙는 항목(#1236) — 두 판에서 이긴 상품이라 최신 우승이 앞에 온다. 그 토너먼트를 지우면 id 가 빠진다. + private val winnerSampleEntry = sampleEntry.copy(winnerTournamentIds = listOf(1402, 1397)) private val pendingRegistrationEntry = pendingSampleEntry.copy(reused = false, refreshNeeded = false) 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 b93161aa9..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 @@ -34,10 +34,10 @@ data class WishDetailResponse( ) val priceHistory: List, @field:Schema( - description = "이 상품이 내 토너먼트 결승에서 뽑힌 적 있는지 — 목록 응답의 같은 필드와 같은 규칙으로 판정한다.", - example = "true", + description = "이 상품이 우승한 내 토너먼트들 — 목록 응답의 같은 필드와 같은 규칙·순서로 내려간다.", + example = "[1402, 1397]", ) - val isWinner: Boolean = false, + val winnerTournamentIds: List = emptyList(), ) { companion object { // requesterId 는 editedByMe(수기 버전을 내가 넣었는지) 파생용 — 편집자 식별자(UUID)는 응답에 노출하지 않는다. @@ -50,7 +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) }, - isWinner = detail.isWinner, + 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 de5eb72d8..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 @@ -32,12 +32,12 @@ data class WishItemResponse( val refreshNeeded: Boolean? = null, @field:Schema( description = - "이 상품이 내 토너먼트 결승에서 뽑힌 적 있는지 — 위시 카드의 우승 칩 표시에 쓴다. " + - "기준은 그룹 합산 순위가 아니라 내가 플레이한 결과이며, 한 번이라도 해당되면 true 다. " + - "그 토너먼트를 삭제하면 다시 false 가 된다.", - example = "true", + "이 상품이 우승한 내 토너먼트들 — 최신 우승 순이고, 비어 있으면 우승 이력이 없다(위시 카드의 우승 칩). " + + "기준은 그룹 합산 순위가 아니라 내가 플레이한 결과라, 같은 소셜 토너먼트라도 참여자마다 다르다. " + + "한 상품이 여러 판에서 이길 수 있어 배열이며, 그 토너먼트를 삭제하면 해당 id 가 빠진다.", + example = "[1402, 1397]", ) - val isWinner: Boolean = false, + val winnerTournamentIds: List = emptyList(), ) { companion object { fun from( @@ -46,7 +46,7 @@ data class WishItemResponse( WishItemResponse( wish = WishView.from(result.wish), item = ItemView.from(result), - isWinner = result.isWinner, + 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 395c50255..d061dbda2 100644 --- a/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt +++ b/src/main/kotlin/com/depromeet/piki/wishlist/service/WishlistService.kt @@ -110,7 +110,7 @@ class WishlistService( val displayByCard = itemDisplayService.resolveDisplay(pageWishes.map { it.displayCard() }) val itemsById = itemRepository.findByIds(pageWishes.map { it.itemId }).associateBy { it.getId() } // 우승 칩(#1236) — 이 페이지에 보이는 상품만 한 번에 물어 N+1 을 만들지 않는다(표시값·상품 조회와 같은 결). - val winnerItemIds = tournamentWinnerReader.findWinnerItemIds(userId, pageWishes.map { it.itemId }) + val winnersByItem = tournamentWinnerReader.findWinnerTournamentIdsByItem(userId, pageWishes.map { it.itemId }) val entries = pageWishes.map { wish -> // item 은 wish 와 함께 영속화되며 병합 시 함께 옮겨진다(WishItemMergeListener). 없으면 영속화 경로가 깨진 코드 버그다. @@ -119,7 +119,7 @@ class WishlistService( wish = wish, item = item, snapshot = displayByCard.getValue(wish.displayCard()), - isWinner = wish.itemId in winnerItemIds, + winnerTournamentIds = winnersByItem[wish.itemId].orEmpty(), ).withSourcePlatform() } @@ -150,13 +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), - isWinner = tournamentWinnerReader.findWinnerItemIds(userId, listOf(wish.itemId)).isNotEmpty(), + 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 d76bfabd5..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,6 +17,6 @@ data class WishDetail( val snapshot: ItemSnapshot, val history: List, val sourcePlatform: String?, - // 이 상품이 내 토너먼트 결승에서 뽑힌 적 있는지(#1236). 목록과 같은 규칙으로 판정해 두 화면이 어긋나지 않게 한다. - val isWinner: Boolean = false, + // 이 상품이 우승한 내 토너먼트들(#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 6aa45e283..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,8 +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)가 링크에서 판정해 채운다. 영속화 계층은 비워 둔다. -// isWinner 도 같은 자리에서 채운다 — 토너먼트에서 파생하는 값이라 위시 영속화와 무관하고, -// 등록 직후 응답에는 쓸 일이 없어 기본값 false 다(막 담은 상품이 우승했을 리 없다). +// winnerTournamentIds 도 같은 자리에서 채운다 — 토너먼트에서 파생하는 값이라 위시 영속화와 무관하고, +// 등록 직후 응답에는 쓸 일이 없어 기본값이 빈 목록이다(막 담은 상품이 우승했을 리 없다). data class WishWithItem( val wish: Wish, val item: Item, @@ -17,5 +17,5 @@ data class WishWithItem( val reused: Boolean = false, val refreshNeeded: Boolean = false, val sourcePlatform: String? = null, - val isWinner: Boolean = false, + 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 index dcb1366d4..761b1cdca 100644 --- a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishWinnerChipIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishWinnerChipIntegrationTest.kt @@ -23,7 +23,6 @@ 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.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.test.web.servlet.setup.MockMvcBuilders import org.springframework.transaction.annotation.Transactional @@ -73,8 +72,8 @@ class WishWinnerChipIntegrationTest : IntegrationTestSupport() { playFinal(mockMvc, tournamentId, cards, winner = winnerCard) - assertEquals(true, isWinnerInList(mockMvc, winnerItemId), "결승에서 고른 상품에 칩이 없다") - assertEquals(false, isWinnerInList(mockMvc, loserItemId), "탈락한 상품에 칩이 붙었다") + assertEquals(listOf(tournamentId), winnersInList(mockMvc, winnerItemId), "결승에서 고른 상품이 그 토너먼트를 가리키지 않는다") + assertEquals(emptyList(), winnersInList(mockMvc, loserItemId), "탈락한 상품에 칩이 붙었다") } @Test @@ -88,8 +87,8 @@ class WishWinnerChipIntegrationTest : IntegrationTestSupport() { playFinal(mockMvc, tournamentId, cards, winner = cardIdOf(cards, winnerItemId)) // 목록과 상세가 같은 규칙을 쓰는지 — 두 화면이 어긋나면 칩이 깜빡이는 것처럼 보인다. - assertEquals(true, isWinnerInDetail(mockMvc, wishIdOf(ownerId, winnerItemId))) - assertEquals(false, isWinnerInDetail(mockMvc, wishIdOf(ownerId, loserItemId))) + assertEquals(listOf(tournamentId), winnersInDetail(mockMvc, wishIdOf(ownerId, winnerItemId))) + assertEquals(emptyList(), winnersInDetail(mockMvc, wishIdOf(ownerId, loserItemId))) } @Test @@ -110,8 +109,37 @@ class WishWinnerChipIntegrationTest : IntegrationTestSupport() { val secondCards = tournamentItemIdsOf(second) playFinal(mockMvc, second, secondCards, winner = cardIdOf(secondCards, rivalB)) - assertEquals(true, isWinnerInList(mockMvc, itemId), "뒤 토너먼트의 탈락이 앞의 우승을 지웠다") - assertEquals(true, isWinnerInList(mockMvc, rivalB), "2차 우승자에 칩이 없다") + 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 @@ -123,7 +151,7 @@ class WishWinnerChipIntegrationTest : IntegrationTestSupport() { val tournamentId = startTournamentWith(mockMvc, listOf(winnerItemId, loserItemId)) val cards = tournamentItemIdsOf(tournamentId) playFinal(mockMvc, tournamentId, cards, winner = cardIdOf(cards, winnerItemId)) - assertTrue(isWinnerInList(mockMvc, winnerItemId), "삭제 전부터 칩이 없어 이 테스트가 의미를 잃는다") + assertTrue(winnersInList(mockMvc, winnerItemId).isNotEmpty(), "삭제 전부터 칩이 없어 이 테스트가 의미를 잃는다") mockMvc .perform( @@ -131,7 +159,7 @@ class WishWinnerChipIntegrationTest : IntegrationTestSupport() { .header(HttpHeaders.AUTHORIZATION, authHeader(ownerId)), ).andExpect(status().isOk) - assertEquals(false, isWinnerInList(mockMvc, winnerItemId), "토너먼트를 지웠는데 칩이 남았다") + assertEquals(emptyList(), winnersInList(mockMvc, winnerItemId), "토너먼트를 지웠는데 칩이 남았다") } @Test @@ -147,8 +175,8 @@ class WishWinnerChipIntegrationTest : IntegrationTestSupport() { // 주최자는 rival 을 골랐다 — 내 플레이 기준이면 mine 에 칩이 없어야 한다. playFinal(mockMvc, tournamentId, cards, winner = cardIdOf(cards, rival)) - assertEquals(false, isWinnerInList(mockMvc, mine), "내가 고르지 않은 상품에 칩이 붙었다") - assertEquals(true, isWinnerInList(mockMvc, rival)) + assertEquals(emptyList(), winnersInList(mockMvc, mine), "내가 고르지 않은 상품에 칩이 붙었다") + assertEquals(listOf(tournamentId), winnersInList(mockMvc, rival)) } @Test @@ -157,15 +185,16 @@ class WishWinnerChipIntegrationTest : IntegrationTestSupport() { saveUser(ownerId, "주최자") val itemId = saveWishItem(ownerId, "담기만 한 상품") - assertEquals(false, isWinnerInList(mockMvc, itemId)) + assertEquals(emptyList(), winnersInList(mockMvc, itemId)) } // ── 헬퍼 ─────────────────────────────────────────────────────────────── - private fun isWinnerInList( + // 목록에서 그 상품이 이긴 토너먼트 id 들(응답 순서 그대로 — 최신 우승이 앞). + private fun winnersInList( mockMvc: MockMvc, itemId: Long, - ): Boolean { + ): List { val result = mockMvc .perform( @@ -177,13 +206,16 @@ class WishWinnerChipIntegrationTest : IntegrationTestSupport() { val entry = entries.firstOrNull { it["item"]["id"].asLong() == itemId } ?: error("위시 목록에 item $itemId 가 없다") - return entry["isWinner"].asBoolean() + // 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 isWinnerInDetail( + private fun winnersInDetail( mockMvc: MockMvc, wishId: Long, - ): Boolean { + ): List { val result = mockMvc .perform( @@ -191,7 +223,8 @@ class WishWinnerChipIntegrationTest : IntegrationTestSupport() { .header(HttpHeaders.AUTHORIZATION, authHeader(ownerId)), ).andExpect(status().isOk) .andReturn() - return objectMapper.readTree(result.response.contentAsString)["data"]["isWinner"].asBoolean() + val ids = objectMapper.readTree(result.response.contentAsString)["data"]["winnerTournamentIds"] + return (0 until ids.size()).map { ids.path(it).asLong() } } private fun wishIdOf( @@ -199,8 +232,11 @@ class WishWinnerChipIntegrationTest : IntegrationTestSupport() { itemId: Long, ): Long = wishJpaRepository - .findByUserIdAndDeletedAtIsNullOrderByIdDesc(userId, org.springframework.data.domain.Limit.of(100)) - .first { it.itemId == itemId } + .findByUserIdAndDeletedAtIsNullOrderByIdDesc( + userId, + org.springframework.data.domain.Limit + .of(100), + ).first { it.itemId == itemId } .getId() // 결승 한 판으로 끝나는 토너먼트를 만들어 시작까지 한다(아이템 2개 = 첫 매치가 곧 결승). @@ -301,5 +337,6 @@ class WishWinnerChipIntegrationTest : IntegrationTestSupport() { .apply(springSecurity()) .build() - private fun authHeader(userId: UUID): String = "Bearer ${jwtProvider.generateAccessToken(userId, IdentityType.MEMBER)}" + private fun authHeader(userId: UUID): String = + "Bearer ${jwtProvider.generateAccessToken(userId, IdentityType.MEMBER)}" } From b478b2e0652eae80c90b9870c2c7d445f7b28d2a Mon Sep 17 00:00:00 2001 From: sevineleven <117634128+sevineleven@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:37:52 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test:=20=EC=9A=B0=EC=8A=B9=20=EC=B9=A9=20?= =?UTF-8?q?=EC=B0=B8=EC=97=AC=EC=9E=90=20=EA=B2=A9=EB=A6=AC=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=20=ED=9D=90=EB=A6=84=EC=9C=BC=EB=A1=9C=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 다른 참여자를 초대 코드로 합류시키고 자기 start 로 플레이를 열어 각자 결승을 둔다 - 이전: 참여자 id 를 저장만 해 주최자 플레이만 검증 - 원인: (CodeRabbit 지적) tu.userId 필터를 제거해도 7건 전부 통과 - 결과: 필터 무력화 시 이 테스트만 실패하는 negative control 성립 - 참여자의 start 는 주최자의 start 와 다른 일(#1027) - 원인: 자기 참여 행을 PENDING 에서 플레이 시작으로 전이시키는 호출. 빼면 매치 기록이 409 - 합류는 시작 전 초대 코드로 - 대안: 주최자 완주 뒤 play-link 개방 - 기각: 시작 뒤에는 합류 경로가 없어 409 --- .../WishWinnerChipIntegrationTest.kt | 52 +++++++++++++++---- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishWinnerChipIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishWinnerChipIntegrationTest.kt index 761b1cdca..6979a40c6 100644 --- a/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishWinnerChipIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/wishlist/controller/WishWinnerChipIntegrationTest.kt @@ -163,20 +163,24 @@ class WishWinnerChipIntegrationTest : IntegrationTestSupport() { } @Test - fun `남의 플레이 결과는 내 위시 칩에 영향을 주지 않는다`() { + fun `같은 토너먼트라도 다른 참여자가 고른 상품은 내 칩이 되지 않는다`() { val mockMvc = buildMockMvc() saveUser(ownerId, "주최자") saveUser(guestPlayerId, "참여자") - val mine = saveWishItem(ownerId, "내 상품") - val rival = saveWishItem(ownerId, "상대 상품") - val tournamentId = startTournamentWith(mockMvc, listOf(mine, rival)) + val itemA = saveWishItem(ownerId, "주최자가 고를 상품") + val itemB = saveWishItem(ownerId, "참여자가 고를 상품") + // 참여자는 시작 전에 초대로 들어온다 — 시작 뒤에는 합류할 수 없다. + val tournamentId = startTournamentWith(mockMvc, listOf(itemA, itemB), invite = guestPlayerId) val cards = tournamentItemIdsOf(tournamentId) - // 주최자는 rival 을 골랐다 — 내 플레이 기준이면 mine 에 칩이 없어야 한다. - playFinal(mockMvc, tournamentId, cards, winner = cardIdOf(cards, rival)) + // 같은 판을 둘이 각자 플레이해 서로 다른 결론을 낸다. 참여자는 자기 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) - assertEquals(emptyList(), winnersInList(mockMvc, mine), "내가 고르지 않은 상품에 칩이 붙었다") - assertEquals(listOf(tournamentId), winnersInList(mockMvc, rival)) + // 내 위시 목록은 내 플레이만 반영한다. 참여자가 고른 B 가 섞이면 "내가 선택한 1등" 이 아니게 된다. + assertEquals(listOf(tournamentId), winnersInList(mockMvc, itemA), "내가 고른 상품에 칩이 없다") + assertEquals(emptyList(), winnersInList(mockMvc, itemB), "다른 참여자가 고른 상품이 내 칩으로 새어 들어왔다") } @Test @@ -240,9 +244,11 @@ class WishWinnerChipIntegrationTest : IntegrationTestSupport() { .getId() // 결승 한 판으로 끝나는 토너먼트를 만들어 시작까지 한다(아이템 2개 = 첫 매치가 곧 결승). + // invite 를 주면 그 사용자를 초대 코드로 합류시킨다 — 시작 전에만 가능하므로 여기서 함께 처리한다. private fun startTournamentWith( mockMvc: MockMvc, itemIds: List, + invite: UUID? = null, ): Long { val created = mockMvc @@ -252,7 +258,18 @@ class WishWinnerChipIntegrationTest : IntegrationTestSupport() { .contentType(MediaType.APPLICATION_JSON) .content("""{"name":"우승 칩 테스트"}"""), ).andReturn() - val tournamentId = objectMapper.readTree(created.response.contentAsString)["data"]["tournamentId"].asLong() + 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") @@ -268,16 +285,31 @@ class WishWinnerChipIntegrationTest : IntegrationTestSupport() { 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(ownerId)) + .header(HttpHeaders.AUTHORIZATION, authHeader(player)) .contentType(MediaType.APPLICATION_JSON) .content( """{"currentRound":2,"firstTournamentItemId":${cards[0]},""" +