generated from ttipo/project-base
feat(backend): 장소 조회 API와 같은 가게 중복 병합 추가
검색·지도·상세 화면이 장소 DB 를 쓰도록 조회 API 를 연다(비로그인 허용).
- GET /api/places: 이름 검색, 좌표+반경 거리순, 분류·반려동물·실내 필터,
키셋 커서 페이지네이션(정렬값+id), 전체 개수, 항목별 "지금 영업 중"(KST)
- GET /api/places/{id}: 영업시간 원문·해석 상태·출처, 반려동물 크기·조건,
이용 안내(요금·주차 등), 추천 이유(추천+확인한 곳만), 출처 표기.
병합된 id 로 들어오면 병합 대상 장소를, 숨김·폐업 추정이면 RES_001 을 준다
- 같은 가게 병합: 관광공사 쪽이 카페·식당·주점이고 100m 안이며 상호가 같거나
포함(짧은 쪽 2글자 이상)이면 상가정보 쪽을 merged_into_id 로 관광공사 쪽에 합친다.
tour-kor·store 가 끝날 때 자동으로, place:sync -- dedupe 로 따로도 돈다
- 사후면세점 브랜드 매장(SH040300)은 목록·필터에서 빼고 이름 검색에만 낸다
(반려동물 필터 결과가 더현대 입점 매장으로 덮이던 문제)
- 공공 반려동물 조건 원문의 글머리표·줄바꿈 정리
e2e(test/place.e2e-spec.ts)는 시작할 때 마이그레이션을 적용하고 동해 좌표에
테스트 장소를 만들어 노출 규칙·필터·커서·상세·오류 코드를 검증한다.
2026-11-02 대구 실측: 같은 가게 21곳 병합, 도심 반려동물 필터 7곳.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
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<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',
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user