const test = require("node:test"); const assert = require("node:assert/strict"); const { buildServer, proxyAirKoreaRequest, proxyHrfcoWaterLevelRequest, proxyKmaWeatherRequest, proxySeoulSubwayRequest } = require("../src/server"); const { resolveEducationOfficeFromNaturalLanguage } = require("../src/neis-office-codes"); 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); }); test("health endpoint reports KRX upstream status when configured", async (t) => { const app = buildServer({ env: { KRX_API_KEY: "krx-key" } }); t.after(async () => { await app.close(); }); const response = await app.inject({ method: "GET", url: "/health" }); assert.equal(response.statusCode, 200); assert.equal(response.json().upstreams.krxConfigured, true); }); test("korean stock search endpoint stays public and caches normalized search queries", async (t) => { const originalFetch = global.fetch; const fetchCalls = []; global.fetch = async (url, options = {}) => { const text = String(url); fetchCalls.push({ url: text, headers: options.headers }); if (text.includes("stk_isu_base_info")) { return new Response( JSON.stringify({ OutBlock_1: [ { ISU_CD: "KR7005930003", ISU_SRT_CD: "005930", ISU_NM: "삼성전자", ISU_ABBRV: "삼성전자", ISU_ENG_NM: "Samsung Electronics", LIST_DD: "19750611", MKT_TP_NM: "KOSPI", SECUGRP_NM: "주권", SECT_TP_NM: "대형주", KIND_STKCERT_TP_NM: "보통주", PARVAL: "100", LIST_SHRS: "5969782550" } ] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); } if (text.includes("ksq_isu_base_info") || text.includes("knx_isu_base_info")) { return new Response( JSON.stringify({ OutBlock_1: [] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); } throw new Error(`unexpected URL: ${url}`); }; const app = buildServer({ env: { KRX_API_KEY: "krx-key", KSKILL_PROXY_CACHE_TTL_MS: "60000" } }); t.after(async () => { global.fetch = originalFetch; await app.close(); }); const first = await app.inject({ method: "GET", url: "/v1/korean-stock/search?q=%20%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90%20&bas_dd=20260404" }); const second = await app.inject({ method: "GET", url: "/v1/korean-stock/search?query=%20%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90%20&date=20260404&limit=10" }); assert.equal(first.statusCode, 200); assert.equal(second.statusCode, 200); assert.equal(fetchCalls.length, 3); assert.equal(first.json().proxy.cache.hit, false); assert.equal(second.json().proxy.cache.hit, true); assert.equal(first.json().items[0].market, "KOSPI"); assert.equal(first.json().items[0].code, "005930"); assert.equal(first.json().items[0].name, "삼성전자"); assert.ok(fetchCalls.every((entry) => entry.url.startsWith("https://data-dbg.krx.co.kr/"))); assert.match(fetchCalls[0].url, /basDd=20260404/); assert.equal(fetchCalls[0].headers.AUTH_KEY, "krx-key"); }); test("korean stock search rate limit does not trust spoofed cf-connecting-ip on direct requests", async (t) => { const app = buildServer({ env: { KSKILL_PROXY_RATE_LIMIT_MAX: "1" } }); t.after(async () => { await app.close(); }); const first = await app.inject({ method: "GET", url: "/v1/korean-stock/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&bas_dd=20260404", headers: { "cf-connecting-ip": "1.1.1.1" } }); const second = await app.inject({ method: "GET", url: "/v1/korean-stock/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&bas_dd=20260404", headers: { "cf-connecting-ip": "2.2.2.2" } }); assert.equal(first.statusCode, 503); assert.equal(first.json().error, "upstream_not_configured"); assert.equal(second.statusCode, 429); assert.equal(second.json().error, "rate_limited"); }); test("korean stock search surfaces degraded upstream metadata when another market fails", async (t) => { const originalFetch = global.fetch; const fetchCalls = []; global.fetch = async (url, options = {}) => { const text = String(url); fetchCalls.push({ url: text, headers: options.headers }); if (text.includes("stk_isu_base_info")) { return new Response( JSON.stringify({ OutBlock_1: [ { ISU_CD: "KR7005930003", ISU_SRT_CD: "005930", ISU_NM: "삼성전자", ISU_ABBRV: "삼성전자", ISU_ENG_NM: "Samsung Electronics", LIST_DD: "19750611", MKT_TP_NM: "KOSPI", SECUGRP_NM: "주권", SECT_TP_NM: "대형주", KIND_STKCERT_TP_NM: "보통주", PARVAL: "100", LIST_SHRS: "5969782550" } ] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); } if (text.includes("ksq_isu_base_info")) { return new Response("boom", { status: 500, statusText: "Internal Server Error" }); } if (text.includes("knx_isu_base_info")) { return new Response( JSON.stringify({ OutBlock_1: [] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); } throw new Error(`unexpected URL: ${url}`); }; const app = buildServer({ env: { KRX_API_KEY: "krx-key" } }); t.after(async () => { global.fetch = originalFetch; await app.close(); }); const response = await app.inject({ method: "GET", url: "/v1/korean-stock/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&bas_dd=20260404" }); assert.equal(response.statusCode, 200); assert.equal(response.json().items.length, 1); assert.equal(response.json().items[0].market, "KOSPI"); assert.equal(response.json().items[0].code, "005930"); assert.equal(response.json().items[0].name, "삼성전자"); assert.equal(response.json().upstream.degraded, true); assert.deepEqual(response.json().upstream.requested_markets, ["KOSPI", "KOSDAQ", "KONEX"]); assert.deepEqual(response.json().upstream.successful_markets, ["KOSPI", "KONEX"]); assert.deepEqual(response.json().upstream.failed_markets, [ { market: "KOSDAQ", code: "upstream_error", status_code: 502, message: "KRX API HTTP 오류 (status: 500): Internal Server Error" } ]); assert.equal(fetchCalls.length, 3); assert.ok(fetchCalls.every((entry) => entry.url.startsWith("https://data-dbg.krx.co.kr/"))); }); test("korean stock search does not cache degraded responses and retries a recovered market", async (t) => { const originalFetch = global.fetch; const fetchCalls = []; let kosdaqAttempts = 0; global.fetch = async (url, options = {}) => { const text = String(url); fetchCalls.push({ url: text, headers: options.headers }); if (text.includes("stk_isu_base_info") || text.includes("knx_isu_base_info")) { return new Response( JSON.stringify({ OutBlock_1: [] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); } if (text.includes("ksq_isu_base_info")) { kosdaqAttempts += 1; if (kosdaqAttempts === 1) { return new Response("boom", { status: 500, statusText: "Internal Server Error" }); } return new Response( JSON.stringify({ OutBlock_1: [ { ISU_CD: "KR7196170005", ISU_SRT_CD: "196170", ISU_NM: "알테오젠", ISU_ABBRV: "알테오젠", ISU_ENG_NM: "Alteogen", LIST_DD: "20140509", MKT_TP_NM: "KOSDAQ", SECUGRP_NM: "주권", SECT_TP_NM: "제약", KIND_STKCERT_TP_NM: "보통주", PARVAL: "500", LIST_SHRS: "53470829" } ] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); } throw new Error(`unexpected URL: ${url}`); }; const app = buildServer({ env: { KRX_API_KEY: "krx-key", KSKILL_PROXY_CACHE_TTL_MS: "60000" } }); t.after(async () => { global.fetch = originalFetch; await app.close(); }); const first = await app.inject({ method: "GET", url: "/v1/korean-stock/search?q=%EC%95%8C%ED%85%8C%EC%98%A4%EC%A0%A0&bas_dd=20260408" }); const second = await app.inject({ method: "GET", url: "/v1/korean-stock/search?q=%EC%95%8C%ED%85%8C%EC%98%A4%EC%A0%A0&bas_dd=20260408" }); assert.equal(first.statusCode, 200); assert.equal(first.json().items.length, 0); assert.equal(first.json().proxy.cache.hit, false); assert.equal(first.json().upstream.degraded, true); assert.deepEqual(first.json().upstream.failed_markets, [ { market: "KOSDAQ", code: "upstream_error", status_code: 502, message: "KRX API HTTP 오류 (status: 500): Internal Server Error" } ]); assert.equal(second.statusCode, 200); assert.equal(second.json().proxy.cache.hit, false); assert.equal(second.json().items.length, 1); assert.equal(second.json().items[0].market, "KOSDAQ"); assert.equal(second.json().items[0].code, "196170"); assert.equal(kosdaqAttempts, 2); assert.equal(fetchCalls.length, 4); }); test("korean stock search reuses per-market base snapshots across different queries for the same date", async (t) => { const originalFetch = global.fetch; const fetchCalls = []; global.fetch = async (url, options = {}) => { const text = String(url); fetchCalls.push({ url: text, headers: options.headers }); if (text.includes("stk_isu_base_info")) { return new Response( JSON.stringify({ OutBlock_1: [ { ISU_CD: "KR7005930003", ISU_SRT_CD: "005930", ISU_NM: "삼성전자", ISU_ABBRV: "삼성전자", ISU_ENG_NM: "Samsung Electronics", LIST_DD: "19750611", MKT_TP_NM: "KOSPI", SECUGRP_NM: "주권", SECT_TP_NM: "대형주", KIND_STKCERT_TP_NM: "보통주", PARVAL: "100", LIST_SHRS: "5969782550" } ] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); } if (text.includes("ksq_isu_base_info") || text.includes("knx_isu_base_info")) { return new Response( JSON.stringify({ OutBlock_1: [] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); } throw new Error(`unexpected URL: ${url}`); }; const app = buildServer({ env: { KRX_API_KEY: "krx-key", KSKILL_PROXY_CACHE_TTL_MS: "60000" } }); t.after(async () => { global.fetch = originalFetch; await app.close(); }); const byKoreanName = await app.inject({ method: "GET", url: "/v1/korean-stock/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&bas_dd=20260404" }); const byEnglishName = await app.inject({ method: "GET", url: "/v1/korean-stock/search?q=Samsung&bas_dd=20260404" }); assert.equal(byKoreanName.statusCode, 200); assert.equal(byEnglishName.statusCode, 200); assert.equal(byKoreanName.json().items[0].code, "005930"); assert.equal(byEnglishName.json().items[0].code, "005930"); assert.equal(fetchCalls.length, 3); }); test("korean stock base-info endpoint returns 503 when proxy server lacks KRX API key", async (t) => { const app = buildServer(); t.after(async () => { await app.close(); }); const response = await app.inject({ method: "GET", url: "/v1/korean-stock/base-info?market=KOSPI&code=005930&bas_dd=20260404" }); assert.equal(response.statusCode, 503); assert.equal(response.json().error, "upstream_not_configured"); }); test("korean stock base-info endpoint normalizes upstream KRX fields", async (t) => { const originalFetch = global.fetch; let calledUrl; let calledHeaders; global.fetch = async (url, options = {}) => { calledUrl = String(url); calledHeaders = options.headers; return new Response( JSON.stringify({ OutBlock_1: [ { ISU_CD: "KR7005930003", ISU_SRT_CD: "005930", ISU_NM: "삼성전자", ISU_ABBRV: "삼성전자", ISU_ENG_NM: "Samsung Electronics", LIST_DD: "19750611", MKT_TP_NM: "KOSPI", SECUGRP_NM: "주권", SECT_TP_NM: "대형주", KIND_STKCERT_TP_NM: "보통주", PARVAL: "100", LIST_SHRS: "5969782550" } ] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); }; const app = buildServer({ env: { KRX_API_KEY: "krx-key" } }); t.after(async () => { global.fetch = originalFetch; await app.close(); }); const response = await app.inject({ method: "GET", url: "/v1/korean-stock/base-info?market=KOSPI&code=005930&bas_dd=20260404" }); assert.equal(response.statusCode, 200); assert.ok(calledUrl.startsWith("https://data-dbg.krx.co.kr/")); assert.match(calledUrl, /stk_isu_base_info/); assert.match(calledUrl, /basDd=20260404/); assert.equal(calledHeaders.AUTH_KEY, "krx-key"); assert.equal(response.json().item.code, "005930"); assert.equal(response.json().item.name, "삼성전자"); assert.equal(response.json().item.listed_shares, 5969782550); }); test("korean stock trade-info endpoint caches successful responses", async (t) => { const originalFetch = global.fetch; let fetchCalls = 0; let calledUrl; global.fetch = async (url) => { fetchCalls += 1; calledUrl = String(url); return new Response( JSON.stringify({ OutBlock_1: [ { BAS_DD: "20260404", ISU_CD: "KR7005930003", ISU_SRT_CD: "005930", ISU_NM: "삼성전자", MKT_NM: "KOSPI", SECT_TP_NM: "대형주", TDD_CLSPRC: "84000", CMPPREVDD_PRC: "1000", FLUC_RT: "1.20", TDD_OPNPRC: "83000", TDD_HGPRC: "84500", TDD_LWPRC: "82800", ACC_TRDVOL: "12345678", ACC_TRDVAL: "1030000000000", MKTCAP: "500000000000000", LIST_SHRS: "5969782550" } ] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); }; const app = buildServer({ env: { KRX_API_KEY: "krx-key", KSKILL_PROXY_CACHE_TTL_MS: "60000" } }); t.after(async () => { global.fetch = originalFetch; await app.close(); }); const first = await app.inject({ method: "GET", url: "/v1/korean-stock/trade-info?market=KOSPI&code=005930&bas_dd=20260404" }); const second = await app.inject({ method: "GET", url: "/v1/korean-stock/trade-info?market=KOSPI&stockCode=005930&date=20260404" }); assert.equal(first.statusCode, 200); assert.equal(second.statusCode, 200); assert.equal(fetchCalls, 1); assert.ok(calledUrl.startsWith("https://data-dbg.krx.co.kr/")); assert.equal(first.json().proxy.cache.hit, false); assert.equal(second.json().proxy.cache.hit, true); assert.equal(first.json().item.close_price, 84000); assert.equal(first.json().item.trading_value, 1030000000000); }); test("korean stock trade-info endpoint does not relabel an unmatched single-row upstream response", async (t) => { const originalFetch = global.fetch; const fetchCalls = []; global.fetch = async (url) => { const text = String(url); fetchCalls.push(text); if (text.includes("stk_bydd_trd")) { return new Response( JSON.stringify({ OutBlock_1: [ { BAS_DD: "20260404", ISU_CD: "KR7000660001", ISU_NM: "하이트진로", MKT_NM: "KOSPI", SECT_TP_NM: "중형주", TDD_CLSPRC: "21000" } ] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); } if (text.includes("stk_isu_base_info")) { return new Response( JSON.stringify({ OutBlock_1: [] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); } throw new Error(`unexpected URL: ${url}`); }; const app = buildServer({ env: { KRX_API_KEY: "krx-key" } }); t.after(async () => { global.fetch = originalFetch; await app.close(); }); const response = await app.inject({ method: "GET", url: "/v1/korean-stock/trade-info?market=KOSPI&code=005930&bas_dd=20260404" }); assert.equal(response.statusCode, 404); assert.equal(response.json().error, "not_found"); assert.equal(fetchCalls.length, 2); assert.ok(fetchCalls.every((entry) => entry.startsWith("https://data-dbg.krx.co.kr/"))); }); test("korean stock trade-info endpoint treats empty trade snapshots as not_found without base-info fallback", async (t) => { const originalFetch = global.fetch; const fetchCalls = []; global.fetch = async (url) => { const text = String(url); fetchCalls.push(text); if (text.includes("stk_bydd_trd")) { return new Response( JSON.stringify({ OutBlock_1: [] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); } if (text.includes("stk_isu_base_info")) { throw new Error("base-info fallback should not run for empty trade snapshots"); } throw new Error(`unexpected URL: ${url}`); }; const app = buildServer({ env: { KRX_API_KEY: "krx-key" } }); t.after(async () => { global.fetch = originalFetch; await app.close(); }); const response = await app.inject({ method: "GET", url: "/v1/korean-stock/trade-info?market=KOSPI&code=005930&bas_dd=20260404" }); assert.equal(response.statusCode, 404); assert.equal(response.json().error, "not_found"); assert.match(response.json().message, /휴장일이거나 데이터가 아직 없을 수 있습니다/); assert.deepEqual(fetchCalls, [ "https://data-dbg.krx.co.kr/svc/apis/sto/stk_bydd_trd?basDd=20260404" ]); }); test("fine dust endpoint stays publicly callable without proxy auth", async (t) => { let providerCalls = 0; const app = buildServer({ env: { AIR_KOREA_OPEN_API_KEY: "airkorea-key" }, provider: async () => { providerCalls += 1; return { station_name: "강남구" }; } }); t.after(async () => { await app.close(); }); const response = await app.inject({ method: "GET", url: "/v1/fine-dust/report?regionHint=%EC%84%9C%EC%9A%B8%20%EA%B0%95%EB%82%A8%EA%B5%AC" }); assert.equal(response.statusCode, 200); assert.equal(response.json().station_name, "강남구"); assert.equal(providerCalls, 1); }); test("fine dust endpoint returns candidate stations when region resolution is ambiguous", async (t) => { const app = buildServer({ env: { AIR_KOREA_OPEN_API_KEY: "airkorea-key" }, provider: async () => { const error = new Error("단일 측정소를 확정하지 못했습니다."); error.statusCode = 400; error.code = "ambiguous_location"; error.sidoName = "광주"; error.candidateStations = ["평동", "오선동"]; throw error; } }); t.after(async () => { await app.close(); }); const response = await app.inject({ method: "GET", url: "/v1/fine-dust/report?regionHint=%EA%B4%91%EC%A3%BC%20%EA%B4%91%EC%82%B0%EA%B5%AC" }); assert.equal(response.statusCode, 400); assert.equal(response.json().error, "ambiguous_location"); assert.equal(response.json().sido_name, "광주"); assert.deepEqual(response.json().candidate_stations, ["평동", "오선동"]); }); test("fine dust endpoint caches successful provider responses", async (t) => { let providerCalls = 0; const app = buildServer({ env: { AIR_KOREA_OPEN_API_KEY: "airkorea-key", KSKILL_PROXY_CACHE_TTL_MS: "60000" }, provider: async () => { providerCalls += 1; return { station_name: "강남구", station_address: "서울 강남구 학동로 426", lookup_mode: "fallback", measured_at: "2026-03-27 21:00", pm10: { value: "42", grade: "보통" }, pm25: { value: "19", grade: "보통" }, khai_grade: "보통" }; } }); t.after(async () => { await app.close(); }); const request = { method: "GET", url: "/v1/fine-dust/report?regionHint=%EC%84%9C%EC%9A%B8%20%EA%B0%95%EB%82%A8%EA%B5%AC" }; const first = await app.inject(request); const second = await app.inject(request); assert.equal(first.statusCode, 200); assert.equal(second.statusCode, 200); assert.equal(providerCalls, 1); assert.equal(first.json().proxy.cache.hit, false); assert.equal(second.json().proxy.cache.hit, true); }); test("proxyAirKoreaRequest injects serviceKey and preserves caller query params", async () => { let calledUrl; const result = await proxyAirKoreaRequest({ service: "ArpltnInforInqireSvc", operation: "getMsrstnAcctoRltmMesureDnsty", query: { returnType: "json", stationName: "강남구", dataTerm: "DAILY", ver: "1.4" }, serviceKey: "test-service-key", fetchImpl: async (url) => { calledUrl = String(url); return new Response('{"ok":true}', { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }); } }); assert.equal(result.statusCode, 200); assert.match(calledUrl, /\/B552584\/ArpltnInforInqireSvc\/getMsrstnAcctoRltmMesureDnsty\?/); assert.match(calledUrl, /stationName=%EA%B0%95%EB%82%A8%EA%B5%AC/); assert.match(calledUrl, /serviceKey=test-service-key/); }); test("public AirKorea passthrough route forwards allowed upstream responses", async (t) => { const originalFetch = global.fetch; global.fetch = async () => new Response('{"response":{"header":{"resultCode":"00"}}}', { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }); const app = buildServer({ env: { AIR_KOREA_OPEN_API_KEY: "airkorea-key" } }); t.after(async () => { global.fetch = originalFetch; await app.close(); }); const response = await app.inject({ method: "GET", url: "/B552584/ArpltnInforInqireSvc/getMsrstnAcctoRltmMesureDnsty?returnType=json&stationName=%EA%B0%95%EB%82%A8%EA%B5%AC&dataTerm=DAILY&ver=1.4" }); assert.equal(response.statusCode, 200); assert.match(response.body, /resultCode/); }); test("seoul subway endpoint caches successful upstream responses for normalized queries", async (t) => { const originalFetch = global.fetch; let fetchCalls = 0; global.fetch = async () => { fetchCalls += 1; return new Response( JSON.stringify({ errorMessage: { status: 200, code: "INFO-000", message: "정상 처리되었습니다." }, realtimeArrivalList: [ { statnNm: "강남", trainLineNm: "2호선", updnLine: "내선", arvlMsg2: "전역 출발", arvlMsg3: "역삼", barvlDt: "60" } ] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); }; const app = buildServer({ env: { SEOUL_OPEN_API_KEY: "seoul-key", KSKILL_PROXY_CACHE_TTL_MS: "60000" } }); t.after(async () => { global.fetch = originalFetch; await app.close(); }); const first = await app.inject({ method: "GET", url: "/v1/seoul-subway/arrival?station=%EA%B0%95%EB%82%A8&start_index=0&end_index=8" }); const second = await app.inject({ method: "GET", url: "/v1/seoul-subway/arrival?stationName=%EA%B0%95%EB%82%A8" }); assert.equal(first.statusCode, 200); assert.equal(second.statusCode, 200); assert.equal(fetchCalls, 1); assert.equal(first.json().proxy.cache.hit, false); assert.equal(second.json().proxy.cache.hit, true); }); test("seoul subway endpoint stays publicly callable without proxy auth", async (t) => { const originalFetch = global.fetch; let calledUrl; global.fetch = async (url) => { calledUrl = String(url); return new Response( JSON.stringify({ errorMessage: { status: 200, code: "INFO-000", message: "정상 처리되었습니다." }, realtimeArrivalList: [ { statnNm: "강남", trainLineNm: "2호선", updnLine: "내선", arvlMsg2: "전역 출발", arvlMsg3: "역삼", barvlDt: "60" } ] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); }; const app = buildServer({ env: { SEOUL_OPEN_API_KEY: "seoul-key" } }); t.after(async () => { global.fetch = originalFetch; await app.close(); }); const response = await app.inject({ method: "GET", url: "/v1/seoul-subway/arrival?stationName=%EA%B0%95%EB%82%A8" }); assert.equal(response.statusCode, 200); assert.equal(response.json().realtimeArrivalList[0].statnNm, "강남"); assert.match(calledUrl, /realtimeStationArrival\/0\/8\/%EA%B0%95%EB%82%A8$/); }); test("seoul subway endpoint returns 503 when proxy server lacks Seoul API key", async (t) => { const app = buildServer(); t.after(async () => { await app.close(); }); const response = await app.inject({ method: "GET", url: "/v1/seoul-subway/arrival?stationName=%EA%B0%95%EB%82%A8" }); assert.equal(response.statusCode, 503); assert.equal(response.json().error, "upstream_not_configured"); }); test("proxySeoulSubwayRequest injects API key and preserves index/station params", async () => { let calledUrl; const result = await proxySeoulSubwayRequest({ stationName: "강남", startIndex: "2", endIndex: "5", apiKey: "test-seoul-key", fetchImpl: async (url) => { calledUrl = String(url); return new Response('{"ok":true}', { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }); } }); assert.equal(result.statusCode, 200); assert.match(calledUrl, /\/api\/subway\/test-seoul-key\/json\/realtimeStationArrival\/2\/5\/%EA%B0%95%EB%82%A8$/); }); test("korea weather endpoint caches successful upstream responses for normalized coordinate queries", async (t) => { const originalFetch = global.fetch; let fetchCalls = 0; global.fetch = async (url) => { fetchCalls += 1; assert.match(String(url), /getVilageFcst/); assert.match(String(url), /base_date=20260405/); assert.match(String(url), /base_time=0500/); assert.match(String(url), /nx=60/); assert.match(String(url), /ny=127/); return new Response( JSON.stringify({ response: { header: { resultCode: "00", resultMsg: "NORMAL_SERVICE" }, body: { dataType: "JSON", items: { item: [ { baseDate: "20260405", baseTime: "0500", category: "TMP", fcstDate: "20260405", fcstTime: "0600", fcstValue: "14", nx: 60, ny: 127 } ] } } } }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); }; const app = buildServer({ env: { KMA_OPEN_API_KEY: "kma-key", KSKILL_PROXY_CACHE_TTL_MS: "60000" }, now: () => new Date("2026-04-05T06:30:00+09:00") }); t.after(async () => { global.fetch = originalFetch; await app.close(); }); const first = await app.inject({ method: "GET", url: "/v1/korea-weather/forecast?lat=37.5665&lon=126.978" }); const second = await app.inject({ method: "GET", url: "/v1/korea-weather/forecast?nx=60&ny=127&baseDate=20260405&baseTime=0500" }); assert.equal(first.statusCode, 200); assert.equal(second.statusCode, 200); assert.equal(fetchCalls, 1); assert.equal(first.json().proxy.cache.hit, false); assert.equal(second.json().proxy.cache.hit, true); assert.deepEqual(first.json().query, { baseDate: "20260405", baseTime: "0500", nx: 60, ny: 127, pageNo: 1, numOfRows: 1000, dataType: "JSON" }); }); test("korea weather endpoint stays publicly callable without proxy auth", async (t) => { const originalFetch = global.fetch; let calledUrl; global.fetch = async (url) => { calledUrl = String(url); return new Response( JSON.stringify({ response: { header: { resultCode: "00", resultMsg: "NORMAL_SERVICE" }, body: { dataType: "JSON", items: { item: [] } } } }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); }; const app = buildServer({ env: { KMA_OPEN_API_KEY: "kma-key" } }); t.after(async () => { global.fetch = originalFetch; await app.close(); }); const response = await app.inject({ method: "GET", url: "/v1/korea-weather/forecast?nx=60&ny=127&baseDate=20260405&baseTime=0500" }); assert.equal(response.statusCode, 200); assert.equal(response.json().response.header.resultCode, "00"); assert.ok(calledUrl.startsWith("https://apis.data.go.kr/")); assert.match(calledUrl, /serviceKey=kma-key/); assert.match(calledUrl, /base_date=20260405/); assert.match(calledUrl, /base_time=0500/); assert.match(calledUrl, /nx=60/); assert.match(calledUrl, /ny=127/); }); test("korea weather endpoint rejects out-of-range coordinates before reaching upstream", async (t) => { const originalFetch = global.fetch; let fetchCalls = 0; global.fetch = async () => { fetchCalls += 1; throw new Error("fetch should not be called for invalid coordinates"); }; const app = buildServer({ env: { KMA_OPEN_API_KEY: "kma-key" } }); t.after(async () => { global.fetch = originalFetch; await app.close(); }); const response = await app.inject({ method: "GET", url: "/v1/korea-weather/forecast?lat=91&lon=126.978" }); assert.equal(response.statusCode, 400); assert.deepEqual(response.json(), { error: "bad_request", message: "Provide valid lat and lon." }); assert.equal(fetchCalls, 0); }); test("korea weather endpoint returns 503 when proxy server lacks KMA API key", async (t) => { const app = buildServer(); t.after(async () => { await app.close(); }); const response = await app.inject({ method: "GET", url: "/v1/korea-weather/forecast?nx=60&ny=127" }); assert.equal(response.statusCode, 503); assert.equal(response.json().error, "upstream_not_configured"); }); test("proxyKmaWeatherRequest injects API key and preserves caller query params", async () => { let calledUrl; const result = await proxyKmaWeatherRequest({ baseDate: "20260405", baseTime: "0500", nx: 60, ny: 127, pageNo: 2, numOfRows: 50, dataType: "JSON", apiKey: "test-kma-key", fetchImpl: async (url) => { calledUrl = String(url); return new Response('{"ok":true}', { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }); } }); assert.equal(result.statusCode, 200); assert.ok(calledUrl.startsWith("https://apis.data.go.kr/")); assert.match(calledUrl, /\/1360000\/VilageFcstInfoService_2\.0\/getVilageFcst\?/); assert.match(calledUrl, /serviceKey=test-kma-key/); assert.match(calledUrl, /base_date=20260405/); assert.match(calledUrl, /base_time=0500/); assert.match(calledUrl, /nx=60/); assert.match(calledUrl, /ny=127/); assert.match(calledUrl, /pageNo=2/); assert.match(calledUrl, /numOfRows=50/); assert.match(calledUrl, /dataType=JSON/); }); test("han river water-level endpoint stays publicly callable without proxy auth", async (t) => { const originalFetch = global.fetch; const fetchCalls = []; global.fetch = async (url) => { const text = String(url); fetchCalls.push(text); if (text.endsWith("/waterlevel/info.json")) { return new Response( JSON.stringify({ content: [ { wlobscd: "1018683", obsnm: "한강대교", agcnm: "한강홍수통제소", addr: "서울특별시 용산구", etcaddr: "한강대교", attwl: "5.5", wrnwl: "8.0", almwl: "10.0", srswl: "11.0", pfh: "13.0", fstnyn: "Y" } ] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); } if (text.includes("/waterlevel/list/10M/1018683.json")) { return new Response( JSON.stringify({ content: [ { wlobscd: "1018683", ymdhm: "202604051900", wl: "0.66", fw: "208.58" } ] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); } throw new Error(`unexpected URL: ${url}`); }; const app = buildServer({ env: { HRFCO_OPEN_API_KEY: "hrfco-key" } }); t.after(async () => { global.fetch = originalFetch; await app.close(); }); const response = await app.inject({ method: "GET", url: "/v1/han-river/water-level?stationName=%ED%95%9C%EA%B0%95%EB%8C%80%EA%B5%90" }); assert.equal(response.statusCode, 200); assert.equal(response.json().station_name, "한강대교"); assert.equal(response.json().water_level.value_m, 0.66); assert.equal(response.json().flow_rate.value_cms, 208.58); assert.equal(response.json().proxy.cache.hit, false); assert.match(fetchCalls[0], /\/waterlevel\/info\.json$/); assert.match(fetchCalls[1], /\/waterlevel\/list\/10M\/1018683\.json$/); }); test("han river water-level endpoint caches normalized station queries", async (t) => { const originalFetch = global.fetch; let fetchCalls = 0; global.fetch = async (url) => { fetchCalls += 1; const text = String(url); if (text.endsWith("/waterlevel/info.json")) { return new Response( JSON.stringify({ content: [ { wlobscd: "1018683", obsnm: "한강대교", agcnm: "한강홍수통제소", addr: "서울특별시 용산구", etcaddr: "한강대교" } ] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); } if (text.includes("/waterlevel/list/10M/1018683.json")) { return new Response( JSON.stringify({ content: [ { wlobscd: "1018683", ymdhm: "202604051900", wl: "0.66", fw: "208.58" } ] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); } throw new Error(`unexpected URL: ${url}`); }; const app = buildServer({ env: { HRFCO_OPEN_API_KEY: "hrfco-key", KSKILL_PROXY_CACHE_TTL_MS: "60000" } }); t.after(async () => { global.fetch = originalFetch; await app.close(); }); const first = await app.inject({ method: "GET", url: "/v1/han-river/water-level?station=%20%ED%95%9C%EA%B0%95%EB%8C%80%EA%B5%90%20" }); const second = await app.inject({ method: "GET", url: "/v1/han-river/water-level?stationName=%ED%95%9C%EA%B0%95%EB%8C%80%EA%B5%90" }); assert.equal(first.statusCode, 200); assert.equal(second.statusCode, 200); assert.equal(fetchCalls, 2); assert.equal(first.json().proxy.cache.hit, false); assert.equal(second.json().proxy.cache.hit, true); }); test("han river water-level endpoint returns ambiguous candidates for broad station names", async (t) => { const originalFetch = global.fetch; global.fetch = async (url) => { const text = String(url); if (text.endsWith("/waterlevel/info.json")) { return new Response( JSON.stringify({ content: [ { wlobscd: "1018683", obsnm: "한강대교", agcnm: "한강홍수통제소" }, { wlobscd: "1018680", obsnm: "한강철교", agcnm: "한강홍수통제소" } ] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); } throw new Error(`unexpected URL: ${url}`); }; const app = buildServer({ env: { HRFCO_OPEN_API_KEY: "hrfco-key" } }); t.after(async () => { global.fetch = originalFetch; await app.close(); }); const response = await app.inject({ method: "GET", url: "/v1/han-river/water-level?stationName=%ED%95%9C%EA%B0%95" }); assert.equal(response.statusCode, 400); assert.equal(response.json().error, "ambiguous_station"); assert.deepEqual(response.json().candidate_stations, ["한강대교", "한강철교"]); }); test("han river water-level endpoint returns 503 when proxy server lacks HRFCO API key", async (t) => { const app = buildServer(); t.after(async () => { await app.close(); }); const response = await app.inject({ method: "GET", url: "/v1/han-river/water-level?stationName=%ED%95%9C%EA%B0%95%EB%8C%80%EA%B5%90" }); assert.equal(response.statusCode, 503); assert.equal(response.json().error, "upstream_not_configured"); }); test("proxyHrfcoWaterLevelRequest injects API key and resolves station code path", async () => { let calledUrls = []; const result = await proxyHrfcoWaterLevelRequest({ stationName: "한강대교", apiKey: "test-hrfco-key", fetchImpl: async (url) => { calledUrls.push(String(url)); if (String(url).endsWith("/waterlevel/info.json")) { return new Response( JSON.stringify({ content: [ { wlobscd: "1018683", obsnm: "한강대교", agcnm: "한강홍수통제소" } ] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); } return new Response( JSON.stringify({ content: [ { wlobscd: "1018683", ymdhm: "202604051900", wl: "0.66", fw: "208.58" } ] }), { status: 200, headers: { "content-type": "application/json;charset=UTF-8" } } ); } }); assert.equal(result.statusCode, 200); assert.equal(JSON.parse(result.body).station_code, "1018683"); assert.match(calledUrls[0], /\/test-hrfco-key\/waterlevel\/info\.json$/); assert.match(calledUrls[1], /\/test-hrfco-key\/waterlevel\/list\/10M\/1018683\.json$/); }); const SAMPLE_APT_TRADE_XML = `
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: { item: [ { 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: { item: [ { 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 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))); });