Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/common/errors/error-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,10 @@ export const ERROR_CATALOG = {
status: HttpStatus.BAD_REQUEST,
message: '유효하지 않은 날짜 형식입니다. (YYYY-MM-DD)',
},
PICKUP_DATE_PAST: {
status: HttpStatus.BAD_REQUEST,
message: '지난 날짜는 조회할 수 없습니다.',
},

// ── 리뷰·좋아요·신고
REVIEW_NOT_FOUND: {
Expand Down
4 changes: 4 additions & 0 deletions src/features/store/constants/store-sort.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const STORE_SORTS = ['POPULAR', 'LATEST', 'BEST_SELLING'] as const;
export type StoreSort = (typeof STORE_SORTS)[number];

export const DEFAULT_STORE_SORT: StoreSort = 'POPULAR';
46 changes: 46 additions & 0 deletions src/features/store/dto/inputs/pickup-stores.input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {
IsArray,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';

import { MAX_SEARCH_PAGE_LIMIT } from '@/features/store/constants/store-search.constants';
import {
STORE_SORTS,
type StoreSort,
} from '@/features/store/constants/store-sort.constants';

export class PickupStoresInput {
@IsString()
date!: string;

@IsOptional()
@IsString()
time?: string | null;

@IsOptional()
@IsArray()
@IsString({ each: true })
@IsNotEmpty({ each: true })
regionIds?: string[];

@IsOptional()
@IsIn(STORE_SORTS)
sort?: StoreSort | null;

@IsOptional()
@IsInt()
@Min(0)
offset?: number;

@IsOptional()
@IsInt()
@Min(1)
@Max(MAX_SEARCH_PAGE_LIMIT)
limit?: number;
}
9 changes: 9 additions & 0 deletions src/features/store/dto/inputs/search-stores.input.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
IsArray,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
Expand All @@ -9,6 +10,10 @@ import {
} from 'class-validator';

import { MAX_SEARCH_PAGE_LIMIT } from '@/features/store/constants/store-search.constants';
import {
STORE_SORTS,
type StoreSort,
} from '@/features/store/constants/store-sort.constants';

export class SearchStoresInput {
@IsOptional()
Expand All @@ -21,6 +26,10 @@ export class SearchStoresInput {
@IsNotEmpty({ each: true })
regionIds?: string[];

@IsOptional()
@IsIn(STORE_SORTS)
sort?: StoreSort | null;

@IsOptional()
@IsInt()
@Min(0)
Expand Down
42 changes: 20 additions & 22 deletions src/features/store/repositories/store.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,26 @@ export interface StoreCandidateRow {
pickup_slot_interval_minutes: number;
min_lead_time_minutes: number;
max_days_ahead: number;
created_at: Date;
latitude: Prisma.Decimal | null;
longitude: Prisma.Decimal | null;
}

const STORE_CANDIDATE_SELECT = {
id: true,
store_name: true,
profile_image_url: true,
address_city: true,
address_neighborhood: true,
region: { select: { name: true } },
pickup_slot_interval_minutes: true,
min_lead_time_minutes: true,
max_days_ahead: true,
created_at: true,
latitude: true,
longitude: true,
} satisfies Prisma.StoreSelect;

export type StoreSearchCandidateRow = StoreCandidateRow;

export interface StoreSearchFilter {
Expand Down Expand Up @@ -76,17 +94,7 @@ export class StoreRepository {
? { region_id: { in: regionIds } }
: {}),
},
select: {
id: true,
store_name: true,
profile_image_url: true,
address_city: true,
address_neighborhood: true,
region: { select: { name: true } },
pickup_slot_interval_minutes: true,
min_lead_time_minutes: true,
max_days_ahead: true,
},
select: STORE_CANDIDATE_SELECT,
});
}

Expand All @@ -96,17 +104,7 @@ export class StoreRepository {
): Promise<StoreSearchCandidateRow[]> {
return this.prisma.store.findMany({
where: buildStoreSearchWhere(filter),
select: {
id: true,
store_name: true,
address_city: true,
address_neighborhood: true,
region: { select: { name: true } },
pickup_slot_interval_minutes: true,
min_lead_time_minutes: true,
max_days_ahead: true,
profile_image_url: true,
},
select: STORE_CANDIDATE_SELECT,
orderBy: { id: 'asc' },
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { ClockService } from '@/common/providers/clock.service';
import { ReviewReadRepository } from '@/features/review';
import { StoreWishlistRepository } from '@/features/review/repositories/store-wishlist.repository';
import { StoreStatsRepository } from '@/features/store/repositories/store-stats.repository';
import { StoreRepository } from '@/features/store/repositories/store.repository';
import { StorePickupStoresQueryResolver } from '@/features/store/resolvers/store-pickup-stores-query.resolver';
import { StoreCardService } from '@/features/store/services/store-card.service';
import { StoreListingService } from '@/features/store/services/store-listing.service';
import { StorePickupAvailabilityService } from '@/features/store/services/store-pickup-availability.service';
import { StorePickupStoresService } from '@/features/store/services/store-pickup-stores.service';
import type { PrismaClient } from '@/generated/prisma/client';
import type { JwtUser } from '@/global/auth';
import { bookedQuantityProviders } from '@/test/booked-quantity';
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';

// 2026-08-19(수) 16:00 KST 고정, 조회 날짜는 다음 날(목)
const NOW = new Date('2026-08-19T07:00:00.000Z');
const DATE = '2026-08-20';
const DATE_WEEKDAY = new Date(Date.UTC(2026, 7, 20)).getUTCDay();

// 날짜·시간·정렬 분기 세부 검증은 service.spec.ts에서 담당. 여기서는 리졸버→서비스→DB 경로만 본다.
describe('StorePickupStoresQueryResolver (real DB)', () => {
let resolver: StorePickupStoresQueryResolver;
let clock: ClockService;
let prisma: PrismaClient;

beforeAll(async () => {
const { module, prisma: p } = await createTestingModuleWithRealDb({
providers: [
StoreCardService,
ReviewReadRepository,
StoreStatsRepository,
StorePickupStoresQueryResolver,
StorePickupStoresService,
StorePickupAvailabilityService,
StoreListingService,
StoreRepository,
StoreWishlistRepository,
ClockService,
...bookedQuantityProviders(),
],
});
resolver = module.get(StorePickupStoresQueryResolver);
clock = module.get(ClockService);
prisma = p;
});

afterAll(async () => {
await closeTruncateConnection();
await disconnectTestPrismaClient();
});

beforeEach(async () => {
await truncateAll();
jest.spyOn(clock, 'now').mockReturnValue(NOW);
});

afterEach(() => {
jest.restoreAllMocks();
});

async function openStore(name: string) {
const store = await createStore(prisma, { store_name: name });
await prisma.storeBusinessHour.create({
data: {
store_id: store.id,
day_of_week: DATE_WEEKDAY,
is_closed: false,
open_time: new Date(Date.UTC(1970, 0, 1, 10, 0, 0)),
close_time: new Date(Date.UTC(1970, 0, 1, 20, 0, 0)),
},
});
return store;
}

it('pickupStores: 로그인 사용자의 찜 여부를 채운다', async () => {
const store = await openStore('리졸버 매장');
const account = await createAccount(prisma, { account_type: 'USER' });
await createStoreWishlist(prisma, {
account_id: account.id,
store_id: store.id,
});
const user = { accountId: account.id.toString() } as JwtUser;

const result = await resolver.pickupStores(user, {
date: DATE,
time: '16:30',
});

expect(result.totalCount).toBe(1);
expect(result.items[0].store).toMatchObject({
storeName: '리졸버 매장',
isWishlisted: true,
});
});

it('pickupStores: 비로그인은 isWishlisted=false', async () => {
await openStore('리졸버 매장');

const result = await resolver.pickupStores(undefined, { date: DATE });

expect(result.items[0].store.isWishlisted).toBe(false);
});
});
28 changes: 28 additions & 0 deletions src/features/store/resolvers/store-pickup-stores-query.resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { UseGuards } from '@nestjs/common';
import { Args, Query, Resolver } from '@nestjs/graphql';

import { PickupStoresInput } from '@/features/store/dto/inputs/pickup-stores.input';
import { StorePickupStoresService } from '@/features/store/services/store-pickup-stores.service';
import type { PickupStoreConnection } from '@/features/store/types/store-pickup-stores-output.type';
import {
CurrentUser,
OptionalJwtAuthGuard,
parseAccountId,
type JwtUser,
} from '@/global/auth';

/** 옵셔널 인증으로 로그인 시에만 isWishlisted를 채운다. */
@Resolver('Query')
export class StorePickupStoresQueryResolver {
constructor(private readonly service: StorePickupStoresService) {}

@Query('pickupStores')
@UseGuards(OptionalJwtAuthGuard)
pickupStores(
@CurrentUser() user: JwtUser | undefined,
@Args('input') input: PickupStoresInput,
): Promise<PickupStoreConnection> {
const accountId = user ? parseAccountId(user) : undefined;
return this.service.pickupStores(input, accountId);
}
}
19 changes: 19 additions & 0 deletions src/features/store/resolvers/store-search-query.resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,23 @@ describe('StoreSearchQueryResolver (real DB)', () => {

expect(result.totalCount).toBe(1);
});

it('searchStores: sort를 서비스로 전달한다', async () => {
const old = await createStore(prisma, { store_name: '리졸버 이전' });
await createStore(prisma, { store_name: '리졸버 최신' });
await prisma.store.update({
where: { id: old.id },
data: { created_at: new Date('2026-01-01T00:00:00Z') },
});

const result = await resolver.searchStores(
{ keyword: '리졸버', sort: 'LATEST' },
undefined,
);

expect(result.items.map((i) => i.storeName)).toEqual([
'리졸버 최신',
'리졸버 이전',
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { StoreRepository } from '@/features/store/repositories/store.repository'
import { StoreTodayPickupQueryResolver } from '@/features/store/resolvers/store-today-pickup-query.resolver';
import { StoreCardService } from '@/features/store/services/store-card.service';
import { StoreListingService } from '@/features/store/services/store-listing.service';
import { StorePickupAvailabilityService } from '@/features/store/services/store-pickup-availability.service';
import { StoreTodayPickupService } from '@/features/store/services/store-today-pickup.service';
import type { PrismaClient } from '@/generated/prisma/client';
import { bookedQuantityProviders } from '@/test/booked-quantity';
Expand All @@ -32,6 +33,7 @@ describe('StoreTodayPickup Query Resolver (real DB)', () => {
StoreStatsRepository,
StoreTodayPickupQueryResolver,
StoreTodayPickupService,
StorePickupAvailabilityService,
StoreListingService,
StoreRepository,
StoreWishlistRepository,
Expand Down
4 changes: 3 additions & 1 deletion src/features/store/services/store-detail-mappers.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ function toRatingAverage(stat: ReviewStat | undefined): number {
return roundRatingAverage(stat.average);
}

function toCoordinate(value: { toString(): string } | null): number | null {
export function toCoordinate(
value: { toString(): string } | null,
): number | null {
return value !== null ? Number(value) : null;
}

Expand Down
Loading
Loading