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

## What changed

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

## How it was verified

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

210 lines
7.6 KiB
JavaScript

const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const {
DEFAULT_PROXY_BASE_URL,
buildOfficialParkingLotApiUrl,
normalizeParkingLotRows,
parseCoordinateQuery,
searchNearbyParkingLotsByCoordinates,
searchNearbyParkingLotsByLocationQuery
} = require("../src/index");
const fixturesDir = path.join(__dirname, "fixtures");
const anchorSearchHtml = fs.readFileSync(path.join(fixturesDir, "anchor-search.html"), "utf8");
const anchorPanel = JSON.parse(fs.readFileSync(path.join(fixturesDir, "anchor-panel.json"), "utf8"));
const parkingApiResponse = JSON.parse(fs.readFileSync(path.join(fixturesDir, "parking-api-response.json"), "utf8"));
const ORIGIN = {
latitude: 37.57371315593711,
longitude: 126.97833785777944
};
test("parseCoordinateQuery recognizes latitude/longitude pairs", () => {
assert.deepEqual(parseCoordinateQuery("37.573713, 126.978338"), {
latitude: 37.573713,
longitude: 126.978338
});
assert.equal(parseCoordinateQuery("광화문"), null);
});
test("buildOfficialParkingLotApiUrl targets the standard public parking API with safe filters", () => {
const url = new URL(buildOfficialParkingLotApiUrl({
serviceKey: "data-key",
pageNo: 2,
numOfRows: 500,
addressHint: "서울특별시 종로구",
parkingType: "노외",
publicOnly: true
}));
assert.equal(url.origin + url.pathname, "https://api.data.go.kr/openapi/tn_pubr_prkplce_info_api");
assert.equal(url.searchParams.get("serviceKey"), "data-key");
assert.equal(url.searchParams.get("type"), "json");
assert.equal(url.searchParams.get("pageNo"), "2");
assert.equal(url.searchParams.get("numOfRows"), "500");
assert.equal(url.searchParams.get("prkplceSe"), "공영");
assert.equal(url.searchParams.get("prkplceType"), "노외");
assert.equal(url.searchParams.get("rdnmadr"), "서울특별시 종로구");
});
test("normalizeParkingLotRows keeps public parking metadata and sorts by distance", () => {
const items = normalizeParkingLotRows(parkingApiResponse, ORIGIN);
assert.equal(items.length, 2);
assert.deepEqual(items.map((item) => [item.id, item.name, item.category, item.type]), [
["111-2-000003", "광화문광장 공영주차장", "공영", "노상"],
["111-2-000001", "종로구청 공영주차장", "공영", "노외"]
]);
assert.equal(items[0].feeInfo, "무료");
assert.equal(items[0].capacity, 20);
assert.equal(items[0].weekday.open, "09:00");
assert.equal(items[0].weekday.close, "21:00");
assert.equal(items[0].hasAccessibleParking, false);
assert.equal(items[1].basicCharge, 1000);
assert.equal(items[1].hasAccessibleParking, true);
assert.equal(items[0].providerCode, "3000000");
assert.equal(items[0].providerName, "서울특별시 종로구");
assert.equal(items[1].providerCode, "3000000");
assert.equal(items[1].providerName, "서울특별시 종로구");
assert.equal(
items[0].mapUrl,
"https://map.kakao.com/link/map/%EA%B4%91%ED%99%94%EB%AC%B8%EA%B4%91%EC%9E%A5%20%EA%B3%B5%EC%98%81%EC%A3%BC%EC%B0%A8%EC%9E%A5,37.57375,126.97836"
);
});
test("normalizeParkingLotRows can include private parking when requested", () => {
const items = normalizeParkingLotRows(parkingApiResponse, ORIGIN, { publicOnly: false });
assert.equal(items.length, 3);
assert.equal(items[0].name, "세종로 민영주차장");
assert.equal(items[0].category, "민영");
});
test("searchNearbyParkingLotsByCoordinates uses the official API directly when apiKey is provided", async () => {
const calls = [];
const fetchImpl = async (url) => {
calls.push(String(url));
return makeResponse(parkingApiResponse);
};
const result = await searchNearbyParkingLotsByCoordinates({
...ORIGIN,
limit: 1,
radius: 500,
addressHint: "서울특별시 종로구",
apiKey: "data-key",
fetchImpl
});
assert.equal(result.items.length, 1);
assert.equal(result.items[0].name, "광화문광장 공영주차장");
assert.equal(result.meta.total, 2);
assert.equal(result.meta.source, "data.go.kr");
assert.match(calls[0], /^https:\/\/api\.data\.go\.kr\/openapi\/tn_pubr_prkplce_info_api\?/);
assert.match(calls[0], /rdnmadr=%EC%84%9C%EC%9A%B8%ED%8A%B9%EB%B3%84%EC%8B%9C\+%EC%A2%85%EB%A1%9C%EA%B5%AC/);
});
test("searchNearbyParkingLotsByCoordinates uses k-skill-proxy by default", async () => {
const calls = [];
const fetchImpl = async (url) => {
calls.push(String(url));
return makeResponse({
anchor: { ...ORIGIN, name: "입력 좌표" },
items: normalizeParkingLotRows(parkingApiResponse, ORIGIN),
meta: { total: 2, limit: 2, source: "k-skill-proxy" }
});
};
const result = await searchNearbyParkingLotsByCoordinates({
...ORIGIN,
limit: 2,
addressHint: "서울특별시 종로구",
fetchImpl
});
assert.equal(result.items.length, 2);
assert.equal(result.meta.source, "k-skill-proxy");
const url = new URL(calls[0]);
assert.equal(url.origin, DEFAULT_PROXY_BASE_URL);
assert.equal(url.pathname, "/v1/parking-lots/search");
assert.equal(url.searchParams.get("latitude"), String(ORIGIN.latitude));
assert.equal(url.searchParams.get("longitude"), String(ORIGIN.longitude));
assert.equal(url.searchParams.get("address_hint"), "서울특별시 종로구");
});
test("searchNearbyParkingLotsByLocationQuery resolves a Kakao anchor and derives an address hint", async () => {
const calls = [];
const fetchImpl = async (url) => {
const resolved = String(url);
calls.push(resolved);
if (resolved.startsWith("https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8")) {
return makeResponse(anchorSearchHtml, "text/html");
}
if (resolved === "https://place-api.map.kakao.com/places/panel3/1001") {
return makeResponse(anchorPanel, "application/json");
}
if (resolved.startsWith("https://api.data.go.kr/openapi/tn_pubr_prkplce_info_api?")) {
const urlObject = new URL(resolved);
assert.equal(urlObject.searchParams.get("rdnmadr"), "서울특별시 종로구");
return makeResponse(parkingApiResponse);
}
throw new Error(`unexpected url: ${resolved}`);
};
const result = await searchNearbyParkingLotsByLocationQuery("광화문", {
limit: 2,
apiKey: "data-key",
fetchImpl
});
assert.equal(result.anchor.name, "광화문");
assert.equal(result.anchor.address, "서울특별시 종로구 세종대로 172");
assert.equal(result.items.length, 2);
assert.deepEqual(calls.slice(0, 2), [
"https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8",
"https://place-api.map.kakao.com/places/panel3/1001"
]);
});
test("searchNearbyParkingLotsByCoordinates validates inputs", async () => {
await assert.rejects(
searchNearbyParkingLotsByCoordinates({ latitude: "x", longitude: 126.9 }),
/latitude and longitude must be finite numbers/
);
await assert.rejects(
searchNearbyParkingLotsByCoordinates({ ...ORIGIN, limit: 0 }),
/limit must be between 1 and 50/
);
await assert.rejects(
searchNearbyParkingLotsByCoordinates({ ...ORIGIN, radius: 0 }),
/radius must be between 1 and 50000/
);
});
function makeResponse(body, contentType = "application/json;charset=UTF-8") {
return {
ok: true,
status: 200,
headers: {
get(name) {
if (String(name).toLowerCase() === "content-type") {
return contentType;
}
return null;
}
},
async text() {
return typeof body === "string" ? body : JSON.stringify(body);
},
async json() {
return typeof body === "string" ? JSON.parse(body) : body;
}
};
}