const test = require("node:test"); const assert = require("node:assert/strict"); const { buildServer, createMemoryCache, isFailureResponse, makeCacheKey, normalizeData4LibraryBookDetailQuery, normalizeData4LibraryBookExistsQuery, normalizeData4LibraryBookSearchQuery, normalizeData4LibraryLibrariesByBookQuery, normalizeData4LibraryLibrarySearchQuery, normalizeKakaoLocalGeocodeQuery, normalizeKosisDataQuery, normalizeKosisMetaQuery, normalizeKosisSearchQuery, proxyAirKoreaRequest, proxyData4LibraryRequest, proxyHrfcoWaterLevelRequest, proxyKakaoLocalRequest, proxyKosisRequest, 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( "", { 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("KOSIS normalizers map public query aliases to upstream params", () => { assert.deepEqual(normalizeKosisSearchQuery({ q: "인구", limit: "3" }), { method: "getList", format: "json", jsonVD: "Y", searchNm: "인구", resultCount: 3, startCount: 1 }); assert.deepEqual(normalizeKosisMetaQuery({ table_id: "DT_1IN0001", meta_type: "itm" }), { method: "getMeta", type: "ITM", format: "json", jsonVD: "Y", orgId: "101", tblId: "DT_1IN0001" }); assert.deepEqual(normalizeKosisDataQuery({ tableId: "DT_1JC1501", prd_se: "y", start: "2023", end: "2024", objL2: "00" }), { method: "getList", format: "json", jsonVD: "Y", orgId: "101", tblId: "DT_1JC1501", itmId: "ALL", prdSe: "Y", startPrdDe: "2023", endPrdDe: "2024", objL2: "00" }); }); test("KOSIS proxy injects the server-side key without accepting client apiKey", async () => { const calls = []; const upstream = await proxyKosisRequest({ operation: "search", apiKey: "server-kosis-key", params: { method: "getList", format: "json", jsonVD: "Y", searchNm: "인구", resultCount: 1, startCount: 1, apiKey: "client-supplied-key" }, fetchImpl: async (url) => { calls.push(String(url)); return new Response("[]", { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }); } }); assert.equal(upstream.statusCode, 200); assert.equal(calls.length, 1); const url = new URL(calls[0]); assert.equal(url.origin + url.pathname, "https://kosis.kr/openapi/statisticsSearch.do"); assert.equal(url.searchParams.get("apiKey"), "server-kosis-key"); assert.equal(url.searchParams.get("searchNm"), "인구"); }); test("KOSIS search endpoint stays public and caches successful upstream responses", async (t) => { const originalFetch = global.fetch; const calls = []; global.fetch = async (url) => { calls.push(String(url)); return new Response(JSON.stringify([{ TBL_ID: "DT_1JC1501", TBL_NM: "1인 가구 비율" }]), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }); }; const app = buildServer({ env: { KOSIS_API_KEY: "server-kosis-key" } }); t.after(async () => { global.fetch = originalFetch; await app.close(); }); const url = `/v1/kosis/search?q=${encodeURIComponent("1인 가구")}&limit=1&apiKey=client-key`; const first = await app.inject({ method: "GET", url }); assert.equal(first.statusCode, 200); assert.equal(first.json()[0].TBL_ID, "DT_1JC1501"); const second = await app.inject({ method: "GET", url }); assert.equal(second.statusCode, 200); assert.equal(second.json()[0].TBL_NM, "1인 가구 비율"); assert.equal(calls.length, 1, "second request should be served from proxy cache"); assert.equal(new URL(calls[0]).searchParams.get("apiKey"), "server-kosis-key"); }); test("Kakao Local geocode normalizer maps public aliases to upstream params", () => { assert.deepEqual(normalizeKakaoLocalGeocodeQuery({ q: "서울역", limit: "3" }), { query: "서울역", size: 3, page: 1 }); }); test("Kakao Local proxy injects the server-side REST API key", async () => { const calls = []; const upstream = await proxyKakaoLocalRequest({ endpoint: "address", apiKey: "server-kakao-key", params: { query: "서울역", size: 1, apiKey: "client-supplied-key" }, fetchImpl: async (url, options) => { calls.push({ url: String(url), headers: options.headers }); return new Response(JSON.stringify({ documents: [] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }); } }); assert.equal(upstream.statusCode, 200); assert.equal(calls.length, 1); const url = new URL(calls[0].url); assert.equal(url.origin + url.pathname, "https://dapi.kakao.com/v2/local/search/address.json"); assert.equal(url.searchParams.get("query"), "서울역"); assert.equal(url.searchParams.get("apiKey"), null); assert.equal(calls[0].headers.authorization, "KakaoAK server-kakao-key"); }); test("Kakao Local geocode endpoint falls back from address to keyword and caches", async (t) => { const originalFetch = global.fetch; const calls = []; global.fetch = async (url) => { calls.push(String(url)); const path = new URL(url).pathname; if (path.endsWith("/search/address.json")) { return new Response(JSON.stringify({ documents: [] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }); } return new Response(JSON.stringify({ documents: [{ place_name: "서울역", x: "126.9706", y: "37.5559" }] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }); }; const app = buildServer({ env: { KAKAO_REST_API_KEY: "server-kakao-key" } }); t.after(async () => { global.fetch = originalFetch; await app.close(); }); const url = `/v1/kakao-local/geocode?q=${encodeURIComponent("서울역")}&limit=1&apiKey=client-key`; const first = await app.inject({ method: "GET", url }); assert.equal(first.statusCode, 200); assert.equal(first.json().documents[0].place_name, "서울역"); const second = await app.inject({ method: "GET", url }); assert.equal(second.statusCode, 200); assert.equal(second.json().documents[0].x, "126.9706"); assert.equal(calls.length, 2, "second request should be served from proxy cache"); assert.equal(new URL(calls[0]).searchParams.get("apiKey"), null); }); 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 = `
000NORMAL SERVICE.
래미안반포동84.99 12 245,000 2024315 2009중개거래 1
`; 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: "밥
국" } ] } ] }); 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, "밥
국"); 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®ion=11&detailRegion=11010&page=2&limit=20" }); const librarySearch = await app.inject({ method: "GET", url: "/v1/data4library/library-search?libraryCode=111001®ion=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/); }); test("lh-notice search endpoint stays public and caches normalized queries", async (t) => { const originalFetch = global.fetch; const fetchCalls = []; global.fetch = async (url, options = {}) => { fetchCalls.push(String(url)); return new Response( JSON.stringify([ { CMN: { CODE: "SUCCESS", ERR_MSG: "", TOTAL_CNT: 2 } }, { dsList: [ { PAN_ID: "2015122300019828", PAN_NM: "2026년 상반기 부산광역시 영구임대주택 예비입주자 모집 공고", UPP_AIS_TP_CD: "06", AIS_TP_CD: "09", AIS_TP_CD_NM: "영구임대", CNP_CD_NM: "부산광역시", PAN_SS: "공고중", PAN_DT: "2026-04-21", CLSG_DT: "2026-05-06", SPL_INF_TP_CD: "051", CCR_CNNT_SYS_DS_CD: "03", DTL_URL: "https://apply.lh.or.kr/detail?panId=2015122300019828" }, { PAN_ID: "2015122300019816", PAN_NM: "2026 전세임대형 든든주택 모집 공고", UPP_AIS_TP_CD: "13", AIS_TP_CD: "17", AIS_TP_CD_NM: "전세임대", CNP_CD_NM: "전국", PAN_SS: "공고중" } ] } ]), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); }; const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-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/lh-notice/search?panSs=%EA%B3%B5%EA%B3%A0%EC%A4%91&limit=10" }); const firstBody = first.json(); assert.equal(first.statusCode, 200); assert.equal(firstBody.proxy.cache.hit, false); assert.equal(firstBody.items.length, 2); assert.equal(firstBody.items[0].pan_id, "2015122300019828"); assert.equal(firstBody.items[0].ais_tp_cd_nm, "영구임대"); assert.equal(firstBody.summary.returned_count, 2); assert.equal(firstBody.summary.total_count, 2); assert.equal(firstBody.query.pan_ss, "공고중"); assert.equal(fetchCalls.length, 1); assert.match(fetchCalls[0], /PG_SZ=10/); assert.match(fetchCalls[0], /PAGE=1/); assert.match(fetchCalls[0], /serviceKey=data-go-key/); const second = await app.inject({ method: "GET", url: "/v1/lh-notice/search?status=%EA%B3%B5%EA%B3%A0%EC%A4%91&pageSize=10" }); const secondBody = second.json(); assert.equal(second.statusCode, 200); assert.equal(secondBody.proxy.cache.hit, true, "alias-normalized second call must reuse cache"); assert.equal(fetchCalls.length, 1, "cache hit must not hit upstream again"); }); test("lh-notice search returns 400 for unsupported panSs", async (t) => { const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } }); t.after(async () => { await app.close(); }); const response = await app.inject({ method: "GET", url: "/v1/lh-notice/search?panSs=%EB%8C%80%EA%B8%B0%EC%A4%91" }); assert.equal(response.statusCode, 400); assert.equal(response.json().error, "bad_request"); assert.match(response.json().message, /panSs must be one of/); }); test("lh-notice search 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/lh-notice/search" }); assert.equal(response.statusCode, 503); assert.equal(response.json().error, "upstream_not_configured"); assert.match(response.json().message, /DATA_GO_KR_API_KEY/); }); test("lh-notice search does not cache upstream XML auth errors so retries self-heal", async (t) => { const originalFetch = global.fetch; const xmlError = ` SERVICE ERROR SERVICE_KEY_IS_NOT_REGISTERED_ERROR 30 `; let mode = "fail"; const successPayload = [ { CMN: { CODE: "SUCCESS", ERR_MSG: "", TOTAL_CNT: 1 } }, { dsList: [{ PAN_ID: "111", PAN_NM: "recovered notice" }] } ]; global.fetch = async () => { if (mode === "fail") { return new Response(xmlError, { status: 200, headers: { "content-type": "text/xml" } }); } return new Response(JSON.stringify(successPayload), { status: 200, headers: { "content-type": "application/json" } }); }; const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } }); t.after(async () => { global.fetch = originalFetch; await app.close(); }); const first = await app.inject({ method: "GET", url: "/v1/lh-notice/search" }); assert.equal(first.statusCode, 502); assert.equal(first.json().error, "upstream_error"); assert.equal(first.json().upstream_code, "30"); mode = "ok"; const second = await app.inject({ method: "GET", url: "/v1/lh-notice/search" }); assert.equal(second.statusCode, 200); assert.equal(second.json().proxy.cache.hit, false, "failure must not have been cached"); assert.equal(second.json().items[0].pan_id, "111"); }); test("lh-notice detail endpoint requires all three codes and caches successful lookups", async (t) => { const originalFetch = global.fetch; const fetchCalls = []; global.fetch = async (url) => { fetchCalls.push(String(url)); return new Response( JSON.stringify([ { CMN: { CODE: "SUCCESS", ERR_MSG: "", TOTAL_CNT: 1 } }, { dsList: [ { PAN_ID: "2015122300019828", PAN_NM: "영구임대 예비입주자 모집", UPP_AIS_TP_CD: "06", AIS_TP_CD_NM: "영구임대", HOUSE_TY: "29㎡", SPL_CNT: "120" } ] } ]), { status: 200, headers: { "content-type": "application/json" } } ); }; const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } }); t.after(async () => { global.fetch = originalFetch; await app.close(); }); const missing = await app.inject({ method: "GET", url: "/v1/lh-notice/detail" }); assert.equal(missing.statusCode, 400); assert.match(missing.json().message, /Provide panId/); const first = await app.inject({ method: "GET", url: "/v1/lh-notice/detail?panId=2015122300019828&ccrCnntSysDsCd=03&splInfTpCd=051" }); assert.equal(first.statusCode, 200); assert.equal(first.json().proxy.cache.hit, false); assert.equal(first.json().notice.pan_id, "2015122300019828"); assert.equal(first.json().notice.ais_tp_cd_nm, "영구임대"); assert.equal(first.json().supply_infos.length, 1); assert.equal(fetchCalls.length, 1); assert.match(fetchCalls[0], /lhLeaseNoticeDtlInfo1\/getLeaseNoticeDtlInfo1/); assert.match(fetchCalls[0], /PAN_ID=2015122300019828/); assert.match(fetchCalls[0], /CCR_CNNT_SYS_DS_CD=03/); assert.match(fetchCalls[0], /SPL_INF_TP_CD=051/); const second = await app.inject({ method: "GET", url: "/v1/lh-notice/detail?panId=2015122300019828&ccrCnntSysDsCd=03&splInfTpCd=051" }); assert.equal(second.statusCode, 200); assert.equal(second.json().proxy.cache.hit, true); assert.equal(fetchCalls.length, 1, "cached detail must not retrigger upstream"); }); // Pins the /v1/lh-notice/detail failure-not-cached contract against BOTH // cache-protection layers: // (a) the early-return catch block in the route handler (no `cache.set` // is reached on upstream failure; see src/server.js lh-notice/detail // route), and // (b) the `isFailureResponse()` guard inside `cache.set` (refuses any // payload with `.error` set; see src/server.js createMemoryCache). // Bypassing either layer alone makes the State 2 self-heal assertion // ("detail failure must not have been cached — retry must hit upstream") // fail — proven by independent sabotage audit in PR #158 Round 3 review. // See the sibling /search failure-not-cached test above for symmetric // coverage across the two lh-notice routes. test("lh-notice detail does not cache upstream XML auth errors so retries self-heal", async (t) => { const originalFetch = global.fetch; const xmlError = ` SERVICE ERROR SERVICE_KEY_IS_NOT_REGISTERED_ERROR 30 `; let mode = "fail"; const fetchCalls = []; const successPayload = [ { CMN: { CODE: "SUCCESS", ERR_MSG: "", TOTAL_CNT: 1 } }, { dsList: [ { PAN_ID: "2015122300019828", PAN_NM: "영구임대 예비입주자 모집 (recovered)", UPP_AIS_TP_CD: "06", AIS_TP_CD_NM: "영구임대", HOUSE_TY: "29㎡", SPL_CNT: "120" } ] } ]; global.fetch = async (url) => { fetchCalls.push(String(url)); if (mode === "fail") { return new Response(xmlError, { status: 200, headers: { "content-type": "text/xml" } }); } return new Response(JSON.stringify(successPayload), { status: 200, headers: { "content-type": "application/json" } }); }; const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } }); t.after(async () => { global.fetch = originalFetch; await app.close(); }); const detailUrl = "/v1/lh-notice/detail?panId=2015122300019828&ccrCnntSysDsCd=03&splInfTpCd=051"; const first = await app.inject({ method: "GET", url: detailUrl }); assert.equal(first.statusCode, 502); assert.equal(first.json().error, "upstream_error"); assert.equal(first.json().upstream_code, "30"); assert.equal(first.json().proxy.cache.hit, false); assert.equal(fetchCalls.length, 1, "first call must hit upstream exactly once"); mode = "ok"; const second = await app.inject({ method: "GET", url: detailUrl }); assert.equal(second.statusCode, 200); assert.equal( second.json().proxy.cache.hit, false, "detail failure must not have been cached — retry must hit upstream" ); assert.equal(second.json().notice.pan_id, "2015122300019828"); assert.match(second.json().notice.pan_nm, /recovered/); assert.equal( fetchCalls.length, 2, "second call must hit upstream again (no cache for failure)" ); mode = "fail"; const third = await app.inject({ method: "GET", url: detailUrl }); assert.equal( third.statusCode, 200, "third call must hit cache (success was cached)" ); assert.equal(third.json().proxy.cache.hit, true); assert.equal( fetchCalls.length, 2, "third call must serve from cache (no new upstream fetch)" ); }); test("health endpoint reports lhNoticeConfigured when DATA_GO_KR_API_KEY is set", async (t) => { const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-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.lhNoticeConfigured, true); });