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>
651 lines
23 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|