Files

283 lines
9.5 KiB
JavaScript
Raw Permalink Normal View History

import express from 'express';
import request from 'supertest';
import { describe, expect, it } from 'vitest';
import { registerProjectContextRoutes } from './routes.js';
/**
* End-to-end route tests over real HTTP.
*
* An earlier unit test invoked the handlers directly. That covered status-code
* mapping but could not see middleware, and the blind spot shipped a real bug:
* the
* server has no global JSON parser — `core-routes` parses only an allowlist of
* path prefixes so the OpenCode proxy keeps an unread stream — so every write
* here arrived with `req.body` undefined and was rejected as malformed.
*
* These tests mount the routes on a bare express app, exactly as production
* does, so a missing body parser fails the suite instead of the user.
*/
const emptyContext = { version: 2, notes: [], todos: [], plans: [] };
const createApp = (overrides = {}) => {
const received = {};
const runtime = {
readContext: async () => emptyContext,
saveTodos: async (_projectId, todos) => {
received.todos = todos;
return { ...emptyContext, todos };
},
createNote: async (_projectId, value) => {
received.note = value;
return {
note: { id: 'n1', body: value.body, createdAt: 1, updatedAt: 1, source: value.source ?? 'manual', pinned: false },
context: emptyContext,
};
},
updateNote: async (_projectId, _noteId, patch) => {
received.notePatch = patch;
return {
note: { id: 'n1', body: 'x', createdAt: 1, updatedAt: 2, source: 'manual', pinned: patch.pinned === true },
context: emptyContext,
};
},
deleteNote: async () => ({ deleted: true, context: emptyContext }),
readPlan: async () => null,
createPlan: async (_projectId, value) => {
received.plan = value;
return { plan: { id: 'p1', file: 'a.md', title: value.title, createdAt: 1, pinned: false }, context: emptyContext };
},
updatePlan: async (_projectId, _planId, value) => {
received.planRaw = value;
return { plan: { id: 'p1', file: 'a.md', title: 'A', createdAt: 1, pinned: false }, context: emptyContext, title: 'A', body: 'x', raw: value.raw };
},
setPlanPinned: async (_projectId, _planId, pinned) => ({
plan: { id: 'p1', file: 'a.md', title: 'A', createdAt: 1, pinned },
context: emptyContext,
}),
deletePlan: async () => ({ deleted: true, context: emptyContext }),
sharePlan: async (_projectId, planId) => (planId === 'p1' ? { plan: { id: 'shared:a.md', file: 'a.md', title: 'A', createdAt: 1, pinned: false, source: 'shared' }, context: emptyContext } : null),
unsharePlan: async (_projectId, planId) => (planId === 'shared:a.md' ? { plan: { id: 'p2', file: 'a.md', title: 'A', createdAt: 1, pinned: false, source: 'personal' }, context: emptyContext } : null),
...overrides,
};
const app = express();
// Deliberately NO app.use(express.json()): production does not have one on
// this path, so adding it here would hide the very defect these tests exist
// to catch.
registerProjectContextRoutes(app, { projectContextRuntime: runtime });
return { app, received };
};
const BASE = '/api/project-context/path_dGVzdA';
describe('project context routes over HTTP', () => {
it('reads the context', async () => {
const { app } = createApp();
const res = await request(app).get(BASE).expect(200);
expect(res.body).toEqual(emptyContext);
});
it('accepts a todos write with a JSON body', async () => {
const { app, received } = createApp();
await request(app)
.put(`${BASE}/todos`)
.send({ todos: [{ id: 't1', text: 'one' }] })
.expect(200);
expect(received.todos).toEqual([{ id: 't1', text: 'one' }]);
});
it('accepts a note create with a JSON body', async () => {
const { app, received } = createApp();
const res = await request(app)
.post(`${BASE}/notes`)
.send({ body: 'hello', source: 'selection', origin: { sessionId: 'ses_1' } })
.expect(201);
expect(received.note.body).toBe('hello');
expect(received.note.source).toBe('selection');
expect(res.body.note.id).toBe('n1');
});
it('accepts a note pin patch with a JSON body', async () => {
const { app, received } = createApp();
const res = await request(app)
.patch(`${BASE}/notes/n1`)
.send({ pinned: true })
.expect(200);
expect(received.notePatch).toEqual({ pinned: true });
expect(res.body.note.pinned).toBe(true);
});
it('accepts a note body patch with a JSON body', async () => {
const { app, received } = createApp();
await request(app)
.patch(`${BASE}/notes/n1`)
.send({ body: 'edited' })
.expect(200);
expect(received.notePatch).toEqual({ body: 'edited' });
});
it('accepts a plan pin patch with a JSON body', async () => {
const { app } = createApp();
const res = await request(app)
.patch(`${BASE}/plans/p1`)
.send({ pinned: true })
.expect(200);
expect(res.body.plan.pinned).toBe(true);
});
it('accepts a plan create with a JSON body', async () => {
const { app, received } = createApp();
await request(app)
.post(`${BASE}/plans`)
.send({ title: 'A', body: 'text' })
.expect(201);
expect(received.plan).toEqual({ title: 'A', body: 'text' });
});
it('accepts a plan save with a JSON body', async () => {
const { app, received } = createApp();
await request(app)
.put(`${BASE}/plans/p1`)
.send({ raw: '# A\n\nx' })
.expect(200);
expect(received.planRaw).toEqual({ raw: '# A\n\nx' });
});
it('deletes a note', async () => {
const { app } = createApp();
await request(app).delete(`${BASE}/notes/n1`).expect(200);
});
it('deletes a plan', async () => {
const { app } = createApp();
await request(app).delete(`${BASE}/plans/p1`).expect(200);
});
it('still rejects a genuinely malformed body', async () => {
const { app } = createApp();
await request(app)
.post(`${BASE}/notes`)
.send({ notBody: 'nope' })
.expect(400);
});
it('rejects malformed todo shapes', async () => {
const { app } = createApp();
await request(app)
.put(`${BASE}/todos`)
.send({ todos: [{ id: 1, text: 'bad id type' }] })
.expect(400);
});
it('rejects an unknown note source', async () => {
const { app } = createApp();
await request(app)
.post(`${BASE}/notes`)
.send({ body: 'hello', source: 'somewhere-else' })
.expect(400);
});
it('rejects a plan pin patch without a boolean', async () => {
const { app } = createApp();
await request(app)
.patch(`${BASE}/plans/p1`)
.send({ pinned: 'yes' })
.expect(400);
});
it('rejects a plan save without raw content', async () => {
const { app } = createApp();
await request(app)
.put(`${BASE}/plans/p1`)
.send({ body: 'wrong field' })
.expect(400);
});
it('shares and unshares a plan, and answers 404 for an unknown one', async () => {
const { app } = createApp();
const shared = await request(app).post('/api/project-context/proj/plans/p1/share');
expect(shared.status).toBe(200);
expect(shared.body.plan.id).toBe('shared:a.md');
const back = await request(app).post('/api/project-context/proj/plans/shared%3Aa.md/unshare');
expect(back.status).toBe(200);
expect(back.body.plan.source).toBe('personal');
expect((await request(app).post('/api/project-context/proj/plans/nope/share')).status).toBe(404);
});
it('answers 400 when sharing without a shared plans folder', async () => {
const { app } = createApp({ sharePlan: async () => { throw new Error('shared plans folder is required'); } });
expect((await request(app).post('/api/project-context/proj/plans/p1/share')).status).toBe(400);
});
it('returns 404 for an unknown plan', async () => {
const { app } = createApp();
await request(app).get(`${BASE}/plans/nope`).expect(404);
});
it('returns 404 when patching a note that does not exist', async () => {
const { app } = createApp({ updateNote: async () => null });
await request(app).patch(`${BASE}/notes/nope`).send({ body: 'x' }).expect(404);
});
it('returns 404 when deleting a note that does not exist', async () => {
const { app } = createApp({ deleteNote: async () => ({ deleted: false, context: emptyContext }) });
await request(app).delete(`${BASE}/notes/nope`).expect(404);
});
it('returns 404 when deleting a plan that does not exist', async () => {
const { app } = createApp({ deletePlan: async () => ({ deleted: false, context: emptyContext }) });
await request(app).delete(`${BASE}/plans/nope`).expect(404);
});
it('returns 404 when saving a plan whose markdown is gone', async () => {
const { app } = createApp({ updatePlan: async () => null });
await request(app).put(`${BASE}/plans/p1`).send({ raw: '# B' }).expect(404);
});
it('surfaces malformed stored context as a server error, not empty data', async () => {
const { app } = createApp({
readContext: async () => {
throw new Error('Stored project context is malformed');
},
});
const res = await request(app).get(BASE).expect(500);
expect(res.body).toEqual({ error: 'Stored project context is malformed' });
});
it('rejects a traversal projectId as a client error', async () => {
const { app } = createApp({
readContext: async () => {
throw new Error('projectId contains unsupported characters');
},
});
await request(app).get('/api/project-context/..%2Fescape').expect(400);
});
});