Files
wandergo/backend/test/place.e2e-spec.ts
T
ttipo 4bd2a9b4ad
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 11m54s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m0s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 11m5s
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
feat(backend): 장소 목록에 관광공사·확인 장소만 받는 trusted 조건 추가
장소 상세 '함께 가기 좋은 곳'은 확인 안 된 상가정보 매장을 빼야 한다(2026-11-02 합의).
앱이 가까운 30곳을 받아 거르면 도심처럼 상가가 빽빽한 곳은 받은 곳이 모두 상가라 칸이 비었다.
관광공사 장소 상세 860곳 중 30곳은 칸이 사라지고 186곳은 덜 보였다(봉산서원은 1.5km 안에
관광공사 장소가 53곳인데 0곳).

trusted=true 면 관광공사 장소와 운영자가 확인한 장소만 주고 '추천 안 함' 판정도 뺀다.
선택 조건이라 기존 호출은 그대로다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:58:27 +09:00

386 lines
14 KiB
TypeScript

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`;
/** 'trusted'(관광공사·확인 장소만) 검증용 묶음 — 위 묶음의 거리순 기대값에 섞이지 않게 5.5km 떨어뜨린다 */
const C2 = { lat: 36.55, lng: 130.5 };
const at2 = (dLat: number) => ({ lat: C2.lat + dLat, lng: C2.lng });
const near2 = `lat=${C2.lat}&lng=${C2.lng}&radius=1000`;
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<App>;
let ds: DataSource;
const ids: Record<string, string> = {};
async function cleanup(): Promise<void> {
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<Place>) => {
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',
});
// trusted 묶음: 관광공사 1 · 확인 안 된 상가 1 · 확인한 상가 1 · 추천 안 함으로 판정한 관광공사·상가 각 1
await save('R1', {
source: 'tour',
sourceId: 'e2e-R1',
category: 'attraction',
name: '이투이 바닷가 전망대',
...at2(0.001),
});
await save('R2', {
source: 'store',
sourceId: 'e2e-R2',
category: 'cafe',
name: '이투이 확인안한카페',
...at2(0.0005), // 가장 가깝다 — trusted 가 아니면 첫 자리를 차지한다
});
await save('R3', {
source: 'store',
sourceId: 'e2e-R3',
category: 'cafe',
name: '이투이 확인한카페',
...at2(0.002),
});
await save('R4', {
source: 'tour',
sourceId: 'e2e-R4',
category: 'cafe',
name: '이투이 추천안함카페',
...at2(0.0025),
});
await save('R5', {
source: 'store',
sourceId: 'e2e-R5',
category: 'bar',
name: '이투이 추천안함바',
...at2(0.003),
});
const curations = ds.getRepository(PlaceCuration);
await curations.save({
placeId: ids.R3,
verdict: 'pending',
verifiedMethod: 'official_channel',
verifiedAt: '2026-11-04',
});
for (const key of ['R4', 'R5']) {
await curations.save({
placeId: ids[key],
verdict: 'not_recommend',
verifiedMethod: 'visit',
verifiedAt: '2026-11-04',
});
}
});
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('trusted 는 관광공사·운영자 확인 장소만 주고, 추천 안 함으로 판정한 곳은 뺀다', async () => {
const all = await get(`/api/places?${near2}`).expect(200);
expect(list(all).data.pageInfo.total).toBe(5);
const res = await get(`/api/places?${near2}&trusted=true`).expect(200);
expect(names(res)).toEqual(['이투이 바닷가 전망대', '이투이 확인한카페']);
expect(list(res).data.pageInfo.total).toBe(2);
// 가장 가까운 곳이 확인 안 된 상가여도 limit 안에 관광공사·확인 장소가 채워진다
const plain = await get(`/api/places?${near2}&limit=1`).expect(200);
expect(names(plain)).toEqual(['이투이 확인안한카페']);
const one = await get(`/api/places?${near2}&trusted=true&limit=1`).expect(
200,
);
expect(names(one)).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'],
['true/false 가 아닌 trusted', 'trusted=1'],
])('%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');
});
});
});