Files
oO/services/api/src/routes/__tests__/admin.test.ts
alvis 9e96540bcc feat(admin): per-user profile view + rebuild action (#81 phase B.1)
Surfaces phase A's profile features in /admin/users/:id so we can verify
they're actually computing useful values before investing in bandit
consumption. The detail GET now includes profile rows joined with registry
metadata (name, value, age, fresh badge, ttlSec, description). Read does
NOT trigger compute — staleness must be visible. A new POST
.../profile/rebuild button force-recomputes and is audit-logged like
reset-bandit.

Refs #81.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 00:27:08 +00:00

506 lines
18 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 } from 'vitest';
import express from 'express';
import * as http from 'http';
import { makeTestDb } from '../../test/db.js';
import { users, integrationTokens, tipViews, tipFeedback, tipScores } 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();
}
});
});
describe('GET /api/admin/health', () => {
it('returns 200 with ok, services array, and checkedAt', async () => {
const { server, call } = await startServer(buildApp());
try {
const { status, body } = await call('GET', '/api/admin/health');
const b = body as { ok: boolean; services: { name: string; status: string }[]; checkedAt: string };
expect(status).toBe(200);
expect(typeof b.ok).toBe('boolean');
expect(Array.isArray(b.services)).toBe(true);
expect(typeof b.checkedAt).toBe('string');
} 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/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();
}
});
});