Files
oO/services/api/src/routes/__tests__/recommender.test.ts
alvis c65bedcf68 feat(api): orchestrator cutover — replace bandit with multi-agent pipeline (ADR-0013 step 6)
POST /recommend now calls ml/serving /recommend with pre-computed agent
snippets + task context instead of /generate + /score/egreedy/v2. Falls
back to a random signal candidate when ml/serving is unavailable.

Removes: remotePolicy, fetchLlmCandidates, sendRewardWithRetry,
candidateCache, pickPromptVersion. Feedback handler keeps inferReward +
tipFeedback writes for observability; reward delivery to the bandit is gone.
tipScores.policy is now 'orchestrator'; promptVersion is 'v4-orchestrator'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 10:37:15 +00:00

159 lines
5.9 KiB
TypeScript

/**
* Integration tests for POST /recommend and tip_scores DB writes.
* Uses a real in-memory SQLite DB. recommender is imported dynamically
* inside beforeAll (same pattern as admin.test.ts) to avoid TDZ issues.
* Uses http.request (not fetch) as the test client so that globalThis.fetch
* mocking doesn't interfere with the test runner itself.
*
* The orchestrator path (ADR-0013): signals fetched for task context/fallback,
* then ml/serving /recommend called. agent_outputs table is empty in tests so
* the orchestrator always uses the raw-task fallback path.
*/
import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
import express from 'express';
import * as http from 'http';
import { makeTestDb } from '../../test/db.js';
import { users, integrationTokens, tipScores } from '../../db/schema.js';
const testDb = makeTestDb();
vi.mock('../../db/index.js', () => ({ db: testDb, rawSqlite: testDb.rawSqlite }));
vi.mock('../../middleware/session.js', () => ({
sessionMiddleware: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(),
requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as any).userId = 'user-1';
next();
},
}));
vi.mock('../../events/bus.js', () => ({ bus: { publish: vi.fn() } }));
/** Minimal http.request wrapper → { status, body } */
function post(url: string): Promise<{ status: number; body: any }> {
return new Promise((resolve, reject) => {
const u = new URL(url);
const req = http.request(
{ hostname: u.hostname, port: Number(u.port), path: u.pathname, method: 'POST',
headers: { 'Content-Type': 'application/json' } },
(res) => {
let data = '';
res.on('data', (c) => { data += c; });
res.on('end', () => {
try { resolve({ status: res.statusCode ?? 0, body: data ? JSON.parse(data) : null }); }
catch { resolve({ status: res.statusCode ?? 0, body: data }); }
});
},
);
req.on('error', reject);
req.end();
});
}
describe('POST /recommend integration', () => {
let server: http.Server;
let baseUrl: string;
let savedFetch: typeof globalThis.fetch;
let clearSignalCache: () => void;
beforeAll(async () => {
await testDb.insert(users).values({
id: 'user-1', email: 'u@test.com', role: 'user',
consentGiven: true, createdAt: new Date().toISOString(),
});
await testDb.insert(integrationTokens).values({
id: 'tok-1', userId: 'user-1', provider: 'todoist',
accessToken: 'fake-token', connectedAt: new Date().toISOString(),
tokenStatus: 'active',
});
const mod = await import('../recommender.js');
const { recommenderRouter } = mod;
clearSignalCache = (mod as any)._clearSignalCacheForTests;
const app = express();
app.use(express.json());
app.use('/api', recommenderRouter);
server = app.listen(0);
const addr = server.address() as { port: number };
baseUrl = `http://localhost:${addr.port}`;
savedFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = savedFetch;
clearSignalCache?.();
});
it('returns 204 when Todoist is empty and orchestrator fails', async () => {
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
if (String(url).includes('todoist.com')) {
return Promise.resolve({ ok: true, status: 200, json: async () => ({ results: [] }) } as any);
}
// /recommend fails → orchestrator returns null, random fallback also empty → 204
return Promise.resolve({ ok: false, status: 503 } as any);
});
const { status } = await post(`${baseUrl}/api/recommend`);
expect(status).toBe(204);
});
it('serves orchestrator tip and writes correct tip_scores columns', async () => {
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
if (String(url).includes('todoist.com')) {
return Promise.resolve({
ok: true, status: 200,
json: async () => ({
results: [{ id: 'task-1', content: 'Write tests', priority: 3, due: { date: '2026-04-10' } }],
}),
} as any);
}
if (String(url).includes('/recommend')) {
return Promise.resolve({
ok: true, status: 200,
json: async () => ({
tip: { id: 'adv-1', content: 'Take a break.', rationale: 'You deserve it.' },
model: 'tip-generator',
}),
} as any);
}
return Promise.resolve({ ok: false, status: 500 } as any);
});
const { status, body } = await post(`${baseUrl}/api/recommend`);
expect(status).toBe(200);
expect(body.tip.source).toBe('llm');
expect(body.tip.kind).toBe('advice');
expect(body.tip.rationale).toBe('You deserve it.');
const rows = await testDb.select().from(tipScores);
const row = rows[rows.length - 1];
expect(row.policy).toBe('orchestrator');
expect(row.promptVersion).toBe('v4-orchestrator');
expect(row.llmModel).toBe('tip-generator');
expect(row.mlScore).toBeNull();
expect(row.tipKind).toBe('advice');
});
it('falls back to random signal tip when orchestrator fails', async () => {
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
if (String(url).includes('todoist.com')) {
return Promise.resolve({
ok: true, status: 200,
json: async () => ({
results: [{ id: 'fallback-1', content: 'Do stuff', priority: 2, due: null }],
}),
} as any);
}
// /recommend fails → falls back to random signal candidate
return Promise.resolve({ ok: false, status: 502 } as any);
});
const { status, body } = await post(`${baseUrl}/api/recommend`);
expect(status).toBe(200);
expect(body.tip.source).toBe('todoist');
const rows = await testDb.select().from(tipScores);
const row = rows[rows.length - 1];
expect(row.policy).toBe('random');
expect(row.promptVersion).toBeNull();
expect(row.llmModel).toBeNull();
});
});