Files
oO/services/api/src/routes/__tests__/profile.test.ts
alvis ed1705cb5d feat(db): drop users.consentGiven/consentAt (ADR-0014 step 8)
Backfills consent_given=1 rows into user_consents as data:core before
dropping the legacy columns. auth.ts now writes user_consents on signup;
POST /consent writes user_consents; admin/user routes cleaned of the old
fields. Migration is idempotent — DROP COLUMN is wrapped in try/catch so
it no-ops on fresh DBs that never had the columns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 11:50:27 +00:00

194 lines
7.4 KiB
TypeScript

/**
* 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',
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('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);
});
});