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>
159 lines
5.9 KiB
TypeScript
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();
|
|
});
|
|
});
|