Skip to content
2 changes: 1 addition & 1 deletion src/config/env.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const envSchema = v.object({
GOOGLE_CLOUD_PROJECT: v.optional(v.string()),
// Gemini 3.x는 global 전용이므로 기본 "global" 사용
GOOGLE_VERTEX_LOCATION: v.optional(v.string(), "global"),
KAKAO_REST_API_KEY: v.optional(v.string()),
KAKAO_REST_API_KEY: v.pipe(v.string(), v.minLength(1)),
});

export type Env = v.InferOutput<typeof envSchema>;
Expand Down
103 changes: 89 additions & 14 deletions src/infrastructures/geocoder/geocoder.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,43 @@
import "reflect-metadata";
import { beforeEach, describe, expect, it } from "bun:test";
import { beforeEach, describe, expect, it, jest } from "bun:test";
import { ConfigModule } from "@nestjs/config";
import { Test } from "@nestjs/testing";
import { AppException } from "../../common/exceptions/app.exception";
import { GeocoderModule } from "./geocoder.module";
import { GeocoderService } from "./geocoder.service";
import type { GeoQuery } from "./geocoder.type";
import { GoogleProvider } from "./providers/google.provider";
import type { GeoCandidate, GeocoderProvider, GeoQuery } from "./geocoder.type";
import { KakaoProvider } from "./providers/kakao.provider";

const query: GeoQuery = { areaName: "서울", placeName: "남산타워" };
const query: GeoQuery = {
areaName: "서울",
areaType: "landmark",
placeName: "남산타워",
};

describe("Geocoder", () => {
let service: GeocoderService;
let kakao: KakaoProvider;
let google: GoogleProvider;

function makeCandidate(overrides: Partial<GeoCandidate> = {}): GeoCandidate {
return {
provider: "kakao",
providerPlaceId: "kakao-1",
placeName: "남산서울타워",
address: "서울 용산구 남산공원길 105",
coordinate: { lat: 37.5512, lng: 126.9882 },
...overrides,
};
}

function makeProvider(
overrides: Partial<GeocoderProvider> = {},
): GeocoderProvider {
return {
name: "kakao",
search: jest.fn().mockResolvedValue([makeCandidate()]),
...overrides,
};
}

beforeEach(async () => {
const module = await Test.createTestingModule({
Expand All @@ -24,24 +48,75 @@ describe("Geocoder", () => {
}).compile();
service = module.get(GeocoderService);
kakao = module.get(KakaoProvider);
google = module.get(GoogleProvider);
});

it("DI 컨테이너에서 GeocoderService를 해석한다", () => {
expect(service).toBeInstanceOf(GeocoderService);
});

it("searchAll은 아직 구현되지 않아 에러를 던진다", async () => {
await expect(service.searchAll(query)).rejects.toThrow("Not implemented");
it("KakaoProvider는 DI 컨테이너에서 해석된다", () => {
expect(kakao.name).toBe("kakao");
});

it("KakaoProvider는 name이 'kakao'이고 search는 에러를 던진다", async () => {
expect(kakao.name).toBe("kakao");
await expect(kakao.search(query)).rejects.toThrow("Not implemented");
it("searchAll은 provider 결과를 병합한다", async () => {
const kakaoProvider = makeProvider({
name: "kakao",
search: jest
.fn()
.mockResolvedValue([
makeCandidate({ provider: "kakao", providerPlaceId: "kakao-1" }),
]),
});
const googleProvider = makeProvider({
name: "google",
search: jest.fn().mockResolvedValue([
makeCandidate({
provider: "google",
providerPlaceId: "google-1",
placeName: "N Seoul Tower",
}),
]),
});
const geocoder = new GeocoderService([kakaoProvider, googleProvider]);

const result = await geocoder.searchAll(query);

expect(kakaoProvider.search).toHaveBeenCalledWith(query);
expect(googleProvider.search).toHaveBeenCalledWith(query);
expect(result).toHaveLength(2);
expect(result.map((candidate) => candidate.provider)).toEqual([
"kakao",
"google",
]);
});

it("GoogleProvider는 name이 'google'이고 search는 에러를 던진다", async () => {
expect(google.name).toBe("google");
await expect(google.search(query)).rejects.toThrow("Not implemented");
it("일부 provider가 실패해도 성공한 결과를 반환한다", async () => {
const successfulProvider = makeProvider({
search: jest.fn().mockResolvedValue([makeCandidate()]),
});
const failedProvider = makeProvider({
name: "google",
search: jest.fn().mockRejectedValue(new Error("provider down")),
});
const geocoder = new GeocoderService([successfulProvider, failedProvider]);

const result = await geocoder.searchAll(query);

expect(result).toEqual([makeCandidate()]);
});

it("모든 provider가 실패하면 GEOCODER_ALL_PROVIDERS_FAILED(502)를 던진다", async () => {
const failedProvider = makeProvider({
search: jest.fn().mockRejectedValue(new Error("provider down")),
});
const geocoder = new GeocoderService([failedProvider]);

const error = await geocoder.searchAll(query).then(
() => undefined,
(error: unknown) => error,
);

expect(error).toBeInstanceOf(AppException);
expect(error).toMatchObject({ errorCode: "GEOCODER_ALL_PROVIDERS_FAILED" });
});
});
23 changes: 20 additions & 3 deletions src/infrastructures/geocoder/geocoder.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Inject, Injectable } from "@nestjs/common";
import { HttpStatus, Inject, Injectable } from "@nestjs/common";
import { AppException } from "../../common/exceptions/app.exception";
import type { GeoCandidate, GeocoderProvider, GeoQuery } from "./geocoder.type";

export const GEOCODER_PROVIDERS = Symbol("GEOCODER_PROVIDERS");
Expand All @@ -10,7 +11,23 @@ export class GeocoderService {
private readonly providers: GeocoderProvider[],
) {}

async searchAll(_query: GeoQuery): Promise<GeoCandidate[]> {
throw new Error("Not implemented");
async searchAll(query: GeoQuery): Promise<GeoCandidate[]> {
const settled = await Promise.allSettled(
this.providers.map((provider) => provider.search(query)),
);
const succeeded = settled.filter(
(result): result is PromiseFulfilledResult<GeoCandidate[]> =>
result.status === "fulfilled",
);

if (succeeded.length === 0 && settled.length > 0) {
throw new AppException(
"GEOCODER_ALL_PROVIDERS_FAILED",
"모든 지도 검색 제공자가 실패했습니다.",
HttpStatus.BAD_GATEWAY,
);
}

return succeeded.flatMap((result) => result.value);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
8 changes: 6 additions & 2 deletions src/infrastructures/geocoder/geocoder.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,27 @@ export interface Coordinate {
lng: number;
}

export type AreaType = "landmark" | "address" | "region";

export interface GeoQuery {
areaName: string;
areaType?: AreaType;
placeName: string;
}

export interface GeoCandidate {
provider: "kakao" | "google";
providerPlaceId: string;
placeName: string;
address: string;
coordinate: Coordinate;
distance?: number;
url?: string;
mapUrl?: string;
phone?: string;
category?: string;
}

export interface GeocoderProvider {
name: GeoCandidate["provider"];
readonly name: GeoCandidate["provider"];
search(query: GeoQuery): Promise<GeoCandidate[]>;
}
160 changes: 160 additions & 0 deletions src/infrastructures/geocoder/providers/kakao.provider.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { afterEach, describe, expect, it, jest } from "bun:test";
import type { ConfigService } from "@nestjs/config";
import { AppException } from "../../../common/exceptions/app.exception";
import type { Env } from "../../../config/env.schema";
import type { GeoQuery } from "../geocoder.type";
import { KakaoProvider } from "./kakao.provider";

const originalFetch = globalThis.fetch;
const query: GeoQuery = {
areaName: "서울 강남구",
areaType: "landmark",
placeName: "카카오프렌즈",
};

function createProvider() {
const configService = {
getOrThrow: jest.fn(() => "test-api-key"),
} as unknown as ConfigService<Env>;

return new KakaoProvider(configService);
}

function createKakaoResponse(overrides: Record<string, unknown> = {}) {
return {
meta: {
same_name: {
region: [],
keyword: "카카오프렌즈",
selected_region: "",
},
pageable_count: 1,
total_count: 1,
is_end: true,
},
documents: [
{
id: "26338954",
place_name: "카카오프렌즈 코엑스점",
category_name: "가정,생활 > 문구,사무용품 > 디자인문구 > 카카오프렌즈",
category_group_code: "",
category_group_name: "",
phone: "02-6002-1880",
address_name: "서울 강남구 삼성동 159",
road_address_name: "서울 강남구 영동대로 513",
x: "127.05902969025047",
y: "37.51207412593136",
place_url: "http://place.map.kakao.com/26338954",
distance: "418",
...overrides,
},
],
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function mockFetchJson(body: unknown, init: ResponseInit = {}) {
const fetchMock = jest.fn().mockResolvedValue(
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
...init,
}),
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
return fetchMock;
}

async function expectAppException(
promise: Promise<unknown>,
errorCode: string,
) {
const error = await promise.then(
() => undefined,
(error: unknown) => error,
);

expect(error).toBeInstanceOf(AppException);
expect(error).toMatchObject({ errorCode });
}

describe("KakaoProvider", () => {
afterEach(() => {
globalThis.fetch = originalFetch;
});

it("keyword search API를 올바른 query와 인증 헤더로 호출한다", async () => {
const fetchMock = mockFetchJson(createKakaoResponse());
const provider = createProvider();

await provider.search(query);

const [url, init] = fetchMock.mock.calls[0] as [
string,
RequestInit | undefined,
];
const requestUrl = new URL(url);
expect(requestUrl.origin).toBe("https://dapi.kakao.com");
expect(requestUrl.pathname).toBe("/v2/local/search/keyword.json");
expect(requestUrl.searchParams.get("query")).toBe(
"서울 강남구 카카오프렌즈",
);
expect(requestUrl.searchParams.get("size")).toBe("15");
expect(init?.headers).toEqual({
Authorization: "KakaoAK test-api-key",
});
});

it("Kakao 응답을 GeoCandidate로 정규화한다", async () => {
mockFetchJson(createKakaoResponse());
const provider = createProvider();

const result = await provider.search(query);

expect(result).toEqual([
{
provider: "kakao",
providerPlaceId: "26338954",
placeName: "카카오프렌즈 코엑스점",
address: "서울 강남구 삼성동 159",
coordinate: {
lat: 37.51207412593136,
lng: 127.05902969025047,
},
distance: 418,
mapUrl: "http://place.map.kakao.com/26338954",
phone: "02-6002-1880",
category: "가정,생활 > 문구,사무용품 > 디자인문구 > 카카오프렌즈",
},
]);
});

it("distance가 없거나 빈 문자열이면 distance를 생략한다", async () => {
mockFetchJson(createKakaoResponse({ distance: "" }));
const provider = createProvider();

const [candidate] = await provider.search(query);

expect(candidate.distance).toBeUndefined();
});

it("429 응답이면 KAKAO_RATE_LIMITED를 던진다", async () => {
mockFetchJson({ error: "rate limited" }, { status: 429 });
const provider = createProvider();

await expectAppException(provider.search(query), "KAKAO_RATE_LIMITED");
});

it("2xx 응답이 아니면 KAKAO_REQUEST_FAILED를 던진다", async () => {
mockFetchJson({ error: "bad gateway" }, { status: 502 });
const provider = createProvider();

await expectAppException(provider.search(query), "KAKAO_REQUEST_FAILED");
});

it("응답 형식이 다르면 KAKAO_RESPONSE_INVALID를 던진다", async () => {
mockFetchJson(createKakaoResponse({ x: "not-a-number" }));
const provider = createProvider();

await expectAppException(provider.search(query), "KAKAO_RESPONSE_INVALID");
});
});
Loading