k-skill/packages/k-skill-proxy/test/naver-news.test.js
Jeffrey (Dongkyu) Kim 4c7877a5c9 Add naver-news-search skill and /v1/naver-news/search proxy route
Closes #143. Proxies the official Naver Search Open API news endpoint
(openapi.naver.com/v1/search/news.json) through k-skill-proxy so users do
not need to issue their own Naver Client ID/Secret. Reuses the existing
NAVER_SEARCH_CLIENT_ID/NAVER_SEARCH_CLIENT_SECRET that naver-shopping already
consumes, since the Naver Developer application enables the 'Search' scope
covering both news and shopping.

Implementation details:
- src/naver-news.js normalizes q/display/start/sort, builds the official URL,
  calls upstream with X-Naver-Client-Id/Secret headers, and parses the JSON
  response into rank/title/description/link/original_link/pub_date items.
- Strips <b> highlight tags and decodes HTML entities in title/description
  using zero-width replacement so compound Korean words like '주식형' are
  preserved (not split into '주식 형').
- Parses RFC822 pubDate into pub_date_iso (ISO-8601 UTC) for clients.
- Deduplicates items by normalized link; drops entries missing title/link.
- Returns 503 upstream_not_configured when proxy keys are absent (no public
  BFF fallback exists for news like it does for shopping, so keys are
  required).
- Failure responses are not cached (failure-aware cache layer).
- Exposes naverNewsApiConfigured on /health.

14 new tests in test/naver-news.test.js cover query validation, URL
building, payload normalization (HTML stripping, entity decoding,
deduplication, missing-field tolerance), plus Fastify integration tests
for 200/400/401/429/500/503 paths, cache hit/miss, header wiring, and
the health flag.
2026-04-22 12:30:02 +09:00

508 lines
16 KiB
JavaScript

const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildNaverNewsSearchUrl,
normalizeNaverNewsSearchQuery,
normalizeNaverNewsSearchPayload
} = require("../src/naver-news");
const { buildServer } = require("../src/server");
test("normalizeNaverNewsSearchQuery validates q/query and clamps display/start/sort", () => {
assert.throws(() => normalizeNaverNewsSearchQuery({}), /Provide q\/query/);
assert.throws(() => normalizeNaverNewsSearchQuery({ q: "" }), /Provide q\/query/);
assert.throws(() => normalizeNaverNewsSearchQuery({ q: " " }), /Provide q\/query/);
assert.throws(() => normalizeNaverNewsSearchQuery({ q: "a" }), /at least 2/);
assert.deepEqual(
normalizeNaverNewsSearchQuery({ query: " 인공지능 ", display: "999", start: "9999", sort: "date" }),
{
query: "인공지능",
display: 100,
start: 1000,
sort: "date"
}
);
assert.deepEqual(
normalizeNaverNewsSearchQuery({ q: "삼성전자" }),
{
query: "삼성전자",
display: 10,
start: 1,
sort: "sim"
}
);
assert.deepEqual(
normalizeNaverNewsSearchQuery({ q: "정부", display: "0", start: "0", sort: "UNKNOWN" }),
{
query: "정부",
display: 1,
start: 1,
sort: "sim"
}
);
assert.deepEqual(
normalizeNaverNewsSearchQuery({ q: "대한민국", display: "-5", start: "-1" }),
{
query: "대한민국",
display: 1,
start: 1,
sort: "sim"
}
);
});
test("normalizeNaverNewsSearchQuery aliases keyword and caps start+display at 1000 window", () => {
assert.deepEqual(
normalizeNaverNewsSearchQuery({ keyword: "스타트업", display: "50", start: "1000" }),
{
query: "스타트업",
display: 50,
start: 1000,
sort: "sim"
}
);
});
test("buildNaverNewsSearchUrl constructs the official Naver Search news endpoint URL", () => {
const url = buildNaverNewsSearchUrl({
query: "인공지능",
display: 10,
start: 1,
sort: "sim"
});
assert.equal(url.hostname, "openapi.naver.com");
assert.equal(url.pathname, "/v1/search/news.json");
assert.equal(url.searchParams.get("query"), "인공지능");
assert.equal(url.searchParams.get("display"), "10");
assert.equal(url.searchParams.get("start"), "1");
assert.equal(url.searchParams.get("sort"), "sim");
const dateUrl = buildNaverNewsSearchUrl({
query: "삼성전자",
display: 30,
start: 20,
sort: "date"
});
assert.equal(dateUrl.searchParams.get("sort"), "date");
assert.equal(dateUrl.searchParams.get("display"), "30");
assert.equal(dateUrl.searchParams.get("start"), "20");
});
test("normalizeNaverNewsSearchPayload maps Naver API items, strips <b> tags and decodes entities", () => {
const result = normalizeNaverNewsSearchPayload(
{
lastBuildDate: "Mon, 26 Sep 2016 11:01:35 +0900",
total: 2566589,
start: 1,
display: 2,
items: [
{
title: "국내 <b>주식</b>형펀드서 사흘째 자금 순유출",
originallink: "http://app.yonhapnews.co.kr/YNA/Basic/SNS/r.aspx?c=AKR20160926019000008",
link: "http://openapi.naver.com/l?AAAC2NSw",
description: "국내 <b>주식</b>형 펀드에서 사흘째 자금이 &quot;빠져나갔다&quot;. 26일 금융투자협회에 따르면 지난 22일 상장지수펀드(ETF)를 제외한 국내 <b>주식</b>형 펀드에서 126억원이 순유출...",
pubDate: "Mon, 26 Sep 2016 07:50:00 +0900"
},
{
title: "두 번째 &amp; <b>기사</b> 제목",
originallink: "",
link: "https://news.naver.com/main/read.nhn?oid=001&aid=000",
description: "두 번째 기사 본문 요약...",
pubDate: "Mon, 26 Sep 2016 07:00:00 +0900"
}
]
},
{ query: "주식", display: 10, start: 1, sort: "sim" }
);
assert.equal(result.items.length, 2);
const first = result.items[0];
assert.equal(first.rank, 1);
assert.equal(first.title, "국내 주식형펀드서 사흘째 자금 순유출");
assert.equal(first.original_link, "http://app.yonhapnews.co.kr/YNA/Basic/SNS/r.aspx?c=AKR20160926019000008");
assert.equal(first.link, "http://openapi.naver.com/l?AAAC2NSw");
assert.match(first.description, /국내 주식형 펀드에서 사흘째 자금이 "빠져나갔다"/);
assert.doesNotMatch(first.description, /<b>/);
assert.equal(first.pub_date, "Mon, 26 Sep 2016 07:50:00 +0900");
assert.equal(first.pub_date_iso, "2016-09-25T22:50:00.000Z");
assert.equal(first.source, "naver-openapi");
const second = result.items[1];
assert.equal(second.rank, 2);
assert.equal(second.title, "두 번째 & 기사 제목");
assert.equal(second.original_link, null);
assert.equal(second.link, "https://news.naver.com/main/read.nhn?oid=001&aid=000");
assert.equal(result.meta.query, "주식");
assert.equal(result.meta.extraction, "naver-openapi");
assert.equal(result.meta.item_count, 2);
assert.equal(result.meta.total, 2566589);
assert.equal(result.meta.start, 1);
assert.equal(result.meta.display, 2);
assert.equal(result.meta.last_build_date, "Mon, 26 Sep 2016 11:01:35 +0900");
assert.equal(result.meta.sort, "sim");
});
test("normalizeNaverNewsSearchPayload skips items without title or link and deduplicates by link", () => {
const result = normalizeNaverNewsSearchPayload(
{
lastBuildDate: "Mon, 22 Apr 2026 00:00:00 +0900",
total: 3,
start: 1,
display: 3,
items: [
{
title: "정상 기사",
originallink: "https://news.example.com/1",
link: "https://n.news.naver.com/mnews/article/1",
description: "본문",
pubDate: "Mon, 22 Apr 2026 00:00:00 +0900"
},
{
title: "",
originallink: "https://news.example.com/2",
link: "https://n.news.naver.com/mnews/article/2",
description: "본문",
pubDate: "Mon, 22 Apr 2026 00:00:00 +0900"
},
{
title: "중복 link 기사",
originallink: "https://news.example.com/dupe",
link: "https://n.news.naver.com/mnews/article/1",
description: "본문",
pubDate: "Mon, 22 Apr 2026 00:00:00 +0900"
}
]
},
{ query: "테스트", display: 10, start: 1, sort: "sim" }
);
assert.equal(result.items.length, 1);
assert.equal(result.items[0].title, "정상 기사");
});
test("normalizeNaverNewsSearchPayload handles missing optional fields gracefully", () => {
const result = normalizeNaverNewsSearchPayload(
{
lastBuildDate: "Mon, 22 Apr 2026 00:00:00 +0900",
total: 1,
start: 1,
display: 1,
items: [
{
title: "간단 기사",
originallink: "https://news.example.com/1",
link: "https://news.example.com/1",
description: "",
pubDate: "invalid-date-string"
}
]
},
{ query: "테스트", display: 10, start: 1, sort: "sim" }
);
assert.equal(result.items.length, 1);
assert.equal(result.items[0].description, null);
assert.equal(result.items[0].pub_date, "invalid-date-string");
assert.equal(result.items[0].pub_date_iso, null);
});
test("naver news search endpoint returns 400 when query is missing", async (t) => {
const app = buildServer({
env: {
NAVER_SEARCH_CLIENT_ID: "client-id",
NAVER_SEARCH_CLIENT_SECRET: "client-secret",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
await app.close();
});
const response = await app.inject({ method: "GET", url: "/v1/naver-news/search" });
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
});
test("naver news search endpoint returns 503 when proxy credentials are missing", async (t) => {
const app = buildServer({ env: { KSKILL_PROXY_CACHE_TTL_MS: "60000" } });
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/naver-news/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90"
});
assert.equal(response.statusCode, 503);
const body = response.json();
assert.equal(body.error, "upstream_not_configured");
assert.match(body.message, /NAVER_SEARCH_CLIENT_ID/);
});
test("naver news search endpoint proxies to official API with correct headers and params", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url, options = {}) => {
fetchCalls.push({ url: String(url), headers: options.headers });
return new Response(
JSON.stringify({
lastBuildDate: "Mon, 22 Apr 2026 10:00:00 +0900",
total: 1234567,
start: 1,
display: 2,
items: [
{
title: "<b>삼성전자</b> 1분기 실적 발표",
originallink: "https://news.example.com/samsung",
link: "https://n.news.naver.com/mnews/article/samsung",
description: "<b>삼성전자</b>가 올해 1분기 실적을 발표했다.",
pubDate: "Mon, 22 Apr 2026 09:30:00 +0900"
},
{
title: "두 번째 기사",
originallink: "https://news.example.com/second",
link: "https://n.news.naver.com/mnews/article/second",
description: "두 번째 기사 요약",
pubDate: "Mon, 22 Apr 2026 08:00:00 +0900"
}
]
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: {
NAVER_SEARCH_CLIENT_ID: "test-client-id",
NAVER_SEARCH_CLIENT_SECRET: "test-client-secret",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/naver-news/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&display=5&sort=date"
});
assert.equal(response.statusCode, 200);
const body = response.json();
assert.equal(body.query.q, "삼성전자");
assert.equal(body.query.display, 5);
assert.equal(body.query.sort, "date");
assert.equal(body.items.length, 2);
assert.equal(body.items[0].title, "삼성전자 1분기 실적 발표");
assert.equal(body.items[0].description, "삼성전자가 올해 1분기 실적을 발표했다.");
assert.equal(body.items[0].source, "naver-openapi");
assert.equal(body.meta.total, 1234567);
assert.equal(body.meta.extraction, "naver-openapi");
assert.equal(body.upstream.provider, "naver-search-api");
assert.equal(body.proxy.cache.hit, false);
assert.equal(fetchCalls.length, 1);
const upstreamUrl = new URL(fetchCalls[0].url);
assert.equal(upstreamUrl.hostname, "openapi.naver.com");
assert.equal(upstreamUrl.pathname, "/v1/search/news.json");
assert.equal(upstreamUrl.searchParams.get("query"), "삼성전자");
assert.equal(upstreamUrl.searchParams.get("display"), "5");
assert.equal(upstreamUrl.searchParams.get("sort"), "date");
assert.equal(fetchCalls[0].headers["X-Naver-Client-Id"], "test-client-id");
assert.equal(fetchCalls[0].headers["X-Naver-Client-Secret"], "test-client-secret");
});
test("naver news search endpoint caches successful responses and serves hit on second call", async (t) => {
const originalFetch = global.fetch;
let fetchCount = 0;
global.fetch = async () => {
fetchCount += 1;
return new Response(
JSON.stringify({
lastBuildDate: "Mon, 22 Apr 2026 10:00:00 +0900",
total: 1,
start: 1,
display: 1,
items: [
{
title: "캐시 테스트 기사",
originallink: "https://news.example.com/cache",
link: "https://n.news.naver.com/mnews/article/cache",
description: "본문",
pubDate: "Mon, 22 Apr 2026 09:00:00 +0900"
}
]
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: {
NAVER_SEARCH_CLIENT_ID: "client-id",
NAVER_SEARCH_CLIENT_SECRET: "client-secret",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const firstResponse = await app.inject({
method: "GET",
url: "/v1/naver-news/search?q=%EC%BA%90%EC%8B%9C"
});
assert.equal(firstResponse.statusCode, 200);
assert.equal(firstResponse.json().proxy.cache.hit, false);
const secondResponse = await app.inject({
method: "GET",
url: "/v1/naver-news/search?q=%EC%BA%90%EC%8B%9C"
});
assert.equal(secondResponse.statusCode, 200);
assert.equal(secondResponse.json().proxy.cache.hit, true);
assert.equal(fetchCount, 1);
});
test("naver news search endpoint surfaces upstream errors without caching them", async (t) => {
const originalFetch = global.fetch;
let fetchCount = 0;
global.fetch = async () => {
fetchCount += 1;
return new Response(
JSON.stringify({
errorMessage: "Authentication failed",
errorCode: "024"
}),
{ status: 401, headers: { "content-type": "application/json" } }
);
};
const app = buildServer({
env: {
NAVER_SEARCH_CLIENT_ID: "bad-id",
NAVER_SEARCH_CLIENT_SECRET: "bad-secret",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/naver-news/search?q=%EB%B0%B0%EB%93%A0"
});
assert.equal(response.statusCode, 401);
assert.equal(response.json().error, "upstream_error");
assert.equal(response.json().upstream.status_code, 401);
const retry = await app.inject({
method: "GET",
url: "/v1/naver-news/search?q=%EB%B0%B0%EB%93%A0"
});
assert.equal(retry.statusCode, 401);
assert.equal(fetchCount, 2, "failures must not be cached");
});
test("naver news search endpoint surfaces upstream 429 rate-limit and echoes status code", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => {
return new Response(
JSON.stringify({
errorMessage: "Request limit exceeded",
errorCode: "010"
}),
{ status: 429, headers: { "content-type": "application/json" } }
);
};
const app = buildServer({
env: {
NAVER_SEARCH_CLIENT_ID: "client-id",
NAVER_SEARCH_CLIENT_SECRET: "client-secret",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/naver-news/search?q=%EC%A0%9C%ED%95%9C"
});
assert.equal(response.statusCode, 429);
assert.equal(response.json().error, "upstream_error");
});
test("naver news search endpoint reports 5xx upstream failures as 502 proxy error", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => {
return new Response("Internal Server Error", {
status: 500,
headers: { "content-type": "text/plain" }
});
};
const app = buildServer({
env: {
NAVER_SEARCH_CLIENT_ID: "client-id",
NAVER_SEARCH_CLIENT_SECRET: "client-secret",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/naver-news/search?q=%EC%9E%A5%EC%95%A0"
});
assert.equal(response.statusCode, 502);
assert.equal(response.json().error, "upstream_error");
assert.equal(response.json().upstream.status_code, 500);
});
test("health endpoint exposes naverNewsApiConfigured flag based on credentials", async (t) => {
const app = buildServer({
env: {
NAVER_SEARCH_CLIENT_ID: "client-id",
NAVER_SEARCH_CLIENT_SECRET: "client-secret"
}
});
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.upstreams.naverNewsApiConfigured, true);
const appNoKeys = buildServer({ env: {} });
const healthNoKeys = await appNoKeys.inject({ method: "GET", url: "/health" });
assert.equal(healthNoKeys.json().upstreams.naverNewsApiConfigured, false);
await appNoKeys.close();
});