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:
2026-11-03 18:57:58 +09:00
parent 71aaf0bcae
commit df48ed78eb
29 changed files with 1604 additions and 15 deletions
+313
View File
@@ -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');
});
});
});