import { INestApplication, ValidationPipe } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Test, TestingModule } from '@nestjs/testing'; import request from 'supertest'; import type { Response } from 'supertest'; import { App } from 'supertest/types'; import { DataSource } from 'typeorm'; import { AppModule } from '@/app.module'; import { HttpExceptionFilter } from '@/common/filters/http-exception.filter'; import { TransformInterceptor } from '@/common/interceptors/transform.interceptor'; import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware'; import { PlaceCuration } from '@/modules/place/entities/place-curation.entity'; import { Place } from '@/modules/place/entities/place.entity'; import { parseOpeningHours } from '@/shared/utils/opening-hours'; /** * 장소 조회 API 계약 — 노출 규칙(병합·숨김·브랜드 매장)·필터·거리순 커서·상세를 실제 DB 로 검증한다. * 시작할 때 마이그레이션을 적용하므로 빈 DB(CI)에서도 돈다. 테스트 장소는 실제 데이터와 섞이지 않게 * 동해 바다 위 좌표에 만들고(source_id 'e2e-'), 시작·끝에 지운다. */ const C = { lat: 36.5, lng: 130.5 }; const at = (dLat: number) => ({ lat: C.lat + dLat, lng: C.lng }); const near = `lat=${C.lat}&lng=${C.lng}&radius=2000`; interface ListItem { id: string; name: string; distanceM: number | null; openNow: boolean | null; hoursOrigin: string; petPolicy: string; } interface ListBody { success: boolean; data: { items: ListItem[]; pageInfo: { hasNext: boolean; nextCursor: string | null; total: number | null; }; }; } interface DetailBody { success: boolean; data: { id: string; name: string; hoursOrigin: string; setting: string | null; hours: { text: string | null; status: string }; pet: { sizes: string[] | null; conditions: string | null }; curation: { reason: string | null } | null; attribution: { provider: string; asOf: string | null }; }; error?: { code: string }; } const list = (res: Response) => res.body as ListBody; const detail = (res: Response) => res.body as DetailBody; const names = (res: Response) => list(res).data.items.map((i) => i.name); describe('장소 조회 API (e2e)', () => { let app: INestApplication; let ds: DataSource; const ids: Record = {}; async function cleanup(): Promise { await ds.query( `UPDATE place SET merged_into_id = NULL WHERE source_id LIKE 'e2e-%'`, ); await ds.query( `DELETE FROM place_curation WHERE place_id IN (SELECT id FROM place WHERE source_id LIKE 'e2e-%')`, ); await ds.query(`DELETE FROM place WHERE source_id LIKE 'e2e-%'`); } beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleFixture.createNestApplication(); app.setGlobalPrefix('api'); app.use(new RequestIdMiddleware().use.bind(new RequestIdMiddleware())); app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true, }), ); app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector))); await app.init(); ds = app.get(DataSource); await ds.runMigrations(); await cleanup(); const places = ds.getRepository(Place); const save = async (key: string, p: Partial) => { const saved = await places.save(places.create(p)); ids[key] = saved.id; return saved; }; await save('T1', { source: 'tour', sourceId: 'e2e-T1', category: 'attraction', name: '이투이 근대골목', ...at(0.001), publicHoursText: '상시 개방', publicHours: parseOpeningHours('상시 개방'), publicPet: { acmpyTypeCd: '전구역 동반가능', acmpyNeedMtr: '목줄 착용' }, publicPetPolicy: 'allowed', sourceModifiedAt: new Date('2026-10-15T00:30:00Z'), }); await save('M1', { source: 'tour', sourceId: 'e2e-M1', category: 'shopping', sourceCategory: 'SH040300', name: '더현대 이투이점', ...at(0.0018), }); const t2 = await save('T2', { source: 'tour', sourceId: 'e2e-T2', category: 'cafe', name: '이투이카페', ...at(0.002), publicHoursText: '10:00~22:00', publicHours: parseOpeningHours('10:00~22:00', '연중무휴'), }); await save('S1', { source: 'store', sourceId: 'e2e-S1', category: 'cafe', name: '이투이카페', ...at(0.00201), mergedIntoId: t2.id, }); await save('S2', { source: 'store', sourceId: 'e2e-S2', category: 'cafe', name: '이투이 골목카페', ...at(0.003), sourceSnapshot: '202609', }); await save('B1', { source: 'tour', sourceId: 'e2e-B1', category: 'shopping', sourceCategory: 'SH040300', name: '이투이 브랜드매장', ...at(0.0015), publicPet: { acmpyTypeCd: '전구역 동반가능' }, publicPetPolicy: 'allowed', }); await save('H1', { source: 'tour', sourceId: 'e2e-H1', category: 'attraction', name: '이투이 숨김', ...at(0.0005), status: 'hidden', }); await save('F1', { source: 'store', sourceId: 'e2e-F1', category: 'cafe', name: '이투이 먼카페', ...at(0.1), }); await ds.getRepository(PlaceCuration).save({ placeId: ids.S2, verdict: 'recommend', reasonKo: '골목 끝 조용한 2층 카페', hoursText: '11:00~20:00', hours: parseOpeningHours('11:00~20:00', '연중무휴'), petPolicy: 'partial', petSizes: ['small'], petConditions: '테라스석만', setting: 'indoor', verifiedMethod: 'phone', verifiedAt: '2026-11-02', }); }); afterAll(async () => { await cleanup(); await app.close(); }); const get = (path: string) => request(app.getHttpServer()).get(path); describe('GET /api/places', () => { it('반경 안을 거리순으로 주고, 숨김·병합·브랜드 매장·반경 밖은 뺀다', async () => { const res = await get(`/api/places?${near}`).expect(200); expect(names(res)).toEqual([ '이투이 근대골목', '더현대 이투이점', // 쇼핑몰 자체는 브랜드 매장이 아니다 '이투이카페', // 관광공사 쪽만 남고 같은 가게의 상가정보는 병합되어 빠진다 '이투이 골목카페', ]); expect(list(res).data.pageInfo.total).toBe(4); const first = list(res).data.items[0]; expect(first.distanceM).toBeGreaterThan(100); expect(first.distanceM).toBeLessThan(120); expect(first.openNow).toBe(true); // 상시 개방 }); it('커서로 이어 받으면 겹치거나 빠지는 장소가 없다', async () => { const p1 = await get(`/api/places?${near}&limit=2`).expect(200); expect(names(p1)).toEqual(['이투이 근대골목', '더현대 이투이점']); expect(list(p1).data.pageInfo.hasNext).toBe(true); const cursor = list(p1).data.pageInfo.nextCursor ?? ''; const p2 = await get( `/api/places?${near}&limit=2&cursor=${encodeURIComponent(cursor)}`, ).expect(200); expect(names(p2)).toEqual(['이투이카페', '이투이 골목카페']); expect(list(p2).data.pageInfo).toMatchObject({ hasNext: false, nextCursor: null, }); }); it('반려동물 필터는 동반 가능 장소만 주고 브랜드 매장은 빼다', async () => { const res = await get(`/api/places?${near}&pet=true`).expect(200); expect(names(res)).toEqual(['이투이 근대골목', '이투이 골목카페']); }); it('이름 검색이면 브랜드 매장도 나온다', async () => { const res = await get( `/api/places?${near}&q=${encodeURIComponent('브랜드매장')}`, ).expect(200); expect(names(res)).toEqual(['이투이 브랜드매장']); }); it('실내 필터는 운영자가 실내로 확인한 장소만 준다', async () => { const res = await get(`/api/places?${near}&indoor=true`).expect(200); expect(names(res)).toEqual(['이투이 골목카페']); }); it('분류 필터', async () => { const res = await get(`/api/places?${near}&category=cafe`).expect(200); expect(names(res)).toEqual(['이투이카페', '이투이 골목카페']); }); it('좌표 없이 이름 검색하면 이름순으로 반경 제한 없이 준다', async () => { const res = await get( `/api/places?q=${encodeURIComponent('이투이')}`, ).expect(200); const found = names(res); expect(found).toContain('이투이 먼카페'); expect(found).toContain('이투이 브랜드매장'); expect(found).not.toContain('이투이 숨김'); expect(found.filter((n) => n === '이투이카페')).toHaveLength(1); expect([...found].sort()).toEqual(found); }); it.each([ ['lat 만 보낸 경우', 'lat=36.5'], ['반경이 너무 작은 경우', `${near.replace('radius=2000', 'radius=5')}`], ['없는 분류', 'category=pub'], ['형식이 틀린 커서', `${near}&cursor=abc`], ['true/false 가 아닌 필터', 'pet=yes'], ])('%s 는 VAL_001(400)', async (_, query) => { const res = await get(`/api/places?${query}`).expect(400); expect(detail(res).error?.code).toBe('VAL_001'); }); }); describe('GET /api/places/:id', () => { it('공공데이터 장소 상세 — 영업시간·반려동물 조건·출처', async () => { const res = await get(`/api/places/${ids.T1}`).expect(200); const d = detail(res).data; expect(d.hoursOrigin).toBe('public'); expect(d.hours.status).toBe('always_open'); expect(d.pet.conditions).toBe('목줄 착용'); expect(d.attribution).toEqual({ provider: '한국관광공사', asOf: '2026-10-15', }); expect(d.curation).toBeNull(); }); it('큐레이션한 장소는 확인한 값과 추천 이유를 준다', async () => { const res = await get(`/api/places/${ids.S2}`).expect(200); const d = detail(res).data; expect(d.hoursOrigin).toBe('curation'); expect(d.hours.text).toBe('11:00~20:00'); expect(d.pet).toEqual({ sizes: ['small'], conditions: '테라스석만' }); expect(d.setting).toBe('indoor'); expect(d.curation?.reason).toBe('골목 끝 조용한 2층 카페'); expect(d.attribution.asOf).toBe('202609'); }); it('병합된 장소 id 로 들어오면 병합 대상 장소를 준다', async () => { const res = await get(`/api/places/${ids.S1}`).expect(200); expect(detail(res).data.id).toBe(ids.T2); }); it('숨김 장소·없는 id 는 RES_001(404), UUID 가 아니면 VAL_001(400)', async () => { const hidden = await get(`/api/places/${ids.H1}`).expect(404); expect(detail(hidden).error?.code).toBe('RES_001'); await get('/api/places/00000000-0000-4000-8000-000000000000').expect(404); const bad = await get('/api/places/abc').expect(400); expect(detail(bad).error?.code).toBe('VAL_001'); }); }); });