diff --git a/.gitignore b/.gitignore index 553a01f..dfd9e6b 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,5 @@ docs/* .env.example caquick_ddl.sql src/features/example/* + +.figma/ diff --git a/src/features/order/repositories/order.repository.ts b/src/features/order/repositories/order.repository.ts index d252967..81d9d43 100644 --- a/src/features/order/repositories/order.repository.ts +++ b/src/features/order/repositories/order.repository.ts @@ -142,6 +142,38 @@ export class OrderRepository { }); } + /** + * 주어진 orderId 중 PICKED_UP 상태이며 active 리뷰가 미작성인 OrderItem을 + * 1건 이상 가진 order의 ID 집합을 반환한다. + * + * 주의: list 매핑에서 order별 개별 쿼리(N+1) 회피용. 단일 IN 쿼리로 처리. + */ + async findReviewableOrderIds(args: { + accountId: bigint; + orderIds: bigint[]; + }): Promise> { + if (args.orderIds.length === 0) return new Set(); + + const rows = await this.prisma.orderItem.findMany({ + where: { + order_id: { in: args.orderIds }, + deleted_at: null, + order: { + account_id: args.accountId, + status: OrderStatus.PICKED_UP, + }, + OR: [ + { review: { is: null } }, + { review: { is: { deleted_at: { not: null } } } }, + ], + }, + select: { order_id: true }, + distinct: ['order_id'], + }); + + return new Set(rows.map((r) => r.order_id.toString())); + } + async findOrderDetailByAccount(args: { orderId: bigint; accountId: bigint }) { return this.prisma.order.findFirst({ where: { 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-review-error-messages.ts b/src/features/user/constants/user-review-error-messages.ts index 23d2863..ceb2c32 100644 --- a/src/features/user/constants/user-review-error-messages.ts +++ b/src/features/user/constants/user-review-error-messages.ts @@ -2,7 +2,8 @@ export const USER_REVIEW_ERRORS = { INVALID_RATING: '별점은 1.0~5.0 사이, 0.5 단위여야 합니다.', CONTENT_TOO_SHORT: '리뷰는 최소 20자 이상이어야 합니다.', CONTENT_TOO_LONG: '리뷰는 최대 1000자까지 작성 가능합니다.', - TOO_MANY_MEDIA: '미디어는 최대 10개까지 첨부할 수 있습니다.', + TOO_MANY_IMAGES: '사진은 최대 10장까지 첨부할 수 있습니다.', + TOO_MANY_VIDEOS: '동영상은 최대 1개까지 첨부할 수 있습니다.', ORDER_ITEM_NOT_FOUND: '주문 아이템을 찾을 수 없습니다.', CANNOT_WRITE_REVIEW: '리뷰를 작성할 수 없는 주문입니다.', REVIEW_ALREADY_EXISTS: '이미 리뷰가 작성된 주문 아이템입니다.', 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/constants/user.constants.ts b/src/features/user/constants/user.constants.ts index db7fac5..bbabab9 100644 --- a/src/features/user/constants/user.constants.ts +++ b/src/features/user/constants/user.constants.ts @@ -5,8 +5,16 @@ export const MAX_NICKNAME_LENGTH = 20; // ── 전화번호 ── -export const MIN_PHONE_LENGTH = 7; -export const MAX_PHONE_LENGTH = 20; +// 정책: 010-XXXX-XXXX 고정 (13자). figma 명세 기준. +export const PHONE_REGEX = /^010-\d{4}-\d{4}$/; +export const PHONE_FORMAT_EXAMPLE = '010-XXXX-XXXX'; + +// ── 생년월일 ── + +// figma 명세 외 정책 결정: 1900-01-01 이전 입력은 거부 (사실상 봇/오입력 방지). +// GraphQL DateTime은 ISO string을 UTC로 해석하므로 비교 기준도 UTC 자정으로 둔다. +// 운영 timezone과 무관하게 동일하게 동작. +export const MIN_BIRTH_DATE = new Date(Date.UTC(1900, 0, 1)); // ── 페이지네이션 ── diff --git a/src/features/user/repositories/user.repository.ts b/src/features/user/repositories/user.repository.ts index 5d4739d..b5f8d55 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 }, @@ -96,18 +117,39 @@ export class UserRepository { async updateProfile(args: { accountId: bigint; nickname?: string; + name?: string; birthDate?: Date | null; phoneNumber?: string | null; }): Promise { - await this.prisma.userProfile.update({ - where: { account_id: args.accountId }, - data: { - ...(args.nickname !== undefined ? { nickname: args.nickname } : {}), - ...(args.birthDate !== undefined ? { birth_date: args.birthDate } : {}), - ...(args.phoneNumber !== undefined - ? { phone_number: args.phoneNumber } - : {}), - }, + const hasName = args.name !== undefined; + const hasProfileFields = + args.nickname !== undefined || + args.birthDate !== undefined || + args.phoneNumber !== undefined; + + // name은 account 테이블, 나머지는 user_profile 테이블이라 + // 두 테이블 부분 실패 방지를 위해 transaction으로 묶는다. + await this.prisma.$transaction(async (tx) => { + if (hasName) { + await tx.account.update({ + where: { id: args.accountId }, + data: { name: args.name }, + }); + } + if (hasProfileFields) { + await tx.userProfile.update({ + where: { account_id: args.accountId }, + data: { + ...(args.nickname !== undefined ? { nickname: args.nickname } : {}), + ...(args.birthDate !== undefined + ? { birth_date: args.birthDate } + : {}), + ...(args.phoneNumber !== undefined + ? { phone_number: args.phoneNumber } + : {}), + }, + }); + } }); } @@ -176,9 +218,7 @@ export class UserRepository { }, }), this.prisma.wishlistItem.count({ - where: { - account_id: accountId, - }, + where: this.visibleWishlistWhere(accountId), }), ]); @@ -347,8 +387,125 @@ export class UserRepository { async countWishlistItems(accountId: bigint): Promise { return this.prisma.wishlistItem.count({ - where: { account_id: accountId }, + where: this.visibleWishlistWhere(accountId), + }); + } + + /** + * 찜 추가 (멱등). 이미 있으면 그대로, 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 회피)용. 가시성 조건(visibleWishlistWhere)을 myWishlist/wishlistCount와 + * 공유하여, recent-view 등에 노출되는 isWishlisted 플래그가 실제 wishlist 표면 + * (목록/카운트)과 일관되도록 한다. + */ + async findWishlistedProductIds(args: { + accountId: bigint; + productIds: bigint[]; + }): Promise> { + if (args.productIds.length === 0) return new Set(); + const rows = await this.prisma.wishlistItem.findMany({ + where: { + ...this.visibleWishlistWhere(args.accountId), + 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 = this.visibleWishlistWhere(args.accountId); + + const [rows, totalCount] = await this.prisma.$transaction([ + this.prisma.wishlistItem.findMany({ + where, + // 같은 밀리초 생성 시 페이지 경계 흔들림 방지를 위해 product_id를 보조 정렬키로 둔다. + orderBy: [{ created_at: 'desc' }, { product_id: '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 { 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-base.service.spec.ts b/src/features/user/services/user-base.service.spec.ts index ce4bd08..d5d639d 100644 --- a/src/features/user/services/user-base.service.spec.ts +++ b/src/features/user/services/user-base.service.spec.ts @@ -183,23 +183,35 @@ describe('UserBaseService (real DB)', () => { expect(service.testNormalizePhoneNumber(' ')).toBeNull(); }); - it('길이가 하한 미만이면 BadRequestException을 던진다', () => { - expect(() => service.testNormalizePhoneNumber('12345')).toThrow( - BadRequestException, + it.each([['010-0000-0000'], ['010-1234-5678'], ['010-9999-9999']])( + '정상 형식 %s 은 그대로 반환한다', + (raw) => { + expect(service.testNormalizePhoneNumber(raw)).toBe(raw); + }, + ); + + it('앞뒤 공백을 trim하여 검증한다', () => { + expect(service.testNormalizePhoneNumber(' 010-1234-5678 ')).toBe( + '010-1234-5678', ); }); - it('숫자와 하이픈 외 문자가 포함되면 BadRequestException을 던진다', () => { - expect(() => service.testNormalizePhoneNumber('010-abc-1234')).toThrow( + it.each([ + ['011-1234-5678'], // 010 prefix 외 + ['019-1234-5678'], // 010 prefix 외 + ['010-123-4567'], // 자릿수 부족 + ['010-12345-6789'], // 자릿수 초과 + ['01012345678'], // 하이픈 없음 + ['010 1234 5678'], // 공백 구분자 + ['+82-10-1234-5678'], // 국가코드 포함 + ['010-abc-1234'], // 문자 포함 + ['010--1234-5678'], // 하이픈 위치 잘못 + ['12345'], // 짧은 임의 문자열 + ])('비정상 형식 %s 은 BadRequestException을 던진다', (raw) => { + expect(() => service.testNormalizePhoneNumber(raw)).toThrow( BadRequestException, ); }); - - it('유효한 전화번호를 반환한다', () => { - expect(service.testNormalizePhoneNumber('010-1234-5678')).toBe( - '010-1234-5678', - ); - }); }); describe('normalizeBirthDate', () => { @@ -222,21 +234,41 @@ describe('UserBaseService (real DB)', () => { ); }); + it('1899-12-31 등 1900-01-01 이전이면 BadRequestException을 던진다', () => { + expect(() => service.testNormalizeBirthDate('1899-12-31')).toThrow( + BadRequestException, + ); + expect(() => service.testNormalizeBirthDate('1850-01-01')).toThrow( + BadRequestException, + ); + }); + + it('1900-01-01은 통과한다 (UTC 기준)', () => { + const result = service.testNormalizeBirthDate('1900-01-01'); + expect(result).toBeInstanceOf(Date); + expect(result?.getUTCFullYear()).toBe(1900); + expect(result?.getUTCMonth()).toBe(0); + expect(result?.getUTCDate()).toBe(1); + }); + it('문자열 날짜를 Date 객체로 변환한다', () => { const result = service.testNormalizeBirthDate('1990-05-15'); expect(result).toBeInstanceOf(Date); - expect(result?.getFullYear()).toBe(1990); + expect(result?.getUTCFullYear()).toBe(1990); + expect(result?.getUTCMonth()).toBe(4); + expect(result?.getUTCDate()).toBe(15); }); - it('오늘 날짜는 미래로 취급하지 않고 그대로 반환한다', () => { - const today = new Date(); - today.setHours(12, 0, 0, 0); + it('오늘(UTC) 날짜는 미래로 취급하지 않고 그대로 반환한다', () => { + const now = new Date(); + const todayIso = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}-${String(now.getUTCDate()).padStart(2, '0')}`; - const result = service.testNormalizeBirthDate(today); + const result = service.testNormalizeBirthDate(todayIso); expect(result).toBeInstanceOf(Date); - // 시간은 00:00:00으로 내부 정규화되지만 날짜 자체는 오늘과 같아야 함 - expect(result?.toDateString()).toBe(today.toDateString()); + expect(result?.getUTCFullYear()).toBe(now.getUTCFullYear()); + expect(result?.getUTCMonth()).toBe(now.getUTCMonth()); + expect(result?.getUTCDate()).toBe(now.getUTCDate()); }); }); diff --git a/src/features/user/services/user-base.service.ts b/src/features/user/services/user-base.service.ts index 9aeab63..dd8709b 100644 --- a/src/features/user/services/user-base.service.ts +++ b/src/features/user/services/user-base.service.ts @@ -9,9 +9,10 @@ import { DEFAULT_PAGINATION_LIMIT, MAX_NICKNAME_LENGTH, MAX_PAGINATION_LIMIT, - MAX_PHONE_LENGTH, + MIN_BIRTH_DATE, MIN_NICKNAME_LENGTH, - MIN_PHONE_LENGTH, + PHONE_FORMAT_EXAMPLE, + PHONE_REGEX, } from '@/features/user/constants/user.constants'; import type { UserAccountWithProfile } from '@/features/user/repositories/user.repository'; import { UserRepository } from '@/features/user/repositories/user.repository'; @@ -89,14 +90,10 @@ export abstract class UserBaseService { if (raw === undefined || raw === null) return null; const trimmed = raw.trim(); if (trimmed.length === 0) return null; - if ( - trimmed.length < MIN_PHONE_LENGTH || - trimmed.length > MAX_PHONE_LENGTH - ) { - throw new BadRequestException('Invalid phone number length.'); - } - if (!/^[0-9-]+$/.test(trimmed)) { - throw new BadRequestException('Invalid phone number format.'); + if (!PHONE_REGEX.test(trimmed)) { + throw new BadRequestException( + `Invalid phone number format. Expected ${PHONE_FORMAT_EXAMPLE}.`, + ); } return trimmed; } @@ -107,11 +104,21 @@ export abstract class UserBaseService { if (Number.isNaN(date.getTime())) { throw new BadRequestException('Invalid birthDate.'); } - const today = new Date(); - today.setHours(0, 0, 0, 0); - const normalized = new Date(date); - normalized.setHours(0, 0, 0, 0); - if (normalized > today) { + // DB가 @db.Date(시간 무시) + GraphQL DateTime이 ISO string을 UTC로 해석하므로 + // timezone 독립적으로 UTC 자정 기준으로 정규화한다. + const normalized = new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), + ); + if (normalized < MIN_BIRTH_DATE) { + throw new BadRequestException( + 'birthDate is too old (before 1900-01-01).', + ); + } + const now = new Date(); + const todayUtc = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), + ); + if (normalized > todayUtc) { throw new BadRequestException('birthDate cannot be in the future.'); } return normalized; diff --git a/src/features/user/services/user-mypage.service.spec.ts b/src/features/user/services/user-mypage.service.spec.ts index 9efea63..67a9e6e 100644 --- a/src/features/user/services/user-mypage.service.spec.ts +++ b/src/features/user/services/user-mypage.service.spec.ts @@ -237,7 +237,84 @@ describe('UserMypageService (real DB)', () => { storeName: '케이크샵', regularPrice: 40000, salePrice: 35000, + isWishlisted: false, }); }); + + 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); + 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-order.service.spec.ts b/src/features/user/services/user-order.service.spec.ts index 5029e14..6cf9019 100644 --- a/src/features/user/services/user-order.service.spec.ts +++ b/src/features/user/services/user-order.service.spec.ts @@ -171,6 +171,158 @@ describe('UserOrderService (real DB)', () => { service.listMyOrders(account.id, { limit: 51 }), ).rejects.toThrow(BadRequestException); }); + + it('주문이 0건이면 빈 connection을 반환한다', async () => { + const account = await setupUser(); + + const result = await service.listMyOrders(account.id); + + expect(result.totalCount).toBe(0); + expect(result.items).toEqual([]); + expect(result.hasMore).toBe(false); + }); + + // ─── hasReviewableItem ─── + describe('hasReviewableItem', () => { + async function createPickedUpOrderWithItem(accountId: bigint) { + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + const order = await createOrder(prisma, { + account_id: accountId, + status: 'PICKED_UP', + }); + const item = await createOrderItem(prisma, { + order_id: order.id, + product_id: product.id, + }); + return { store, product, order, item }; + } + + async function createReview(args: { + orderItemId: bigint; + accountId: bigint; + storeId: bigint; + productId: bigint; + deletedAt?: Date | null; + }) { + return prisma.review.create({ + data: { + order_item_id: args.orderItemId, + account_id: args.accountId, + store_id: args.storeId, + product_id: args.productId, + rating: 5, + content: '리뷰 더미 텍스트입니다. 만족합니다.', + deleted_at: args.deletedAt ?? null, + }, + }); + } + + it('PICKED_UP + 리뷰 미작성 item이 1건이면 true', async () => { + const account = await setupUser(); + await createPickedUpOrderWithItem(account.id); + + const result = await service.listMyOrders(account.id); + + expect(result.items).toHaveLength(1); + expect(result.items[0].hasReviewableItem).toBe(true); + }); + + it('PICKED_UP + 모든 item에 active 리뷰가 있으면 false', async () => { + const account = await setupUser(); + const { item, store, product } = await createPickedUpOrderWithItem( + account.id, + ); + await createReview({ + orderItemId: item.id, + accountId: account.id, + storeId: store.id, + productId: product.id, + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].hasReviewableItem).toBe(false); + }); + + it('PICKED_UP + 모든 item의 리뷰가 soft-delete면 true', async () => { + const account = await setupUser(); + const { item, store, product } = await createPickedUpOrderWithItem( + account.id, + ); + await createReview({ + orderItemId: item.id, + accountId: account.id, + storeId: store.id, + productId: product.id, + deletedAt: new Date(), + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].hasReviewableItem).toBe(true); + }); + + it('CONFIRMED 등 비-PICKED_UP 상태는 false', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + const order = await createOrder(prisma, { + account_id: account.id, + status: 'CONFIRMED', + }); + await createOrderItem(prisma, { + order_id: order.id, + product_id: product.id, + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].status).toBe('CONFIRMED'); + expect(result.items[0].hasReviewableItem).toBe(false); + }); + + it('CANCELED 상태는 false', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + const order = await createOrder(prisma, { + account_id: account.id, + status: 'CANCELED', + }); + await createOrderItem(prisma, { + order_id: order.id, + product_id: product.id, + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].status).toBe('CANCELED'); + expect(result.items[0].hasReviewableItem).toBe(false); + }); + + it('PICKED_UP + 일부 item에만 리뷰 미작성이면 true (혼합 케이스)', async () => { + const account = await setupUser(); + const { item, store, product, order } = + await createPickedUpOrderWithItem(account.id); + // item1에는 리뷰가 있고, item2에는 없음 + await createReview({ + orderItemId: item.id, + accountId: account.id, + storeId: store.id, + productId: product.id, + }); + const product2 = await createProduct(prisma, { store_id: store.id }); + await createOrderItem(prisma, { + order_id: order.id, + product_id: product2.id, + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].hasReviewableItem).toBe(true); + }); + }); }); // ─── getMyOrder ─── diff --git a/src/features/user/services/user-order.service.ts b/src/features/user/services/user-order.service.ts index dc7cf25..594165c 100644 --- a/src/features/user/services/user-order.service.ts +++ b/src/features/user/services/user-order.service.ts @@ -51,6 +51,13 @@ export class UserOrderService { const hasMore = orders.length > limit; const sliced = hasMore ? orders.slice(0, limit) : orders; + // N+1 회피: PICKED_UP + 미작성 리뷰가 있는 order id 집합을 단일 IN 쿼리로 조회 + const reviewableOrderIds = + await this.orderRepository.findReviewableOrderIds({ + accountId, + orderIds: sliced.map((o) => o.id), + }); + return { items: sliced.map((order) => { const firstItem = order.items[0]; @@ -69,6 +76,7 @@ export class UserOrderService { additionalItemCount: Math.max(0, itemCount - 1), totalPrice: order.total_price, storeName: firstItem?.store?.store_name ?? '매장 정보 없음', + hasReviewableItem: reviewableOrderIds.has(order.id.toString()), }; }), totalCount, diff --git a/src/features/user/services/user-profile.service.spec.ts b/src/features/user/services/user-profile.service.spec.ts index e5af413..f0711c8 100644 --- a/src/features/user/services/user-profile.service.spec.ts +++ b/src/features/user/services/user-profile.service.spec.ts @@ -222,6 +222,98 @@ describe('UserProfileService (real DB)', () => { service.updateMyProfile(me.id, { nickname: 'taken' }), ).rejects.toThrow(ConflictException); }); + + it('name만 단독 업데이트하면 Account.name이 갱신된다', async () => { + const account = await createAccount(prisma, { + account_type: 'USER', + name: '구이름', + }); + await createUserProfile(prisma, { account_id: account.id }); + + const result = await service.updateMyProfile(account.id, { + name: '새이름', + }); + + expect(result.name).toBe('새이름'); + const saved = await prisma.account.findUniqueOrThrow({ + where: { id: account.id }, + }); + expect(saved.name).toBe('새이름'); + }); + + it('name 입력 시 trim 후 저장한다', async () => { + const account = await createAccount(prisma, { + account_type: 'USER', + name: '구이름', + }); + await createUserProfile(prisma, { account_id: account.id }); + + const result = await service.updateMyProfile(account.id, { + name: ' 홍길동 ', + }); + + expect(result.name).toBe('홍길동'); + }); + + it('name이 빈 문자열이면 BadRequestException', async () => { + const account = await createAccount(prisma, { + account_type: 'USER', + name: '구이름', + }); + await createUserProfile(prisma, { account_id: account.id }); + + await expect( + service.updateMyProfile(account.id, { name: '' }), + ).rejects.toThrow(BadRequestException); + }); + + it('name이 공백-only이면 BadRequestException', async () => { + const account = await createAccount(prisma, { + account_type: 'USER', + name: '구이름', + }); + await createUserProfile(prisma, { account_id: account.id }); + + await expect( + service.updateMyProfile(account.id, { name: ' ' }), + ).rejects.toThrow(BadRequestException); + }); + + it('name + nickname 동시 업데이트 시 둘 다 반영된다', async () => { + const account = await createAccount(prisma, { + account_type: 'USER', + name: '구이름', + }); + await createUserProfile(prisma, { + account_id: account.id, + nickname: 'oldNick', + }); + + const result = await service.updateMyProfile(account.id, { + name: '새이름', + nickname: 'newNick', + }); + + expect(result.name).toBe('새이름'); + expect(result.profile.nickname).toBe('newNick'); + }); + + it('name 미지정 시 기존 Account.name이 유지된다', async () => { + const account = await createAccount(prisma, { + account_type: 'USER', + name: '유지될이름', + }); + await createUserProfile(prisma, { account_id: account.id }); + + await service.updateMyProfile(account.id, { + nickname: 'newNick', + }); + + const saved = await prisma.account.findUniqueOrThrow({ + where: { id: account.id }, + }); + expect(saved.name).toBe('유지될이름'); + }); }); // ─── updateMyProfileImage ─── diff --git a/src/features/user/services/user-profile.service.ts b/src/features/user/services/user-profile.service.ts index 6c32d01..9564388 100644 --- a/src/features/user/services/user-profile.service.ts +++ b/src/features/user/services/user-profile.service.ts @@ -73,10 +73,11 @@ export class UserProfileService extends UserBaseService { await this.requireActiveUser(accountId); const hasNickname = input.nickname !== undefined; + const hasName = input.name !== undefined; const hasBirthDate = input.birthDate !== undefined; const hasPhoneNumber = input.phoneNumber !== undefined; - if (!hasNickname && !hasBirthDate && !hasPhoneNumber) { + if (!hasNickname && !hasName && !hasBirthDate && !hasPhoneNumber) { throw new BadRequestException('No fields to update.'); } @@ -89,6 +90,16 @@ export class UserProfileService extends UserBaseService { if (isTaken) throw new ConflictException('Nickname already exists.'); } + // figma 명세: 이름은 필수값. 전송되었지만 trim 후 빈 문자열이면 reject. + let name: string | undefined = undefined; + if (hasName) { + const normalized = this.normalizeName(input.name); + if (!normalized) { + throw new BadRequestException('Name cannot be empty.'); + } + name = normalized; + } + const birthDate = hasBirthDate ? this.normalizeBirthDate(input.birthDate) : undefined; @@ -99,6 +110,7 @@ export class UserProfileService extends UserBaseService { await this.repo.updateProfile({ accountId, ...(hasNickname ? { nickname } : {}), + ...(hasName ? { name } : {}), ...(hasBirthDate ? { birthDate } : {}), ...(hasPhoneNumber ? { phoneNumber } : {}), }); 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..b9f3ed5 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,60 @@ 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('비활성 store/product에 대한 wishlist는 isWishlisted=false로 매핑된다 (myWishlist 가시성과 일치)', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const inactiveStore = await createStore(prisma, { is_active: false }); + const productOfInactiveStore = await createProduct(prisma, { + store_id: inactiveStore.id, + }); + // recent-view 항목으로는 보이지만, 그 product의 store가 비활성이라 + // myWishlist에는 노출되지 않음 → isWishlisted도 false여야 일관됨 + await createRecentProductView(prisma, { + account_id: account.id, + product_id: productOfInactiveStore.id, + }); + await prisma.wishlistItem.create({ + data: { + account_id: account.id, + product_id: productOfInactiveStore.id, + }, + }); + + const result = await service.list(account.id); + + expect(result.items).toHaveLength(1); + expect(result.items[0].productId).toBe( + productOfInactiveStore.id.toString(), + ); + expect(result.items[0].isWishlisted).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-review.service.spec.ts b/src/features/user/services/user-review.service.spec.ts index 4c8daf7..aede91b 100644 --- a/src/features/user/services/user-review.service.spec.ts +++ b/src/features/user/services/user-review.service.spec.ts @@ -175,7 +175,30 @@ describe('UserReviewService (real DB)', () => { ).rejects.toThrow(BadRequestException); }); - it('media가 10개를 초과하면 BadRequestException', async () => { + it('사진 10장 + 동영상 1개(총 11개)는 통과한다', async () => { + const ctx = await setupReviewableOrderItem(); + await expect( + service.writeReview(ctx.accountId, { + orderItemId: ctx.orderItemId.toString(), + rating: 5, + content: VALID_CONTENT, + media: [ + ...Array.from({ length: 10 }, (_, i) => ({ + mediaType: 'IMAGE' as const, + mediaUrl: `https://s3.example.com/${i}.jpg`, + sortOrder: i, + })), + { + mediaType: 'VIDEO' as const, + mediaUrl: 'https://s3.example.com/v.mp4', + sortOrder: 10, + }, + ], + }), + ).resolves.toBeDefined(); + }); + + it('사진 11장이면 BadRequestException (TOO_MANY_IMAGES)', async () => { const ctx = await setupReviewableOrderItem(); await expect( service.writeReview(ctx.accountId, { @@ -191,6 +214,47 @@ describe('UserReviewService (real DB)', () => { ).rejects.toThrow(BadRequestException); }); + it('동영상 2개면 BadRequestException (TOO_MANY_VIDEOS)', async () => { + const ctx = await setupReviewableOrderItem(); + await expect( + service.writeReview(ctx.accountId, { + orderItemId: ctx.orderItemId.toString(), + rating: 5, + content: VALID_CONTENT, + media: [ + { + mediaType: 'VIDEO' as const, + mediaUrl: 'https://s3.example.com/v1.mp4', + sortOrder: 0, + }, + { + mediaType: 'VIDEO' as const, + mediaUrl: 'https://s3.example.com/v2.mp4', + sortOrder: 1, + }, + ], + }), + ).rejects.toThrow(BadRequestException); + }); + + it('사진 0 + 동영상 1개만 있어도 통과한다', async () => { + const ctx = await setupReviewableOrderItem(); + await expect( + service.writeReview(ctx.accountId, { + orderItemId: ctx.orderItemId.toString(), + rating: 5, + content: VALID_CONTENT, + media: [ + { + mediaType: 'VIDEO' as const, + mediaUrl: 'https://s3.example.com/v.mp4', + sortOrder: 0, + }, + ], + }), + ).resolves.toBeDefined(); + }); + it('orderItem이 본인 소유가 아니면 NotFoundException', async () => { const me = await createAccount(prisma, { account_type: 'USER' }); await createUserProfile(prisma, { account_id: me.id }); diff --git a/src/features/user/services/user-review.service.ts b/src/features/user/services/user-review.service.ts index 0981cf4..ebea84a 100644 --- a/src/features/user/services/user-review.service.ts +++ b/src/features/user/services/user-review.service.ts @@ -46,7 +46,9 @@ interface ReviewRow { const MIN_CONTENT_LENGTH = 20; const MAX_CONTENT_LENGTH = 1000; -const MAX_MEDIA_COUNT = 10; +// figma 명세: 사진 최대 10장 / 동영상 1개. 합쳐서 최대 11개까지 허용. +const MAX_IMAGE_COUNT = 10; +const MAX_VIDEO_COUNT = 1; const MAX_LIMIT = 50; @Injectable() @@ -230,8 +232,20 @@ export class UserReviewService { private validateMedia( media?: { mediaType: string; mediaUrl: string }[], ): void { - if (media && media.length > MAX_MEDIA_COUNT) { - throw new BadRequestException(USER_REVIEW_ERRORS.TOO_MANY_MEDIA); + if (!media || media.length === 0) return; + + let imageCount = 0; + let videoCount = 0; + for (const m of media) { + if (m.mediaType === 'VIDEO') videoCount++; + else imageCount++; + } + + if (imageCount > MAX_IMAGE_COUNT) { + throw new BadRequestException(USER_REVIEW_ERRORS.TOO_MANY_IMAGES); + } + if (videoCount > MAX_VIDEO_COUNT) { + throw new BadRequestException(USER_REVIEW_ERRORS.TOO_MANY_VIDEOS); } } 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-input.type.ts b/src/features/user/types/user-input.type.ts index 3fc9ce9..d0edb35 100644 --- a/src/features/user/types/user-input.type.ts +++ b/src/features/user/types/user-input.type.ts @@ -7,6 +7,7 @@ export interface CompleteOnboardingInput { export interface UpdateMyProfileInput { nickname?: string | null; + name?: string | null; birthDate?: Date | null; phoneNumber?: string | null; } 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-order-output.type.ts b/src/features/user/types/user-order-output.type.ts index 4013c1f..76ee5db 100644 --- a/src/features/user/types/user-order-output.type.ts +++ b/src/features/user/types/user-order-output.type.ts @@ -11,6 +11,7 @@ export interface MyOrderSummary { additionalItemCount: number; totalPrice: number; storeName: string; + hasReviewableItem: boolean; } export interface MyOrderConnection { 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-order.graphql b/src/features/user/user-order.graphql index 0ed313a..41025f7 100644 --- a/src/features/user/user-order.graphql +++ b/src/features/user/user-order.graphql @@ -37,6 +37,8 @@ type MyOrderSummary { totalPrice: Int! """매장명""" storeName: String! + """주문 내 OrderItem 중 리뷰 작성 가능한 것이 1건이라도 있는지 (PICKED_UP + 활성 리뷰 미존재)""" + hasReviewableItem: Boolean! } """주문 상세""" diff --git a/src/features/user/user-profile.graphql b/src/features/user/user-profile.graphql index f4865ac..a52b9f5 100644 --- a/src/features/user/user-profile.graphql +++ b/src/features/user/user-profile.graphql @@ -62,6 +62,8 @@ input CompleteOnboardingInput { input UpdateMyProfileInput { """닉네임""" nickname: String + """이름(전송 시 trim 후 비어있으면 reject)""" + name: String """생년월일""" birthDate: DateTime """전화번호""" 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 {}