/** * 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(); }); });