Files
kme_content_adapter/tests/unit/proxy.test.js
Peter.Morton f840587e5e feat: content fetch, sitemap fixes, remove oidcAuthFlow
- Add contentFetchFlow() to proxy (FR-001 through FR-012)
- Add extractArticleBody() helper with vkm:articleBody / articleBody fallback
- Dynamic proxyBaseUrl derivation from x-forwarded-proto/host headers
- Forward query/size/category params on /sitemap.xml requests
- Add Accept: application/ld+json header to content API calls
- Remove oidcAuthFlow() - unmatched requests now return 404 Not Found
- Fix xmlbuilder2 import: default import, call as xmlbuilder2.create(...)
- Version bump 0.2.0 → 0.3.0
- 45/45 tests passing

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 16:40:06 -05:00

698 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import xmlbuilder2 from 'xmlbuilder2';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const proxyPath = join(__dirname, '../../src/proxyScripts/kmeContentSourceAdapter.js');
const proxyCode = readFileSync(proxyPath, 'utf-8');
const proxyScript = new vm.Script(proxyCode, { filename: 'kmeContentSourceAdapter.js' });
const helpersPath = join(__dirname, '../../src/globalVariables/kmeContentSourceAdapterHelpers.js');
const helpersCode = readFileSync(helpersPath, 'utf-8');
const helpersWrapped = `(function() {\n${helpersCode}\n})()`;
const helpersScript = new vm.Script(helpersWrapped, { filename: 'kmeContentSourceAdapterHelpers.js' });
/**
* Evaluate the helpers file in a context built from the provided deps, returning
* the helpers object. Mirrors how server.js loads globalVariables/ JS files.
*/
function makeHelpers(deps) {
return helpersScript.runInContext(vm.createContext(deps));
}
/**
* Build a minimal VM context satisfying the vm-context contract.
* @param {import('node:test').TestContext} t
* @param {object} [overrides] shallow-merge over defaults
*/
function makeContext(t, overrides = {}) {
const _store = {};
const redis = {
hSet: t.mock.fn(async (key, field, value) => {
_store[`${key}:${field}`] = value;
return 1;
}),
hGet: t.mock.fn(async (key, field) => _store[`${key}:${field}`] ?? null),
};
let statusCode = null;
let body = '';
const headers = {};
const res = {
writeHead: t.mock.fn((code, hdrs = {}) => {
statusCode = code;
Object.assign(headers, hdrs);
}),
end: t.mock.fn((b = '') => { body += String(b); }),
get statusCode() { return statusCode; },
get body() { return body; },
get headers() { return headers; },
};
const defaultSettings = {
tokenUrl: 'https://auth.example.com/token',
username: 'testuser',
password: 'testpass',
clientId: 'test-client',
scope: 'openid',
};
const defaultAxiosMock = {
post: t.mock.fn(async () => ({
data: { id_token: 'mock-token', expires_in: 9_999_999_999 },
})),
get: t.mock.fn(async () => ({
data: { 'hydra:member': [] },
})),
};
// Resolve the final axios and settings — overrides take precedence.
// Helpers must close over the SAME axios/settings that the VM context will use,
// otherwise tests that pass error-throwing axios overrides would get helpers
// that still use the success-returning default.
const resolvedAxios = overrides.axios ?? defaultAxiosMock;
const resolvedSettings = overrides.kme_CSA_settings ?? defaultSettings;
const kmeContentSourceAdapterHelpers = makeHelpers({
URLSearchParams,
console,
axios: resolvedAxios,
redis,
kme_CSA_settings: resolvedSettings,
xmlbuilder2,
});
const ctx = vm.createContext({
URLSearchParams,
URL,
console,
axios: resolvedAxios,
redis,
kme_CSA_settings: defaultSettings,
xmlbuilder2,
kmeContentSourceAdapterHelpers,
req: { url: '/', method: 'GET', headers: {} },
res,
...overrides,
});
// Expose helpers for assertion
ctx._redis = redis;
ctx._res = res;
ctx._store = _store;
ctx._axios = resolvedAxios;
return ctx;
}
/** Run the proxy script in a context and await the async IIFE completing. */
async function runScript(ctx) {
// script.runInContext() returns the Promise from the async IIFE
const result = proxyScript.runInContext(ctx);
if (result && typeof result.then === 'function') {
await result;
}
}
// ---------------------------------------------------------------------------
// User Story 1 — Successful Authenticated Request
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Phase 6 — Stampede Guard (FR-013)
// ---------------------------------------------------------------------------
describe('stampede guard', () => {
test('concurrent requests → exactly one fetch, both get 200', async (t) => {
// Shared kme_CSA_settings across both contexts (same reference)
const kme_CSA_settings = {
tokenUrl: 'https://auth.example.com/token',
username: 'testuser',
password: 'testpass',
clientId: 'test-client',
scope: 'openid',
};
// Shared Redis store
const _store = {};
const redis = {
hSet: t.mock.fn(async (key, field, value) => {
_store[`${key}:${field}`] = value;
return 1;
}),
hGet: t.mock.fn(async (key, field) => _store[`${key}:${field}`] ?? null),
};
// Slow axios post mock — 50ms delay before returning token
const mockAxiosPost = t.mock.fn(async () => {
await new Promise(resolve => setTimeout(resolve, 50));
return { data: { id_token: 'stampede-token', expires_in: 9_999_999_999 } };
});
const mockAxiosGet = t.mock.fn(async () => ({
data: { 'vkm:articleBody': '<p>Article</p>' },
}));
const sharedAxios = { post: mockAxiosPost, get: mockAxiosGet };
// Build two contexts sharing kme_CSA_settings, redis, and axios references
function makeRes(tctx) {
let statusCode = null;
let body = '';
return {
writeHead: tctx.mock.fn((code) => { statusCode = code; }),
end: tctx.mock.fn((b = '') => { body += String(b); }),
get statusCode() { return statusCode; },
get body() { return body; },
};
}
const res1 = makeRes(t);
const res2 = makeRes(t);
// Helpers must share the same redis/kme_CSA_settings/axios so the stampede guard works
const sharedHelpers = makeHelpers({
URLSearchParams, console, axios: sharedAxios,
redis, kme_CSA_settings, xmlbuilder2,
});
const kmeURL = encodeURIComponent('https://kme.example.com/article/1');
const ctx1 = vm.createContext({
URLSearchParams, URL, console, axios: sharedAxios,
redis, kme_CSA_settings, xmlbuilder2,
kmeContentSourceAdapterHelpers: sharedHelpers,
req: { url: `/?kmeURL=${kmeURL}`, method: 'GET', headers: {} },
res: res1,
});
const ctx2 = vm.createContext({
URLSearchParams, URL, console, axios: sharedAxios,
redis, kme_CSA_settings, xmlbuilder2,
kmeContentSourceAdapterHelpers: sharedHelpers,
req: { url: `/?kmeURL=${kmeURL}`, method: 'GET', headers: {} },
res: res2,
});
// Fire both concurrently
const p1 = proxyScript.runInContext(ctx1);
const p2 = proxyScript.runInContext(ctx2);
await Promise.all([p1, p2]);
assert.strictEqual(mockAxiosPost.mock.calls.length, 1, 'stampede guard: only one fetch');
assert.strictEqual(res1.statusCode, 200);
assert.strictEqual(res2.statusCode, 200);
assert.ok(res1.body.includes('<p>Article</p>'));
assert.ok(res2.body.includes('<p>Article</p>'));
});
});
// ---------------------------------------------------------------------------
// Sitemap flow — US1 (T004)
// ---------------------------------------------------------------------------
describe('sitemap flow', () => {
function makeSitemapContext(t, axiosGetImpl, settingsOverrides = {}) {
const ctx = makeContext(t, {
req: { url: '/sitemap.xml', method: 'GET', headers: { host: 'proxy.example.com', 'x-forwarded-proto': 'https' } },
});
// Add sitemap-specific settings
ctx.kme_CSA_settings.searchApiBaseUrl = 'https://search.example.com/api';
ctx.kme_CSA_settings.tenant = 'test-tenant';
Object.assign(ctx.kme_CSA_settings, settingsOverrides);
// Pre-seed token cache so getValidToken() returns immediately
ctx._store['authorization:token'] = 'sitemap-token';
ctx._store['authorization:expiry'] = '9999999999';
// Replace axios.get with the provided implementation
ctx._axios.get = t.mock.fn(axiosGetImpl ?? (async () => ({
data: { 'hydra:member': [] },
})));
return ctx;
}
test('happy path — items present → 200 with correct XML and loc values', async (t) => {
const ctx = makeSitemapContext(t, async () => ({
data: {
'hydra:member': [
{ 'hydra:member': [{ 'vkm:url': 'https://kme.example.com/doc-1' }] },
{ 'hydra:member': [{ 'vkm:url': 'https://kme.example.com/doc-2' }] },
],
},
}));
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 200);
assert.strictEqual(ctx._res.headers['Content-Type'], 'application/xml');
assert.ok(ctx._res.body.includes('<?xml'), 'body should start with XML declaration');
assert.ok(ctx._res.body.includes('<urlset'), 'body should contain urlset');
assert.ok(
ctx._res.body.includes('<loc>https://proxy.example.com/?kmeURL=https%3A%2F%2Fkme.example.com%2Fdoc-1</loc>'),
'body should contain encoded loc for doc-1',
);
assert.ok(
ctx._res.body.includes('<loc>https://proxy.example.com/?kmeURL=https%3A%2F%2Fkme.example.com%2Fdoc-2</loc>'),
'body should contain encoded loc for doc-2',
);
});
test('happy path — zero items → 200 with empty urlset', async (t) => {
const ctx = makeSitemapContext(t, async () => ({ data: { 'hydra:member': [] } }));
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 200);
assert.strictEqual(ctx._res.headers['Content-Type'], 'application/xml');
assert.ok(ctx._res.body.includes('<urlset'), 'body should contain urlset');
assert.ok(!ctx._res.body.includes('<url>'), 'body should not contain url elements');
});
test('items with empty vkm:url filtered — only valid items appear', async (t) => {
const ctx = makeSitemapContext(t, async () => ({
data: {
'hydra:member': [
{ 'hydra:member': [{ 'vkm:url': '' }] },
{ 'hydra:member': [{ 'vkm:url': 'https://kme.example.com/valid' }] },
],
},
}));
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 200);
const locMatches = ctx._res.body.match(/<loc>/g);
assert.strictEqual(locMatches?.length ?? 0, 1, 'exactly one <loc> element expected');
assert.ok(ctx._res.body.includes('valid'), 'the valid URL should appear in the loc');
});
// US3 error scenarios (T011b)
test('upstream 503 → 502 with Search service error message', async (t) => {
const searchErr = Object.assign(new Error('Request failed with status code 503'), {
response: { status: 503 },
});
const ctx = makeSitemapContext(t, async () => { throw searchErr; });
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 502);
assert.ok(ctx._res.body.includes('Search service error: HTTP 503'), `body was: ${ctx._res.body}`);
});
test('timeout ECONNABORTED → 504 Search service timeout', async (t) => {
const timeoutErr = Object.assign(new Error('timeout'), { code: 'ECONNABORTED' });
const ctx = makeSitemapContext(t, async () => { throw timeoutErr; });
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 504);
assert.ok(ctx._res.body.includes('Search service timeout'), `body was: ${ctx._res.body}`);
});
test('timeout ERR_CANCELED → 504 Search service timeout', async (t) => {
const timeoutErr = Object.assign(new Error('canceled'), { code: 'ERR_CANCELED' });
const ctx = makeSitemapContext(t, async () => { throw timeoutErr; });
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 504);
assert.ok(ctx._res.body.includes('Search service timeout'), `body was: ${ctx._res.body}`);
});
test('missing searchApiBaseUrl → 500 Configuration error', async (t) => {
const ctx = makeSitemapContext(t, null, { searchApiBaseUrl: undefined });
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 500);
assert.strictEqual(ctx._res.body, 'Configuration error: missing required field: searchApiBaseUrl');
});
test('missing tenant → 500 Configuration error', async (t) => {
const ctx = makeSitemapContext(t, null, { tenant: undefined });
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 500);
assert.strictEqual(ctx._res.body, 'Configuration error: missing required field: tenant');
});
});
// ---------------------------------------------------------------------------
// extractArticleBody helper — T004
// ---------------------------------------------------------------------------
describe('extractArticleBody helper', () => {
const minimalDeps = {
URLSearchParams,
URL,
console,
axios: { post: async () => ({}), get: async () => ({}) },
redis: { hGet: async () => null, hSet: async () => 1 },
kme_CSA_settings: {},
xmlbuilder2,
};
const helpers = makeHelpers(minimalDeps);
test('valid HTML string → returns the string', () => {
assert.strictEqual(helpers.extractArticleBody({ 'vkm:articleBody': '<p>Hello</p>' }), '<p>Hello</p>');
});
test('empty string → null', () => {
assert.strictEqual(helpers.extractArticleBody({ 'vkm:articleBody': '' }), null);
});
test('whitespace-only string → null', () => {
assert.strictEqual(helpers.extractArticleBody({ 'vkm:articleBody': ' ' }), null);
});
test('null field value → null', () => {
assert.strictEqual(helpers.extractArticleBody({ 'vkm:articleBody': null }), null);
});
test('field absent ({}) → null', () => {
assert.strictEqual(helpers.extractArticleBody({}), null);
});
test('null input → null', () => {
assert.strictEqual(helpers.extractArticleBody(null), null);
});
test('non-object input (string) → null', () => {
assert.strictEqual(helpers.extractArticleBody('not-an-object'), null);
});
});
// ---------------------------------------------------------------------------
// US-content-fetch: happy path — T007
// ---------------------------------------------------------------------------
describe('US-content-fetch: happy path', () => {
test('cached token + valid article response → 200 text/html with body', async (t) => {
const contentAxios = {
post: t.mock.fn(async () => ({ data: { id_token: 'mock-token', expires_in: 9_999_999_999 } })),
get: t.mock.fn(async () => ({ data: { 'vkm:articleBody': '<p>Hello</p>' } })),
};
const ctx = makeContext(t, {
req: { url: '/?kmeURL=https://kme.example.com/content/article/123', method: 'GET', headers: {} },
axios: contentAxios,
});
// Pre-seed token cache → cache hit, no axios.post call
ctx._store['authorization:token'] = 'cached-token';
ctx._store['authorization:expiry'] = '9999999999';
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 200);
assert.ok(
ctx._res.headers['Content-Type'].startsWith('text/html'),
`Content-Type was: ${ctx._res.headers['Content-Type']}`,
);
assert.strictEqual(ctx._res.body, '<p>Hello</p>');
assert.strictEqual(contentAxios.post.mock.calls.length, 0, 'should not re-fetch token on cache hit');
});
test('cache miss (fresh token acquired) → 200 text/html with body', async (t) => {
const contentAxios = {
post: t.mock.fn(async () => ({ data: { id_token: 'fresh-token', expires_in: 9_999_999_999 } })),
get: t.mock.fn(async () => ({ data: { 'vkm:articleBody': '<p>Hello</p>' } })),
};
const ctx = makeContext(t, {
req: { url: '/?kmeURL=https://kme.example.com/content/article/123', method: 'GET', headers: {} },
axios: contentAxios,
});
// No pre-seeded token → cache miss → axios.post will be called for fresh token
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 200);
assert.ok(
ctx._res.headers['Content-Type'].startsWith('text/html'),
`Content-Type was: ${ctx._res.headers['Content-Type']}`,
);
assert.strictEqual(ctx._res.body, '<p>Hello</p>');
assert.strictEqual(contentAxios.post.mock.calls.length, 1, 'should have fetched fresh token');
});
});
// ---------------------------------------------------------------------------
// US-content-fetch: input validation — T009
// ---------------------------------------------------------------------------
describe('US-content-fetch: input validation', () => {
test('no kmeURL param → 404 Not Found, axios.get not called', async (t) => {
const ctx = makeContext(t, {
req: { url: '/?someOtherParam=value', method: 'GET', headers: {} },
});
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 404);
assert.strictEqual(ctx._axios.get.mock.calls.length, 0, 'axios.get should not be called');
});
test('empty kmeURL → 400 kmeURL parameter is required', async (t) => {
const ctx = makeContext(t, {
req: { url: '/?kmeURL=', method: 'GET', headers: {} },
});
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 400);
assert.ok(ctx._res.body.includes('kmeURL parameter is required'), `body was: ${ctx._res.body}`);
assert.strictEqual(ctx._axios.get.mock.calls.length, 0, 'axios.get should not be called');
});
test('whitespace-only kmeURL (%20) → 400', async (t) => {
const ctx = makeContext(t, {
req: { url: '/?kmeURL=%20', method: 'GET', headers: {} },
});
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 400);
assert.strictEqual(ctx._axios.get.mock.calls.length, 0, 'axios.get should not be called');
});
test('relative path kmeURL → 400 well-formed absolute http/https URL', async (t) => {
const ctx = makeContext(t, {
req: { url: '/?kmeURL=relative/path', method: 'GET', headers: {} },
});
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 400);
assert.ok(
ctx._res.body.includes('well-formed absolute http/https URL'),
`body was: ${ctx._res.body}`,
);
assert.strictEqual(ctx._axios.get.mock.calls.length, 0, 'axios.get should not be called');
});
test('ftp protocol kmeURL → 400', async (t) => {
const ctx = makeContext(t, {
req: { url: '/?kmeURL=ftp://example.com/article', method: 'GET', headers: {} },
});
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 400);
assert.strictEqual(ctx._axios.get.mock.calls.length, 0, 'axios.get should not be called');
});
test(':::malformed kmeURL → 400', async (t) => {
const ctx = makeContext(t, {
req: { url: '/?kmeURL=:::malformed', method: 'GET', headers: {} },
});
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 400);
assert.strictEqual(ctx._axios.get.mock.calls.length, 0, 'axios.get should not be called');
});
});
// ---------------------------------------------------------------------------
// US-content-fetch: upstream errors — T010
// ---------------------------------------------------------------------------
describe('US-content-fetch: upstream errors', () => {
/** Build a context with kmeURL set and (optionally) a pre-seeded token. */
function makeUpstreamErrCtx(t, axiosGetImpl, { seedToken = true } = {}) {
const ctx = makeContext(t, {
req: { url: '/?kmeURL=https://kme.example.com/article', method: 'GET', headers: {} },
axios: {
post: t.mock.fn(async () => ({ data: { id_token: 'mock-token', expires_in: 9_999_999_999 } })),
get: t.mock.fn(axiosGetImpl),
},
});
if (seedToken) {
ctx._store['authorization:token'] = 'cached-tok';
ctx._store['authorization:expiry'] = '9999999999';
}
return ctx;
}
test('getValidToken throws → 502 token acquisition failed', async (t) => {
const ctx = makeContext(t, {
req: { url: '/?kmeURL=https://kme.example.com/article', method: 'GET', headers: {} },
axios: {
post: t.mock.fn(async () => { throw new Error('token service down'); }),
get: t.mock.fn(),
},
});
// No pre-seeded token → getValidToken will try to POST → throw
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 502);
assert.ok(ctx._res.body.includes('token acquisition failed'), `body was: ${ctx._res.body}`);
});
test('upstream 404 → proxy 404 article not found at upstream', async (t) => {
const err = Object.assign(new Error('Not Found'), { response: { status: 404 } });
const ctx = makeUpstreamErrCtx(t, async () => { throw err; });
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 404);
assert.ok(ctx._res.body.includes('article not found at upstream'), `body was: ${ctx._res.body}`);
});
test('upstream 410 → proxy 404', async (t) => {
const err = Object.assign(new Error('Gone'), { response: { status: 410 } });
const ctx = makeUpstreamErrCtx(t, async () => { throw err; });
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 404);
});
test('upstream 503 → proxy 502 upstream error HTTP 503', async (t) => {
const err = Object.assign(new Error('Service Unavailable'), { response: { status: 503 } });
const ctx = makeUpstreamErrCtx(t, async () => { throw err; });
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 502);
assert.ok(ctx._res.body.includes('upstream error HTTP 503'), `body was: ${ctx._res.body}`);
});
test('ECONNABORTED → proxy 502 upstream request timed out', async (t) => {
const err = Object.assign(new Error('timeout'), { code: 'ECONNABORTED' });
const ctx = makeUpstreamErrCtx(t, async () => { throw err; });
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 502);
assert.ok(ctx._res.body.includes('upstream request timed out'), `body was: ${ctx._res.body}`);
});
test('ERR_CANCELED → proxy 502', async (t) => {
const err = Object.assign(new Error('canceled'), { code: 'ERR_CANCELED' });
const ctx = makeUpstreamErrCtx(t, async () => { throw err; });
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 502);
});
test('network error (no response, no code) → proxy 502 containing Bad Gateway:', async (t) => {
const err = new Error('ENOTFOUND');
const ctx = makeUpstreamErrCtx(t, async () => { throw err; });
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 502);
assert.ok(ctx._res.body.includes('Bad Gateway:'), `body was: ${ctx._res.body}`);
});
});
// ---------------------------------------------------------------------------
// US-content-fetch: body parsing — T011
// ---------------------------------------------------------------------------
describe('US-content-fetch: body parsing', () => {
/** Build a context that will produce a given data value from axios.get. */
function makeBodyCtx(t, dataValue) {
const ctx = makeContext(t, {
req: { url: '/?kmeURL=https://kme.example.com/article', method: 'GET', headers: {} },
axios: {
post: t.mock.fn(async () => ({ data: { id_token: 'mock-token', expires_in: 9_999_999_999 } })),
get: t.mock.fn(async () => ({ data: dataValue })),
},
});
ctx._store['authorization:token'] = 'cached-tok';
ctx._store['authorization:expiry'] = '9999999999';
return ctx;
}
test('unparseable string response → 502 unparseable response from upstream', async (t) => {
const ctx = makeBodyCtx(t, 'not json{{{');
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 502);
assert.ok(
ctx._res.body.includes('unparseable response from upstream'),
`body was: ${ctx._res.body}`,
);
});
test('vkm:articleBody absent (undefined) → 404 article body not present', async (t) => {
const ctx = makeBodyCtx(t, { 'vkm:articleBody': undefined });
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 404);
assert.ok(ctx._res.body.includes('article body not present'), `body was: ${ctx._res.body}`);
});
test('vkm:articleBody is null → 404', async (t) => {
const ctx = makeBodyCtx(t, { 'vkm:articleBody': null });
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 404);
});
test('vkm:articleBody is empty string → 404', async (t) => {
const ctx = makeBodyCtx(t, { 'vkm:articleBody': '' });
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 404);
});
test('vkm:articleBody is whitespace-only → 404', async (t) => {
const ctx = makeBodyCtx(t, { 'vkm:articleBody': ' ' });
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 404);
});
});
// ---------------------------------------------------------------------------
// US-content-fetch: passthrough preserved — T013
// ---------------------------------------------------------------------------
describe('US-content-fetch: passthrough preserved', () => {
test('GET with no kmeURL, not sitemap → 404 Not Found, axios.get not called', async (t) => {
const ctx = makeContext(t, {
req: { url: '/?someOtherParam=value', method: 'GET', headers: {} },
});
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 404);
assert.strictEqual(ctx._axios.get.mock.calls.length, 0, 'axios.get must not be called');
});
});