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:
2026-04-16 07:44:37 +00:00
parent c5ea18ec6e
commit faf44c18fc
48 changed files with 6151 additions and 40 deletions

View 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();
}
});
});