From 9a0f2d75af6e159fe548e379651b4a8e03dd0bbf Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 30 Apr 2026 01:35:35 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(user):=20=EC=B0=9C=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20mutation=20+=20isWishlisted=20=EB=85=B8=EC=B6=9C=20?= =?UTF-8?q?+=20=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=EC=9D=BC=EA=B4=80=EC=84=B1?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 마이페이지 figma 02-main-login의 찜 기능을 백엔드에 도입한다. 하트 버튼 클릭 시 추가/해제하는 멱등 mutation과 목록 조회, 그리고 최근 본 상품에 isWishlisted 매핑까지 함께 반영한다. ## 변경 사항 - SDL 신규: user-wishlist.graphql (myWishlist / addToWishlist / removeFromWishlist) - SDL 갱신: RecentViewedProductSummary.isWishlisted: Boolean! - 신규 service: UserWishlistService (UserBaseService 상속) - 신규 resolver: UserWishlistQueryResolver / UserWishlistMutationResolver - UserRepository: upsertWishlistItem / softDeleteWishlistItem / findWishlistedProductIds (단일 IN 쿼리, N+1 회피) / findWishlistItems - ProductRepository: existsActiveProduct (active+soft-delete 검증, 재사용 가능한 가벼운 헬퍼) - 카운트 일관성 버그 수정: countWishlistItems / getViewerCounts.wishlistCount에 deleted_at: null 필터 누락 → soft-delete된 위시리스트도 카운트에 포함되던 문제 수정 - user-mypage.service / user-recent-view.service: 매핑 시 isWishlisted 계산 (findWishlistedProductIds set으로 일괄 조회, N+1 회피) - 회귀 테스트 다수 - UserWishlistService spec 15건 - UserWishlistResolver spec 2건 (NotFound 전파 / 추가→목록→해제 시나리오) - mypage spec: recentViewedProducts.isWishlisted 매핑 검증 보강 - recent-view spec: list isWishlisted 매핑 검증 추가 --- .../repositories/product.repository.ts | 17 + .../constants/user-wishlist-error-messages.ts | 5 + .../user/repositories/user.repository.ts | 126 ++++++- .../user-recent-view.resolver.spec.ts | 2 + .../user-wishlist-mutation.resolver.ts | 35 ++ .../resolvers/user-wishlist-query.resolver.ts | 28 ++ .../resolvers/user-wishlist.resolver.spec.ts | 88 +++++ .../user/services/user-mypage.service.spec.ts | 32 ++ .../user/services/user-mypage.service.ts | 8 + .../services/user-recent-view.service.spec.ts | 28 ++ .../user/services/user-recent-view.service.ts | 9 + .../services/user-wishlist.service.spec.ts | 309 ++++++++++++++++++ .../user/services/user-wishlist.service.ts | 101 ++++++ .../user/types/user-mypage-output.type.ts | 1 + .../user/types/user-wishlist-output.type.ts | 20 ++ src/features/user/user-mypage.graphql | 2 + src/features/user/user-wishlist.graphql | 32 ++ src/features/user/user.module.ts | 6 + 18 files changed, 848 insertions(+), 1 deletion(-) create mode 100644 src/features/user/constants/user-wishlist-error-messages.ts create mode 100644 src/features/user/resolvers/user-wishlist-mutation.resolver.ts create mode 100644 src/features/user/resolvers/user-wishlist-query.resolver.ts create mode 100644 src/features/user/resolvers/user-wishlist.resolver.spec.ts create mode 100644 src/features/user/services/user-wishlist.service.spec.ts create mode 100644 src/features/user/services/user-wishlist.service.ts create mode 100644 src/features/user/types/user-wishlist-output.type.ts create mode 100644 src/features/user/user-wishlist.graphql diff --git a/src/features/product/repositories/product.repository.ts b/src/features/product/repositories/product.repository.ts index 8c3dc41..22c10ba 100644 --- a/src/features/product/repositories/product.repository.ts +++ b/src/features/product/repositories/product.repository.ts @@ -78,6 +78,23 @@ export class ProductRepository { }); } + /** + * active product가 존재하는지(soft-delete 아님 + 매장도 active/soft-delete 아님) 가벼운 검증. + * 판매 가능한 상품인지 확인하는 용도. 다른 도메인(wishlist, cart 등)에서 활용. + */ + async existsActiveProduct(productId: bigint): Promise { + const found = await this.prisma.product.findFirst({ + where: { + id: productId, + is_active: true, + deleted_at: null, + store: { is_active: true, deleted_at: null }, + }, + select: { id: true }, + }); + return Boolean(found); + } + async findProductById(args: { productId: bigint; storeId: bigint }) { return this.prisma.product.findFirst({ where: { diff --git a/src/features/user/constants/user-wishlist-error-messages.ts b/src/features/user/constants/user-wishlist-error-messages.ts new file mode 100644 index 0000000..70ccf22 --- /dev/null +++ b/src/features/user/constants/user-wishlist-error-messages.ts @@ -0,0 +1,5 @@ +export const USER_WISHLIST_ERRORS = { + PRODUCT_NOT_FOUND: '상품을 찾을 수 없습니다.', + INVALID_OFFSET: '오프셋은 0 이상이어야 합니다.', + INVALID_LIMIT: '조회 개수는 1~50 사이여야 합니다.', +} as const; diff --git a/src/features/user/repositories/user.repository.ts b/src/features/user/repositories/user.repository.ts index d917fed..38f48ea 100644 --- a/src/features/user/repositories/user.repository.ts +++ b/src/features/user/repositories/user.repository.ts @@ -199,6 +199,7 @@ export class UserRepository { this.prisma.wishlistItem.count({ where: { account_id: accountId, + deleted_at: null, }, }), ]); @@ -368,10 +369,133 @@ export class UserRepository { async countWishlistItems(accountId: bigint): Promise { return this.prisma.wishlistItem.count({ - where: { account_id: accountId }, + where: { account_id: accountId, deleted_at: null }, + }); + } + + /** + * 찜 추가 (멱등). 이미 있으면 그대로, soft-delete된 경우 deleted_at=null로 복원. + */ + async upsertWishlistItem(args: { + accountId: bigint; + productId: bigint; + now: Date; + }): Promise { + await this.prisma.wishlistItem.upsert({ + where: { + account_id_product_id: { + account_id: args.accountId, + product_id: args.productId, + }, + }, + create: { + account_id: args.accountId, + product_id: args.productId, + }, + update: { deleted_at: null, updated_at: args.now }, }); } + /** + * 찜 해제 (멱등). active 항목만 soft-delete. + */ + async softDeleteWishlistItem(args: { + accountId: bigint; + productId: bigint; + now: Date; + }): Promise { + await this.prisma.wishlistItem.updateMany({ + where: { + account_id: args.accountId, + product_id: args.productId, + deleted_at: null, + }, + data: { deleted_at: args.now }, + }); + } + + /** + * 주어진 productIds 중 사용자가 찜한 것들의 product_id 집합을 단일 IN 쿼리로 반환. + * 매핑(N+1 회피)용. + */ + async findWishlistedProductIds(args: { + accountId: bigint; + productIds: bigint[]; + }): Promise> { + if (args.productIds.length === 0) return new Set(); + const rows = await this.prisma.wishlistItem.findMany({ + where: { + account_id: args.accountId, + deleted_at: null, + product_id: { in: args.productIds }, + }, + select: { product_id: true }, + }); + return new Set(rows.map((r) => r.product_id.toString())); + } + + /** + * 내 찜 목록 조회. 비활성/soft-delete된 product/store는 제외. + */ + async findWishlistItems(args: { + accountId: bigint; + offset: number; + limit: number; + }): Promise<{ + items: { + product_id: bigint; + created_at: Date; + product: { + name: string; + regular_price: number; + sale_price: number | null; + images: { image_url: string }[]; + store: { store_name: string }; + }; + }[]; + totalCount: number; + }> { + const where = { + account_id: args.accountId, + deleted_at: null, + product: { + deleted_at: null, + is_active: true, + store: { deleted_at: null, is_active: true }, + }, + }; + + const [rows, totalCount] = await this.prisma.$transaction([ + this.prisma.wishlistItem.findMany({ + where, + orderBy: { created_at: 'desc' }, + skip: args.offset, + take: args.limit, + select: { + product_id: true, + created_at: true, + product: { + select: { + name: true, + regular_price: true, + sale_price: true, + store: { select: { store_name: true } }, + images: { + where: { deleted_at: null }, + orderBy: { sort_order: 'asc' }, + take: 1, + select: { image_url: true }, + }, + }, + }, + }, + }), + this.prisma.wishlistItem.count({ where }), + ]); + + return { items: rows, totalCount }; + } + async countMyReviews(accountId: bigint): Promise { return this.prisma.review.count({ where: { account_id: accountId }, diff --git a/src/features/user/resolvers/user-recent-view.resolver.spec.ts b/src/features/user/resolvers/user-recent-view.resolver.spec.ts index d835208..df74cc0 100644 --- a/src/features/user/resolvers/user-recent-view.resolver.spec.ts +++ b/src/features/user/resolvers/user-recent-view.resolver.spec.ts @@ -3,6 +3,7 @@ import type { PrismaClient } from '@prisma/client'; import { ProductRepository } from '@/features/product/repositories/product.repository'; import { RecentProductViewRepository } from '@/features/user/repositories/recent-product-view.repository'; +import { UserRepository } from '@/features/user/repositories/user.repository'; import { UserRecentViewMutationResolver } from '@/features/user/resolvers/user-recent-view-mutation.resolver'; import { UserRecentViewQueryResolver } from '@/features/user/resolvers/user-recent-view-query.resolver'; import { UserRecentViewService } from '@/features/user/services/user-recent-view.service'; @@ -33,6 +34,7 @@ describe('User Recent View Resolvers (real DB)', () => { UserRecentViewService, RecentProductViewRepository, ProductRepository, + UserRepository, ], }); diff --git a/src/features/user/resolvers/user-wishlist-mutation.resolver.ts b/src/features/user/resolvers/user-wishlist-mutation.resolver.ts new file mode 100644 index 0000000..a11d57d --- /dev/null +++ b/src/features/user/resolvers/user-wishlist-mutation.resolver.ts @@ -0,0 +1,35 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Mutation, Resolver } from '@nestjs/graphql'; + +import { UserWishlistService } from '@/features/user/services/user-wishlist.service'; +import { + CurrentUser, + JwtAuthGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +@Resolver('Mutation') +@UseGuards(JwtAuthGuard) +export class UserWishlistMutationResolver { + constructor(private readonly wishlistService: UserWishlistService) {} + + @Mutation('addToWishlist') + addToWishlist( + @CurrentUser() user: JwtUser, + @Args('productId') productId: string, + ): Promise { + return this.wishlistService.addToWishlist(parseAccountId(user), productId); + } + + @Mutation('removeFromWishlist') + removeFromWishlist( + @CurrentUser() user: JwtUser, + @Args('productId') productId: string, + ): Promise { + return this.wishlistService.removeFromWishlist( + parseAccountId(user), + productId, + ); + } +} diff --git a/src/features/user/resolvers/user-wishlist-query.resolver.ts b/src/features/user/resolvers/user-wishlist-query.resolver.ts new file mode 100644 index 0000000..baf8b9a --- /dev/null +++ b/src/features/user/resolvers/user-wishlist-query.resolver.ts @@ -0,0 +1,28 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { UserWishlistService } from '@/features/user/services/user-wishlist.service'; +import type { + MyWishlistConnection, + MyWishlistInput, +} from '@/features/user/types/user-wishlist-output.type'; +import { + CurrentUser, + JwtAuthGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +@Resolver('Query') +@UseGuards(JwtAuthGuard) +export class UserWishlistQueryResolver { + constructor(private readonly wishlistService: UserWishlistService) {} + + @Query('myWishlist') + myWishlist( + @CurrentUser() user: JwtUser, + @Args('input') input?: MyWishlistInput, + ): Promise { + return this.wishlistService.myWishlist(parseAccountId(user), input); + } +} diff --git a/src/features/user/resolvers/user-wishlist.resolver.spec.ts b/src/features/user/resolvers/user-wishlist.resolver.spec.ts new file mode 100644 index 0000000..ae2a04b --- /dev/null +++ b/src/features/user/resolvers/user-wishlist.resolver.spec.ts @@ -0,0 +1,88 @@ +import { NotFoundException } from '@nestjs/common'; +import type { PrismaClient } from '@prisma/client'; + +import { ProductRepository } from '@/features/product/repositories/product.repository'; +import { UserRepository } from '@/features/user/repositories/user.repository'; +import { UserWishlistMutationResolver } from '@/features/user/resolvers/user-wishlist-mutation.resolver'; +import { UserWishlistQueryResolver } from '@/features/user/resolvers/user-wishlist-query.resolver'; +import { UserWishlistService } from '@/features/user/services/user-wishlist.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createProduct, + createStore, + createUserProfile, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('User Wishlist Resolver (real DB)', () => { + let mutationResolver: UserWishlistMutationResolver; + let queryResolver: UserWishlistQueryResolver; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + UserWishlistMutationResolver, + UserWishlistQueryResolver, + UserWishlistService, + UserRepository, + ProductRepository, + ], + }); + mutationResolver = module.get(UserWishlistMutationResolver); + queryResolver = module.get(UserWishlistQueryResolver); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('addToWishlist → 존재하지 않는 productId면 NotFoundException 전파', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: account.id }); + + await expect( + mutationResolver.addToWishlist( + { accountId: account.id.toString() }, + '999999', + ), + ).rejects.toThrow(NotFoundException); + }); + + it('addToWishlist → removeFromWishlist → myWishlist 시나리오', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: account.id }); + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + + await mutationResolver.addToWishlist( + { accountId: account.id.toString() }, + product.id.toString(), + ); + + const list1 = await queryResolver.myWishlist({ + accountId: account.id.toString(), + }); + expect(list1.totalCount).toBe(1); + expect(list1.items[0].productId).toBe(product.id.toString()); + + await mutationResolver.removeFromWishlist( + { accountId: account.id.toString() }, + product.id.toString(), + ); + + const list2 = await queryResolver.myWishlist({ + accountId: account.id.toString(), + }); + expect(list2.totalCount).toBe(0); + expect(list2.items).toEqual([]); + }); +}); diff --git a/src/features/user/services/user-mypage.service.spec.ts b/src/features/user/services/user-mypage.service.spec.ts index 9efea63..902760e 100644 --- a/src/features/user/services/user-mypage.service.spec.ts +++ b/src/features/user/services/user-mypage.service.spec.ts @@ -237,7 +237,39 @@ describe('UserMypageService (real DB)', () => { storeName: '케이크샵', regularPrice: 40000, salePrice: 35000, + isWishlisted: false, }); }); + + it('recentViewedProducts에 찜한 상품은 isWishlisted=true로 매핑된다', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const wishlisted = await createProduct(prisma, { store_id: store.id }); + const notWishlisted = await createProduct(prisma, { store_id: store.id }); + await createRecentProductView(prisma, { + account_id: account.id, + product_id: wishlisted.id, + }); + await createRecentProductView(prisma, { + account_id: account.id, + product_id: notWishlisted.id, + }); + await prisma.wishlistItem.create({ + data: { account_id: account.id, product_id: wishlisted.id }, + }); + // 다른 계정 찜 + 본인 soft-delete 찜은 isWishlisted에 영향 안 줌 + const other = await setupUser(); + await prisma.wishlistItem.create({ + data: { account_id: other.id, product_id: notWishlisted.id }, + }); + + const result = await service.getOverview(account.id); + + const map = new Map( + result.recentViewedProducts.map((p) => [p.productId, p.isWishlisted]), + ); + expect(map.get(wishlisted.id.toString())).toBe(true); + expect(map.get(notWishlisted.id.toString())).toBe(false); + }); }); }); diff --git a/src/features/user/services/user-mypage.service.ts b/src/features/user/services/user-mypage.service.ts index 62c47f1..947449a 100644 --- a/src/features/user/services/user-mypage.service.ts +++ b/src/features/user/services/user-mypage.service.ts @@ -45,6 +45,13 @@ export class UserMypageService { ), ]); + // N+1 회피: 최근 본 상품 productId 묶음으로 단일 IN 쿼리로 찜 여부 조회 + const wishlistedProductIds = + await this.userRepository.findWishlistedProductIds({ + accountId, + productIds: recentViews.map((v) => v.product_id), + }); + return { counts: { customDraftCount, @@ -76,6 +83,7 @@ export class UserMypageService { regularPrice: view.product.regular_price, storeName: view.product.store.store_name, viewedAt: view.viewed_at, + isWishlisted: wishlistedProductIds.has(view.product_id.toString()), })), }; } diff --git a/src/features/user/services/user-recent-view.service.spec.ts b/src/features/user/services/user-recent-view.service.spec.ts index dd76e97..b8eabaa 100644 --- a/src/features/user/services/user-recent-view.service.spec.ts +++ b/src/features/user/services/user-recent-view.service.spec.ts @@ -3,6 +3,7 @@ import type { PrismaClient } from '@prisma/client'; import { ProductRepository } from '@/features/product/repositories/product.repository'; import { RecentProductViewRepository } from '@/features/user/repositories/recent-product-view.repository'; +import { UserRepository } from '@/features/user/repositories/user.repository'; import { UserRecentViewService } from '@/features/user/services/user-recent-view.service'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; @@ -26,6 +27,7 @@ describe('UserRecentViewService (real DB)', () => { UserRecentViewService, RecentProductViewRepository, ProductRepository, + UserRepository, ], }); @@ -87,6 +89,32 @@ describe('UserRecentViewService (real DB)', () => { expect(result.items[1].salePrice).toBe(9000); }); + it('찜한 상품은 isWishlisted=true, 안 한 상품은 false로 매핑된다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + const wishlisted = await createProduct(prisma, { store_id: store.id }); + const notWishlisted = await createProduct(prisma, { store_id: store.id }); + await createRecentProductView(prisma, { + account_id: account.id, + product_id: wishlisted.id, + }); + await createRecentProductView(prisma, { + account_id: account.id, + product_id: notWishlisted.id, + }); + await prisma.wishlistItem.create({ + data: { account_id: account.id, product_id: wishlisted.id }, + }); + + const result = await service.list(account.id); + + const map = new Map( + result.items.map((p) => [p.productId, p.isWishlisted]), + ); + expect(map.get(wishlisted.id.toString())).toBe(true); + expect(map.get(notWishlisted.id.toString())).toBe(false); + }); + it('pagination: offset + limit < totalCount면 hasMore true', async () => { const account = await createAccount(prisma, { account_type: 'USER' }); const store = await createStore(prisma); diff --git a/src/features/user/services/user-recent-view.service.ts b/src/features/user/services/user-recent-view.service.ts index 4af9cce..163ce4a 100644 --- a/src/features/user/services/user-recent-view.service.ts +++ b/src/features/user/services/user-recent-view.service.ts @@ -7,6 +7,7 @@ import { import { parseId } from '@/common/utils/id-parser'; import { ProductRepository } from '@/features/product/repositories/product.repository'; import { RecentProductViewRepository } from '@/features/user/repositories/recent-product-view.repository'; +import { UserRepository } from '@/features/user/repositories/user.repository'; import type { RecentViewedProductConnection } from '@/features/user/types/user-mypage-output.type'; /** 계정당 최대 보관 개수 */ @@ -23,6 +24,7 @@ export class UserRecentViewService { constructor( private readonly recentViewRepo: RecentProductViewRepository, private readonly productRepo: ProductRepository, + private readonly userRepo: UserRepository, ) {} async list( @@ -46,6 +48,12 @@ export class UserRecentViewService { limit, }); + // N+1 회피: 단일 IN 쿼리로 찜 여부 조회 + const wishlistedProductIds = await this.userRepo.findWishlistedProductIds({ + accountId, + productIds: items.map((v) => v.product_id), + }); + return { items: items.map((view) => ({ productId: view.product_id.toString(), @@ -55,6 +63,7 @@ export class UserRecentViewService { regularPrice: view.product.regular_price, storeName: view.product.store.store_name, viewedAt: view.viewed_at, + isWishlisted: wishlistedProductIds.has(view.product_id.toString()), })), totalCount, hasMore: offset + limit < totalCount, diff --git a/src/features/user/services/user-wishlist.service.spec.ts b/src/features/user/services/user-wishlist.service.spec.ts new file mode 100644 index 0000000..9667d2b --- /dev/null +++ b/src/features/user/services/user-wishlist.service.spec.ts @@ -0,0 +1,309 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import type { PrismaClient } from '@prisma/client'; + +import { ProductRepository } from '@/features/product/repositories/product.repository'; +import { UserRepository } from '@/features/user/repositories/user.repository'; +import { UserWishlistService } from '@/features/user/services/user-wishlist.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createProduct, + createStore, + createUserProfile, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('UserWishlistService (real DB)', () => { + let service: UserWishlistService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [UserWishlistService, UserRepository, ProductRepository], + }); + service = module.get(UserWishlistService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + async function setupUser() { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: account.id }); + return account; + } + + // ─── addToWishlist ─── + describe('addToWishlist', () => { + it('처음 추가 시 wishlistItem row가 생성된다', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + + const result = await service.addToWishlist( + account.id, + product.id.toString(), + ); + + expect(result).toBe(true); + const row = await prisma.wishlistItem.findUnique({ + where: { + account_id_product_id: { + account_id: account.id, + product_id: product.id, + }, + }, + }); + expect(row).not.toBeNull(); + expect(row?.deleted_at).toBeNull(); + }); + + it('이미 active 상태로 있으면 멱등 (true 반환, 추가 row 없음)', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + + await service.addToWishlist(account.id, product.id.toString()); + await service.addToWishlist(account.id, product.id.toString()); + + const count = await prisma.wishlistItem.count({ + where: { account_id: account.id, product_id: product.id }, + }); + expect(count).toBe(1); + }); + + it('soft-delete된 row가 있으면 deleted_at=null로 복원된다', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + + await prisma.wishlistItem.create({ + data: { + account_id: account.id, + product_id: product.id, + deleted_at: new Date(), + }, + }); + + await service.addToWishlist(account.id, product.id.toString()); + + const row = await prisma.wishlistItem.findUnique({ + where: { + account_id_product_id: { + account_id: account.id, + product_id: product.id, + }, + }, + }); + expect(row?.deleted_at).toBeNull(); + }); + + it('존재하지 않는 productId면 NotFoundException', async () => { + const account = await setupUser(); + await expect(service.addToWishlist(account.id, '999999')).rejects.toThrow( + NotFoundException, + ); + }); + + it('비활성 product면 NotFoundException', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { + store_id: store.id, + is_active: false, + }); + + await expect( + service.addToWishlist(account.id, product.id.toString()), + ).rejects.toThrow(NotFoundException); + }); + + it('soft-delete된 product면 NotFoundException', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + await prisma.product.update({ + where: { id: product.id }, + data: { deleted_at: new Date() }, + }); + + await expect( + service.addToWishlist(account.id, product.id.toString()), + ).rejects.toThrow(NotFoundException); + }); + + it('비활성 store에 속한 product면 NotFoundException', async () => { + const account = await setupUser(); + const store = await createStore(prisma, { is_active: false }); + const product = await createProduct(prisma, { store_id: store.id }); + + await expect( + service.addToWishlist(account.id, product.id.toString()), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ─── removeFromWishlist ─── + describe('removeFromWishlist', () => { + it('정상 soft-delete', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + await service.addToWishlist(account.id, product.id.toString()); + + const result = await service.removeFromWishlist( + account.id, + product.id.toString(), + ); + + expect(result).toBe(true); + const row = await prisma.wishlistItem.findUnique({ + where: { + account_id_product_id: { + account_id: account.id, + product_id: product.id, + }, + }, + }); + expect(row?.deleted_at).not.toBeNull(); + }); + + it('이미 없는 상품을 제거해도 멱등 (true 반환)', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + + const result = await service.removeFromWishlist( + account.id, + product.id.toString(), + ); + + expect(result).toBe(true); + }); + }); + + // ─── myWishlist ─── + describe('myWishlist', () => { + it('자기 찜만 반환 + 추가 시각 desc 정렬', async () => { + const me = await setupUser(); + const other = await setupUser(); + const store = await createStore(prisma, { store_name: '매장A' }); + const p1 = await createProduct(prisma, { + store_id: store.id, + name: '상품1', + }); + const p2 = await createProduct(prisma, { + store_id: store.id, + name: '상품2', + }); + + await service.addToWishlist(me.id, p1.id.toString()); + await new Promise((r) => setTimeout(r, 10)); + await service.addToWishlist(me.id, p2.id.toString()); + await service.addToWishlist(other.id, p1.id.toString()); + + const result = await service.myWishlist(me.id); + + expect(result.totalCount).toBe(2); + expect(result.items).toHaveLength(2); + expect(result.items[0].productId).toBe(p2.id.toString()); // 최근 추가가 먼저 + expect(result.items[0].productName).toBe('상품2'); + expect(result.items[0].storeName).toBe('매장A'); + }); + + it('soft-delete된 wishlist 항목은 제외된다', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + + await service.addToWishlist(account.id, product.id.toString()); + await service.removeFromWishlist(account.id, product.id.toString()); + + const result = await service.myWishlist(account.id); + + expect(result.totalCount).toBe(0); + expect(result.items).toEqual([]); + }); + + it('비활성/삭제된 product는 제외된다', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const activeProduct = await createProduct(prisma, { store_id: store.id }); + const inactiveProduct = await createProduct(prisma, { + store_id: store.id, + }); + const deletedProduct = await createProduct(prisma, { + store_id: store.id, + }); + + // active 상태에서 모두 찜 추가 + await prisma.wishlistItem.createMany({ + data: [ + { account_id: account.id, product_id: activeProduct.id }, + { account_id: account.id, product_id: inactiveProduct.id }, + { account_id: account.id, product_id: deletedProduct.id }, + ], + }); + // 이후 product 상태 변경 + await prisma.product.update({ + where: { id: inactiveProduct.id }, + data: { is_active: false }, + }); + await prisma.product.update({ + where: { id: deletedProduct.id }, + data: { deleted_at: new Date() }, + }); + + const result = await service.myWishlist(account.id); + + expect(result.totalCount).toBe(1); + expect(result.items[0].productId).toBe(activeProduct.id.toString()); + }); + + it('페이지네이션이 동작한다 (offset/limit/hasMore)', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + for (let i = 0; i < 5; i++) { + const p = await createProduct(prisma, { store_id: store.id }); + await service.addToWishlist(account.id, p.id.toString()); + } + + const page1 = await service.myWishlist(account.id, { + offset: 0, + limit: 2, + }); + expect(page1.totalCount).toBe(5); + expect(page1.items).toHaveLength(2); + expect(page1.hasMore).toBe(true); + + const page2 = await service.myWishlist(account.id, { + offset: 4, + limit: 2, + }); + expect(page2.items).toHaveLength(1); + expect(page2.hasMore).toBe(false); + }); + + it('offset 음수는 BadRequestException', async () => { + const account = await setupUser(); + await expect( + service.myWishlist(account.id, { offset: -1 }), + ).rejects.toThrow(BadRequestException); + }); + + it('limit이 50 초과면 BadRequestException', async () => { + const account = await setupUser(); + await expect( + service.myWishlist(account.id, { limit: 51 }), + ).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/src/features/user/services/user-wishlist.service.ts b/src/features/user/services/user-wishlist.service.ts new file mode 100644 index 0000000..bf51870 --- /dev/null +++ b/src/features/user/services/user-wishlist.service.ts @@ -0,0 +1,101 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; + +import { parseId } from '@/common/utils/id-parser'; +import { ProductRepository } from '@/features/product/repositories/product.repository'; +import { USER_WISHLIST_ERRORS } from '@/features/user/constants/user-wishlist-error-messages'; +import { + DEFAULT_PAGINATION_LIMIT, + MAX_PAGINATION_LIMIT, +} from '@/features/user/constants/user.constants'; +import { UserRepository } from '@/features/user/repositories/user.repository'; +import { UserBaseService } from '@/features/user/services/user-base.service'; +import type { + MyWishlistConnection, + MyWishlistInput, +} from '@/features/user/types/user-wishlist-output.type'; + +@Injectable() +export class UserWishlistService extends UserBaseService { + constructor( + repo: UserRepository, + private readonly productRepository: ProductRepository, + ) { + super(repo); + } + + async addToWishlist( + accountId: bigint, + productIdStr: string, + ): Promise { + await this.requireActiveUser(accountId); + const productId = parseId(productIdStr); + + const exists = await this.productRepository.existsActiveProduct(productId); + if (!exists) { + throw new NotFoundException(USER_WISHLIST_ERRORS.PRODUCT_NOT_FOUND); + } + + await this.repo.upsertWishlistItem({ + accountId, + productId, + now: new Date(), + }); + return true; + } + + async removeFromWishlist( + accountId: bigint, + productIdStr: string, + ): Promise { + await this.requireActiveUser(accountId); + const productId = parseId(productIdStr); + + await this.repo.softDeleteWishlistItem({ + accountId, + productId, + now: new Date(), + }); + return true; + } + + async myWishlist( + accountId: bigint, + input?: MyWishlistInput, + ): Promise { + await this.requireActiveUser(accountId); + + const offset = input?.offset ?? 0; + const limit = input?.limit ?? DEFAULT_PAGINATION_LIMIT; + + if (offset < 0) { + throw new BadRequestException(USER_WISHLIST_ERRORS.INVALID_OFFSET); + } + if (limit < 1 || limit > MAX_PAGINATION_LIMIT) { + throw new BadRequestException(USER_WISHLIST_ERRORS.INVALID_LIMIT); + } + + const { items, totalCount } = await this.repo.findWishlistItems({ + accountId, + offset, + limit, + }); + + return { + items: items.map((row) => ({ + productId: row.product_id.toString(), + productName: row.product.name, + representativeImageUrl: row.product.images[0]?.image_url ?? null, + salePrice: row.product.sale_price, + regularPrice: row.product.regular_price, + storeName: row.product.store.store_name, + addedAt: row.created_at, + })), + totalCount, + hasMore: offset + limit < totalCount, + }; + } +} diff --git a/src/features/user/types/user-mypage-output.type.ts b/src/features/user/types/user-mypage-output.type.ts index 7ee99ae..7608e3f 100644 --- a/src/features/user/types/user-mypage-output.type.ts +++ b/src/features/user/types/user-mypage-output.type.ts @@ -26,6 +26,7 @@ export interface RecentViewedProductSummary { regularPrice: number; storeName: string; viewedAt: Date; + isWishlisted: boolean; } export interface RecentViewedProductConnection { diff --git a/src/features/user/types/user-wishlist-output.type.ts b/src/features/user/types/user-wishlist-output.type.ts new file mode 100644 index 0000000..9fe2115 --- /dev/null +++ b/src/features/user/types/user-wishlist-output.type.ts @@ -0,0 +1,20 @@ +export interface WishlistItemSummary { + productId: string; + productName: string; + representativeImageUrl: string | null; + salePrice: number | null; + regularPrice: number; + storeName: string; + addedAt: Date; +} + +export interface MyWishlistConnection { + items: WishlistItemSummary[]; + totalCount: number; + hasMore: boolean; +} + +export interface MyWishlistInput { + offset?: number | null; + limit?: number | null; +} diff --git a/src/features/user/user-mypage.graphql b/src/features/user/user-mypage.graphql index 233951e..2a9037e 100644 --- a/src/features/user/user-mypage.graphql +++ b/src/features/user/user-mypage.graphql @@ -46,4 +46,6 @@ type RecentViewedProductSummary { regularPrice: Int! storeName: String! viewedAt: DateTime! + """현재 사용자의 찜 여부""" + isWishlisted: Boolean! } diff --git a/src/features/user/user-wishlist.graphql b/src/features/user/user-wishlist.graphql new file mode 100644 index 0000000..17f8650 --- /dev/null +++ b/src/features/user/user-wishlist.graphql @@ -0,0 +1,32 @@ +extend type Query { + """내 찜 목록""" + myWishlist(input: MyWishlistInput): MyWishlistConnection! +} + +extend type Mutation { + """찜 추가 (멱등: 이미 있어도 true 반환, soft-delete된 항목은 복원)""" + addToWishlist(productId: ID!): Boolean! + """찜 해제 (멱등: 이미 없어도 true 반환)""" + removeFromWishlist(productId: ID!): Boolean! +} + +input MyWishlistInput { + offset: Int = 0 + limit: Int = 20 +} + +type MyWishlistConnection { + items: [WishlistItemSummary!]! + totalCount: Int! + hasMore: Boolean! +} + +type WishlistItemSummary { + productId: ID! + productName: String! + representativeImageUrl: String + salePrice: Int + regularPrice: Int! + storeName: String! + addedAt: DateTime! +} diff --git a/src/features/user/user.module.ts b/src/features/user/user.module.ts index 7c48247..6ed7e06 100644 --- a/src/features/user/user.module.ts +++ b/src/features/user/user.module.ts @@ -18,6 +18,8 @@ import { UserReviewMutationResolver } from '@/features/user/resolvers/user-revie import { UserReviewQueryResolver } from '@/features/user/resolvers/user-review-query.resolver'; import { UserSearchMutationResolver } from '@/features/user/resolvers/user-search-mutation.resolver'; import { UserSearchQueryResolver } from '@/features/user/resolvers/user-search-query.resolver'; +import { UserWishlistMutationResolver } from '@/features/user/resolvers/user-wishlist-mutation.resolver'; +import { UserWishlistQueryResolver } from '@/features/user/resolvers/user-wishlist-query.resolver'; import { UserEngagementService } from '@/features/user/services/user-engagement.service'; import { UserMypageService } from '@/features/user/services/user-mypage.service'; import { UserNotificationService } from '@/features/user/services/user-notification.service'; @@ -26,6 +28,7 @@ import { UserProfileService } from '@/features/user/services/user-profile.servic import { UserRecentViewService } from '@/features/user/services/user-recent-view.service'; import { UserReviewService } from '@/features/user/services/user-review.service'; import { UserSearchService } from '@/features/user/services/user-search.service'; +import { UserWishlistService } from '@/features/user/services/user-wishlist.service'; /** * User 도메인 모듈 @@ -41,6 +44,7 @@ import { UserSearchService } from '@/features/user/services/user-search.service' UserOrderService, UserRecentViewService, UserReviewService, + UserWishlistService, UserRepository, RecentProductViewRepository, ReviewRepository, @@ -57,6 +61,8 @@ import { UserSearchService } from '@/features/user/services/user-search.service' UserNotificationMutationResolver, UserSearchMutationResolver, UserEngagementMutationResolver, + UserWishlistQueryResolver, + UserWishlistMutationResolver, ], }) export class UserModule {} From 02be4b2a31608e5e02c1c84d3896817bb2ab1350 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 30 Apr 2026 01:43:52 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(user):=20wishlistCount=EC=99=80=20myWis?= =?UTF-8?q?hlist=20=EA=B0=80=EC=8B=9C=EC=84=B1=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit countWishlistItems / getViewerCounts.wishlistCount 가 wishlist soft-delete만 필터링하고 비활성/삭제 product/store에 연결된 row는 카운트에 포함하던 문제 수정. findWishlistItems와 동일한 가시성 조건(product/store active+not-deleted)을 공유하도록 visibleWishlistWhere helper로 통일하여 카운트와 목록 길이가 항상 일치하도록 한다. (Codex 리뷰 P2: count badge vs. list contents 불일치 회피) - visibleWishlistWhere private helper 도입 (UserRepository) - getViewerCounts.wishlistCount / countWishlistItems / findWishlistItems 모두 동일 helper 사용 - 회귀 테스트 1건: 4건 wishlist 중 inactive product / soft-delete product / inactive store 의 product 3건은 카운트에서 제외, visible 1건만 카운트 --- .../user/repositories/user.repository.ts | 38 ++++++++++------ .../user/services/user-mypage.service.spec.ts | 45 +++++++++++++++++++ 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/src/features/user/repositories/user.repository.ts b/src/features/user/repositories/user.repository.ts index 38f48ea..09f5724 100644 --- a/src/features/user/repositories/user.repository.ts +++ b/src/features/user/repositories/user.repository.ts @@ -34,6 +34,27 @@ export class UserRepository { return { ...where, deleted_at: null }; } + /** + * 화면에 노출 가능한 wishlist row 조건. + * - wishlist 자체가 active (deleted_at: null) + * - 연결된 product 가 active + soft-delete 아님 + * - 연결된 store 가 active + soft-delete 아님 + * + * count 와 list 가 같은 가시성 기준을 공유하도록 하여 + * 마이페이지 카운트 카드와 실제 목록 길이 불일치를 방지한다. + */ + private visibleWishlistWhere(accountId: bigint) { + return { + account_id: accountId, + deleted_at: null, + product: { + deleted_at: null, + is_active: true, + store: { deleted_at: null, is_active: true }, + }, + } as const; + } + async findAccountWithProfile( accountId: bigint, options?: { withDeleted?: boolean }, @@ -197,10 +218,7 @@ export class UserRepository { }, }), this.prisma.wishlistItem.count({ - where: { - account_id: accountId, - deleted_at: null, - }, + where: this.visibleWishlistWhere(accountId), }), ]); @@ -369,7 +387,7 @@ export class UserRepository { async countWishlistItems(accountId: bigint): Promise { return this.prisma.wishlistItem.count({ - where: { account_id: accountId, deleted_at: null }, + where: this.visibleWishlistWhere(accountId), }); } @@ -455,15 +473,7 @@ export class UserRepository { }[]; totalCount: number; }> { - const where = { - account_id: args.accountId, - deleted_at: null, - product: { - deleted_at: null, - is_active: true, - store: { deleted_at: null, is_active: true }, - }, - }; + const where = this.visibleWishlistWhere(args.accountId); const [rows, totalCount] = await this.prisma.$transaction([ this.prisma.wishlistItem.findMany({ diff --git a/src/features/user/services/user-mypage.service.spec.ts b/src/features/user/services/user-mypage.service.spec.ts index 902760e..67a9e6e 100644 --- a/src/features/user/services/user-mypage.service.spec.ts +++ b/src/features/user/services/user-mypage.service.spec.ts @@ -241,6 +241,51 @@ describe('UserMypageService (real DB)', () => { }); }); + it('wishlistCount는 비활성/삭제 product가 연결된 wishlist는 제외한다 (myWishlist 가시성과 일치)', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const visibleProduct = await createProduct(prisma, { + store_id: store.id, + }); + const inactiveProduct = await createProduct(prisma, { + store_id: store.id, + }); + const deletedProduct = await createProduct(prisma, { + store_id: store.id, + }); + const inactiveStore = await createStore(prisma); + const productOfInactiveStore = await createProduct(prisma, { + store_id: inactiveStore.id, + }); + + // 4개 모두 active 상태에서 찜 추가 + await prisma.wishlistItem.createMany({ + data: [ + { account_id: account.id, product_id: visibleProduct.id }, + { account_id: account.id, product_id: inactiveProduct.id }, + { account_id: account.id, product_id: deletedProduct.id }, + { account_id: account.id, product_id: productOfInactiveStore.id }, + ], + }); + // 이후 상태 변경 (3개는 invisible로 만든다) + await prisma.product.update({ + where: { id: inactiveProduct.id }, + data: { is_active: false }, + }); + await prisma.product.update({ + where: { id: deletedProduct.id }, + data: { deleted_at: new Date() }, + }); + await prisma.store.update({ + where: { id: inactiveStore.id }, + data: { is_active: false }, + }); + + const result = await service.getOverview(account.id); + + expect(result.counts.wishlistCount).toBe(1); + }); + it('recentViewedProducts에 찜한 상품은 isWishlisted=true로 매핑된다', async () => { const account = await setupUser(); const store = await createStore(prisma);