/** * Admin route integration tests. * * A real Express app + in-memory SQLite DB per test suite. * Auth and admin middleware are mocked so we can focus on route logic. */ 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, tipViews, tipFeedback, tipScores, userProfileFeatures } from '../../db/schema.js'; // ---- in-memory DB ---- const testDb = makeTestDb(); vi.mock('../../db/index.js', () => ({ db: testDb, rawSqlite: testDb.rawSqlite })); // Bypass auth — all requests arrive pre-authenticated as 'admin-1' 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 = 'admin-1'; next(); }, })); vi.mock('../../middleware/admin.js', () => ({ requireAdmin: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(), })); const { adminRouter } = await import('../admin.js'); // ---- seed ---- const NOW = new Date().toISOString(); const DAY_AGO = new Date(Date.now() - 23 * 60 * 60 * 1000).toISOString(); beforeAll(async () => { await testDb.insert(users).values([ { id: 'admin-1', email: 'admin@test.com', role: 'admin', consentGiven: true, consentAt: NOW, createdAt: NOW }, { id: 'user-1', email: 'alice@test.com', role: 'user', consentGiven: true, consentAt: NOW, createdAt: NOW }, { id: 'user-2', email: 'bob@test.com', role: 'user', consentGiven: false, createdAt: NOW }, ]); await testDb.insert(integrationTokens).values([ { id: 'tok-1', userId: 'user-1', provider: 'todoist', accessToken: 'secret', connectedAt: NOW }, ]); await testDb.insert(tipViews).values([ { id: 'tv-1', userId: 'user-1', tipId: 'tip:a', servedAt: DAY_AGO }, { id: 'tv-2', userId: 'user-1', tipId: 'tip:b', servedAt: NOW }, { id: 'tv-3', userId: 'user-2', tipId: 'tip:c', servedAt: NOW }, ]); await testDb.insert(tipFeedback).values([ { id: 'tf-1', userId: 'user-1', tipId: 'tip:a', action: 'done', dwellMs: 60_000, rewardMilli: 1000, createdAt: DAY_AGO }, { id: 'tf-2', userId: 'user-1', tipId: 'tip:b', action: 'snooze', dwellMs: null, rewardMilli: 100, createdAt: NOW }, ]); // Seed tip_scores with two LLM models + two prompt_versions for #92. // tip:a (done, r=1.0) → qwen2.5 / v1 / task // tip:b (snooze, r=.1) → qwen2.5 / v2 / advice // tip:c (no feedback) → llama3 / v1 / task await testDb.insert(tipScores).values([ { id: 'ts-1', userId: 'user-1', tipId: 'tip:a', policy: 'egreedy', servedAt: DAY_AGO, llmModel: 'qwen2.5:7b', promptVersion: 'v1', tipKind: 'task' }, { id: 'ts-2', userId: 'user-1', tipId: 'tip:b', policy: 'egreedy', servedAt: NOW, llmModel: 'qwen2.5:7b', promptVersion: 'v2', tipKind: 'advice' }, { id: 'ts-3', userId: 'user-2', tipId: 'tip:c', policy: 'egreedy', servedAt: NOW, llmModel: 'llama3:3b', promptVersion: 'v1', tipKind: 'task' }, ]); }); // ---- test helpers ---- function buildApp() { const app = express(); app.use(express.json()); app.use('/api/admin', adminRouter); return app; } function call( server: http.Server, method: string, path: string, body?: unknown, ): Promise<{ status: number; body: unknown }> { return new Promise((resolve, reject) => { const { port } = server.address() as { port: number }; const req = http.request( { method, hostname: '127.0.0.1', port, path, headers: { 'Content-Type': 'application/json' } }, (res) => { let data = ''; res.on('data', (c) => (data += c)); res.on('end', () => { try { resolve({ status: res.statusCode!, body: JSON.parse(data) }); } catch { resolve({ status: res.statusCode!, body: data }); } }); }, ); req.on('error', reject); if (body) req.write(JSON.stringify(body)); req.end(); }); } function startServer(app: express.Application): Promise<{ server: http.Server; call: (method: string, path: string, body?: unknown) => ReturnType }> { return new Promise((resolve) => { const server = http.createServer(app); server.listen(0, () => resolve({ server, call: (m, p, b) => call(server, m, p, b) }), ); }); } // ---- tests ---- describe('GET /api/admin/stats', () => { it('returns dau, wau, tips, reactions, user totals', async () => { const { server, call } = await startServer(buildApp()); try { const { status, body } = await call('GET', '/api/admin/stats'); const b = body as Record; expect(status).toBe(200); expect(typeof b.dau).toBe('number'); expect(typeof b.wau).toBe('number'); expect(b.tipsServedLast7d).toBeGreaterThanOrEqual(3); expect(b.totalUsers).toBe(3); expect(b.activatedUsers).toBeGreaterThanOrEqual(2); expect(b.reactionsLast7d).toBeDefined(); } finally { server.close(); } }); }); describe('GET /api/admin/users', () => { it('returns paginated list with total', async () => { const { server, call } = await startServer(buildApp()); try { const { status, body } = await call('GET', '/api/admin/users?limit=10&offset=0'); const b = body as { users: unknown[]; total: number }; expect(status).toBe(200); expect(b.total).toBe(3); expect(b.users).toHaveLength(3); } finally { server.close(); } }); it('respects limit', async () => { const { server, call } = await startServer(buildApp()); try { const { status, body } = await call('GET', '/api/admin/users?limit=2&offset=0'); const b = body as { users: unknown[]; total: number }; expect(status).toBe(200); expect(b.users).toHaveLength(2); expect(b.total).toBe(3); } finally { server.close(); } }); }); describe('GET /api/admin/users/:id', () => { it('returns user detail with integrations and tip stats', async () => { const { server, call } = await startServer(buildApp()); try { const { status, body } = await call('GET', '/api/admin/users/user-1'); const b = body as { user: { email: string; role: string }; integrations: { provider: string }[]; tipsServed: number; recentFeedback: unknown[]; }; expect(status).toBe(200); expect(b.user.email).toBe('alice@test.com'); expect(b.user.role).toBe('user'); expect(b.integrations).toHaveLength(1); expect(b.integrations[0].provider).toBe('todoist'); expect(b.tipsServed).toBe(2); expect(b.recentFeedback).toHaveLength(2); } finally { server.close(); } }); it('returns 404 for unknown user', async () => { const { server, call } = await startServer(buildApp()); try { const { status } = await call('GET', '/api/admin/users/nonexistent'); expect(status).toBe(404); } finally { server.close(); } }); it('includes profile feature views (#81 phase B)', async () => { const { server, call } = await startServer(buildApp()); try { const { status, body } = await call('GET', '/api/admin/users/user-1'); expect(status).toBe(200); const b = body as { profile: Array<{ name: string; value: number | string | null; fresh: boolean; ageSec: number | null; ttlSec: number; description: string }> }; expect(Array.isArray(b.profile)).toBe(true); // 5 features registered expect(b.profile.length).toBe(5); const names = b.profile.map((p) => p.name).sort(); expect(names).toEqual([ 'completion_rate_30d', 'dismiss_rate_30d', 'mean_dwell_ms_30d', 'preferred_hour', 'tip_volume_30d', ]); // Read endpoint must NOT trigger compute → all rows fresh=false, ageSec=null for (const r of b.profile) { expect(r.fresh).toBe(false); expect(r.ageSec).toBeNull(); expect(r.ttlSec).toBeGreaterThan(0); expect(r.description.length).toBeGreaterThan(0); } } finally { server.close(); } }); }); describe('POST /api/admin/users/:id/profile/rebuild — #81 phase B', () => { it('recomputes profile, returns fresh values, audit-logs the action', async () => { const { server, call } = await startServer(buildApp()); try { const { status, body } = await call('POST', '/api/admin/users/user-1/profile/rebuild'); expect(status).toBe(200); const b = body as { ok: boolean; profile: Array<{ name: string; value: number | null; fresh: boolean }> }; expect(b.ok).toBe(true); const tipVolume = b.profile.find((r) => r.name === 'tip_volume_30d')!; expect(tipVolume.value).toBe(2); // seed has tv-1 + tv-2 expect(tipVolume.fresh).toBe(true); } finally { server.close(); } }); it('returns 404 for unknown user', async () => { const { server, call } = await startServer(buildApp()); try { const { status } = await call('POST', '/api/admin/users/nonexistent/profile/rebuild'); expect(status).toBe(404); } finally { server.close(); } }); }); describe('GET /api/admin/audit', () => { it('returns list and total', async () => { const { server, call } = await startServer(buildApp()); try { const { status, body } = await call('GET', '/api/admin/audit'); const b = body as { actions: unknown[]; total: number }; expect(status).toBe(200); expect(Array.isArray(b.actions)).toBe(true); expect(typeof b.total).toBe('number'); } finally { server.close(); } }); }); describe('POST /api/admin/users/:id/revoke-integration', () => { it('removes the integration and writes an audit entry', async () => { const { server, call } = await startServer(buildApp()); try { const { status, body } = await call( 'POST', '/api/admin/users/user-1/revoke-integration', { provider: 'todoist' }, ); expect(status).toBe(200); expect((body as { ok: boolean }).ok).toBe(true); // Integration should be gone const detail = await call('GET', '/api/admin/users/user-1'); expect((detail.body as { integrations: unknown[] }).integrations).toHaveLength(0); // Audit log should contain the action const audit = await call('GET', '/api/admin/audit'); const actions = (audit.body as { actions: { action: string }[] }).actions; expect(actions.some((x) => x.action === 'revoke_integration')).toBe(true); } finally { server.close(); } }); it('returns 404 for non-existent integration', async () => { const { server, call } = await startServer(buildApp()); try { const { status } = await call( 'POST', '/api/admin/users/user-2/revoke-integration', { provider: 'todoist' }, ); expect(status).toBe(404); } finally { server.close(); } }); it('returns 400 when provider is missing', async () => { const { server, call } = await startServer(buildApp()); try { const { status } = await call('POST', '/api/admin/users/user-1/revoke-integration', {}); expect(status).toBe(400); } finally { server.close(); } }); it('returns 404 when target user does not exist', async () => { const { server, call } = await startServer(buildApp()); try { const { status } = await call( 'POST', '/api/admin/users/ghost/revoke-integration', { provider: 'todoist' }, ); expect(status).toBe(404); } finally { server.close(); } }); }); describe('GET /api/admin/users — pagination', () => { it('offset skips rows', async () => { const { server, call } = await startServer(buildApp()); try { const page0 = await call('GET', '/api/admin/users?limit=2&offset=0'); const page1 = await call('GET', '/api/admin/users?limit=2&offset=2'); const b0 = page0.body as { users: { id: string }[]; total: number }; const b1 = page1.body as { users: { id: string }[]; total: number }; expect(b0.users).toHaveLength(2); expect(b1.users).toHaveLength(1); // total stays constant expect(b0.total).toBe(3); expect(b1.total).toBe(3); // no overlap const ids0 = b0.users.map((u) => u.id); const ids1 = b1.users.map((u) => u.id); expect(ids0.every((id) => !ids1.includes(id))).toBe(true); } finally { server.close(); } }); it('offset beyond total returns empty users array', async () => { const { server, call } = await startServer(buildApp()); try { const { status, body } = await call('GET', '/api/admin/users?limit=10&offset=999'); const b = body as { users: unknown[]; total: number }; expect(status).toBe(200); expect(b.users).toHaveLength(0); expect(b.total).toBe(3); } finally { server.close(); } }); it('limit is capped at 200', async () => { const { server, call } = await startServer(buildApp()); try { // Passing a huge limit should not crash and should return at most all users const { status, body } = await call('GET', '/api/admin/users?limit=9999'); const b = body as { users: unknown[] }; expect(status).toBe(200); expect(b.users.length).toBeLessThanOrEqual(200); } finally { server.close(); } }); }); describe('GET /api/admin/events', () => { it('returns events array and nextSince', async () => { const { server, call } = await startServer(buildApp()); try { const { status, body } = await call('GET', '/api/admin/events'); const b = body as { events: unknown[]; nextSince: number }; expect(status).toBe(200); expect(Array.isArray(b.events)).toBe(true); expect(typeof b.nextSince).toBe('number'); } finally { server.close(); } }); }); // --------------------------------------------------------------------------- // Health endpoint — mock fetch so tests don't depend on running services. // --------------------------------------------------------------------------- describe('GET /api/admin/health', () => { const EXPECTED_HTTP_SERVICES = ['api', 'ml-serving', 'mlflow'] as const; const EXPECTED_INTERNAL = ['sqlite', 'event-bus'] as const; const VALID_STATUSES = new Set(['ok', 'degraded', 'down']); type ServiceRow = { name: string; status: string; latencyMs: number }; type HealthBody = { ok: boolean; services: ServiceRow[]; checkedAt: string }; function mockFetch(upServices: Set) { // Resolve service name by port (matches defaults in config.ts). // Up services return HTTP 200; absent ones throw (simulates connection refused → 'down'). vi.stubGlobal('fetch', async (url: string) => { const s = String(url); let name: string; if (s.includes(':8000')) name = 'ml-serving'; else if (s.includes(':5000')) name = 'mlflow'; else name = 'api'; if (!upServices.has(name)) throw new Error(`ECONNREFUSED ${name}`); return { ok: true, json: async () => ({ ok: true, status: 'healthy' }) }; }); } afterEach(() => vi.unstubAllGlobals()); it('shape: 200, typed fields, all expected services present', async () => { mockFetch(new Set(['api', 'ml-serving', 'mlflow'])); const { server, call } = await startServer(buildApp()); try { const { status, body } = await call('GET', '/api/admin/health'); const b = body as HealthBody; expect(status).toBe(200); expect(typeof b.ok).toBe('boolean'); expect(Array.isArray(b.services)).toBe(true); expect(typeof b.checkedAt).toBe('string'); expect(new Date(b.checkedAt).getTime()).toBeGreaterThan(0); const names = b.services.map((s) => s.name); for (const svc of [...EXPECTED_HTTP_SERVICES, ...EXPECTED_INTERNAL]) { expect(names).toContain(svc); } for (const svc of b.services) { expect(VALID_STATUSES).toContain(svc.status); expect(typeof svc.latencyMs).toBe('number'); } } finally { server.close(); } }); it('ok=true when all HTTP services respond 200', async () => { mockFetch(new Set(['api', 'ml-serving', 'mlflow'])); const { server, call } = await startServer(buildApp()); try { const { body } = await call('GET', '/api/admin/health'); const b = body as HealthBody; for (const name of EXPECTED_HTTP_SERVICES) { const svc = b.services.find((s) => s.name === name); expect(svc?.status, `${name} should be ok`).toBe('ok'); } expect(b.ok).toBe(true); } finally { server.close(); } }); it('ml-serving=down and ok=false when ml-serving is unreachable', async () => { mockFetch(new Set(['api', 'mlflow'])); // ml-serving absent const { server, call } = await startServer(buildApp()); try { const { body } = await call('GET', '/api/admin/health'); const b = body as HealthBody; const mlSvc = b.services.find((s) => s.name === 'ml-serving'); expect(mlSvc?.status).toBe('down'); expect(b.ok).toBe(false); } finally { server.close(); } }); it('mlflow=down and ok=false when mlflow is unreachable', async () => { mockFetch(new Set(['api', 'ml-serving'])); // mlflow absent const { server, call } = await startServer(buildApp()); try { const { body } = await call('GET', '/api/admin/health'); const b = body as HealthBody; const svc = b.services.find((s) => s.name === 'mlflow'); expect(svc?.status).toBe('down'); expect(b.ok).toBe(false); } finally { server.close(); } }); it('sqlite and event-bus are always present regardless of HTTP service status', async () => { mockFetch(new Set()); // all HTTP services down const { server, call } = await startServer(buildApp()); try { const { body } = await call('GET', '/api/admin/health'); const b = body as HealthBody; expect(b.services.find((s) => s.name === 'sqlite')?.status).toBe('ok'); expect(b.services.find((s) => s.name === 'event-bus')?.status).toBe('ok'); } finally { server.close(); } }); }); describe('GET /api/admin/users/:id — edge cases', () => { it('user with no integrations and no tips has empty arrays and 0 count', async () => { const { server, call } = await startServer(buildApp()); try { // user-2 has no integrations and no feedback seeded const { status, body } = await call('GET', '/api/admin/users/user-2'); const b = body as { user: { id: string }; integrations: unknown[]; tipsServed: number; recentFeedback: unknown[]; }; expect(status).toBe(200); expect(b.integrations).toHaveLength(0); expect(b.recentFeedback).toHaveLength(0); } finally { server.close(); } }); }); describe('GET /api/admin/data-quality — #81 phase B.4 profile freshness', () => { type Body = { profileFreshness: Array<{ feature: string; ttlSec: number; totalEligible: number; missing: number; stale: number; }>; }; it('reports each registered feature once', async () => { const { server, call } = await startServer(buildApp()); try { const { status, body } = await call('GET', '/api/admin/data-quality'); expect(status).toBe(200); const b = body as Body; const names = b.profileFreshness.map((r) => r.feature).sort(); expect(names).toEqual([ 'completion_rate_30d', 'dismiss_rate_30d', 'mean_dwell_ms_30d', 'preferred_hour', 'tip_volume_30d', ]); } finally { server.close(); } }); it('counts eligible users (with tip_views in 30d) and flags missing rows', async () => { // Reset profile rows so this test is independent of any previous rebuild test // that may have populated rows for some users. await testDb.delete(userProfileFeatures); const { server, call } = await startServer(buildApp()); try { const { body } = await call('GET', '/api/admin/data-quality'); const b = body as Body; // Seed has tip_views for user-1 and user-2 within 30d → 2 eligible. const completion = b.profileFreshness.find((r) => r.feature === 'completion_rate_30d')!; expect(completion.totalEligible).toBe(2); // No profile rows seeded → both missing expect(completion.missing).toBe(2); expect(completion.stale).toBe(0); } finally { server.close(); } }); }); describe('GET /api/admin/reward-analytics — #92 quality breakdowns', () => { type Row = { key: string | null; served: number; done: number; snooze: number; dismiss: number; avgRewardMilli: number | null; }; type Body = { byModel: Row[]; byPromptVersion: Row[]; byKind: Row[] }; it('groups tips by llm_model with reaction + reward aggregates', async () => { const { server, call } = await startServer(buildApp()); try { const { status, body } = await call('GET', '/api/admin/reward-analytics?days=30'); expect(status).toBe(200); const b = body as Body; const qwen = b.byModel.find((r) => r.key === 'qwen2.5:7b')!; expect(qwen).toBeDefined(); expect(qwen.served).toBe(2); // tip:a + tip:b expect(qwen.done).toBe(1); expect(qwen.snooze).toBe(1); // avg of reward_milli: (1000 + 100) / 2 = 550 expect(qwen.avgRewardMilli).toBeCloseTo(550, 0); const llama = b.byModel.find((r) => r.key === 'llama3:3b')!; expect(llama.served).toBe(1); expect(llama.done).toBe(0); expect(llama.avgRewardMilli).toBeNull(); // no reaction → no reward } finally { server.close(); } }); it('groups by prompt_version', async () => { const { server, call } = await startServer(buildApp()); try { const { body } = await call('GET', '/api/admin/reward-analytics?days=30'); const b = body as Body; const v1 = b.byPromptVersion.find((r) => r.key === 'v1')!; expect(v1.served).toBe(2); // tip:a + tip:c expect(v1.done).toBe(1); const v2 = b.byPromptVersion.find((r) => r.key === 'v2')!; expect(v2.served).toBe(1); expect(v2.snooze).toBe(1); } finally { server.close(); } }); it('groups by tip_kind', async () => { const { server, call } = await startServer(buildApp()); try { const { body } = await call('GET', '/api/admin/reward-analytics?days=30'); const b = body as Body; const task = b.byKind.find((r) => r.key === 'task')!; expect(task.served).toBe(2); // tip:a + tip:c const advice = b.byKind.find((r) => r.key === 'advice')!; expect(advice.served).toBe(1); expect(advice.snooze).toBe(1); } finally { server.close(); } }); }); describe('GET /api/admin/stats — field types', () => { it('reactionsLast7d has correct action counts', async () => { const { server, call } = await startServer(buildApp()); try { const { body } = await call('GET', '/api/admin/stats'); const b = body as { reactionsLast7d: Record }; // We seeded 'done' and 'snooze' feedback expect(typeof b.reactionsLast7d['done']).toBe('number'); expect(typeof b.reactionsLast7d['snooze']).toBe('number'); } finally { server.close(); } }); });