feat: ε-greedy v1 as active policy; dwell-time reward inference; offline sim framework
- Promote egreedy-v1 to active serving policy (ADR-0007): /score/egreedy + /reward/egreedy
replaces linucb-v1 endpoints after offline sim shows +10.7% mean reward (−0.548 vs −0.606)
- Replace explicit helpful/not_helpful feedback with dwell-time inferred reward (inferReward):
dismiss=−1.0, snooze=+0.1, done<15s=−0.3, done 15s–2min=+1.0, done 2–10min=+0.6, done>10min=+0.3
- Add ml/serving ε-greedy endpoints: /score/egreedy, /reward/egreedy, /stats/egreedy/{user_id}
with d=7 feature vector (base 5 + sin/cos day-of-week encoding)
- Add offline simulation framework (ml/experiments/sim): rule/LLM/claude-code judges,
two-phase score+reward, synthetic personas, task generator; results stored in sim_runs/sim_events
- Add /admin/simulations page: start runs, live-poll status, reward curve SVG, action/persona tables
- Fix egreedy day_of_week training skew: reward endpoint now uses actual dow instead of hardcoded 0
- Fix runner.py proxy bypass: httpx.Client(trust_env=False) for localhost ML calls
- Add dwellMs to TipFeedbackEvent contract and bus.test.ts fixture
- Schema: sim_runs, sim_events tables; tip_feedback gains dwell_ms, reward_milli columns
- ADR-0006: admin console framework; ADR-0007: egreedy-v1 policy selection rationale
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
370
services/api/src/routes/__tests__/admin.test.ts
Normal file
370
services/api/src/routes/__tests__/admin.test.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* 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 } from '../../db/schema.js';
|
||||
|
||||
// ---- in-memory DB ----
|
||||
const testDb = makeTestDb();
|
||||
|
||||
vi.mock('../../db/index.js', () => ({ db: testDb }));
|
||||
|
||||
// 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', createdAt: DAY_AGO },
|
||||
{ id: 'tf-2', userId: 'user-1', tipId: 'tip:b', action: 'snooze', createdAt: NOW },
|
||||
]);
|
||||
});
|
||||
|
||||
// ---- 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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/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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user