Files
oO/services/api/src/routes/__tests__/admin.test.ts
alvis f8d66aa01f chore: remove Airflow completely from the stack
Drop all four Airflow containers (db, init, webserver, scheduler) from the
mlops compose profile, leaving MLflow as the sole mlops service. Remove
AIRFLOW_* env vars, config fields, health-check entries, DAG trigger code
in admin/bench routes, the airflow_dag_run_id schema column, Airflow nav
links and DAG-run links in the admin UI, the two Airflow DAG files
(bench_dag.py, sim_dag.py), and all related docs/ADR references.
Simulations now run exclusively via the subprocess path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 16:38:46 +00:00

651 lines
23 KiB
TypeScript

/**
* 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<typeof call> }> {
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<string, unknown>;
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<string>) {
// 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<string, number> };
// We seeded 'done' and 'snooze' feedback
expect(typeof b.reactionsLast7d['done']).toBe('number');
expect(typeof b.reactionsLast7d['snooze']).toBe('number');
} finally {
server.close();
}
});
});