/** * 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 }> { 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); }); });