mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
Server-side: extract shared CORS, validation, caching, prompt building, and response shaping into api/_summarize-handler.js factory. Each endpoint (Groq, OpenRouter, Ollama) becomes a thin wrapper calling createSummarizeHandler() with a provider config: credentials, API URL, model, headers, and provider label. Client-side: replace three near-identical tryOllama/tryGroq/tryOpenRouter functions with a single tryApiProvider() driven by an API_PROVIDERS config array. Add runApiChain() helper that loops the chain with progress callbacks. Simplify translateText() from three copy-pasted blocks to a single loop over the same provider array. - groq-summarize.js: 297 → 30 lines - openrouter-summarize.js: 295 → 33 lines - ollama-summarize.js: 289 → 34 lines - summarization.ts: 336 → 239 lines - New _summarize-handler.js: 315 lines (shared) - Net: -566 lines of duplication removed Adding a new LLM provider now requires only a provider config object in the endpoint file + one entry in the API_PROVIDERS array. Tests: 13 new tests for the shared factory (cache key, dedup, handler creation, fallback, error casing, HTTP methods). All 42 existing tests pass unchanged. https://claude.ai/code/session_01AGg9fG6LZ8Y6XhvLszdfeY
196 lines
6.2 KiB
JavaScript
196 lines
6.2 KiB
JavaScript
/**
|
|
* Tests for api/_summarize-handler.js shared factory
|
|
* Validates that the extracted shared logic (cache key, dedup, handler creation)
|
|
* works identically to the original per-provider implementations.
|
|
*/
|
|
|
|
import { strict as assert } from 'node:assert';
|
|
import test from 'node:test';
|
|
import { createSummarizeHandler, getCacheKey, deduplicateHeadlines } from './_summarize-handler.js';
|
|
|
|
const ORIGINAL_FETCH = globalThis.fetch;
|
|
|
|
test.afterEach(() => {
|
|
globalThis.fetch = ORIGINAL_FETCH;
|
|
});
|
|
|
|
// ── getCacheKey ──
|
|
|
|
test('getCacheKey produces stable keys for same input', () => {
|
|
const a = getCacheKey(['A', 'B'], 'brief', '', 'full', 'en');
|
|
const b = getCacheKey(['A', 'B'], 'brief', '', 'full', 'en');
|
|
assert.equal(a, b);
|
|
});
|
|
|
|
test('getCacheKey varies by mode', () => {
|
|
const brief = getCacheKey(['A'], 'brief', '', 'full', 'en');
|
|
const analysis = getCacheKey(['A'], 'analysis', '', 'full', 'en');
|
|
assert.notEqual(brief, analysis);
|
|
});
|
|
|
|
test('getCacheKey varies by variant', () => {
|
|
const full = getCacheKey(['A'], 'brief', '', 'full', 'en');
|
|
const tech = getCacheKey(['A'], 'brief', '', 'tech', 'en');
|
|
assert.notEqual(full, tech);
|
|
});
|
|
|
|
test('getCacheKey varies by lang', () => {
|
|
const en = getCacheKey(['A'], 'brief', '', 'full', 'en');
|
|
const fr = getCacheKey(['A'], 'brief', '', 'full', 'fr');
|
|
assert.notEqual(en, fr);
|
|
});
|
|
|
|
test('getCacheKey includes geoContext hash', () => {
|
|
const noGeo = getCacheKey(['A'], 'brief', '', 'full', 'en');
|
|
const withGeo = getCacheKey(['A'], 'brief', 'Middle East tensions', 'full', 'en');
|
|
assert.notEqual(noGeo, withGeo);
|
|
});
|
|
|
|
test('getCacheKey translate mode uses variant as target lang', () => {
|
|
const key = getCacheKey(['A'], 'translate', '', 'fr', 'en');
|
|
assert.equal(key.includes(':translate:fr:'), true);
|
|
});
|
|
|
|
// ── deduplicateHeadlines ──
|
|
|
|
test('deduplicateHeadlines removes near-duplicate headlines', () => {
|
|
const headlines = [
|
|
'Iran tests new missile in Strait of Hormuz',
|
|
'Iran tests new missile near Strait of Hormuz region',
|
|
'US Treasury announces new sanctions on Russia',
|
|
];
|
|
const unique = deduplicateHeadlines(headlines);
|
|
assert.equal(unique.length, 2);
|
|
assert.equal(unique[0], headlines[0]);
|
|
assert.equal(unique[1], headlines[2]);
|
|
});
|
|
|
|
test('deduplicateHeadlines keeps distinct headlines', () => {
|
|
const headlines = [
|
|
'SpaceX launches new Starship rocket',
|
|
'Apple announces new AI features for iPhone',
|
|
'Bitcoin surges past $100,000 milestone',
|
|
];
|
|
const unique = deduplicateHeadlines(headlines);
|
|
assert.equal(unique.length, 3);
|
|
});
|
|
|
|
// ── createSummarizeHandler ──
|
|
|
|
test('factory creates handler that returns fallback when credentials missing', async () => {
|
|
const handler = createSummarizeHandler({
|
|
name: 'test-provider',
|
|
logTag: '[Test]',
|
|
skipReason: 'TEST_KEY not configured',
|
|
getCredentials: () => null,
|
|
});
|
|
|
|
const request = new Request('https://worldmonitor.app/api/test', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': '50',
|
|
},
|
|
body: JSON.stringify({ headlines: ['A', 'B'] }),
|
|
});
|
|
|
|
const response = await handler(request);
|
|
assert.equal(response.status, 200);
|
|
const body = await response.json();
|
|
assert.equal(body.fallback, true);
|
|
assert.equal(body.skipped, true);
|
|
assert.equal(body.reason, 'TEST_KEY not configured');
|
|
});
|
|
|
|
test('factory creates handler that calls provider API and returns summary', async () => {
|
|
globalThis.fetch = async () => new Response(JSON.stringify({
|
|
choices: [{ message: { content: 'Test summary.' } }],
|
|
usage: { total_tokens: 10 },
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
|
|
const handler = createSummarizeHandler({
|
|
name: 'test-provider',
|
|
logTag: '[Test]',
|
|
skipReason: 'TEST_KEY not configured',
|
|
getCredentials: () => ({
|
|
apiUrl: 'https://test.example.com/v1/chat/completions',
|
|
model: 'test-model',
|
|
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer test' },
|
|
}),
|
|
});
|
|
|
|
const request = new Request('https://worldmonitor.app/api/test', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': '50',
|
|
'origin': 'https://tauri.localhost',
|
|
},
|
|
body: JSON.stringify({ headlines: ['Event A', 'Event B'] }),
|
|
});
|
|
|
|
const response = await handler(request);
|
|
assert.equal(response.status, 200);
|
|
const body = await response.json();
|
|
assert.equal(body.provider, 'test-provider');
|
|
assert.equal(body.summary, 'Test summary.');
|
|
assert.equal(body.cached, false);
|
|
assert.equal(body.model, 'test-model');
|
|
});
|
|
|
|
test('factory handler returns error message with correct provider name casing', async () => {
|
|
globalThis.fetch = async () => new Response('Internal error', { status: 500 });
|
|
|
|
const handler = createSummarizeHandler({
|
|
name: 'myProvider',
|
|
logTag: '[MyProvider]',
|
|
skipReason: 'n/a',
|
|
getCredentials: () => ({
|
|
apiUrl: 'https://test.example.com/v1/chat/completions',
|
|
model: 'test-model',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
});
|
|
|
|
const request = new Request('https://worldmonitor.app/api/test', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': '50',
|
|
'origin': 'https://tauri.localhost',
|
|
},
|
|
body: JSON.stringify({ headlines: ['Event A', 'Event B'] }),
|
|
});
|
|
|
|
const response = await handler(request);
|
|
const body = await response.json();
|
|
assert.equal(body.fallback, true);
|
|
assert.equal(body.error, 'MyProvider API error');
|
|
});
|
|
|
|
test('factory handler returns 405 for non-POST', async () => {
|
|
const handler = createSummarizeHandler({
|
|
name: 'test',
|
|
logTag: '[Test]',
|
|
skipReason: 'n/a',
|
|
getCredentials: () => null,
|
|
});
|
|
|
|
const request = new Request('https://worldmonitor.app/api/test', { method: 'GET' });
|
|
const response = await handler(request);
|
|
assert.equal(response.status, 405);
|
|
});
|
|
|
|
test('factory handler returns 204 for OPTIONS', async () => {
|
|
const handler = createSummarizeHandler({
|
|
name: 'test',
|
|
logTag: '[Test]',
|
|
skipReason: 'n/a',
|
|
getCredentials: () => null,
|
|
});
|
|
|
|
const request = new Request('https://worldmonitor.app/api/test', { method: 'OPTIONS' });
|
|
const response = await handler(request);
|
|
assert.equal(response.status, 204);
|
|
});
|