k-skill/packages/k-skill-proxy/test/server.test.js
Jeffrey (Dongkyu) Kim e1656541a4
Fix parking lot lookups: force HTTPS, cache full dataset, normalize provider fields (#156)
Data.go.kr 이 tn_pubr_prkplce_info_api 를 HTTPS 로만 서비스하고 HTTP 요청은 301 로 리다이렉트하기 때문에 Node fetch 가 `response.ok=false` 로 떨어져 기능이 전체 실패하고 있었다. 이 커밋은 HTTPS 로 직접 호출하도록 수정하면서, 업스트림의 주소/지역 필터가 실제로는 동작하지 않고 페이지당 응답이 1000rows 기준 26s 에 달해 20s fetch timeout 에 꾸준히 걸리던 문제까지 함께 해결한다.

## What changed

- packages/k-skill-proxy/src/parking-lots.js
  - PARKING_LOT_API_URL 을 `http://` → `https://` 로 고정 (root cause).
  - 업스트림 address/geo 필터가 신뢰 불가하므로 full-dataset 을 한 번 로드해 프로세스 메모리에 6시간 TTL 로 캐시하고, 동시 호출자는 in-flight promise 를 공유하도록 한다. nearby 쿼리는 캐시된 행을 좌표 거리로 필터링해 서비스한다.
  - DATASET_PAGE_SIZE=300, fetch timeout 30s 로 페이지당 응답이 20s 를 넘기지 않도록 맞췄다.
- packages/k-skill-proxy/src/server.js
  - 더 이상 의미 없어진 numOfRows / maxPages 쿼리 파라미터를 라우트에서 제거하고, 응답 payload 의 query echo 도 정리했다.
- packages/k-skill-proxy/test/server.test.js
  - 새 캐시 기반 동작을 검증하는 테스트로 교체: (1) full dataset load + 좌표 필터 + 프록시 응답 캐시 재사용, (2) public_only 기본값 및 해제 시 동작, (3) 좌표 검증 실패 400, (4) 업스트림 키 미설정 시 503.
- packages/parking-lot-search/src/index.js
  - OFFICIAL_API_URL 도 HTTPS 로 맞춰 직접 호출 모드 사용자도 같은 버그를 밟지 않게 한다.
- packages/parking-lot-search/src/parse.js
  - 업스트림이 `insttCode` / `insttNm` (camelCase) 를 돌려주는데 parser 가 snake_case (`instt_code`, `instt_nm`) 만 인식해 providerCode/providerName 이 비어 있던 문제를 수정.
- packages/parking-lot-search/test/* 및 fixtures
  - HTTPS URL 매칭으로 업데이트하고, insttCode/insttNm 회귀 테스트를 fixture/assertion 에 추가.
- docs/features/parking-lot-search.md, parking-lot-search/SKILL.md, packages/parking-lot-search/README.md
  - 공식 endpoint 표기를 HTTPS 로 통일.
- .changeset/parking-lot-https-fix.md
  - parking-lot-search 패키지 patch 릴리즈 노트 추가.

## How it was verified

- `npm run ci` (lint + typecheck + tests + pack:dry-run) 통과.
- 로컬에서 실제 `DATA_GO_KR_API_KEY` 로 k-skill-proxy 를 기동해 live 호출 검증:
  - 광화문 (37.573713, 126.978338) cold cache: 30s 내 전체 18,868 rows 로드, 2km 내 47개 공영주차장 반환 (세종로 414m, 서린노외 456m 등).
  - 강남역 (37.497952, 127.027621) warm cache: 31ms 응답, 1.5km 내 13개 반환 (역삼문화공원 380m, 역삼푸른솔도서관 421m 등).
- 업스트림 직접 HTTPS 호출로 `resultCode=00 NORMAL_SERVICE` 정상 동작 확인.
2026-04-22 10:52:57 +09:00

3687 lines
106 KiB
JavaScript

const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildServer,
createMemoryCache,
isFailureResponse,
makeCacheKey,
normalizeData4LibraryBookDetailQuery,
normalizeData4LibraryBookExistsQuery,
normalizeData4LibraryBookSearchQuery,
normalizeData4LibraryLibrariesByBookQuery,
normalizeData4LibraryLibrarySearchQuery,
proxyAirKoreaRequest,
proxyData4LibraryRequest,
proxyHrfcoWaterLevelRequest,
proxyKmaWeatherRequest,
proxySeoulSubwayRequest
} = require("../src/server");
const { resolveEducationOfficeFromNaturalLanguage } = require("../src/neis-office-codes");
test("makeCacheKey requires a non-empty route to prevent cross-route collisions", () => {
assert.throws(() => makeCacheKey({ q: "강남" }), /route/);
assert.throws(() => makeCacheKey({ route: "", q: "강남" }), /route/);
assert.throws(() => makeCacheKey(null), /route/);
assert.throws(() => makeCacheKey(undefined), /route/);
const sameShapeA = makeCacheKey({ route: "alpha", q: "x" });
const sameShapeB = makeCacheKey({ route: "beta", q: "x" });
assert.notEqual(sameShapeA, sameShapeB, "different routes must yield different cache keys");
const duplicate = makeCacheKey({ route: "alpha", q: "x" });
assert.equal(sameShapeA, duplicate, "same input must produce same key");
});
test("isFailureResponse flags explicit errors, upstream degradation, and empty-items-with-warnings", () => {
assert.equal(isFailureResponse({ error: "bad_request", message: "..." }), true);
assert.equal(isFailureResponse({ upstream: { degraded: true } }), true);
assert.equal(
isFailureResponse({ items: [], warnings: ["upstream invalid"] }),
true,
"empty items plus warnings must be treated as failure"
);
assert.equal(isFailureResponse({ items: [{ id: 1 }], warnings: ["sample feed"] }), false,
"non-empty items with warnings is a graceful fallback, not a failure");
assert.equal(isFailureResponse({ items: [] }), false, "empty items alone is valid (zero hits)");
assert.equal(isFailureResponse({ warnings: [] }), false);
assert.equal(isFailureResponse({ forecast: [], baseDate: "20260420" }), false,
"routes without items/warnings convention pass through");
assert.equal(isFailureResponse(null), false);
assert.equal(isFailureResponse("string"), false);
});
test("createMemoryCache refuses to store failure responses", () => {
const cache = createMemoryCache();
assert.equal(cache.set("k1", { items: [], warnings: ["upstream invalid"] }, 60000), false);
assert.equal(cache.get("k1"), null, "failure payload must not be persisted");
assert.equal(cache.set("k2", { error: "bad_request" }, 60000), false);
assert.equal(cache.get("k2"), null);
assert.equal(cache.set("k3", { upstream: { degraded: true } }, 60000), false);
assert.equal(cache.get("k3"), null);
assert.equal(cache.set("k4", { items: [{ id: 1 }] }, 60000), true);
assert.deepEqual(cache.get("k4"), { items: [{ id: 1 }] }, "successful payload must be stored");
});
test("food-safety search does not cache upstream failures so transient errors self-heal", async (t) => {
const originalFetch = global.fetch;
const recallCalls = [];
let recallMode = "fail";
global.fetch = async (url) => {
const text = String(url);
if (text.includes("PrsecImproptFoodInfoService03/getPrsecImproptFoodList01")) {
return new Response(
JSON.stringify({ body: { items: [] } }),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
}
if (text.includes("openapi.foodsafetykorea.go.kr/api/live-food-key/I0490/json/1/50")) {
recallCalls.push(recallMode);
if (recallMode === "fail") {
return new Response(
"<script>alert('인증키가 유효하지 않습니다.'); history.back();</script>",
{ status: 200, headers: { "content-type": "text/html" } }
);
}
return new Response(
JSON.stringify({
I0490: {
row: [
{
PRDLST_NM: "재시도 성공 식품",
BSSH_NM: "회복푸드",
RTRVLPRVNS: "회수 조치"
}
]
}
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
DATA_GO_KR_API_KEY: "data-go-key",
FOODSAFETYKOREA_API_KEY: "live-food-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const url = `/v1/mfds/food-safety/search?query=${encodeURIComponent("재시도 성공 식품")}&limit=3`;
const first = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 200);
assert.equal(first.json().items.length, 0);
assert.match(first.json().warnings.join(" "), /not valid JSON/);
assert.equal(first.json().proxy.cache.hit, false);
recallMode = "ok";
const second = await app.inject({ method: "GET", url });
assert.equal(second.statusCode, 200);
assert.equal(
second.json().proxy.cache.hit,
false,
"failure response must not have been cached - retry must hit upstream"
);
assert.equal(second.json().items.length, 1);
assert.equal(second.json().items[0].product_name, "재시도 성공 식품");
const third = await app.inject({ method: "GET", url });
assert.equal(third.json().proxy.cache.hit, true, "successful payload must now be cached");
assert.equal(third.json().items[0].product_name, "재시도 성공 식품");
assert.equal(recallCalls.length, 2, "upstream hit on first (fail) and second (recovered) - third served from cache");
});
test("health endpoint stays public and reports auth/upstream status", async (t) => {
const app = buildServer({
provider: async () => {
throw new Error("provider should not be called");
}
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/health"
});
assert.equal(response.statusCode, 200);
const body = response.json();
assert.equal(body.ok, true);
assert.equal(body.auth.tokenRequired, false);
assert.equal(body.upstreams.airKoreaConfigured, false);
assert.equal(body.upstreams.kmaOpenApiConfigured, false);
assert.equal(body.upstreams.krxConfigured, false);
assert.equal(body.upstreams.seoulOpenApiConfigured, false);
assert.equal(body.upstreams.hrfcoConfigured, false);
assert.equal(body.upstreams.data4libraryConfigured, false);
});
test("health endpoint reports KRX upstream status when configured", async (t) => {
const app = buildServer({
env: {
KRX_API_KEY: "krx-key"
}
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/health"
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().upstreams.krxConfigured, true);
});
test("korean stock search endpoint stays public and caches normalized search queries", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url, options = {}) => {
const text = String(url);
fetchCalls.push({ url: text, headers: options.headers });
if (text.includes("stk_isu_base_info")) {
return new Response(
JSON.stringify({
OutBlock_1: [
{
ISU_CD: "KR7005930003",
ISU_SRT_CD: "005930",
ISU_NM: "삼성전자",
ISU_ABBRV: "삼성전자",
ISU_ENG_NM: "Samsung Electronics",
LIST_DD: "19750611",
MKT_TP_NM: "KOSPI",
SECUGRP_NM: "주권",
SECT_TP_NM: "대형주",
KIND_STKCERT_TP_NM: "보통주",
PARVAL: "100",
LIST_SHRS: "5969782550"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("ksq_isu_base_info") || text.includes("knx_isu_base_info")) {
return new Response(
JSON.stringify({
OutBlock_1: []
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
KRX_API_KEY: "krx-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=%20%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90%20&bas_dd=20260404"
});
const second = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?query=%20%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90%20&date=20260404&limit=10"
});
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(fetchCalls.length, 3);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(first.json().items[0].market, "KOSPI");
assert.equal(first.json().items[0].code, "005930");
assert.equal(first.json().items[0].name, "삼성전자");
assert.ok(fetchCalls.every((entry) => entry.url.startsWith("https://data-dbg.krx.co.kr/")));
assert.match(fetchCalls[0].url, /basDd=20260404/);
assert.equal(fetchCalls[0].headers.AUTH_KEY, "krx-key");
});
test("korean stock search rate limit does not trust spoofed cf-connecting-ip on direct requests", async (t) => {
const app = buildServer({
env: {
KSKILL_PROXY_RATE_LIMIT_MAX: "1"
}
});
t.after(async () => {
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&bas_dd=20260404",
headers: {
"cf-connecting-ip": "1.1.1.1"
}
});
const second = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&bas_dd=20260404",
headers: {
"cf-connecting-ip": "2.2.2.2"
}
});
assert.equal(first.statusCode, 503);
assert.equal(first.json().error, "upstream_not_configured");
assert.equal(second.statusCode, 429);
assert.equal(second.json().error, "rate_limited");
});
test("korean stock search surfaces degraded upstream metadata when another market fails", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url, options = {}) => {
const text = String(url);
fetchCalls.push({ url: text, headers: options.headers });
if (text.includes("stk_isu_base_info")) {
return new Response(
JSON.stringify({
OutBlock_1: [
{
ISU_CD: "KR7005930003",
ISU_SRT_CD: "005930",
ISU_NM: "삼성전자",
ISU_ABBRV: "삼성전자",
ISU_ENG_NM: "Samsung Electronics",
LIST_DD: "19750611",
MKT_TP_NM: "KOSPI",
SECUGRP_NM: "주권",
SECT_TP_NM: "대형주",
KIND_STKCERT_TP_NM: "보통주",
PARVAL: "100",
LIST_SHRS: "5969782550"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("ksq_isu_base_info")) {
return new Response("boom", {
status: 500,
statusText: "Internal Server Error"
});
}
if (text.includes("knx_isu_base_info")) {
return new Response(
JSON.stringify({
OutBlock_1: []
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
KRX_API_KEY: "krx-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&bas_dd=20260404"
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().items.length, 1);
assert.equal(response.json().items[0].market, "KOSPI");
assert.equal(response.json().items[0].code, "005930");
assert.equal(response.json().items[0].name, "삼성전자");
assert.equal(response.json().upstream.degraded, true);
assert.deepEqual(response.json().upstream.requested_markets, ["KOSPI", "KOSDAQ", "KONEX"]);
assert.deepEqual(response.json().upstream.successful_markets, ["KOSPI", "KONEX"]);
assert.deepEqual(response.json().upstream.failed_markets, [
{
market: "KOSDAQ",
code: "upstream_error",
status_code: 502,
message: "KRX API HTTP 오류 (status: 500): Internal Server Error"
}
]);
assert.equal(fetchCalls.length, 3);
assert.ok(fetchCalls.every((entry) => entry.url.startsWith("https://data-dbg.krx.co.kr/")));
});
test("korean stock search does not cache degraded responses and retries a recovered market", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
let kosdaqAttempts = 0;
global.fetch = async (url, options = {}) => {
const text = String(url);
fetchCalls.push({ url: text, headers: options.headers });
if (text.includes("stk_isu_base_info") || text.includes("knx_isu_base_info")) {
return new Response(
JSON.stringify({
OutBlock_1: []
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("ksq_isu_base_info")) {
kosdaqAttempts += 1;
if (kosdaqAttempts === 1) {
return new Response("boom", {
status: 500,
statusText: "Internal Server Error"
});
}
return new Response(
JSON.stringify({
OutBlock_1: [
{
ISU_CD: "KR7196170005",
ISU_SRT_CD: "196170",
ISU_NM: "알테오젠",
ISU_ABBRV: "알테오젠",
ISU_ENG_NM: "Alteogen",
LIST_DD: "20140509",
MKT_TP_NM: "KOSDAQ",
SECUGRP_NM: "주권",
SECT_TP_NM: "제약",
KIND_STKCERT_TP_NM: "보통주",
PARVAL: "500",
LIST_SHRS: "53470829"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
KRX_API_KEY: "krx-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=%EC%95%8C%ED%85%8C%EC%98%A4%EC%A0%A0&bas_dd=20260408"
});
const second = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=%EC%95%8C%ED%85%8C%EC%98%A4%EC%A0%A0&bas_dd=20260408"
});
assert.equal(first.statusCode, 200);
assert.equal(first.json().items.length, 0);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(first.json().upstream.degraded, true);
assert.deepEqual(first.json().upstream.failed_markets, [
{
market: "KOSDAQ",
code: "upstream_error",
status_code: 502,
message: "KRX API HTTP 오류 (status: 500): Internal Server Error"
}
]);
assert.equal(second.statusCode, 200);
assert.equal(second.json().proxy.cache.hit, false);
assert.equal(second.json().items.length, 1);
assert.equal(second.json().items[0].market, "KOSDAQ");
assert.equal(second.json().items[0].code, "196170");
assert.equal(kosdaqAttempts, 2);
assert.equal(fetchCalls.length, 4);
});
test("korean stock search reuses per-market base snapshots across different queries for the same date", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url, options = {}) => {
const text = String(url);
fetchCalls.push({ url: text, headers: options.headers });
if (text.includes("stk_isu_base_info")) {
return new Response(
JSON.stringify({
OutBlock_1: [
{
ISU_CD: "KR7005930003",
ISU_SRT_CD: "005930",
ISU_NM: "삼성전자",
ISU_ABBRV: "삼성전자",
ISU_ENG_NM: "Samsung Electronics",
LIST_DD: "19750611",
MKT_TP_NM: "KOSPI",
SECUGRP_NM: "주권",
SECT_TP_NM: "대형주",
KIND_STKCERT_TP_NM: "보통주",
PARVAL: "100",
LIST_SHRS: "5969782550"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("ksq_isu_base_info") || text.includes("knx_isu_base_info")) {
return new Response(
JSON.stringify({
OutBlock_1: []
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
KRX_API_KEY: "krx-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const byKoreanName = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&bas_dd=20260404"
});
const byEnglishName = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=Samsung&bas_dd=20260404"
});
assert.equal(byKoreanName.statusCode, 200);
assert.equal(byEnglishName.statusCode, 200);
assert.equal(byKoreanName.json().items[0].code, "005930");
assert.equal(byEnglishName.json().items[0].code, "005930");
assert.equal(fetchCalls.length, 3);
});
test("korean stock base-info endpoint returns 503 when proxy server lacks KRX API key", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korean-stock/base-info?market=KOSPI&code=005930&bas_dd=20260404"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("korean stock base-info endpoint normalizes upstream KRX fields", async (t) => {
const originalFetch = global.fetch;
let calledUrl;
let calledHeaders;
global.fetch = async (url, options = {}) => {
calledUrl = String(url);
calledHeaders = options.headers;
return new Response(
JSON.stringify({
OutBlock_1: [
{
ISU_CD: "KR7005930003",
ISU_SRT_CD: "005930",
ISU_NM: "삼성전자",
ISU_ABBRV: "삼성전자",
ISU_ENG_NM: "Samsung Electronics",
LIST_DD: "19750611",
MKT_TP_NM: "KOSPI",
SECUGRP_NM: "주권",
SECT_TP_NM: "대형주",
KIND_STKCERT_TP_NM: "보통주",
PARVAL: "100",
LIST_SHRS: "5969782550"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
};
const app = buildServer({
env: {
KRX_API_KEY: "krx-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korean-stock/base-info?market=KOSPI&code=005930&bas_dd=20260404"
});
assert.equal(response.statusCode, 200);
assert.ok(calledUrl.startsWith("https://data-dbg.krx.co.kr/"));
assert.match(calledUrl, /stk_isu_base_info/);
assert.match(calledUrl, /basDd=20260404/);
assert.equal(calledHeaders.AUTH_KEY, "krx-key");
assert.equal(response.json().item.code, "005930");
assert.equal(response.json().item.name, "삼성전자");
assert.equal(response.json().item.listed_shares, 5969782550);
});
test("korean stock trade-info endpoint caches successful responses", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
let calledUrl;
global.fetch = async (url) => {
fetchCalls += 1;
calledUrl = String(url);
return new Response(
JSON.stringify({
OutBlock_1: [
{
BAS_DD: "20260404",
ISU_CD: "KR7005930003",
ISU_SRT_CD: "005930",
ISU_NM: "삼성전자",
MKT_NM: "KOSPI",
SECT_TP_NM: "대형주",
TDD_CLSPRC: "84000",
CMPPREVDD_PRC: "1000",
FLUC_RT: "1.20",
TDD_OPNPRC: "83000",
TDD_HGPRC: "84500",
TDD_LWPRC: "82800",
ACC_TRDVOL: "12345678",
ACC_TRDVAL: "1030000000000",
MKTCAP: "500000000000000",
LIST_SHRS: "5969782550"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
};
const app = buildServer({
env: {
KRX_API_KEY: "krx-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/korean-stock/trade-info?market=KOSPI&code=005930&bas_dd=20260404"
});
const second = await app.inject({
method: "GET",
url: "/v1/korean-stock/trade-info?market=KOSPI&stockCode=005930&date=20260404"
});
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(fetchCalls, 1);
assert.ok(calledUrl.startsWith("https://data-dbg.krx.co.kr/"));
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(first.json().item.close_price, 84000);
assert.equal(first.json().item.trading_value, 1030000000000);
});
test("korean stock trade-info endpoint does not relabel an unmatched single-row upstream response", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.includes("stk_bydd_trd")) {
return new Response(
JSON.stringify({
OutBlock_1: [
{
BAS_DD: "20260404",
ISU_CD: "KR7000660001",
ISU_NM: "하이트진로",
MKT_NM: "KOSPI",
SECT_TP_NM: "중형주",
TDD_CLSPRC: "21000"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("stk_isu_base_info")) {
return new Response(
JSON.stringify({
OutBlock_1: []
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
KRX_API_KEY: "krx-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korean-stock/trade-info?market=KOSPI&code=005930&bas_dd=20260404"
});
assert.equal(response.statusCode, 404);
assert.equal(response.json().error, "not_found");
assert.equal(fetchCalls.length, 2);
assert.ok(fetchCalls.every((entry) => entry.startsWith("https://data-dbg.krx.co.kr/")));
});
test("korean stock trade-info endpoint treats empty trade snapshots as not_found without base-info fallback", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.includes("stk_bydd_trd")) {
return new Response(
JSON.stringify({
OutBlock_1: []
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("stk_isu_base_info")) {
throw new Error("base-info fallback should not run for empty trade snapshots");
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
KRX_API_KEY: "krx-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korean-stock/trade-info?market=KOSPI&code=005930&bas_dd=20260404"
});
assert.equal(response.statusCode, 404);
assert.equal(response.json().error, "not_found");
assert.match(response.json().message, /휴장일이거나 데이터가 아직 없을 수 있습니다/);
assert.deepEqual(fetchCalls, [
"https://data-dbg.krx.co.kr/svc/apis/sto/stk_bydd_trd?basDd=20260404"
]);
});
test("fine dust endpoint stays publicly callable without proxy auth", async (t) => {
let providerCalls = 0;
const app = buildServer({
env: {
AIR_KOREA_OPEN_API_KEY: "airkorea-key"
},
provider: async () => {
providerCalls += 1;
return { station_name: "강남구" };
}
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/fine-dust/report?regionHint=%EC%84%9C%EC%9A%B8%20%EA%B0%95%EB%82%A8%EA%B5%AC"
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().station_name, "강남구");
assert.equal(providerCalls, 1);
});
test("fine dust endpoint returns candidate stations when region resolution is ambiguous", async (t) => {
const app = buildServer({
env: {
AIR_KOREA_OPEN_API_KEY: "airkorea-key"
},
provider: async () => {
const error = new Error("단일 측정소를 확정하지 못했습니다.");
error.statusCode = 400;
error.code = "ambiguous_location";
error.sidoName = "광주";
error.candidateStations = ["평동", "오선동"];
throw error;
}
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/fine-dust/report?regionHint=%EA%B4%91%EC%A3%BC%20%EA%B4%91%EC%82%B0%EA%B5%AC"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "ambiguous_location");
assert.equal(response.json().sido_name, "광주");
assert.deepEqual(response.json().candidate_stations, ["평동", "오선동"]);
});
test("fine dust endpoint caches successful provider responses", async (t) => {
let providerCalls = 0;
const app = buildServer({
env: {
AIR_KOREA_OPEN_API_KEY: "airkorea-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
},
provider: async () => {
providerCalls += 1;
return {
station_name: "강남구",
station_address: "서울 강남구 학동로 426",
lookup_mode: "fallback",
measured_at: "2026-03-27 21:00",
pm10: { value: "42", grade: "보통" },
pm25: { value: "19", grade: "보통" },
khai_grade: "보통"
};
}
});
t.after(async () => {
await app.close();
});
const request = {
method: "GET",
url: "/v1/fine-dust/report?regionHint=%EC%84%9C%EC%9A%B8%20%EA%B0%95%EB%82%A8%EA%B5%AC"
};
const first = await app.inject(request);
const second = await app.inject(request);
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(providerCalls, 1);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
});
test("proxyAirKoreaRequest injects serviceKey and preserves caller query params", async () => {
let calledUrl;
const result = await proxyAirKoreaRequest({
service: "ArpltnInforInqireSvc",
operation: "getMsrstnAcctoRltmMesureDnsty",
query: {
returnType: "json",
stationName: "강남구",
dataTerm: "DAILY",
ver: "1.4"
},
serviceKey: "test-service-key",
fetchImpl: async (url) => {
calledUrl = String(url);
return new Response('{"ok":true}', {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
}
});
assert.equal(result.statusCode, 200);
assert.match(calledUrl, /\/B552584\/ArpltnInforInqireSvc\/getMsrstnAcctoRltmMesureDnsty\?/);
assert.match(calledUrl, /stationName=%EA%B0%95%EB%82%A8%EA%B5%AC/);
assert.match(calledUrl, /serviceKey=test-service-key/);
});
test("public AirKorea passthrough route forwards allowed upstream responses", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () =>
new Response('{"response":{"header":{"resultCode":"00"}}}', {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
const app = buildServer({
env: {
AIR_KOREA_OPEN_API_KEY: "airkorea-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/B552584/ArpltnInforInqireSvc/getMsrstnAcctoRltmMesureDnsty?returnType=json&stationName=%EA%B0%95%EB%82%A8%EA%B5%AC&dataTerm=DAILY&ver=1.4"
});
assert.equal(response.statusCode, 200);
assert.match(response.body, /resultCode/);
});
test("seoul subway endpoint caches successful upstream responses for normalized queries", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
global.fetch = async () => {
fetchCalls += 1;
return new Response(
JSON.stringify({
errorMessage: {
status: 200,
code: "INFO-000",
message: "정상 처리되었습니다."
},
realtimeArrivalList: [
{
statnNm: "강남",
trainLineNm: "2호선",
updnLine: "내선",
arvlMsg2: "전역 출발",
arvlMsg3: "역삼",
barvlDt: "60"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
};
const app = buildServer({
env: {
SEOUL_OPEN_API_KEY: "seoul-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/seoul-subway/arrival?station=%EA%B0%95%EB%82%A8&start_index=0&end_index=8"
});
const second = await app.inject({
method: "GET",
url: "/v1/seoul-subway/arrival?stationName=%EA%B0%95%EB%82%A8"
});
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(fetchCalls, 1);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
});
test("seoul subway endpoint stays publicly callable without proxy auth", async (t) => {
const originalFetch = global.fetch;
let calledUrl;
global.fetch = async (url) => {
calledUrl = String(url);
return new Response(
JSON.stringify({
errorMessage: {
status: 200,
code: "INFO-000",
message: "정상 처리되었습니다."
},
realtimeArrivalList: [
{
statnNm: "강남",
trainLineNm: "2호선",
updnLine: "내선",
arvlMsg2: "전역 출발",
arvlMsg3: "역삼",
barvlDt: "60"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
};
const app = buildServer({
env: {
SEOUL_OPEN_API_KEY: "seoul-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/seoul-subway/arrival?stationName=%EA%B0%95%EB%82%A8"
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().realtimeArrivalList[0].statnNm, "강남");
assert.match(calledUrl, /realtimeStationArrival\/0\/8\/%EA%B0%95%EB%82%A8$/);
});
test("seoul subway endpoint returns 503 when proxy server lacks Seoul API key", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/seoul-subway/arrival?stationName=%EA%B0%95%EB%82%A8"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("proxySeoulSubwayRequest injects API key and preserves index/station params", async () => {
let calledUrl;
const result = await proxySeoulSubwayRequest({
stationName: "강남",
startIndex: "2",
endIndex: "5",
apiKey: "test-seoul-key",
fetchImpl: async (url) => {
calledUrl = String(url);
return new Response('{"ok":true}', {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
}
});
assert.equal(result.statusCode, 200);
assert.match(calledUrl, /\/api\/subway\/test-seoul-key\/json\/realtimeStationArrival\/2\/5\/%EA%B0%95%EB%82%A8$/);
});
test("korea weather endpoint caches successful upstream responses for normalized coordinate queries", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
global.fetch = async (url) => {
fetchCalls += 1;
assert.match(String(url), /getVilageFcst/);
assert.match(String(url), /base_date=20260405/);
assert.match(String(url), /base_time=0500/);
assert.match(String(url), /nx=60/);
assert.match(String(url), /ny=127/);
return new Response(
JSON.stringify({
response: {
header: {
resultCode: "00",
resultMsg: "NORMAL_SERVICE"
},
body: {
dataType: "JSON",
items: {
item: [
{
baseDate: "20260405",
baseTime: "0500",
category: "TMP",
fcstDate: "20260405",
fcstTime: "0600",
fcstValue: "14",
nx: 60,
ny: 127
}
]
}
}
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
};
const app = buildServer({
env: {
KMA_OPEN_API_KEY: "kma-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
},
now: () => new Date("2026-04-05T06:30:00+09:00")
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/korea-weather/forecast?lat=37.5665&lon=126.978"
});
const second = await app.inject({
method: "GET",
url: "/v1/korea-weather/forecast?nx=60&ny=127&baseDate=20260405&baseTime=0500"
});
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(fetchCalls, 1);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.deepEqual(first.json().query, {
baseDate: "20260405",
baseTime: "0500",
nx: 60,
ny: 127,
pageNo: 1,
numOfRows: 1000,
dataType: "JSON"
});
});
test("korea weather endpoint stays publicly callable without proxy auth", async (t) => {
const originalFetch = global.fetch;
let calledUrl;
global.fetch = async (url) => {
calledUrl = String(url);
return new Response(
JSON.stringify({
response: {
header: {
resultCode: "00",
resultMsg: "NORMAL_SERVICE"
},
body: {
dataType: "JSON",
items: {
item: []
}
}
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
};
const app = buildServer({
env: {
KMA_OPEN_API_KEY: "kma-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korea-weather/forecast?nx=60&ny=127&baseDate=20260405&baseTime=0500"
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().response.header.resultCode, "00");
assert.ok(calledUrl.startsWith("https://apis.data.go.kr/"));
assert.match(calledUrl, /serviceKey=kma-key/);
assert.match(calledUrl, /base_date=20260405/);
assert.match(calledUrl, /base_time=0500/);
assert.match(calledUrl, /nx=60/);
assert.match(calledUrl, /ny=127/);
});
test("korea weather endpoint rejects out-of-range coordinates before reaching upstream", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
global.fetch = async () => {
fetchCalls += 1;
throw new Error("fetch should not be called for invalid coordinates");
};
const app = buildServer({
env: {
KMA_OPEN_API_KEY: "kma-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korea-weather/forecast?lat=91&lon=126.978"
});
assert.equal(response.statusCode, 400);
assert.deepEqual(response.json(), {
error: "bad_request",
message: "Provide valid lat and lon."
});
assert.equal(fetchCalls, 0);
});
test("korea weather endpoint returns 503 when proxy server lacks KMA API key", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korea-weather/forecast?nx=60&ny=127"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("proxyKmaWeatherRequest injects API key and preserves caller query params", async () => {
let calledUrl;
const result = await proxyKmaWeatherRequest({
baseDate: "20260405",
baseTime: "0500",
nx: 60,
ny: 127,
pageNo: 2,
numOfRows: 50,
dataType: "JSON",
apiKey: "test-kma-key",
fetchImpl: async (url) => {
calledUrl = String(url);
return new Response('{"ok":true}', {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
}
});
assert.equal(result.statusCode, 200);
assert.ok(calledUrl.startsWith("https://apis.data.go.kr/"));
assert.match(calledUrl, /\/1360000\/VilageFcstInfoService_2\.0\/getVilageFcst\?/);
assert.match(calledUrl, /serviceKey=test-kma-key/);
assert.match(calledUrl, /base_date=20260405/);
assert.match(calledUrl, /base_time=0500/);
assert.match(calledUrl, /nx=60/);
assert.match(calledUrl, /ny=127/);
assert.match(calledUrl, /pageNo=2/);
assert.match(calledUrl, /numOfRows=50/);
assert.match(calledUrl, /dataType=JSON/);
});
test("han river water-level endpoint stays publicly callable without proxy auth", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.endsWith("/waterlevel/info.json")) {
return new Response(
JSON.stringify({
content: [
{
wlobscd: "1018683",
obsnm: "한강대교",
agcnm: "한강홍수통제소",
addr: "서울특별시 용산구",
etcaddr: "한강대교",
attwl: "5.5",
wrnwl: "8.0",
almwl: "10.0",
srswl: "11.0",
pfh: "13.0",
fstnyn: "Y"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("/waterlevel/list/10M/1018683.json")) {
return new Response(
JSON.stringify({
content: [
{
wlobscd: "1018683",
ymdhm: "202604051900",
wl: "0.66",
fw: "208.58"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
HRFCO_OPEN_API_KEY: "hrfco-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/han-river/water-level?stationName=%ED%95%9C%EA%B0%95%EB%8C%80%EA%B5%90"
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().station_name, "한강대교");
assert.equal(response.json().water_level.value_m, 0.66);
assert.equal(response.json().flow_rate.value_cms, 208.58);
assert.equal(response.json().proxy.cache.hit, false);
assert.match(fetchCalls[0], /\/waterlevel\/info\.json$/);
assert.match(fetchCalls[1], /\/waterlevel\/list\/10M\/1018683\.json$/);
});
test("han river water-level endpoint caches normalized station queries", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
global.fetch = async (url) => {
fetchCalls += 1;
const text = String(url);
if (text.endsWith("/waterlevel/info.json")) {
return new Response(
JSON.stringify({
content: [
{
wlobscd: "1018683",
obsnm: "한강대교",
agcnm: "한강홍수통제소",
addr: "서울특별시 용산구",
etcaddr: "한강대교"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("/waterlevel/list/10M/1018683.json")) {
return new Response(
JSON.stringify({
content: [
{
wlobscd: "1018683",
ymdhm: "202604051900",
wl: "0.66",
fw: "208.58"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
HRFCO_OPEN_API_KEY: "hrfco-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/han-river/water-level?station=%20%ED%95%9C%EA%B0%95%EB%8C%80%EA%B5%90%20"
});
const second = await app.inject({
method: "GET",
url: "/v1/han-river/water-level?stationName=%ED%95%9C%EA%B0%95%EB%8C%80%EA%B5%90"
});
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(fetchCalls, 2);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
});
test("han river water-level endpoint returns ambiguous candidates for broad station names", async (t) => {
const originalFetch = global.fetch;
global.fetch = async (url) => {
const text = String(url);
if (text.endsWith("/waterlevel/info.json")) {
return new Response(
JSON.stringify({
content: [
{ wlobscd: "1018683", obsnm: "한강대교", agcnm: "한강홍수통제소" },
{ wlobscd: "1018680", obsnm: "한강철교", agcnm: "한강홍수통제소" }
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
HRFCO_OPEN_API_KEY: "hrfco-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/han-river/water-level?stationName=%ED%95%9C%EA%B0%95"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "ambiguous_station");
assert.deepEqual(response.json().candidate_stations, ["한강대교", "한강철교"]);
});
test("han river water-level endpoint returns 503 when proxy server lacks HRFCO API key", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/han-river/water-level?stationName=%ED%95%9C%EA%B0%95%EB%8C%80%EA%B5%90"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("proxyHrfcoWaterLevelRequest injects API key and resolves station code path", async () => {
let calledUrls = [];
const result = await proxyHrfcoWaterLevelRequest({
stationName: "한강대교",
apiKey: "test-hrfco-key",
fetchImpl: async (url) => {
calledUrls.push(String(url));
if (String(url).endsWith("/waterlevel/info.json")) {
return new Response(
JSON.stringify({
content: [
{ wlobscd: "1018683", obsnm: "한강대교", agcnm: "한강홍수통제소" }
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
return new Response(
JSON.stringify({
content: [
{ wlobscd: "1018683", ymdhm: "202604051900", wl: "0.66", fw: "208.58" }
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
});
assert.equal(result.statusCode, 200);
assert.equal(JSON.parse(result.body).station_code, "1018683");
assert.match(calledUrls[0], /\/test-hrfco-key\/waterlevel\/info\.json$/);
assert.match(calledUrls[1], /\/test-hrfco-key\/waterlevel\/list\/10M\/1018683\.json$/);
});
const SAMPLE_APT_TRADE_XML = `<?xml version="1.0" encoding="UTF-8"?>
<response>
<header><resultCode>000</resultCode><resultMsg>NORMAL SERVICE.</resultMsg></header>
<body>
<items>
<item>
<aptNm>래미안</aptNm><umdNm>반포동</umdNm><excluUseAr>84.99</excluUseAr>
<floor>12</floor><dealAmount> 245,000</dealAmount>
<dealYear>2024</dealYear><dealMonth>3</dealMonth><dealDay>15</dealDay>
<buildYear>2009</buildYear><dealingGbn>중개거래</dealingGbn><cdealType></cdealType>
</item>
</items>
<totalCount>1</totalCount>
</body>
</response>`;
const SAMPLE_KRX_BASE_INFO = {
OutBlock_1: [
{
ISU_CD: "KR7005930003",
ISU_SRT_CD: "005930",
ISU_NM: "삼성전자",
ISU_ABBRV: "삼성전자",
ISU_ENG_NM: "Samsung Electronics",
LIST_DD: "19750611",
MKT_TP_NM: "KOSPI",
SECUGRP_NM: "주권",
SECT_TP_NM: "전기전자",
KIND_STKCERT_TP_NM: "보통주",
PARVAL: "100",
LIST_SHRS: "5,919,638,922"
}
]
};
const SAMPLE_KRX_TRADE_INFO = {
OutBlock_1: [
{
BAS_DD: "20260404",
ISU_CD: "KR7005930003",
ISU_SRT_CD: "005930",
ISU_NM: "삼성전자",
MKT_NM: "KOSPI",
SECT_TP_NM: "전기전자",
TDD_CLSPRC: "85,000",
CMPPREVDD_PRC: "1,200",
FLUC_RT: "1.43",
TDD_OPNPRC: "84,100",
TDD_HGPRC: "85,400",
TDD_LWPRC: "83,900",
ACC_TRDVOL: "12,345,678",
ACC_TRDVAL: "1,045,678,900,000",
MKTCAP: "503,169,308,370,000",
LIST_SHRS: "5,919,638,922"
}
]
};
test("real estate region-code endpoint returns matching codes", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/real-estate/region-code?q=%EA%B0%95%EB%82%A8%EA%B5%AC"
});
assert.equal(response.statusCode, 200);
const body = response.json();
assert.ok(body.results.length > 0);
assert.ok(body.results.some((r) => r.lawd_cd === "11680"));
assert.equal(body.proxy.cache.hit, false);
});
test("real estate region-code endpoint returns 400 for missing query", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/real-estate/region-code"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
});
test("real estate transaction endpoint returns 503 without API key", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/real-estate/apartment/trade?lawd_cd=11680&deal_ymd=202403"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("real estate transaction endpoint returns 404 for invalid asset type", async (t) => {
const app = buildServer({
env: { DATA_GO_KR_API_KEY: "test-key" }
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/real-estate/mansion/trade?lawd_cd=11680&deal_ymd=202403"
});
assert.equal(response.statusCode, 404);
});
test("real estate transaction endpoint returns 404 for commercial/rent", async (t) => {
const app = buildServer({
env: { DATA_GO_KR_API_KEY: "test-key" }
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/real-estate/commercial/rent?lawd_cd=11680&deal_ymd=202403"
});
assert.equal(response.statusCode, 404);
});
test("real estate transaction endpoint returns 400 for invalid lawd_cd", async (t) => {
const app = buildServer({
env: { DATA_GO_KR_API_KEY: "test-key" }
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/real-estate/apartment/trade?lawd_cd=abc&deal_ymd=202403"
});
assert.equal(response.statusCode, 400);
});
test("real estate transaction endpoint fetches and returns parsed data", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => {
return new Response(SAMPLE_APT_TRADE_XML, {
status: 200,
headers: { "content-type": "text/xml;charset=UTF-8" }
});
};
const app = buildServer({
env: { DATA_GO_KR_API_KEY: "test-key" }
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/real-estate/apartment/trade?lawd_cd=11680&deal_ymd=202403"
});
assert.equal(response.statusCode, 200);
const body = response.json();
assert.equal(body.items.length, 1);
assert.equal(body.items[0].name, "래미안");
assert.equal(body.items[0].price_10k, 245000);
assert.equal(body.query.asset_type, "apartment");
assert.equal(body.query.deal_type, "trade");
assert.equal(body.proxy.cache.hit, false);
});
test("real estate transaction endpoint caches successful responses", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
global.fetch = async () => {
fetchCalls += 1;
return new Response(SAMPLE_APT_TRADE_XML, {
status: 200,
headers: { "content-type": "text/xml;charset=UTF-8" }
});
};
const app = buildServer({
env: {
DATA_GO_KR_API_KEY: "test-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/real-estate/apartment/trade?lawd_cd=11680&deal_ymd=202403"
});
const second = await app.inject({
method: "GET",
url: "/v1/real-estate/apartment/trade?lawd_cd=11680&deal_ymd=202403"
});
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(fetchCalls, 1);
});
test("health endpoint reports molitConfigured status", async (t) => {
const app = buildServer({
env: { DATA_GO_KR_API_KEY: "test-key" }
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/health"
});
assert.equal(response.json().upstreams.molitConfigured, true);
});
test("health endpoint reports foodsafetyKoreaConfigured when FOODSAFETYKOREA_API_KEY is set", async (t) => {
const app = buildServer({
env: { FOODSAFETYKOREA_API_KEY: "food-key" }
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/health"
});
assert.equal(response.json().upstreams.foodsafetyKoreaConfigured, true);
});
const SAMPLE_NEIS_MEAL_JSON = JSON.stringify({
mealServiceDietInfo: [
{
head: [{ LIST_TOTAL_COUNT: 1 }]
},
{
row: [
{
ATPT_OFCDC_SC_CODE: "J10",
SD_SCHUL_CODE: "1234567",
MLSV_YMD: "20260410",
MMEAL_SC_CODE: "2",
DDISH_NM: "밥<br/>국"
}
]
}
]
});
test("neis school-meal endpoint returns 503 without KEDU_INFO_KEY", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/neis/school-meal?educationOfficeCode=J10&schoolCode=1234567&mealDate=20260410"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("neis school-meal endpoint returns 400 when mealDate is invalid", async (t) => {
const app = buildServer({
env: { KEDU_INFO_KEY: "test-key" }
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/neis/school-meal?educationOfficeCode=J10&schoolCode=1234567&mealDate=2026041"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
});
test("neis school-meal endpoint proxies NEIS JSON and caches", async (t) => {
const originalFetch = global.fetch;
let fetchedUrl = "";
let fetchCalls = 0;
global.fetch = async (url) => {
fetchCalls += 1;
fetchedUrl = String(url);
return new Response(SAMPLE_NEIS_MEAL_JSON, {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
};
const app = buildServer({
env: {
KEDU_INFO_KEY: "neis-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/neis/school-meal?educationOfficeCode=J10&schoolCode=1234567&mealDate=2026-04-10&mealKindCode=2"
});
const second = await app.inject({
method: "GET",
url: "/v1/neis/school-meal?educationOfficeCode=J10&schoolCode=1234567&mealDate=2026-04-10&mealKindCode=2"
});
assert.equal(first.statusCode, 200);
assert.equal(first.json().mealServiceDietInfo[1].row[0].DDISH_NM, "밥<br/>국");
assert.equal(first.json().query.meal_date, "20260410");
assert.equal(first.json().query.meal_kind_code, "2");
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(fetchCalls, 1);
assert.ok(fetchedUrl.includes("open.neis.go.kr/hub/mealServiceDietInfo"));
assert.ok(fetchedUrl.includes("KEY=neis-key"));
assert.ok(fetchedUrl.includes("ATPT_OFCDC_SC_CODE=J10"));
assert.ok(fetchedUrl.includes("SD_SCHUL_CODE=1234567"));
assert.ok(fetchedUrl.includes("MLSV_YMD=20260410"));
assert.ok(fetchedUrl.includes("MMEAL_SC_CODE=2"));
});
test("health endpoint reports neisSchoolMealConfigured when KEDU_INFO_KEY is set", async (t) => {
const app = buildServer({
env: { KEDU_INFO_KEY: "x" }
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/health"
});
assert.equal(response.json().upstreams.neisSchoolMealConfigured, true);
});
test("resolveEducationOfficeFromNaturalLanguage maps Seoul office phrases to B10", () => {
const a = resolveEducationOfficeFromNaturalLanguage("서울특별시교육청");
assert.equal(a.ok, true);
assert.equal(a.code, "B10");
const b = resolveEducationOfficeFromNaturalLanguage("B10");
assert.equal(b.ok, true);
assert.equal(b.code, "B10");
});
test("resolveEducationOfficeFromNaturalLanguage returns ambiguous for 경상", () => {
const r = resolveEducationOfficeFromNaturalLanguage("경상");
assert.equal(r.ok, false);
assert.equal(r.reason, "ambiguous");
});
const SAMPLE_NEIS_SCHOOL_JSON = JSON.stringify({
schoolInfo: [
{ head: [{ LIST_TOTAL_COUNT: 1 }] },
{
row: [
{
ATPT_OFCDC_SC_CODE: "B10",
SD_SCHUL_CODE: "7010123",
SCHUL_NM: "서울미래초등학교",
ORG_RDNMA: "서울특별시 …"
}
]
}
]
});
test("neis school-search returns 400 without schoolName", async (t) => {
const app = buildServer({ env: { KEDU_INFO_KEY: "k" } });
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: `/v1/neis/school-search?educationOffice=${encodeURIComponent("서울특별시교육청")}`
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
});
test("neis school-search returns ambiguous_education_office for 경상", async (t) => {
const app = buildServer({ env: { KEDU_INFO_KEY: "k" } });
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: `/v1/neis/school-search?educationOffice=${encodeURIComponent("경상")}&schoolName=${encodeURIComponent("중학교")}`
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "ambiguous_education_office");
assert.ok(Array.isArray(response.json().candidate_codes));
});
test("neis school-search proxies schoolInfo and resolves 교육청 이름", async (t) => {
const originalFetch = global.fetch;
let fetchedUrl = "";
let fetchCalls = 0;
global.fetch = async (url) => {
fetchCalls += 1;
fetchedUrl = String(url);
return new Response(SAMPLE_NEIS_SCHOOL_JSON, {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
};
const app = buildServer({
env: {
KEDU_INFO_KEY: "neis-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const edu = encodeURIComponent("서울특별시교육청");
const school = encodeURIComponent("미래초등학교");
const first = await app.inject({
method: "GET",
url: `/v1/neis/school-search?educationOffice=${edu}&schoolName=${school}`
});
const second = await app.inject({
method: "GET",
url: `/v1/neis/school-search?educationOffice=${edu}&schoolName=${school}`
});
assert.equal(first.statusCode, 200);
assert.equal(first.json().schoolInfo[1].row[0].SCHUL_NM, "서울미래초등학교");
assert.equal(first.json().resolved_education_office.atpt_ofcdc_sc_code, "B10");
assert.equal(first.json().resolved_education_office.input, "서울특별시교육청");
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(fetchCalls, 1);
assert.ok(fetchedUrl.includes("open.neis.go.kr/hub/schoolInfo"));
assert.ok(fetchedUrl.includes("ATPT_OFCDC_SC_CODE=B10"));
assert.ok(fetchedUrl.includes("SCHUL_NM"));
assert.ok(decodeURIComponent(fetchedUrl).includes("미래초등학교"));
});
test("neis school-search maps rejected upstream fetches to a 502 proxy error", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => {
throw new Error("boom");
};
const app = buildServer({ env: { KEDU_INFO_KEY: "k" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: `/v1/neis/school-search?educationOffice=${encodeURIComponent("서울특별시교육청")}&schoolName=${encodeURIComponent("미래초등학교")}`
});
assert.equal(response.statusCode, 502);
assert.deepEqual(response.json(), {
error: "proxy_error",
message: "boom"
});
});
test("neis school-meal maps rejected upstream fetches to a 502 proxy error", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => {
throw new Error("boom");
};
const app = buildServer({ env: { KEDU_INFO_KEY: "k" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/neis/school-meal?educationOfficeCode=B10&schoolCode=7010123&mealDate=20260410"
});
assert.equal(response.statusCode, 502);
assert.deepEqual(response.json(), {
error: "proxy_error",
message: "boom"
});
});
test("household waste info endpoint requires SGG_NM filter", async (t) => {
const app = buildServer({
env: { DATA_GO_KR_API_KEY: "test-key" }
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/household-waste/info"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
});
test("household waste info endpoint reports 503 when DATA_GO_KR_API_KEY is missing", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC&pageNo=1&numOfRows=100"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("household waste info endpoint requires pageNo and numOfRows with cond", async (t) => {
const app = buildServer({
env: { DATA_GO_KR_API_KEY: "test-key" }
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
});
test("household waste info endpoint injects serviceKey, forces returnType=json, and caches", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
fetchCalls.push(String(url));
return new Response(
JSON.stringify({
response: {
body: {
items: [
{
SGG_NM: "강남구",
MNG_ZONE_NM: "역삼1동",
EMSN_PLC: "지정장소",
LF_WST_EMSN_DOW: "월,수,금",
LF_WST_EMSN_BGNG_TM: "18:00",
LF_WST_EMSN_END_TM: "23:00"
}
]
}
}
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
};
const app = buildServer({
env: {
DATA_GO_KR_API_KEY: "test-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const url =
"/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC&pageNo=1&numOfRows=100";
const first = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 200);
const firstBody = first.json();
assert.equal(firstBody.proxy.cache.hit, false);
assert.equal(firstBody.query.sgg_nm, "강남구");
assert.equal(firstBody.query.page_no, "1");
assert.equal(firstBody.query.num_of_rows, "100");
assert.equal(firstBody.response.body.items[0].SGG_NM, "강남구");
assert.equal(fetchCalls.length, 1);
const upstream = new URL(fetchCalls[0]);
assert.equal(upstream.origin + upstream.pathname, "https://apis.data.go.kr/1741000/household_waste_info/info");
assert.equal(upstream.searchParams.get("serviceKey"), "test-key");
assert.equal(upstream.searchParams.get("returnType"), "json");
assert.equal(upstream.searchParams.get("pageNo"), "1");
assert.equal(upstream.searchParams.get("numOfRows"), "100");
assert.equal(upstream.searchParams.get("cond[SGG_NM::LIKE]"), "강남구");
const second = await app.inject({ method: "GET", url });
assert.equal(second.statusCode, 200);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(fetchCalls.length, 1);
});
test("household waste info endpoint rejects user-supplied pageNo and numOfRows when not 1 and 100", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
global.fetch = async () => {
fetchCalls += 1;
return new Response(JSON.stringify({ response: { body: { items: [] } } }), {
status: 200,
headers: { "content-type": "application/json" }
});
};
const app = buildServer({
env: { DATA_GO_KR_API_KEY: "test-key" }
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC&pageNo=99&numOfRows=5"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
assert.equal(fetchCalls, 0);
});
test("household waste info endpoint accepts explicit pageNo=1 and numOfRows=100", async (t) => {
const originalFetch = global.fetch;
let capturedUrl = "";
global.fetch = async (url) => {
capturedUrl = String(url);
return new Response(JSON.stringify({ response: { body: { items: [] } } }), {
status: 200,
headers: { "content-type": "application/json" }
});
};
const app = buildServer({
env: { DATA_GO_KR_API_KEY: "test-key" }
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC&pageNo=1&numOfRows=100"
});
assert.equal(response.statusCode, 200);
const u = new URL(capturedUrl);
assert.equal(u.searchParams.get("pageNo"), "1");
assert.equal(u.searchParams.get("numOfRows"), "100");
});
test("household waste info endpoint rejects non-integer pageNo", async (t) => {
const app = buildServer({
env: { DATA_GO_KR_API_KEY: "test-key" }
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC&pageNo=abc&numOfRows=100"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
});
test("household waste info endpoint ignores user-supplied returnType override", async (t) => {
const originalFetch = global.fetch;
let capturedUrl = "";
global.fetch = async (url) => {
capturedUrl = String(url);
return new Response(JSON.stringify({ response: { body: { items: [] } } }), {
status: 200,
headers: { "content-type": "application/json" }
});
};
const app = buildServer({
env: { DATA_GO_KR_API_KEY: "test-key" }
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EC%88%98%EC%9B%90%EC%8B%9C&pageNo=1&numOfRows=100&returnType=xml"
});
assert.equal(response.statusCode, 200);
assert.equal(new URL(capturedUrl).searchParams.get("returnType"), "json");
});
test("household waste info endpoint surfaces upstream non-200 as 502", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => new Response("oops", { status: 500 });
const app = buildServer({
env: { DATA_GO_KR_API_KEY: "test-key" }
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC&pageNo=1&numOfRows=100"
});
assert.equal(response.statusCode, 502);
assert.equal(response.json().error, "upstream_error");
});
test("mfds drug-safety lookup endpoint returns 503 when DATA_GO_KR_API_KEY is missing", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/mfds/drug-safety/lookup?itemName=%ED%83%80%EC%9D%B4%EB%A0%88%EB%86%80"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("mfds drug-safety lookup endpoint proxies official drug surfaces and caches", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.includes("DrbEasyDrugInfoService/getDrbEasyDrugList")) {
return new Response(
JSON.stringify({
body: {
items: [
{
itemName: "타이레놀정160밀리그램",
entpName: "한국얀센",
efcyQesitm: "감기로 인한 발열 및 동통에 사용합니다.",
intrcQesitm: "다른 해열진통제와 함께 복용하지 마십시오."
}
]
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("SafeStadDrugService/getSafeStadDrugInq")) {
return new Response(
JSON.stringify({
body: {
items: [
{
PRDLST_NM: "판콜에스내복액",
BSSH_NM: "동화약품",
EFCY_QESITM: "감기 증상 완화",
INTRC_QESITM: "다른 감기약과 병용 주의"
}
]
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
DATA_GO_KR_API_KEY: "test-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const url =
"/v1/mfds/drug-safety/lookup?itemName=%ED%83%80%EC%9D%B4%EB%A0%88%EB%86%80&itemName=%ED%8C%90%EC%BD%9C&limit=5";
const first = await app.inject({ method: "GET", url });
const second = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 200);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(fetchCalls.length, 4);
assert.equal(first.json().query.item_names[0], "타이레놀");
assert.equal(first.json().items[0].source, "drug_easy_info");
assert.equal(first.json().items[1].source, "safe_standby_medicine");
assert.ok(fetchCalls.every((entry) => entry.includes("test-key")));
});
test("mfds drug-safety lookup endpoint still parses legacy body.items.item shape", async (t) => {
const originalFetch = global.fetch;
global.fetch = async (url) => {
const text = String(url);
if (text.includes("DrbEasyDrugInfoService/getDrbEasyDrugList")) {
return new Response(
JSON.stringify({
body: {
items: {
item: [
{
itemName: "레거시타이레놀",
entpName: "레거시얀센"
}
]
}
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("SafeStadDrugService/getSafeStadDrugInq")) {
return new Response(
JSON.stringify({
body: {
items: {
item: {
PRDLST_NM: "레거시판콜",
BSSH_NM: "레거시동화"
}
}
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
DATA_GO_KR_API_KEY: "legacy-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/mfds/drug-safety/lookup?itemName=%ED%83%80%EC%9D%B4%EB%A0%88%EB%86%80&limit=5"
});
assert.equal(response.statusCode, 200);
const items = response.json().items;
assert.equal(items.length, 2);
assert.equal(items[0].source, "drug_easy_info");
assert.equal(items[0].item_name, "레거시타이레놀");
assert.equal(items[1].source, "safe_standby_medicine");
assert.equal(items[1].item_name, "레거시판콜");
});
test("mfds food-safety search endpoint requires query", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/mfds/food-safety/search"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
});
test("mfds food-safety search endpoint uses sample recall fallback without proxy secrets and caches", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.includes("openapi.foodsafetykorea.go.kr/api/sample/I0490/json/1/50")) {
return new Response(
JSON.stringify({
I0490: {
row: [
{
PRDLST_NM: "맛있는김밥",
BSSH_NM: "예시식품",
RTRVLPRVNS: "대장균 기준 규격 부적합"
}
]
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const url = "/v1/mfds/food-safety/search?query=%EA%B9%80%EB%B0%A5&limit=5";
const first = await app.inject({ method: "GET", url });
const second = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 200);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(fetchCalls.length, 1);
assert.equal(first.json().items[0].source, "foodsafetykorea_recall");
assert.match(first.json().warnings.join(" "), /sample feed/);
});
test("mfds food-safety search endpoint uses live recall key when configured", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.includes("PrsecImproptFoodInfoService03/getPrsecImproptFoodList01")) {
return new Response(
JSON.stringify({
body: {
items: [
{
item: {
PRDUCT: "예시 유부초밥",
ENTRPS: "예시푸드",
IMPROPT_ITM: "황색포도상구균",
INSPCT_RESULT: "기준 부적합"
}
}
]
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("openapi.foodsafetykorea.go.kr/api/live-food-key/I0490/json/1/50")) {
return new Response(
JSON.stringify({
I0490: {
row: [
{
PRDLST_NM: "예시 유부초밥",
BSSH_NM: "예시푸드",
RTRVLPRVNS: "회수 조치"
}
]
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
DATA_GO_KR_API_KEY: "data-go-key",
FOODSAFETYKOREA_API_KEY: "live-food-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/mfds/food-safety/search?query=%EC%9C%A0%EB%B6%80%EC%B4%88%EB%B0%A5&limit=5"
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().items[0].product_name, "예시 유부초밥");
assert.ok(fetchCalls.some((entry) => entry.includes("data-go-key")));
assert.ok(fetchCalls.some((entry) => entry.includes("live-food-key")));
assert.doesNotMatch(response.json().warnings.join(" "), /sample feed/);
});
test("mfds health-food-ingredient endpoint requires query", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/mfds/food-safety/health-food-ingredient"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
});
test("mfds health-food-ingredient endpoint uses sample fallback without key and caches", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.includes("openapi.foodsafetykorea.go.kr/api/sample/I-0040/json/1/")) {
return new Response(
JSON.stringify({
"I-0040": {
row: [
{
APLC_RAWMTRL_NM: "차전자피식이섬유",
HF_FNCLTY_MTRAL_RCOGN_NO: "2010-42",
FNCLTY_CN: "배변활동 원활에 도움",
DAY_INTK_CN: "5.4g/일",
IFTKN_ATNT_MATR_CN: "충분한 물과 함께 섭취",
BSSH_NM: "예시바이오",
PRMS_DT: "20100315"
}
]
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("openapi.foodsafetykorea.go.kr/api/sample/I-0050/json/1/")) {
return new Response(
JSON.stringify({ "I-0050": { total_count: "0", row: [] } }),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const url = "/v1/mfds/food-safety/health-food-ingredient?query=%EC%B0%A8%EC%A0%84%EC%9E%90%ED%94%BC&limit=5";
const first = await app.inject({ method: "GET", url });
const second = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 200);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(first.json().items[0].source, "foodsafetykorea_health_food_ingredient");
assert.equal(first.json().items[0].ingredient_name, "차전자피식이섬유");
assert.match((first.json().warnings || []).join(" "), /sample feed/);
});
test("mfds health-food-ingredient endpoint uses live key when configured", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.includes("openapi.foodsafetykorea.go.kr/api/live-food-key/I-0040/json/1/")) {
return new Response(
JSON.stringify({
"I-0040": {
row: [
{
APLC_RAWMTRL_NM: "차전자피",
HF_FNCLTY_MTRAL_RCOGN_NO: "2010-42",
FNCLTY_CN: "배변활동 원활",
DAY_INTK_CN: "5g/일",
IFTKN_ATNT_MATR_CN: "의사 상담 권장",
BSSH_NM: "예시바이오",
PRMS_DT: "20100315"
}
]
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("openapi.foodsafetykorea.go.kr/api/live-food-key/I-0050/json/1/")) {
return new Response(
JSON.stringify({ "I-0050": { total_count: "0", row: [] } }),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
FOODSAFETYKOREA_API_KEY: "live-food-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/mfds/food-safety/health-food-ingredient?query=%EC%B0%A8%EC%A0%84%EC%9E%90%ED%94%BC&limit=5"
});
assert.equal(response.statusCode, 200);
assert.ok(fetchCalls.some((entry) => entry.includes("live-food-key")));
assert.ok(!response.json().warnings || !response.json().warnings.some((w) => /sample feed/.test(w)));
});
test("mfds inspection-fail endpoint requires query", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/mfds/food-safety/inspection-fail"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
});
test("mfds inspection-fail endpoint uses sample fallback without key and caches", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.includes("openapi.foodsafetykorea.go.kr/api/sample/I2620/json/1/")) {
return new Response(
JSON.stringify({
I2620: {
row: [
{
PRDTNM: "쪽갓",
PRDLST_CD_NM: "쪽갓",
BSSHNM: "박*영",
ADDR: "경기도 고양시",
TEST_ITMNM: "다이아지논",
STDR_STND: "0.01 mg/kg이하(PLS)",
TESTANALS_RSLT: "1.62 mg/kg",
CRET_DTM: "2025.08.27",
INSTT_NM: "강남농수산물검사소"
}
]
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const url = "/v1/mfds/food-safety/inspection-fail?query=%EC%AA%BD%EA%B0%93&limit=5";
const first = await app.inject({ method: "GET", url });
const second = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 200);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(first.json().items[0].source, "foodsafetykorea_inspection_fail");
assert.equal(first.json().items[0].product_name, "쪽갓");
assert.match((first.json().warnings || []).join(" "), /sample feed/);
});
test("mfds product-report endpoint requires query", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/mfds/food-safety/product-report"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
});
test("mfds product-report endpoint uses sample fallback without key and caches", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.includes("openapi.foodsafetykorea.go.kr/api/sample/I0030/json/1/")) {
return new Response(
JSON.stringify({
I0030: {
row: [
{
PRDLST_REPORT_NO: "20140017002183",
PRDLST_NM: "차전자피 다이어트",
BSSH_NM: "예시건강(주)",
RAWMTRL_NM: "차전자피식이섬유",
PRIMARY_FNCLTY: "배변활동 원활에 도움",
IFTKN_ATNT_MATR_CN: "충분한 물과 함께 섭취",
STDR_STND: "식이섬유: 표시량의 80% 이상",
PRDT_SHAP_CD_NM: "분말",
POG_DAYCNT: "제조일로부터 24개월",
PRMS_DT: "20200101"
}
]
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const url = "/v1/mfds/food-safety/product-report?query=%EC%B0%A8%EC%A0%84%EC%9E%90%ED%94%BC&limit=5";
const first = await app.inject({ method: "GET", url });
const second = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 200);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(first.json().items[0].source, "foodsafetykorea_product_report");
assert.equal(first.json().items[0].product_name, "차전자피 다이어트");
assert.equal(first.json().items[0].raw_materials, "차전자피식이섬유");
assert.match((first.json().warnings || []).join(" "), /sample feed/);
});
test("mfds product-report endpoint uses live key with server-side filter", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.includes("openapi.foodsafetykorea.go.kr/api/live-food-key/I0030/json/1/")) {
return new Response(
JSON.stringify({
I0030: {
row: [
{
PRDLST_REPORT_NO: "20200001",
PRDLST_NM: "차전자피 솔루션",
BSSH_NM: "예시바이오",
RAWMTRL_NM: "차전자피식이섬유",
PRIMARY_FNCLTY: "배변활동 원활",
IFTKN_ATNT_MATR_CN: "의사 상담 권장",
STDR_STND: "식이섬유 80% 이상",
PRDT_SHAP_CD_NM: "분말",
POG_DAYCNT: "24개월",
PRMS_DT: "20200527"
}
]
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
FOODSAFETYKOREA_API_KEY: "live-food-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/mfds/food-safety/product-report?query=%EC%B0%A8%EC%A0%84%EC%9E%90%ED%94%BC&limit=5"
});
assert.equal(response.statusCode, 200);
assert.ok(fetchCalls.some((entry) => entry.includes("live-food-key")));
assert.ok(fetchCalls.some((entry) => entry.includes("PRDLST_NM=")));
assert.ok(fetchCalls.some((entry) => entry.includes("RAWMTRL_NM=")));
assert.ok(!response.json().warnings || !response.json().warnings.some((w) => /sample feed/.test(w)));
});
test("data4library book-search normalizes query aliases and bounds pagination", () => {
const normalized = normalizeData4LibraryBookSearchQuery({
q: " 역사 ",
page: "2",
limit: "250"
});
assert.deepEqual(normalized, {
keyword: "역사",
pageNo: 2,
pageSize: 100
});
assert.throws(
() => normalizeData4LibraryBookSearchQuery({ pageNo: "1" }),
/Provide keyword/
);
});
test("data4library remaining query normalizers preserve narrow aliases", () => {
assert.deepEqual(
normalizeData4LibraryBookDetailQuery({
isbn: "978-8971998557",
loanInfo: "y"
}),
{
isbn13: "9788971998557",
loaninfoYN: "Y"
}
);
assert.deepEqual(
normalizeData4LibraryBookDetailQuery({
isbn: "0-306-40615-2"
}),
{
isbn13: "0306406152",
loaninfoYN: "N"
}
);
assert.deepEqual(
normalizeData4LibraryBookExistsQuery({
libraryCode: "111001",
isbn13: "0-306-40615-2"
}),
{
libCode: "111001",
isbn13: "9780306406157"
}
);
assert.deepEqual(
normalizeData4LibraryBookExistsQuery({
libraryCode: "111001",
isbn13: "0-8044-2957-X"
}),
{
libCode: "111001",
isbn13: "9780804429573"
}
);
assert.deepEqual(
normalizeData4LibraryLibrariesByBookQuery({
isbn13: "9788971998557",
region: "11",
detailRegion: "11010",
page: "2",
limit: "250"
}),
{
isbn: "9788971998557",
region: "11",
pageNo: 2,
pageSize: 100,
dtl_region: "11010"
}
);
assert.deepEqual(
normalizeData4LibraryLibrarySearchQuery({
libraryCode: "111001",
region: "11",
detailRegion: "11010",
page: "3",
limit: "20"
}),
{
pageNo: 3,
pageSize: 20,
libCode: "111001",
region: "11",
dtl_region: "11010"
}
);
assert.throws(
() => normalizeData4LibraryBookDetailQuery({ isbn13: "9788971998557", loanInfo: "maybe" }),
/loaninfoYN must be Y or N/
);
assert.throws(
() => normalizeData4LibraryBookExistsQuery({ libraryCode: "lib-1", isbn13: "9788971998557" }),
/Provide valid libraryCode/
);
assert.throws(
() => normalizeData4LibraryBookExistsQuery({ libraryCode: "111001", isbn13: "0-306-40615-3" }),
/Provide valid isbn13/
);
assert.throws(
() => normalizeData4LibraryLibrariesByBookQuery({ isbn13: "9788971998557" }),
/Provide region/
);
assert.throws(
() => normalizeData4LibraryLibrarySearchQuery({ detailRegion: "seoul" }),
/Provide valid dtl_region/
);
});
test("data4library proxy helper injects authKey and strips caller auth overrides", async () => {
const fetchCalls = [];
const upstream = await proxyData4LibraryRequest({
operation: "srchBooks",
params: {
keyword: "역사",
pageNo: 1,
pageSize: 10,
authKey: "caller-key",
format: "xml"
},
authKey: "server-key",
fetchImpl: async (url) => {
fetchCalls.push(String(url));
return new Response(JSON.stringify({ response: { resultNum: 0, docs: [] } }), {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
}
});
assert.equal(upstream.statusCode, 200);
assert.equal(fetchCalls.length, 1);
const url = new URL(fetchCalls[0]);
assert.equal(url.origin + url.pathname, "https://data4library.kr/api/srchBooks");
assert.equal(url.searchParams.get("authKey"), "server-key");
assert.equal(url.searchParams.get("keyword"), "역사");
assert.equal(url.searchParams.get("format"), "json");
});
test("data4library book-search endpoint reports 503 without DATA4LIBRARY_AUTH_KEY", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/data4library/book-search?keyword=%EC%97%AD%EC%82%AC"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("data4library book-search endpoint proxies JSON and caches normalized query aliases", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.startsWith("https://data4library.kr/api/srchBooks")) {
return new Response(
JSON.stringify({
response: {
resultNum: 1,
docs: [
{
doc: {
bookname: "역사의 역사",
authors: "유시민",
publisher: "돌베개",
publication_year: "2018",
isbn13: "9788971998557"
}
}
]
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
DATA4LIBRARY_AUTH_KEY: "server-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/data4library/book-search?q=%20%EC%97%AD%EC%82%AC%20&page=1&limit=10&authKey=caller-key"
});
const second = await app.inject({
method: "GET",
url: "/v1/data4library/book-search?keyword=%EC%97%AD%EC%82%AC&pageNo=1&pageSize=10"
});
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(fetchCalls.length, 1);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(first.json().query.keyword, "역사");
const upstream = new URL(fetchCalls[0]);
assert.equal(upstream.searchParams.get("authKey"), "server-key");
assert.equal(upstream.searchParams.get("format"), "json");
assert.equal(upstream.searchParams.get("authKey") === "caller-key", false);
});
test("data4library detail, libraries-by-book, and library-search endpoints proxy expected operations", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
return new Response(
JSON.stringify({
response: {
echoedPath: new URL(text).pathname
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
};
const app = buildServer({
env: {
DATA4LIBRARY_AUTH_KEY: "server-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const detail = await app.inject({
method: "GET",
url: "/v1/data4library/book-detail?isbn=978-8971998557&loanInfo=y&authKey=caller-key&format=xml"
});
const librariesByBook = await app.inject({
method: "GET",
url: "/v1/data4library/libraries-by-book?isbn13=9788971998557&region=11&detailRegion=11010&page=2&limit=20"
});
const librarySearch = await app.inject({
method: "GET",
url: "/v1/data4library/library-search?libraryCode=111001&region=11&detailRegion=11010&page=3&pageSize=30"
});
assert.equal(detail.statusCode, 200);
assert.equal(librariesByBook.statusCode, 200);
assert.equal(librarySearch.statusCode, 200);
assert.equal(fetchCalls.length, 3);
const [detailUrl, librariesByBookUrl, librarySearchUrl] = fetchCalls.map((entry) => new URL(entry));
assert.equal(detailUrl.origin + detailUrl.pathname, "https://data4library.kr/api/srchDtlList");
assert.equal(detailUrl.searchParams.get("isbn13"), "9788971998557");
assert.equal(detailUrl.searchParams.get("loaninfoYN"), "Y");
assert.equal(librariesByBookUrl.origin + librariesByBookUrl.pathname, "https://data4library.kr/api/libSrchByBook");
assert.equal(librariesByBookUrl.searchParams.get("isbn"), "9788971998557");
assert.equal(librariesByBookUrl.searchParams.get("region"), "11");
assert.equal(librariesByBookUrl.searchParams.get("dtl_region"), "11010");
assert.equal(librariesByBookUrl.searchParams.get("pageNo"), "2");
assert.equal(librariesByBookUrl.searchParams.get("pageSize"), "20");
assert.equal(librarySearchUrl.origin + librarySearchUrl.pathname, "https://data4library.kr/api/libSrch");
assert.equal(librarySearchUrl.searchParams.get("libCode"), "111001");
assert.equal(librarySearchUrl.searchParams.get("region"), "11");
assert.equal(librarySearchUrl.searchParams.get("dtl_region"), "11010");
assert.equal(librarySearchUrl.searchParams.get("pageNo"), "3");
assert.equal(librarySearchUrl.searchParams.get("pageSize"), "30");
for (const upstream of [detailUrl, librariesByBookUrl, librarySearchUrl]) {
assert.equal(upstream.searchParams.get("authKey"), "server-key");
assert.equal(upstream.searchParams.get("format"), "json");
}
assert.deepEqual(detail.json().query, {
isbn13: "9788971998557",
loaninfoYN: "Y"
});
assert.deepEqual(librariesByBook.json().query, {
isbn: "9788971998557",
region: "11",
pageNo: 2,
pageSize: 20,
dtl_region: "11010"
});
assert.deepEqual(librarySearch.json().query, {
pageNo: 3,
pageSize: 30,
libCode: "111001",
region: "11",
dtl_region: "11010"
});
});
test("data4library book-exists endpoint requires library code and isbn13 then proxies", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
fetchCalls.push(String(url));
return new Response(JSON.stringify({ response: { result: { hasBook: "Y", loanAvailable: "N" } } }), {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
};
const app = buildServer({
env: {
DATA4LIBRARY_AUTH_KEY: "server-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const bad = await app.inject({
method: "GET",
url: "/v1/data4library/book-exists?libraryCode=111001&isbn13=abc"
});
const invalidIsbn10 = await app.inject({
method: "GET",
url: "/v1/data4library/book-exists?libraryCode=111001&isbn13=0-306-40615-3"
});
const ok = await app.inject({
method: "GET",
url: "/v1/data4library/book-exists?libraryCode=111001&isbn13=9788971998557"
});
const isbn10 = await app.inject({
method: "GET",
url: "/v1/data4library/book-exists?libraryCode=111001&isbn13=0-306-40615-2"
});
const isbn10X = await app.inject({
method: "GET",
url: "/v1/data4library/book-exists?libraryCode=111001&isbn13=0-8044-2957-X"
});
assert.equal(bad.statusCode, 400);
assert.equal(invalidIsbn10.statusCode, 400);
assert.equal(invalidIsbn10.json().error, "bad_request");
assert.equal(ok.statusCode, 200);
assert.equal(isbn10.statusCode, 200);
assert.equal(isbn10X.statusCode, 200);
assert.equal(fetchCalls.length, 3);
const upstream = new URL(fetchCalls[0]);
assert.equal(upstream.origin + upstream.pathname, "https://data4library.kr/api/bookExist");
assert.equal(upstream.searchParams.get("libCode"), "111001");
assert.equal(upstream.searchParams.get("isbn13"), "9788971998557");
const isbn10Upstream = new URL(fetchCalls[1]);
assert.equal(isbn10Upstream.origin + isbn10Upstream.pathname, "https://data4library.kr/api/bookExist");
assert.equal(isbn10Upstream.searchParams.get("libCode"), "111001");
assert.equal(isbn10Upstream.searchParams.get("isbn13"), "9780306406157");
assert.deepEqual(isbn10.json().query, {
library_code: "111001",
isbn13: "9780306406157"
});
const isbn10XUpstream = new URL(fetchCalls[2]);
assert.equal(isbn10XUpstream.origin + isbn10XUpstream.pathname, "https://data4library.kr/api/bookExist");
assert.equal(isbn10XUpstream.searchParams.get("libCode"), "111001");
assert.equal(isbn10XUpstream.searchParams.get("isbn13"), "9780804429573");
assert.deepEqual(isbn10X.json().query, {
library_code: "111001",
isbn13: "9780804429573"
});
});
function buildParkingDatasetFetchMock({ fetchCalls, totalCount, seoulRow, otherRows = [] }) {
return async (url) => {
const resolved = String(url);
fetchCalls.push(resolved);
const urlObject = new URL(resolved);
assert.match(resolved, /^https:\/\/api\.data\.go\.kr\/openapi\/tn_pubr_prkplce_info_api\?/);
assert.equal(urlObject.searchParams.get("serviceKey"), "data-key");
assert.equal(urlObject.searchParams.get("type"), "json");
assert.ok(!urlObject.searchParams.has("rdnmadr"));
assert.ok(!urlObject.searchParams.has("lnmadr"));
assert.ok(!urlObject.searchParams.has("prkplceSe"));
const pageNo = Number(urlObject.searchParams.get("pageNo") || 1);
const items = pageNo === 1 ? [seoulRow, ...otherRows] : [];
return new Response(JSON.stringify({
response: {
header: { resultCode: "00", resultMsg: "NORMAL_SERVICE" },
body: {
pageNo,
numOfRows: items.length,
totalCount,
items
}
}
}), { status: 200, headers: { "content-type": "application/json" } });
};
}
test("parking lot search endpoint caches the full dataset and serves nearby queries from memory", async (t) => {
const { resetParkingDatasetCacheForTests } = require("../src/parking-lots");
resetParkingDatasetCacheForTests();
const originalFetch = global.fetch;
const fetchCalls = [];
const seoulRow = {
prkplceNo: "111-2-000003",
prkplceNm: "광화문광장 공영주차장",
prkplceSe: "공영",
prkplceType: "노상",
rdnmadr: "서울특별시 종로구 세종대로 172",
prkcmprt: "20",
parkingchrgeInfo: "무료",
institutionNm: "서울특별시 종로구청",
latitude: "37.57375",
longitude: "126.97836",
pwdbsPpkZoneYn: "N",
referenceDate: "2026-03-01",
insttCode: "3000000",
insttNm: "서울특별시 종로구"
};
const jejuRow = {
prkplceNo: "500-2-000001",
prkplceNm: "제주공영주차장",
prkplceSe: "공영",
prkplceType: "노외",
rdnmadr: "제주특별자치도 제주시 연동 1",
prkcmprt: "100",
parkingchrgeInfo: "무료",
latitude: "33.4761",
longitude: "126.5472",
pwdbsPpkZoneYn: "N",
referenceDate: "2026-03-01"
};
global.fetch = buildParkingDatasetFetchMock({
fetchCalls,
totalCount: 2,
seoulRow,
otherRows: [jejuRow]
});
const app = buildServer({
env: {
DATA_GO_KR_API_KEY: "data-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
resetParkingDatasetCacheForTests();
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/parking-lots/search?latitude=37.57371315593711&longitude=126.97833785777944&limit=2&radius=1000"
});
const second = await app.inject({
method: "GET",
url: "/v1/parking-lots/search?lat=37.57371315593711&lon=126.97833785777944&limit=2&radius=1000"
});
const third = await app.inject({
method: "GET",
url: "/v1/parking-lots/search?latitude=37.58&longitude=126.98&limit=2&radius=2000"
});
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(third.statusCode, 200);
assert.equal(first.json().items.length, 1);
assert.equal(first.json().items[0].name, "광화문광장 공영주차장");
assert.equal(first.json().items[0].providerName, "서울특별시 종로구");
assert.equal(first.json().meta.datasetCacheHit, false);
assert.equal(third.json().meta.datasetCacheHit, true);
assert.equal(fetchCalls.length, 1);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
});
test("parking lot search endpoint filters cached dataset by radius and excludes private lots by default", async (t) => {
const { resetParkingDatasetCacheForTests } = require("../src/parking-lots");
resetParkingDatasetCacheForTests();
const originalFetch = global.fetch;
const fetchCalls = [];
const privateSeoulRow = {
prkplceNo: "111-2-000002",
prkplceNm: "세종로 민영주차장",
prkplceSe: "민영",
prkplceType: "노외",
rdnmadr: "서울특별시 종로구 세종대로 170",
prkcmprt: "100",
parkingchrgeInfo: "유료",
latitude: "37.573714",
longitude: "126.978339",
referenceDate: "2026-03-01"
};
const publicSeoulRow = {
prkplceNo: "111-2-000003",
prkplceNm: "광화문광장 공영주차장",
prkplceSe: "공영",
prkplceType: "노상",
rdnmadr: "서울특별시 종로구 세종대로 172",
prkcmprt: "20",
parkingchrgeInfo: "무료",
latitude: "37.57375",
longitude: "126.97836",
pwdbsPpkZoneYn: "N",
referenceDate: "2026-03-01"
};
global.fetch = buildParkingDatasetFetchMock({
fetchCalls,
totalCount: 2,
seoulRow: publicSeoulRow,
otherRows: [privateSeoulRow]
});
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-key" } });
t.after(async () => {
global.fetch = originalFetch;
resetParkingDatasetCacheForTests();
await app.close();
});
const publicOnly = await app.inject({
method: "GET",
url: "/v1/parking-lots/search?latitude=37.57375&longitude=126.97836&limit=5&radius=500"
});
const includePrivate = await app.inject({
method: "GET",
url: "/v1/parking-lots/search?latitude=37.57375&longitude=126.97836&limit=5&radius=500&public_only=false"
});
assert.equal(publicOnly.statusCode, 200);
assert.equal(includePrivate.statusCode, 200);
assert.equal(publicOnly.json().items.length, 1);
assert.equal(publicOnly.json().items[0].name, "광화문광장 공영주차장");
const includedCategories = includePrivate.json().items.map((item) => item.category);
assert.equal(includePrivate.json().items.length, 2);
assert.ok(includedCategories.includes("공영"));
assert.ok(includedCategories.includes("민영"));
});
test("parking lot search endpoint validates coordinates before upstream calls", async (t) => {
const app = buildServer({
env: {
DATA_GO_KR_API_KEY: "data-key"
}
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/parking-lots/search?latitude=oops&longitude=126.9"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
assert.match(response.json().message, /latitude and longitude/);
});
test("parking lot search endpoint reports missing Data.go.kr key", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/parking-lots/search?latitude=37.57371315593711&longitude=126.97833785777944"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
assert.match(response.json().message, /DATA_GO_KR_API_KEY/);
});