Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 107 additions & 25 deletions e2e/place.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import "reflect-metadata";
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
jest,
} from "bun:test";
import type { INestApplication } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { Test } from "@nestjs/testing";
import { BunHonoAdapter } from "../src/adapters/bun-hono.adapter";
import { HttpExceptionFilter } from "../src/common/filters/http-exception.filter";
import { ResponseInterceptor } from "../src/common/interceptors/response.interceptor";
import { AiService } from "../src/infrastructures/ai/ai.service";
import { GeocoderService } from "../src/infrastructures/geocoder/geocoder.service";
import type { GeoCandidate } from "../src/infrastructures/geocoder/geocoder.type";
import { ScraperService } from "../src/infrastructures/scraper/scraper.service";
import type { ScrapedPost } from "../src/infrastructures/scraper/scraper.type";
import { PlaceModule } from "../src/modules/place/place.module";

// 스크래퍼는 가짜로 대체(네트워크 차단). 이후 단계(AI)가 아직 stub이라 500이 나는 흐름을 검증.
// 외부 어댑터(스크래퍼/AI/지오코더)만 가짜로 대체한다. 그 외 조립(DI·컨트롤러·파이프·
// 인터셉터·필터·PlaceService 조합)은 전부 실제로 관통시켜 e2e 계약을 검증한다.
const FAKE_POST: ScrapedPost = {
shortcode: "abc123",
typename: "image",
Expand All @@ -20,6 +32,21 @@ const FAKE_POST: ScrapedPost = {
location: null,
};

function candidate(overrides: Partial<GeoCandidate> = {}): GeoCandidate {
return {
provider: "kakao",
providerPlaceId: "kakao-1",
placeName: "어니언 성수",
address: "서울 성동구 아차산로 8",
coordinate: { lat: 37.5445, lng: 127.0559 },
...overrides,
};
}

const scraper = { fetchPost: jest.fn() };
const ai = { extract: jest.fn() };
const geocoder = { searchAll: jest.fn() };

let app: INestApplication;
let baseUrl: string;

Expand All @@ -31,7 +58,11 @@ beforeAll(async () => {
],
})
.overrideProvider(ScraperService)
.useValue({ fetchPost: async () => FAKE_POST })
.useValue(scraper)
.overrideProvider(AiService)
.useValue(ai)
.overrideProvider(GeocoderService)
.useValue(geocoder)
.compile();

const adapter = new BunHonoAdapter();
Expand All @@ -51,42 +82,93 @@ afterAll(async () => {
await app.close();
});

beforeEach(() => {
scraper.fetchPost.mockReset();
ai.extract.mockReset();
geocoder.searchAll.mockReset();
});

function postPlaces(url: string) {
return fetch(`${baseUrl}/api/v1/place/places`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ method: "instagram_url", data: { url } }),
});
}

const URL = "https://www.instagram.com/p/abc123/";

describe("POST /api/v1/place/places (e2e)", () => {
it("유효한 body는 미구현 AI stub으로 인해 500을 반환한다", async () => {
// given
const requestBody = {
method: "instagram_url",
data: { url: "https://www.instagram.com/p/abc123/" },
it("유효한 body는 장소별 그룹(PlaceMatch[]) 구조로 201을 반환한다", async () => {
scraper.fetchPost.mockResolvedValue(FAKE_POST);
ai.extract.mockResolvedValue({
places: [
{
place_name: "어니언 성수",
area_name: "성수동",
area_type: "landmark",
relation: "카페",
},
{
place_name: "대림창고",
area_name: "성수동",
area_type: "landmark",
relation: "다음 코스",
},
],
});
geocoder.searchAll
.mockResolvedValueOnce([candidate()])
.mockResolvedValueOnce([candidate({ placeName: "대림창고" })]);

const res = await postPlaces(URL);

expect(res.status).toBe(201);
const { data } = (await res.json()) as {
data: Array<{
extracted: { placeName: string; relation: string };
matches: Array<{ placeName: string }>;
}>;
};
expect(data).toHaveLength(2);
expect(data[0].extracted.placeName).toBe("어니언 성수");
expect(data[0].extracted.relation).toBe("카페");
expect(data[0].matches[0].placeName).toBe("어니언 성수");
expect(data[1].extracted.placeName).toBe("대림창고");
expect(data[1].matches[0].placeName).toBe("대림창고");
});

// when
const res = await fetch(`${baseUrl}/api/v1/place/places`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(requestBody),
it("모든 장소의 지오코딩이 실패하면 502 GEOCODER_ALL_FAILED를 반환한다", async () => {
scraper.fetchPost.mockResolvedValue(FAKE_POST);
ai.extract.mockResolvedValue({
places: [
{
place_name: "어니언 성수",
area_name: "성수동",
area_type: "landmark",
relation: "카페",
},
],
});
geocoder.searchAll.mockRejectedValue(new Error("provider down"));

const res = await postPlaces(URL);

// then
expect(res.status).toBe(500);
expect(res.status).toBe(502);
const body = (await res.json()) as { errorCode: string };
expect(body.errorCode).toBe("INTERNAL_SERVER_ERROR");
expect(body.errorCode).toBe("GEOCODER_ALL_FAILED");
});

it("유효하지 않은 body는 400 VALIDATION_ERROR를 반환한다", async () => {
// given
const requestBody = {
method: "unknown_method",
data: { url: "not-a-url" },
};

// when
const res = await fetch(`${baseUrl}/api/v1/place/places`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(requestBody),
body: JSON.stringify({
method: "unknown_method",
data: { url: "not-a-url" },
}),
});

// then
expect(res.status).toBe(400);
const body = (await res.json()) as { errorCode: string };
expect(body.errorCode).toBe("VALIDATION_ERROR");
Expand Down
3 changes: 3 additions & 0 deletions src/common/swagger/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { generateSchema } from "@nestjs/swagger";

export type SchemaObject = ReturnType<typeof generateSchema>["schema"];
54 changes: 30 additions & 24 deletions src/modules/place/place.controller.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,51 @@
import { Body, Controller, Get, Post, Query } from "@nestjs/common";
import { Body, Controller, Post } from "@nestjs/common";
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { ValibotPipe } from "../../common/pipes/valibot.pipe";
import { GeocoderService } from "../../infrastructures/geocoder/geocoder.service";
import type { GeoCandidate } from "../../infrastructures/geocoder/geocoder.type";
import {
type CreatePlaceRequest,
createPlaceRequestApiSchema,
createPlaceRequestSchema,
type TestGeocodeRequest,
testGeocodeRequestSchema,
errorResponseApiSchema,
placeMatchListResponseApiSchema,
} from "./place.dto";
import { PlaceService } from "./place.service";
import type { PlaceCandidate } from "./place.type";
import type { PlaceMatch } from "./place.type";

@ApiTags("place")
@Controller("api/v1/place")
export class PlaceController {
constructor(
private readonly placeService: PlaceService,
// TODO: KakaoProvider 확인용 임시 의존성으로 전체 플로우 연동 후 제거합니다.
private readonly geocoderService: GeocoderService,
) {}
constructor(private readonly placeService: PlaceService) {}

@Post("places")
@ApiOperation({
summary: "인스타그램 URL에서 장소를 추출한 후 지오코딩한다",
description:
"scrap → AI extraction → geocoding fan-out",
})
@ApiBody({ schema: createPlaceRequestApiSchema })
@ApiResponse({
status: 201,
description: "장소별 그룹(PlaceMatch[])",
schema: placeMatchListResponseApiSchema,
})
@ApiResponse({
status: 400,
description: "요청 형식 오류 (VALIDATION_ERROR)",
schema: errorResponseApiSchema,
})
@ApiResponse({
status: 502,
description: "외부 연동 실패 (SCRAPER_REQUEST_FAILED / GEOCODER_ALL_FAILED)",
schema: errorResponseApiSchema,
})
async createPlace(
@Body(new ValibotPipe(createPlaceRequestSchema)) body: CreatePlaceRequest,
): Promise<PlaceCandidate[]> {
): Promise<PlaceMatch[]> {
switch (body.method) {
case "instagram_url":
return this.placeService.extractFromUrl(body.data.url);
default:
throw new Error(`Unsupported method: ${body.method}`);
}
}

// TODO: KakaoProvider 확인용 임시 테스트 엔드포인트로 전체 플로우 연동 후 제거합니다.
@Get("_test/geocode")
async testGeocode(
@Query(new ValibotPipe(testGeocodeRequestSchema)) query: TestGeocodeRequest,
): Promise<GeoCandidate[]> {
return this.geocoderService.searchAll({
placeName: query.placeName,
areaName: query.areaName,
areaType: query.areaType,
});
}
}
72 changes: 64 additions & 8 deletions src/modules/place/place.dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { toJsonSchema } from "@valibot/to-json-schema";
import * as v from "valibot";
import type { SchemaObject } from "../../common/swagger/schema";
import { AREA_TYPES } from "../../infrastructures/geocoder/geocoder.type";

export const createPlaceRequestSchema = v.object({
Expand All @@ -10,12 +12,66 @@ export const createPlaceRequestSchema = v.object({

export type CreatePlaceRequest = v.InferOutput<typeof createPlaceRequestSchema>;

// TODO: KakaoProvider 확인용 임시 의존성으로 전체 플로우 연동 후 제거합니다.
export const testGeocodeRequestSchema = v.object({
placeName: v.pipe(v.string(), v.minLength(1)),
areaName: v.pipe(v.string(), v.minLength(1)),
areaType: v.optional(v.picklist(AREA_TYPES)),
});
export const createPlaceRequestApiSchema = toJsonSchema(
createPlaceRequestSchema,
) as SchemaObject;

const coordinateSchema: SchemaObject = {
type: "object",
properties: {
lat: { type: "number", example: 37.5445 },
lng: { type: "number", example: 127.0559 },
},
};

const geoCandidateSchema: SchemaObject = {
type: "object",
properties: {
provider: { type: "string", enum: ["kakao", "google"] },
providerPlaceId: { type: "string" },
placeName: { type: "string" },
address: { type: "string" },
coordinate: coordinateSchema,
distance: { type: "number", nullable: true },
mapUrl: { type: "string", nullable: true },
phone: { type: "string", nullable: true },
category: { type: "string", nullable: true },
},
};

const extractedPlaceSchema: SchemaObject = {
type: "object",
properties: {
placeName: { type: "string" },
areaName: { type: "string" },
areaType: { type: "string", enum: [...AREA_TYPES] },
relation: { type: "string" },
},
};

const placeMatchSchema: SchemaObject = {
type: "object",
properties: {
extracted: extractedPlaceSchema,
matches: {
type: "array",
items: geoCandidateSchema,
description: "지오코딩 결과 후보",
},
},
};

export const placeMatchListResponseApiSchema: SchemaObject = {
type: "object",
properties: {
data: { type: "array", items: placeMatchSchema },
},
};

// TODO: KakaoProvider 확인용 임시 의존성으로 전체 플로우 연동 후 제거합니다.
export type TestGeocodeRequest = v.InferOutput<typeof testGeocodeRequestSchema>;
export const errorResponseApiSchema: SchemaObject = {
type: "object",
properties: {
errorCode: { type: "string", example: "GEOCODER_ALL_FAILED" },
message: { type: "string", example: "장소 검색이 모두 실패했습니다." },
},
};
Comment on lines +19 to +77

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# GeoCandidate 타입 정의에서 필수/선택 필드 확인
rg -n 'interface GeoCandidate' -A 20 src/infrastructures/geocoder/geocoder.type.ts

Repository: mash-up-kr/Team-MINO-Node

Length of output: 583


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== place.dto.ts ==" 
nl -ba src/modules/place/place.dto.ts | sed -n '1,180p'

echo
echo "== related place types ==" 
rg -n 'interface .*Place|type .*Place|interface .*Error|type .*Error|extractedPlace|placeMatch|errorResponse' -A 20 -B 5 src | sed -n '1,260p'

Repository: mash-up-kr/Team-MINO-Node

Length of output: 222


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== place.dto.ts =="
sed -n '1,180p' src/modules/place/place.dto.ts

echo
echo "== related place types =="
rg -n 'interface .*Place|type .*Place|interface .*Error|type .*Error|extractedPlace|placeMatch|errorResponse' -A 20 -B 5 src | sed -n '1,260p'

Repository: mash-up-kr/Team-MINO-Node

Length of output: 18663


required 배열을 추가해 Swagger 응답 스키마를 실제 타입과 맞춰주세요 coordinateSchema, geoCandidateSchema, extractedPlaceSchema, placeMatchSchema, placeMatchListResponseApiSchema, errorResponseApiSchema 모두 required가 없어 응답 필드가 전부 선택적으로 표시됩니다. 특히 geoCandidateSchema는 GeoCandidate에 맞춰 provider, providerPlaceId, placeName, address, coordinate만 필수로 두고 나머지는 제외하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/place/place.dto.ts` around lines 19 - 77, 각 스키마에 실제 응답 타입과 일치하는
required 배열을 추가하세요. coordinateSchema, geoCandidateSchema, extractedPlaceSchema,
placeMatchSchema, placeMatchListResponseApiSchema, errorResponseApiSchema의 필수
필드를 타입 정의에 맞춰 지정하고, 특히 geoCandidateSchema는 provider, providerPlaceId, placeName,
address, coordinate만 required로 설정하며 distance, mapUrl, phone, category는 제외하세요.

Loading
Loading