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>
This commit is contained in:
2026-04-25 00:27:08 +00:00
parent 7d4c29e137
commit 9e96540bcc
7 changed files with 233 additions and 6 deletions

View File

@@ -190,6 +190,62 @@ describe('GET /api/admin/users/:id', () => {
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', () => {