mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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` 정상 동작 확인.
210 lines
7.6 KiB
JavaScript
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;
|
|
}
|
|
};
|
|
}
|