generated from ttipo/project-base
7ed3411abb
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 21m7s
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m54s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 11m31s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m47s
CI (Flutter) / Flutter (analyze · test) (push) Successful in 20m20s
CI / Frontend (lint · type-check · test) (push) Successful in 11m31s
CI / Backend (lint · test · e2e · build) (push) Successful in 12m46s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m53s
CI 의 빈 DB 에서 e2e 가 앱을 띄운 뒤에 마이그레이션을 돌려, 실행기의 기동 정리가 아직 없는 테이블을 찾다가 기동이 실패했다. 앱을 닫지 못해 1분 주기 정리 작업이 남아 jest 가 끝나지 않았고 CI 가 러너 제한 시간까지 멈춰 있었다. - 기동 정리는 실패해도 기동을 막지 않고 error 로그만 남긴다 - 앱 전체를 띄우는 e2e 는 운영처럼 마이그레이션을 먼저 돌린다 - 준비가 실패해도 afterAll 에서 앱을 반드시 닫는다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
390 lines
14 KiB
TypeScript
390 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();
|
|
// 운영처럼 마이그레이션을 기동 훅보다 먼저 돌린다 — 빈 DB 에서 기동 훅(코스 생성 정리)이 테이블을 찾는다
|
|
ds = moduleFixture.get(DataSource);
|
|
await ds.runMigrations();
|
|
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();
|
|
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 () => {
|
|
// 준비가 실패해도 앱은 닫는다 — 닫지 않으면 주기 작업이 남아 jest 가 끝나지 않는다
|
|
try {
|
|
await cleanup();
|
|
} finally {
|
|
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');
|
|
});
|
|
});
|
|
});
|