diff --git a/src/features/store/constants/store-wishlist-error-messages.ts b/src/features/store/constants/store-wishlist-error-messages.ts new file mode 100644 index 0000000..49a6f02 --- /dev/null +++ b/src/features/store/constants/store-wishlist-error-messages.ts @@ -0,0 +1,4 @@ +export const STORE_WISHLIST_ERRORS = { + STORE_NOT_FOUND: '존재하지 않는 매장입니다.', + USER_ONLY: '매장 찜은 일반 사용자만 이용할 수 있습니다.', +} as const; diff --git a/src/features/store/repositories/store-wishlist.repository.ts b/src/features/store/repositories/store-wishlist.repository.ts new file mode 100644 index 0000000..3d7de06 --- /dev/null +++ b/src/features/store/repositories/store-wishlist.repository.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '@/prisma'; + +@Injectable() +export class StoreWishlistRepository { + constructor(private readonly prisma: PrismaService) {} + + /** 매장 찜 추가 (멱등). 없으면 생성, soft-delete된 경우 복원. */ + async upsertStoreWishlist(args: { + accountId: bigint; + storeId: bigint; + now: Date; + }): Promise { + await this.prisma.storeWishlistItem.upsert({ + where: { + account_id_store_id: { + account_id: args.accountId, + store_id: args.storeId, + }, + }, + create: { account_id: args.accountId, store_id: args.storeId }, + update: { deleted_at: null, updated_at: args.now }, + }); + } + + /** 매장 찜 해제 (멱등). active 항목만 soft-delete. */ + async softDeleteStoreWishlist(args: { + accountId: bigint; + storeId: bigint; + now: Date; + }): Promise { + await this.prisma.storeWishlistItem.updateMany({ + where: { + account_id: args.accountId, + store_id: args.storeId, + deleted_at: null, + }, + data: { deleted_at: args.now }, + }); + } + + /** + * 주어진 storeIds 중 사용자가 찜한 store_id 집합(string)을 단일 IN 쿼리로 반환. + * 비활성/soft-delete된 매장은 제외해 목록 가시성과 일관되게 한다(N+1 회피). + */ + async findWishlistedStoreIds(args: { + accountId: bigint; + storeIds: bigint[]; + }): Promise> { + if (args.storeIds.length === 0) return new Set(); + const rows = await this.prisma.storeWishlistItem.findMany({ + where: { + account_id: args.accountId, + store_id: { in: args.storeIds }, + deleted_at: null, + store: { is_active: true, deleted_at: null }, + }, + select: { store_id: true }, + }); + return new Set(rows.map((r) => r.store_id.toString())); + } + + /** 활성 USER 계정 여부. 매장 찜은 구매자(USER)만 가능 → 인기 랭킹 무결성 보호. */ + async isActiveUserAccount(accountId: bigint): Promise { + const account = await this.prisma.account.findFirst({ + where: { id: accountId, account_type: 'USER', deleted_at: null }, + select: { id: true }, + }); + return Boolean(account); + } +} diff --git a/src/features/store/repositories/store.repository.ts b/src/features/store/repositories/store.repository.ts index 7c26baf..44d55ff 100644 --- a/src/features/store/repositories/store.repository.ts +++ b/src/features/store/repositories/store.repository.ts @@ -45,6 +45,15 @@ export class StoreRepository { }); } + /** 활성 매장 존재 검증(찜 등). */ + async existsActiveStore(storeId: bigint): Promise { + const found = await this.prisma.store.findFirst({ + where: { id: storeId, is_active: true, deleted_at: null }, + select: { id: true }, + }); + return Boolean(found); + } + /** 매장별 활성 찜 수. */ async aggregateWishlistCounts( storeIds: bigint[], diff --git a/src/features/store/resolvers/store-query.resolver.spec.ts b/src/features/store/resolvers/store-query.resolver.spec.ts index d9eea0f..38b6d1d 100644 --- a/src/features/store/resolvers/store-query.resolver.spec.ts +++ b/src/features/store/resolvers/store-query.resolver.spec.ts @@ -1,5 +1,6 @@ import type { PrismaClient } from '@prisma/client'; +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; import { StoreRepository } from '@/features/store/repositories/store.repository'; import { StoreQueryResolver } from '@/features/store/resolvers/store-query.resolver'; import { StoreListingService } from '@/features/store/services/store-listing.service'; @@ -18,7 +19,12 @@ describe('Store Query Resolver (real DB)', () => { beforeAll(async () => { const { module, prisma: p } = await createTestingModuleWithRealDb({ - providers: [StoreQueryResolver, StoreListingService, StoreRepository], + providers: [ + StoreQueryResolver, + StoreListingService, + StoreRepository, + StoreWishlistRepository, + ], }); resolver = module.get(StoreQueryResolver); prisma = p; @@ -36,7 +42,7 @@ describe('Store Query Resolver (real DB)', () => { it('popularStores: 서비스에 위임해 커넥션을 반환한다', async () => { await createStore(prisma, { store_name: '리졸버매장' }); - const result = await resolver.popularStores(); + const result = await resolver.popularStores(undefined); expect(result.totalCount).toBe(1); expect(result.items[0].storeName).toBe('리졸버매장'); @@ -48,7 +54,7 @@ describe('Store Query Resolver (real DB)', () => { const target = await createStore(prisma, { region_id: region.id }); await createStore(prisma); // region 없는 매장 - const result = await resolver.popularStores({ + const result = await resolver.popularStores(undefined, { regionIds: [region.id.toString()], }); diff --git a/src/features/store/resolvers/store-query.resolver.ts b/src/features/store/resolvers/store-query.resolver.ts index 5956804..346906a 100644 --- a/src/features/store/resolvers/store-query.resolver.ts +++ b/src/features/store/resolvers/store-query.resolver.ts @@ -1,20 +1,31 @@ +import { UseGuards } from '@nestjs/common'; import { Args, Query, Resolver } from '@nestjs/graphql'; import { PopularStoresInput } from '@/features/store/dto/inputs/popular-stores.input'; import { StoreListingService } from '@/features/store/services/store-listing.service'; import type { PopularStoreConnection } from '@/features/store/types/store-output.type'; +import { + CurrentUser, + OptionalJwtAuthGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; /** * 매장 조회 resolver. 인기 매장 리스트는 비로그인도 접근 가능한 public query. + * 옵셔널 인증으로 로그인 시에만 isWishlisted를 채운다. */ @Resolver('Query') export class StoreQueryResolver { constructor(private readonly storeListingService: StoreListingService) {} @Query('popularStores') + @UseGuards(OptionalJwtAuthGuard) popularStores( + @CurrentUser() user: JwtUser | undefined, @Args('input', { nullable: true }) input?: PopularStoresInput, ): Promise { - return this.storeListingService.popularStores(input); + const accountId = user ? parseAccountId(user) : undefined; + return this.storeListingService.popularStores(input, accountId); } } diff --git a/src/features/store/resolvers/store-wishlist-mutation.resolver.spec.ts b/src/features/store/resolvers/store-wishlist-mutation.resolver.spec.ts new file mode 100644 index 0000000..21896d0 --- /dev/null +++ b/src/features/store/resolvers/store-wishlist-mutation.resolver.spec.ts @@ -0,0 +1,93 @@ +import { NotFoundException } from '@nestjs/common'; +import type { PrismaClient } from '@prisma/client'; + +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { StoreWishlistMutationResolver } from '@/features/store/resolvers/store-wishlist-mutation.resolver'; +import { StoreWishlistService } from '@/features/store/services/store-wishlist.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createStore, + createStoreWishlist, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('Store Wishlist Mutation Resolver (real DB)', () => { + let resolver: StoreWishlistMutationResolver; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + StoreWishlistMutationResolver, + StoreWishlistService, + StoreWishlistRepository, + StoreRepository, + ], + }); + resolver = module.get(StoreWishlistMutationResolver); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('addStoreToWishlist: accountId 변환 후 찜을 생성한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + + const ok = await resolver.addStoreToWishlist( + { accountId: account.id.toString() }, + store.id.toString(), + ); + + expect(ok).toBe(true); + const row = await prisma.storeWishlistItem.findUniqueOrThrow({ + where: { + account_id_store_id: { + account_id: account.id, + store_id: store.id, + }, + }, + }); + expect(row.deleted_at).toBeNull(); + }); + + it('addStoreToWishlist: 없는 매장이면 NotFoundException 전파', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await expect( + resolver.addStoreToWishlist( + { accountId: account.id.toString() }, + '999999', + ), + ).rejects.toThrow(NotFoundException); + }); + + it('removeStoreFromWishlist: 찜을 해제한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: store.id, + }); + + const ok = await resolver.removeStoreFromWishlist( + { accountId: account.id.toString() }, + store.id.toString(), + ); + + expect(ok).toBe(true); + const remaining = await prisma.storeWishlistItem.count({ + where: { account_id: account.id, store_id: store.id, deleted_at: null }, + }); + expect(remaining).toBe(0); + }); +}); diff --git a/src/features/store/resolvers/store-wishlist-mutation.resolver.ts b/src/features/store/resolvers/store-wishlist-mutation.resolver.ts new file mode 100644 index 0000000..9bcb5e9 --- /dev/null +++ b/src/features/store/resolvers/store-wishlist-mutation.resolver.ts @@ -0,0 +1,38 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Mutation, Resolver } from '@nestjs/graphql'; + +import { StoreWishlistService } from '@/features/store/services/store-wishlist.service'; +import { + CurrentUser, + JwtAuthGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +@Resolver('Mutation') +@UseGuards(JwtAuthGuard) +export class StoreWishlistMutationResolver { + constructor(private readonly storeWishlistService: StoreWishlistService) {} + + @Mutation('addStoreToWishlist') + addStoreToWishlist( + @CurrentUser() user: JwtUser, + @Args('storeId') storeId: string, + ): Promise { + return this.storeWishlistService.addStoreToWishlist( + parseAccountId(user), + storeId, + ); + } + + @Mutation('removeStoreFromWishlist') + removeStoreFromWishlist( + @CurrentUser() user: JwtUser, + @Args('storeId') storeId: string, + ): Promise { + return this.storeWishlistService.removeStoreFromWishlist( + parseAccountId(user), + storeId, + ); + } +} diff --git a/src/features/store/services/store-listing.service.spec.ts b/src/features/store/services/store-listing.service.spec.ts index ca7c4c3..16c7110 100644 --- a/src/features/store/services/store-listing.service.spec.ts +++ b/src/features/store/services/store-listing.service.spec.ts @@ -1,10 +1,12 @@ import type { PrismaClient } from '@prisma/client'; +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; import { StoreRepository } from '@/features/store/repositories/store.repository'; import { StoreListingService } from '@/features/store/services/store-listing.service'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { + createAccount, createOrder, createOrderItem, createProduct, @@ -23,7 +25,11 @@ describe('StoreListingService (real DB)', () => { beforeAll(async () => { const { module, prisma: p } = await createTestingModuleWithRealDb({ - providers: [StoreListingService, StoreRepository], + providers: [ + StoreListingService, + StoreRepository, + StoreWishlistRepository, + ], }); service = module.get(StoreListingService); prisma = p; @@ -206,5 +212,25 @@ describe('StoreListingService (real DB)', () => { expect(result.totalCount).toBe(0); }); + + it('로그인 사용자의 찜 매장은 isWishlisted=true, 비로그인/미찜은 false', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const wished = await createStore(prisma, { store_name: '찜한매장' }); + await createStore(prisma, { store_name: '안찜매장' }); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: wished.id, + }); + + const loggedIn = await service.popularStores(undefined, account.id); + const byName = new Map( + loggedIn.items.map((s) => [s.storeName, s.isWishlisted]), + ); + expect(byName.get('찜한매장')).toBe(true); + expect(byName.get('안찜매장')).toBe(false); + + const anonymous = await service.popularStores(); + expect(anonymous.items.every((s) => s.isWishlisted === false)).toBe(true); + }); }); }); diff --git a/src/features/store/services/store-listing.service.ts b/src/features/store/services/store-listing.service.ts index a5f0f58..3546961 100644 --- a/src/features/store/services/store-listing.service.ts +++ b/src/features/store/services/store-listing.service.ts @@ -7,6 +7,7 @@ import { RANKING_RECENT_ORDER_DAYS, } from '@/features/store/constants/store-ranking.constants'; import type { PopularStoresInput } from '@/features/store/dto/inputs/popular-stores.input'; +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; import { StoreRepository } from '@/features/store/repositories/store.repository'; import { toPopularStore } from '@/features/store/services/store-mappers.helper'; import { @@ -19,7 +20,10 @@ const DAY_MS = 24 * 60 * 60 * 1000; @Injectable() export class StoreListingService { - constructor(private readonly repo: StoreRepository) {} + constructor( + private readonly repo: StoreRepository, + private readonly wishlistRepo: StoreWishlistRepository, + ) {} /** * 인기 매장 리스트. 후보 매장의 주문·찜·평점을 실시간 집계해 점수화·정렬한 뒤 @@ -29,6 +33,7 @@ export class StoreListingService { */ async popularStores( input?: PopularStoresInput, + accountId?: bigint, ): Promise { const offset = input?.offset ?? 0; const limit = input?.limit ?? DEFAULT_POPULAR_STORES_LIMIT; @@ -76,9 +81,16 @@ export class StoreListingService { const totalCount = scored.length; const page = scored.slice(offset, offset + limit); - const imagesByStore = await this.repo.findStoreCakeImages( - page.map((s) => s.candidate.id), - ); + const pageStoreIds = page.map((s) => s.candidate.id); + const [imagesByStore, wishlistedIds] = await Promise.all([ + this.repo.findStoreCakeImages(pageStoreIds), + accountId + ? this.wishlistRepo.findWishlistedStoreIds({ + accountId, + storeIds: pageStoreIds, + }) + : Promise.resolve(new Set()), + ]); const items = page.map((entry, idx) => toPopularStore( @@ -86,6 +98,7 @@ export class StoreListingService { entry.metrics, offset + idx + 1, imagesByStore.get(entry.candidate.id) ?? [], + wishlistedIds.has(entry.candidate.id.toString()), ), ); diff --git a/src/features/store/services/store-mappers.helper.spec.ts b/src/features/store/services/store-mappers.helper.spec.ts index cc14247..9d8f480 100644 --- a/src/features/store/services/store-mappers.helper.spec.ts +++ b/src/features/store/services/store-mappers.helper.spec.ts @@ -54,6 +54,7 @@ describe('store-mappers.helper', () => { }, 1, ['a.png', 'b.png'], + true, ); expect(result).toMatchObject({ @@ -63,6 +64,7 @@ describe('store-mappers.helper', () => { ratingAverage: 4.7, reviewCount: 9, cakeImageUrls: ['a.png', 'b.png'], + isWishlisted: true, }); }); }); diff --git a/src/features/store/services/store-mappers.helper.ts b/src/features/store/services/store-mappers.helper.ts index bc5d3d6..4d7720c 100644 --- a/src/features/store/services/store-mappers.helper.ts +++ b/src/features/store/services/store-mappers.helper.ts @@ -16,6 +16,7 @@ export function toPopularStore( metrics: StoreMetrics, rank: number, cakeImageUrls: string[], + isWishlisted: boolean, ): PopularStore { return { id: row.id.toString(), @@ -26,5 +27,6 @@ export function toPopularStore( reviewCount: metrics.reviewCount, regionLabel: buildRegionLabel(row), cakeImageUrls, + isWishlisted, }; } diff --git a/src/features/store/services/store-wishlist.service.spec.ts b/src/features/store/services/store-wishlist.service.spec.ts new file mode 100644 index 0000000..ec3e719 --- /dev/null +++ b/src/features/store/services/store-wishlist.service.spec.ts @@ -0,0 +1,153 @@ +import { + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import type { PrismaClient } from '@prisma/client'; + +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { StoreWishlistService } from '@/features/store/services/store-wishlist.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createStore, + createStoreWishlist, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('StoreWishlistService (real DB)', () => { + let service: StoreWishlistService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + StoreWishlistService, + StoreWishlistRepository, + StoreRepository, + ], + }); + service = module.get(StoreWishlistService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + async function activeWishlistCount( + accountId: bigint, + storeId: bigint, + ): Promise { + return prisma.storeWishlistItem.count({ + where: { account_id: accountId, store_id: storeId, deleted_at: null }, + }); + } + + describe('addStoreToWishlist', () => { + it('매장을 찜한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + + const ok = await service.addStoreToWishlist( + account.id, + store.id.toString(), + ); + + expect(ok).toBe(true); + expect(await activeWishlistCount(account.id, store.id)).toBe(1); + }); + + it('중복 추가는 멱등하다(1건 유지)', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + + await service.addStoreToWishlist(account.id, store.id.toString()); + await service.addStoreToWishlist(account.id, store.id.toString()); + + expect(await activeWishlistCount(account.id, store.id)).toBe(1); + }); + + it('soft-delete된 찜은 복원한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: store.id, + deleted_at: new Date(), + }); + + await service.addStoreToWishlist(account.id, store.id.toString()); + + expect(await activeWishlistCount(account.id, store.id)).toBe(1); + }); + + it('존재하지 않는 매장이면 NotFoundException', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await expect( + service.addStoreToWishlist(account.id, '999999'), + ).rejects.toThrow(NotFoundException); + }); + + it('비활성 매장이면 NotFoundException', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const inactive = await createStore(prisma, { is_active: false }); + await expect( + service.addStoreToWishlist(account.id, inactive.id.toString()), + ).rejects.toThrow(NotFoundException); + }); + + it('유효하지 않은 storeId면 BadRequestException', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await expect( + service.addStoreToWishlist(account.id, 'not-a-number'), + ).rejects.toThrow(BadRequestException); + }); + + it('USER가 아닌 계정(SELLER)은 찜할 수 없다(Forbidden)', async () => { + const seller = await createAccount(prisma, { account_type: 'SELLER' }); + const store = await createStore(prisma); + await expect( + service.addStoreToWishlist(seller.id, store.id.toString()), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('removeStoreFromWishlist', () => { + it('찜을 해제한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: store.id, + }); + + const ok = await service.removeStoreFromWishlist( + account.id, + store.id.toString(), + ); + + expect(ok).toBe(true); + expect(await activeWishlistCount(account.id, store.id)).toBe(0); + }); + + it('찜이 없어도 멱등하게 true를 반환한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + + const ok = await service.removeStoreFromWishlist( + account.id, + store.id.toString(), + ); + + expect(ok).toBe(true); + }); + }); +}); diff --git a/src/features/store/services/store-wishlist.service.ts b/src/features/store/services/store-wishlist.service.ts new file mode 100644 index 0000000..4f7ca96 --- /dev/null +++ b/src/features/store/services/store-wishlist.service.ts @@ -0,0 +1,55 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; + +import { parseId } from '@/common/utils/id-parser'; +import { STORE_WISHLIST_ERRORS } from '@/features/store/constants/store-wishlist-error-messages'; +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; + +@Injectable() +export class StoreWishlistService { + constructor( + private readonly wishlistRepo: StoreWishlistRepository, + private readonly storeRepo: StoreRepository, + ) {} + + /** 매장 찜 추가 (멱등). 존재하지 않거나 비활성 매장이면 404. */ + async addStoreToWishlist( + accountId: bigint, + storeIdStr: string, + ): Promise { + // 매장 찜은 구매자(USER)만 가능. SELLER/ADMIN 찜이 인기 랭킹을 조작하지 못하도록 차단. + const isUser = await this.wishlistRepo.isActiveUserAccount(accountId); + if (!isUser) { + throw new ForbiddenException(STORE_WISHLIST_ERRORS.USER_ONLY); + } + const storeId = parseId(storeIdStr); + const exists = await this.storeRepo.existsActiveStore(storeId); + if (!exists) { + throw new NotFoundException(STORE_WISHLIST_ERRORS.STORE_NOT_FOUND); + } + await this.wishlistRepo.upsertStoreWishlist({ + accountId, + storeId, + now: new Date(), + }); + return true; + } + + /** 매장 찜 해제 (멱등). 없는 항목이어도 true. */ + async removeStoreFromWishlist( + accountId: bigint, + storeIdStr: string, + ): Promise { + const storeId = parseId(storeIdStr); + await this.wishlistRepo.softDeleteStoreWishlist({ + accountId, + storeId, + now: new Date(), + }); + return true; + } +} diff --git a/src/features/store/store-wishlist.graphql b/src/features/store/store-wishlist.graphql new file mode 100644 index 0000000..2d9dd0a --- /dev/null +++ b/src/features/store/store-wishlist.graphql @@ -0,0 +1,6 @@ +extend type Mutation { + """매장 찜 추가 (멱등: 이미 있어도 true, soft-delete된 항목은 복원). 로그인 필요.""" + addStoreToWishlist(storeId: ID!): Boolean! + """매장 찜 해제 (멱등: 이미 없어도 true). 로그인 필요.""" + removeStoreFromWishlist(storeId: ID!): Boolean! +} diff --git a/src/features/store/store.module.ts b/src/features/store/store.module.ts index d407612..a817aa1 100644 --- a/src/features/store/store.module.ts +++ b/src/features/store/store.module.ts @@ -1,11 +1,21 @@ import { Module } from '@nestjs/common'; +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; import { StoreRepository } from '@/features/store/repositories/store.repository'; import { StoreQueryResolver } from '@/features/store/resolvers/store-query.resolver'; +import { StoreWishlistMutationResolver } from '@/features/store/resolvers/store-wishlist-mutation.resolver'; import { StoreListingService } from '@/features/store/services/store-listing.service'; +import { StoreWishlistService } from '@/features/store/services/store-wishlist.service'; @Module({ - providers: [StoreRepository, StoreListingService, StoreQueryResolver], + providers: [ + StoreRepository, + StoreWishlistRepository, + StoreListingService, + StoreWishlistService, + StoreQueryResolver, + StoreWishlistMutationResolver, + ], exports: [StoreRepository], }) export class StoreModule {} diff --git a/src/features/store/store.types.graphql b/src/features/store/store.types.graphql index 54badc9..96bc82d 100644 --- a/src/features/store/store.types.graphql +++ b/src/features/store/store.types.graphql @@ -30,4 +30,6 @@ type PopularStore { regionLabel: String """대표 케이크 이미지(최대 4장).""" cakeImageUrls: [String!]! + """로그인 사용자의 찜 여부(비로그인 시 false).""" + isWishlisted: Boolean! } diff --git a/src/features/store/types/store-output.type.ts b/src/features/store/types/store-output.type.ts index 83447ee..8e2ac6d 100644 --- a/src/features/store/types/store-output.type.ts +++ b/src/features/store/types/store-output.type.ts @@ -11,6 +11,7 @@ export interface PopularStore { reviewCount: number; regionLabel: string | null; cakeImageUrls: string[]; + isWishlisted: boolean; } export interface PopularStoreConnection { diff --git a/src/global/auth/auth-global.module.ts b/src/global/auth/auth-global.module.ts index 7721987..6b27bef 100644 --- a/src/global/auth/auth-global.module.ts +++ b/src/global/auth/auth-global.module.ts @@ -4,6 +4,7 @@ import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import { JwtAuthGuard } from '@/global/auth/guards/jwt-auth.guard'; +import { OptionalJwtAuthGuard } from '@/global/auth/guards/optional-jwt-auth.guard'; /** * 전역 인증 인프라 모듈 @@ -26,7 +27,7 @@ import { JwtAuthGuard } from '@/global/auth/guards/jwt-auth.guard'; }, }), ], - providers: [JwtAuthGuard], - exports: [JwtAuthGuard, PassportModule, JwtModule], + providers: [JwtAuthGuard, OptionalJwtAuthGuard], + exports: [JwtAuthGuard, OptionalJwtAuthGuard, PassportModule, JwtModule], }) export class AuthGlobalModule {} diff --git a/src/global/auth/guards/optional-jwt-auth.guard.spec.ts b/src/global/auth/guards/optional-jwt-auth.guard.spec.ts new file mode 100644 index 0000000..98bdc91 --- /dev/null +++ b/src/global/auth/guards/optional-jwt-auth.guard.spec.ts @@ -0,0 +1,23 @@ +import { OptionalJwtAuthGuard } from '@/global/auth/guards/optional-jwt-auth.guard'; +import type { JwtUser } from '@/global/auth/types/jwt-payload.type'; + +describe('OptionalJwtAuthGuard', () => { + const guard = new OptionalJwtAuthGuard(); + const user: JwtUser = { accountId: '1', accountType: 'USER' }; + + it('user가 있으면 그대로 반환한다', () => { + expect(guard.handleRequest(null, user)).toBe(user); + }); + + it('user가 false면(토큰 없음) undefined를 반환한다', () => { + expect(guard.handleRequest(null, false)).toBeUndefined(); + }); + + it('user가 null이면 undefined를 반환한다', () => { + expect(guard.handleRequest(null, null)).toBeUndefined(); + }); + + it('에러가 있어도 user가 있으면 통과시킨다(에러를 throw하지 않음)', () => { + expect(guard.handleRequest(new Error('expired'), user)).toBe(user); + }); +}); diff --git a/src/global/auth/guards/optional-jwt-auth.guard.ts b/src/global/auth/guards/optional-jwt-auth.guard.ts new file mode 100644 index 0000000..fe905c8 --- /dev/null +++ b/src/global/auth/guards/optional-jwt-auth.guard.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; + +import { JwtAuthGuard } from '@/global/auth/guards/jwt-auth.guard'; +import type { JwtUser } from '@/global/auth/types/jwt-payload.type'; + +/** + * 옵셔널 JWT 가드. + * + * 토큰이 있으면 인증해 req.user를 채우고, 없거나 검증 실패해도 요청을 통과시킨다. + * 비로그인 접근을 허용하면서 로그인 시에만 부가 정보(예: isWishlisted)를 채우는 + * public query에 사용한다. + */ +@Injectable() +export class OptionalJwtAuthGuard extends JwtAuthGuard { + override handleRequest( + _err: unknown, + user: TUser | false | null, + ): TUser | undefined { + return user || undefined; + } +} diff --git a/src/global/auth/index.ts b/src/global/auth/index.ts index 5f3951c..f1a509d 100644 --- a/src/global/auth/index.ts +++ b/src/global/auth/index.ts @@ -3,6 +3,7 @@ */ export * from '@/global/auth/auth-global.module'; export * from '@/global/auth/guards/jwt-auth.guard'; +export * from '@/global/auth/guards/optional-jwt-auth.guard'; export * from '@/global/auth/decorators/current-user.decorator'; export * from '@/global/auth/types/jwt-payload.type'; export * from '@/global/auth/constants/auth-cookie.constants';