feat(profile): /api/profile + eligibility filter + inference framework (ADR-0014 steps 4-6)
Step 4 — /api/profile read-through API:
GET /api/profile → { user, prefs, consents, contexts }
PATCH /api/profile/prefs/:scope upsert user_preferences (source='user')
PATCH /api/profile/consents grant / revoke consent keys
PATCH /api/profile/contexts create / activate / deactivate contexts
Legacy consentGiven bit folded in as data:core fallback.
Step 5 — registry-driven eligibility filter:
fetchRegistry() exported from agent-registry.ts.
profile/eligibility.ts: getEligibleAgentIds(userId) — filters by required
consents, silenced_in_contexts, and user_preferences[enabled=false].
fetchOrchestratorTip filters agent_outputs to eligible set before calling
ml/serving /recommend. Fail-closed: registry unavailable → empty set.
Step 6 — shared context-inference framework (#111) + time-of-day proof (#112):
ml/agents/inference/: UserHistory, FeedbackEvent, run_inference().
Framework: cold-start, min_history gating, error fallback, structured logs.
TimeOfDayAgent v1.1.0: inferred_params=[preferred_hour]; also reads
quiet_start/quiet_end from agent_prefs. agent_prefs injected by TS caller.
AgentInput gains agent_prefs field.
ml/serving: POST /agents/{agent_id}/infer endpoint.
agent-outputs.ts computeAndStore: loads prefs before compute, calls /infer
after, persists results (source='inferred'); user overrides never touched.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
201
services/api/src/routes/__tests__/profile.test.ts
Normal file
201
services/api/src/routes/__tests__/profile.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Integration tests for GET/PATCH /api/profile (ADR-0014 step 4).
|
||||
* Real in-memory SQLite; auth middleware mocked so requests arrive as 'user-1'.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
||||
import express from 'express';
|
||||
import * as http from 'http';
|
||||
import { makeTestDb } from '../../test/db.js';
|
||||
import { users, userPreferences, userConsents, userContexts } from '../../db/schema.js';
|
||||
|
||||
const testDb = makeTestDb();
|
||||
|
||||
vi.mock('../../db/index.js', () => ({ db: testDb, rawSqlite: testDb.rawSqlite }));
|
||||
|
||||
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 = 'user-1';
|
||||
next();
|
||||
},
|
||||
}));
|
||||
|
||||
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 !== undefined) 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) }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const profileRouter = (await import('../profile.js')).default;
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/profile', profileRouter);
|
||||
|
||||
const { server, call: c } = await startServer(app);
|
||||
afterAll(() => server.close());
|
||||
|
||||
const NOW = new Date().toISOString();
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.insert(users).values({
|
||||
id: 'user-1',
|
||||
email: 'a@example.com',
|
||||
name: 'Alice',
|
||||
image: null,
|
||||
role: 'user',
|
||||
consentGiven: false,
|
||||
tone: 'direct',
|
||||
tipKindsJson: JSON.stringify(['task', 'advice']),
|
||||
createdAt: NOW,
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/profile', () => {
|
||||
it('returns user globals with empty prefs/consents/contexts', async () => {
|
||||
const res = await c('GET', '/api/profile');
|
||||
expect(res.status).toBe(200);
|
||||
const body = res.body as any;
|
||||
expect(body.user).toMatchObject({ id: 'user-1', tone: 'direct', tipKinds: ['task', 'advice'] });
|
||||
expect(body.prefs).toEqual({});
|
||||
expect(body.consents).toEqual({});
|
||||
expect(body.contexts).toEqual([]);
|
||||
});
|
||||
|
||||
it('surfaces legacy consentGiven as data:core when no consent row exists', async () => {
|
||||
await testDb.update(users).set({ consentGiven: true, consentAt: NOW });
|
||||
const res = await c('GET', '/api/profile');
|
||||
expect((res.body as any).consents['data:core']).toMatchObject({ revokedAt: null });
|
||||
await testDb.update(users).set({ consentGiven: false });
|
||||
});
|
||||
|
||||
it('includes prefs grouped by scope', async () => {
|
||||
await testDb.insert(userPreferences).values([
|
||||
{ userId: 'user-1', scope: 'orchestrator', key: 'quietHours', valueJson: '"22:00-07:00"', source: 'user', updatedAt: NOW },
|
||||
{ userId: 'user-1', scope: 'agent:focus-area', key: 'areas', valueJson: '["work","health"]', source: 'inferred', updatedAt: NOW },
|
||||
]);
|
||||
const res = await c('GET', '/api/profile');
|
||||
const body = res.body as any;
|
||||
expect(body.prefs['orchestrator']).toMatchObject({ quietHours: '22:00-07:00' });
|
||||
expect(body.prefs['agent:focus-area']).toMatchObject({ areas: ['work', 'health'] });
|
||||
});
|
||||
|
||||
it('includes consents', async () => {
|
||||
await testDb.insert(userConsents).values([
|
||||
{ userId: 'user-1', consentKey: 'data:core', grantedAt: NOW, revokedAt: null },
|
||||
{ userId: 'user-1', consentKey: 'data:todoist', grantedAt: NOW, revokedAt: NOW },
|
||||
]);
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
expect(body.consents['data:core'].revokedAt).toBeNull();
|
||||
expect(body.consents['data:todoist'].revokedAt).toBe(NOW);
|
||||
});
|
||||
|
||||
it('includes contexts', async () => {
|
||||
await testDb.insert(userContexts).values({
|
||||
userId: 'user-1', name: 'work', active: true, scheduleJson: null, createdAt: NOW,
|
||||
});
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
expect(body.contexts).toContainEqual(expect.objectContaining({ name: 'work', active: true }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/profile/prefs/:scope', () => {
|
||||
it('upserts preference keys with source=user', async () => {
|
||||
const res = await c('PATCH', '/api/profile/prefs/orchestrator', { tone: 'gentle' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
expect(body.prefs['orchestrator']['tone']).toBe('gentle');
|
||||
});
|
||||
|
||||
it('overwrites an inferred value with user source', async () => {
|
||||
await testDb.insert(userPreferences).values({
|
||||
userId: 'user-1', scope: 'agent:momentum', key: 'enabled', valueJson: 'false',
|
||||
source: 'inferred', updatedAt: NOW,
|
||||
}).onConflictDoUpdate({
|
||||
target: [userPreferences.userId, userPreferences.scope, userPreferences.key],
|
||||
set: { valueJson: 'false', source: 'inferred', updatedAt: NOW },
|
||||
});
|
||||
|
||||
await c('PATCH', '/api/profile/prefs/agent:momentum', { enabled: true });
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
expect(body.prefs['agent:momentum']['enabled']).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 400 for non-object body', async () => {
|
||||
const res = await c('PATCH', '/api/profile/prefs/orchestrator', [1, 2]);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/profile/consents', () => {
|
||||
it('grants a new consent key', async () => {
|
||||
const res = await c('PATCH', '/api/profile/consents', { grant: ['data:calendar'] });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
expect(body.consents['data:calendar'].revokedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('revokes an existing active consent', async () => {
|
||||
await c('PATCH', '/api/profile/consents', { grant: ['agent:overdue-task'] });
|
||||
await c('PATCH', '/api/profile/consents', { revoke: ['agent:overdue-task'] });
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
expect(body.consents['agent:overdue-task'].revokedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns 400 when grant is not an array', async () => {
|
||||
const res = await c('PATCH', '/api/profile/consents', { grant: 'data:core' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/profile/contexts', () => {
|
||||
it('creates a new context', async () => {
|
||||
const res = await c('PATCH', '/api/profile/contexts', { name: 'vacation', active: false });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
expect(body.contexts).toContainEqual(expect.objectContaining({ name: 'vacation', active: false }));
|
||||
});
|
||||
|
||||
it('toggles active on existing context', async () => {
|
||||
await c('PATCH', '/api/profile/contexts', { name: 'home', active: false });
|
||||
await c('PATCH', '/api/profile/contexts', { name: 'home', active: true });
|
||||
const body = (await c('GET', '/api/profile')).body as any;
|
||||
const ctx = (body.contexts as any[]).find((x) => x.name === 'home');
|
||||
expect(ctx?.active).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 400 when name is missing', async () => {
|
||||
const res = await c('PATCH', '/api/profile/contexts', { active: true });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user