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>
194 lines
7.4 KiB
TypeScript
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);
|
|
});
|
|
});
|