@critical-path/server 0.24.0 → 0.24.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.test.ts DELETED
@@ -1,1055 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { CriticalPathRouter } from './router.js';
3
- import { createNextHandler } from './adapters/next.js';
4
- import { createSvelteKitHandler } from './adapters/sveltekit.js';
5
- import { createUniversalHandler } from './adapters/universal.js';
6
- import { InMemoryStore, createRolePolicy, ForbiddenError, type CriticalPathPlugin } from '@critical-path/core';
7
-
8
- describe('@critical-path/server Router Tests', () => {
9
- it('handles project creation and retrieval over HTTP Fetch Requests', async () => {
10
- const router = new CriticalPathRouter();
11
-
12
- // 1. Create project
13
- const postReq = new Request('http://localhost:3000/api/critical-path/projects', {
14
- method: 'POST',
15
- headers: { 'Content-Type': 'application/json' },
16
- body: JSON.stringify({ key: 'HTTP', name: 'HTTP Project' })
17
- });
18
-
19
- const postRes = await router.handleRequest(postReq);
20
- expect(postRes.status).toBe(201);
21
- const postData = await postRes.json();
22
- expect(postData.project.key).toBe('HTTP');
23
-
24
- // 2. Get projects
25
- const getReq = new Request('http://localhost:3000/api/critical-path/projects');
26
- const getRes = await router.handleRequest(getReq);
27
- expect(getRes.status).toBe(200);
28
- const getData = await getRes.json();
29
- expect(getData.projects.length).toBe(1);
30
- expect(getData.projects[0].name).toBe('HTTP Project');
31
- });
32
-
33
- it('handles task status updates via PATCH', async () => {
34
- const router = new CriticalPathRouter();
35
-
36
- // Create project & task
37
- const proj = await router.engine.createProject({ key: 'TSK', name: 'Task Proj' });
38
- const task = await router.engine.createTask({
39
- projectId: proj.id,
40
- title: 'Initial Task',
41
- status: 'todo',
42
- priority: 'medium'
43
- });
44
-
45
- // Update status to done
46
- const patchReq = new Request(`http://localhost:3000/api/critical-path/tasks/${task.id}`, {
47
- method: 'PATCH',
48
- headers: { 'Content-Type': 'application/json' },
49
- body: JSON.stringify({ status: 'done' })
50
- });
51
-
52
- const patchRes = await router.handleRequest(patchReq);
53
- expect(patchRes.status).toBe(200);
54
- const patchData = await patchRes.json();
55
- expect(patchData.task.status).toBe('done');
56
- });
57
-
58
- it('handles workflows and task transition validation over HTTP', async () => {
59
- const router = new CriticalPathRouter();
60
-
61
- // 1. Create Workflow via POST
62
- const wfReq = new Request('http://localhost:3000/api/critical-path/workflows', {
63
- method: 'POST',
64
- headers: { 'Content-Type': 'application/json' },
65
- body: JSON.stringify({
66
- name: 'Strict API Workflow',
67
- defaultStatusKey: 'todo',
68
- statuses: [
69
- { key: 'todo', label: 'To Do', category: 'not_started' },
70
- { key: 'done', label: 'Done', category: 'completed' }
71
- ],
72
- transitions: [
73
- { id: 't1', fromStatusKey: 'todo', toStatusKey: 'done', name: 'Finish' }
74
- ]
75
- })
76
- });
77
-
78
- const wfRes = await router.handleRequest(wfReq);
79
- expect(wfRes.status).toBe(201);
80
- const wfData = await wfRes.json();
81
- const wfId = wfData.workflow.id;
82
-
83
- // 2. Create Project linked to workflow
84
- const proj = await router.engine.createProject({ key: 'API', name: 'API Proj', workflowId: wfId });
85
- const task = await router.engine.createTask({ projectId: proj.id, title: 'API Task', status: 'todo', priority: 'medium' });
86
-
87
- // 3. GET allowed transitions
88
- const transReq = new Request(`http://localhost:3000/api/critical-path/tasks/${task.id}/transitions`);
89
- const transRes = await router.handleRequest(transReq);
90
- expect(transRes.status).toBe(200);
91
- const transData = await transRes.json();
92
- expect(transData.allowedNextStatuses).toEqual(['done']);
93
- expect(transData.allowedPreviousStatuses).toBeDefined();
94
-
95
- // 4. Invalid status transition -> 400 error
96
- const invalidPatchReq = new Request(`http://localhost:3000/api/critical-path/tasks/${task.id}`, {
97
- method: 'PATCH',
98
- headers: { 'Content-Type': 'application/json' },
99
- body: JSON.stringify({ status: 'in_progress' })
100
- });
101
- const invalidRes = await router.handleRequest(invalidPatchReq);
102
- expect(invalidRes.status).toBe(400);
103
- });
104
-
105
- it('handles comments and attachments CRUD via router', async () => {
106
- const router = new CriticalPathRouter();
107
- const proj = await router.engine.createProject({ key: 'COM', name: 'Comment Proj' });
108
- const task = await router.engine.createTask({ projectId: proj.id, title: 'Comment Task', status: 'todo' });
109
-
110
- // 1. POST comment via /tasks/:taskId/comments
111
- const postCmtReq = new Request(`http://localhost:3000/api/critical-path/tasks/${task.id}/comments`, {
112
- method: 'POST',
113
- headers: { 'Content-Type': 'application/json' },
114
- body: JSON.stringify({ content: 'Root comment' })
115
- });
116
- const postCmtRes = await router.handleRequest(postCmtReq);
117
- expect(postCmtRes.status).toBe(201);
118
- const postCmtData = await postCmtRes.json();
119
- const commentId = postCmtData.comment.id;
120
-
121
- // 2. GET comments via /tasks/:taskId/comments
122
- const getCmtsReq = new Request(`http://localhost:3000/api/critical-path/tasks/${task.id}/comments`);
123
- const getCmtsRes = await router.handleRequest(getCmtsReq);
124
- expect(getCmtsRes.status).toBe(200);
125
- const getCmtsData = await getCmtsRes.json();
126
- expect(getCmtsData.comments.length).toBe(1);
127
-
128
- // 3. PATCH comment
129
- const patchCmtReq = new Request(`http://localhost:3000/api/critical-path/comments/${commentId}`, {
130
- method: 'PATCH',
131
- headers: { 'Content-Type': 'application/json' },
132
- body: JSON.stringify({ content: 'Updated comment content' })
133
- });
134
- const patchCmtRes = await router.handleRequest(patchCmtReq);
135
- expect(patchCmtRes.status).toBe(200);
136
- const patchCmtData = await patchCmtRes.json();
137
- expect(patchCmtData.comment.content).toBe('Updated comment content');
138
-
139
- // 4. POST attachment
140
- const postAttReq = new Request(`http://localhost:3000/api/critical-path/attachments`, {
141
- method: 'POST',
142
- headers: { 'Content-Type': 'application/json' },
143
- body: JSON.stringify({
144
- filename: 'spec.md',
145
- url: 'https://storage.example.com/spec.md',
146
- mimeType: 'text/markdown',
147
- sizeBytes: 100,
148
- taskId: task.id,
149
- projectId: proj.id
150
- })
151
- });
152
- const postAttRes = await router.handleRequest(postAttReq);
153
- expect(postAttRes.status).toBe(201);
154
- const postAttData = await postAttRes.json();
155
- const attachmentId = postAttData.attachment.id;
156
-
157
- // 5. GET attachments for task
158
- const getAttsReq = new Request(`http://localhost:3000/api/critical-path/attachments?taskId=${task.id}`);
159
- const getAttsRes = await router.handleRequest(getAttsReq);
160
- expect(getAttsRes.status).toBe(200);
161
- const getAttsData = await getAttsRes.json();
162
- expect(getAttsData.attachments.length).toBe(1);
163
-
164
- // 6. DELETE attachment
165
- const delAttReq = new Request(`http://localhost:3000/api/critical-path/attachments/${attachmentId}`, {
166
- method: 'DELETE'
167
- });
168
- const delAttRes = await router.handleRequest(delAttReq);
169
- expect(delAttRes.status).toBe(200);
170
-
171
- // 7. DELETE comment
172
- const delCmtReq = new Request(`http://localhost:3000/api/critical-path/comments/${commentId}`, {
173
- method: 'DELETE'
174
- });
175
- const delCmtRes = await router.handleRequest(delCmtReq);
176
- expect(delCmtRes.status).toBe(200);
177
-
178
- // 8. Rejects large data URIs with 400
179
- const invalidAttReq = new Request(`http://localhost:3000/api/critical-path/attachments`, {
180
- method: 'POST',
181
- headers: { 'Content-Type': 'application/json' },
182
- body: JSON.stringify({
183
- filename: 'huge.pdf',
184
- url: `data:application/pdf;base64,${'B'.repeat(3000)}`,
185
- taskId: task.id
186
- })
187
- });
188
- const invalidAttRes = await router.handleRequest(invalidAttReq);
189
- expect(invalidAttRes.status).toBe(400);
190
- const invalidAttData = await invalidAttRes.json();
191
- expect(invalidAttData.error).toContain('Attachment URL cannot be a large data URI');
192
- });
193
-
194
- it('handles emoji reactions on comments via POST and DELETE /comments/:id/reactions', async () => {
195
- const router = new CriticalPathRouter();
196
- const proj = await router.engine.createProject({ key: 'RCT', name: 'Reaction Proj' });
197
- const task = await router.engine.createTask({ projectId: proj.id, title: 'Reaction Task', status: 'todo' });
198
- const comment = await router.engine.addComment({ taskId: task.id, authorId: 'u1', content: 'Nice job!' });
199
-
200
- // 1. Add reaction via POST
201
- const addReactReq = new Request(`http://localhost:3000/api/critical-path/comments/${comment.id}/reactions`, {
202
- method: 'POST',
203
- headers: { 'Content-Type': 'application/json' },
204
- body: JSON.stringify({ emoji: '❤️' })
205
- });
206
- const addReactRes = await router.handleRequest(addReactReq);
207
- expect(addReactRes.status).toBe(200);
208
- const addReactData = await addReactRes.json();
209
- expect(addReactData.comment.reactions).toHaveLength(1);
210
- expect(addReactData.comment.reactions[0].emoji).toBe('❤️');
211
- expect(addReactData.comment.reactions[0].userId).toBe('anonymous');
212
-
213
- // 2. Add reaction validation error (missing emoji, or identity claimed in the body)
214
- const badReactReq = new Request(`http://localhost:3000/api/critical-path/comments/${comment.id}/reactions`, {
215
- method: 'POST',
216
- headers: { 'Content-Type': 'application/json' },
217
- body: JSON.stringify({ emoji: '❤️', userId: 'u2' })
218
- });
219
- const badReactRes = await router.handleRequest(badReactReq);
220
- expect(badReactRes.status).toBe(400);
221
-
222
- // 3. Remove reaction via DELETE
223
- const delReactReq = new Request(
224
- `http://localhost:3000/api/critical-path/comments/${comment.id}/reactions?emoji=${encodeURIComponent('❤️')}`,
225
- { method: 'DELETE' }
226
- );
227
- const delReactRes = await router.handleRequest(delReactReq);
228
- expect(delReactRes.status).toBe(200);
229
- const delReactData = await delReactRes.json();
230
- expect(delReactData.comment.reactions).toHaveLength(0);
231
- });
232
-
233
- it('handles deliverables CRUD and summary rollups over HTTP', async () => {
234
- const router = new CriticalPathRouter();
235
- const proj = await router.engine.createProject({ key: 'DEL', name: 'Deliverable Proj' });
236
-
237
- // 1. Create Deliverable via POST
238
- const createReq = new Request('http://localhost:3000/api/critical-path/deliverables', {
239
- method: 'POST',
240
- headers: { 'Content-Type': 'application/json' },
241
- body: JSON.stringify({
242
- projectId: proj.id,
243
- title: 'Brand Video 30s',
244
- format: 'ProRes 422'
245
- })
246
- });
247
- const createRes = await router.handleRequest(createReq);
248
- expect(createRes.status).toBe(201);
249
- const createData = await createRes.json();
250
- expect(createData.deliverable.title).toBe('Brand Video 30s');
251
- const deliverableId = createData.deliverable.id;
252
-
253
- // 2. Create task tied to deliverable
254
- await router.engine.createTask({
255
- projectId: proj.id,
256
- title: 'Animation Pass 1',
257
- status: 'done',
258
- estimatedHours: 10,
259
- loggedHours: 10,
260
- deliverableId
261
- });
262
-
263
- // 3. Get Deliverables by projectId
264
- const getListReq = new Request(`http://localhost:3000/api/critical-path/deliverables?projectId=${proj.id}`);
265
- const getListRes = await router.handleRequest(getListReq);
266
- expect(getListRes.status).toBe(200);
267
- const getListData = await getListRes.json();
268
- expect(getListData.deliverables).toHaveLength(1);
269
-
270
- // 4. Get Deliverable Summary
271
- const summaryReq = new Request(`http://localhost:3000/api/critical-path/deliverables/${deliverableId}/summary`);
272
- const summaryRes = await router.handleRequest(summaryReq);
273
- expect(summaryRes.status).toBe(200);
274
- const summaryData = await summaryRes.json();
275
- expect(summaryData.summary.totalTasks).toBe(1);
276
- expect(summaryData.summary.completedTasks).toBe(1);
277
- expect(summaryData.summary.estimatedHours).toBe(10);
278
-
279
- // 5. Update Deliverable via PATCH
280
- const patchReq = new Request(`http://localhost:3000/api/critical-path/deliverables/${deliverableId}`, {
281
- method: 'PATCH',
282
- headers: { 'Content-Type': 'application/json' },
283
- body: JSON.stringify({ status: 'delivered' })
284
- });
285
- const patchRes = await router.handleRequest(patchReq);
286
- expect(patchRes.status).toBe(200);
287
- const patchData = await patchRes.json();
288
- expect(patchData.deliverable.status).toBe('delivered');
289
- expect(patchData.deliverable.deliveredAt).toBeDefined();
290
-
291
- // 6. Delete Deliverable via DELETE
292
- const delReq = new Request(`http://localhost:3000/api/critical-path/deliverables/${deliverableId}`, {
293
- method: 'DELETE'
294
- });
295
- const delRes = await router.handleRequest(delReq);
296
- expect(delRes.status).toBe(200);
297
- const delData = await delRes.json();
298
- expect(delData.success).toBe(true);
299
- });
300
-
301
- it('serves Timeline Ladder and Critical Path analysis over HTTP', async () => {
302
- const router = new CriticalPathRouter();
303
- const project = await router.engine.createProject({ key: 'LAD', name: 'Ladder Project' });
304
-
305
- const t1 = await router.engine.createTask({
306
- projectId: project.id,
307
- title: 'Foundation Architecture',
308
- status: 'done',
309
- estimatedHours: 8,
310
- progress: 100
311
- });
312
-
313
- const t2 = await router.engine.createTask({
314
- projectId: project.id,
315
- title: 'UI Implementation',
316
- status: 'in_progress',
317
- estimatedHours: 16,
318
- progress: 25
319
- });
320
-
321
- await router.engine.addDependency({
322
- taskId: t2.id,
323
- dependsOnTaskId: t1.id,
324
- type: 'blocking'
325
- });
326
-
327
- // 1. GET /projects/:id/critical-path
328
- const cpmReq = new Request(`http://localhost:3000/api/critical-path/projects/${project.id}/critical-path`);
329
- const cpmRes = await router.handleRequest(cpmReq);
330
- expect(cpmRes.status).toBe(200);
331
- const cpmData = await cpmRes.json();
332
- expect(cpmData.analysis.totalDurationHours).toBe(24);
333
- expect(cpmData.analysis.criticalTaskIds).toEqual([t1.id, t2.id]);
334
-
335
- // 2. GET /projects/:id/ladder
336
- const ladderReq = new Request(`http://localhost:3000/api/critical-path/projects/${project.id}/ladder?level=all`);
337
- const ladderRes = await router.handleRequest(ladderReq);
338
- expect(ladderRes.status).toBe(200);
339
- const ladderData = await ladderRes.json();
340
- expect(ladderData.ladder.macro).toBeDefined();
341
- expect(ladderData.ladder.macro.criticalPathDurationHours).toBe(24);
342
- expect(ladderData.ladder.standard.tasks).toHaveLength(2);
343
- expect(ladderData.ladder.concrete[t2.id]).toBeDefined();
344
-
345
- // 3. GET /tasks/:id/ladder
346
- const taskLadderReq = new Request(`http://localhost:3000/api/critical-path/tasks/${t2.id}/ladder`);
347
- const taskLadderRes = await router.handleRequest(taskLadderReq);
348
- expect(taskLadderRes.status).toBe(200);
349
- const taskLadderData = await taskLadderRes.json();
350
- expect(taskLadderData.taskLadder.taskId).toBe(t2.id);
351
- expect(taskLadderData.taskLadder.standard.title).toBe('UI Implementation');
352
- expect(taskLadderData.taskLadder.standard.cpm.earlyStart).toBe(8);
353
- expect(taskLadderData.taskLadder.metrics).toBeDefined();
354
-
355
- // 4. GET /tasks/:id/metrics
356
- const metricsReq = new Request(`http://localhost:3000/api/critical-path/tasks/${t2.id}/metrics`);
357
- const metricsRes = await router.handleRequest(metricsReq);
358
- expect(metricsRes.status).toBe(200);
359
- const metricsData = await metricsRes.json();
360
- expect(metricsData.metrics.taskId).toBe(t2.id);
361
- expect(metricsData.metrics.realityDelta.estimatedHours).toBe(16);
362
- expect(metricsData.metrics.progress).toBeDefined();
363
- expect(metricsData.metrics.evm).toBeDefined();
364
-
365
- // 5. GET /tasks/:id/progress-history
366
- const historyReq = new Request(`http://localhost:3000/api/critical-path/tasks/${t2.id}/progress-history`);
367
- const historyRes = await router.handleRequest(historyReq);
368
- expect(historyRes.status).toBe(200);
369
- const historyData = await historyRes.json();
370
- expect(historyData.progressHistory.taskId).toBe(t2.id);
371
- expect(historyData.progressHistory.points.length).toBeGreaterThanOrEqual(1);
372
-
373
- // 6. GET /projects/:id/workload
374
- const projectWorkloadReq = new Request(
375
- `http://localhost:3000/api/critical-path/projects/${project.id}/workload?interval=week&metric=scheduled`
376
- );
377
- const projectWorkloadRes = await router.handleRequest(projectWorkloadReq);
378
- expect(projectWorkloadRes.status).toBe(200);
379
- const projectWorkloadData = await projectWorkloadRes.json();
380
- expect(projectWorkloadData.workload.projectId).toBe(project.id);
381
- expect(projectWorkloadData.workload.buckets.length).toBeGreaterThan(0);
382
- expect(projectWorkloadData.workload.totalHours).toBeGreaterThan(0);
383
-
384
- // 7. GET /workload (global)
385
- const globalWorkloadReq = new Request(
386
- `http://localhost:3000/api/critical-path/workload?interval=day`
387
- );
388
- const globalWorkloadRes = await router.handleRequest(globalWorkloadReq);
389
- expect(globalWorkloadRes.status).toBe(200);
390
- const globalWorkloadData = await globalWorkloadRes.json();
391
- expect(globalWorkloadData.workload.buckets.length).toBeGreaterThan(0);
392
- });
393
-
394
- it('handles agent status updates via POST /status', async () => {
395
- const router = new CriticalPathRouter();
396
- const req = new Request('http://localhost:3000/api/critical-path/status', {
397
- method: 'POST',
398
- headers: { 'Content-Type': 'application/json' },
399
- body: JSON.stringify({
400
- status: 'Executing compilation step',
401
- taskId: 't-123',
402
- projectId: 'p-456',
403
- details: 'compiling typescript files',
404
- isEngaged: true
405
- })
406
- });
407
-
408
- const res = await router.handleRequest(req);
409
- expect(res.status).toBe(200);
410
- const data = await res.json();
411
- expect(data.success).toBe(true);
412
- expect(data.status).toBe('Executing compilation step');
413
- expect(typeof data.timestamp).toBe('number');
414
- });
415
-
416
- describe('engine invariants and error mapping', () => {
417
- const base = 'http://localhost:3000/api/critical-path';
418
- const post = (path: string, body: unknown) =>
419
- new Request(`${base}${path}`, {
420
- method: 'POST',
421
- headers: { 'Content-Type': 'application/json' },
422
- body: typeof body === 'string' ? body : JSON.stringify(body)
423
- });
424
-
425
- it('rejects dependency cycles created over HTTP with 409', async () => {
426
- const router = new CriticalPathRouter();
427
- const proj = await router.engine.createProject({ key: 'CYC', name: 'Cycle' });
428
- const a = await router.engine.createTask({ projectId: proj.id, title: 'A' });
429
- const b = await router.engine.createTask({ projectId: proj.id, title: 'B' });
430
-
431
- const first = await router.handleRequest(post(`/tasks/${a.id}/dependencies`, { dependsOnTaskId: b.id }));
432
- expect(first.status).toBe(201);
433
-
434
- const cyclic = await router.handleRequest(post(`/tasks/${b.id}/dependencies`, { dependsOnTaskId: a.id }));
435
- expect(cyclic.status).toBe(409);
436
- expect((await cyclic.json()).cyclePath).toBeDefined();
437
-
438
- const missing = await router.handleRequest(post(`/tasks/${b.id}/dependencies`, {}));
439
- expect(missing.status).toBe(400);
440
- });
441
-
442
- it('validates time entries and rolls hours up to the task', async () => {
443
- const router = new CriticalPathRouter();
444
- const proj = await router.engine.createProject({ key: 'TIME', name: 'Time' });
445
- const task = await router.engine.createTask({ projectId: proj.id, title: 'Timed' });
446
-
447
- const negative = await router.handleRequest(post('/time-entries', { taskId: task.id, hours: -3 }));
448
- expect(negative.status).toBe(400);
449
-
450
- const ok = await router.handleRequest(post('/time-entries', { taskId: task.id, hours: 2 }));
451
- expect(ok.status).toBe(201);
452
- expect((await router.engine.getTask(task.id))?.loggedHours).toBe(2);
453
- });
454
-
455
- it('deletes projects through the engine, removing their tasks', async () => {
456
- const router = new CriticalPathRouter();
457
- const proj = await router.engine.createProject({ key: 'GONE', name: 'Gone' });
458
- const task = await router.engine.createTask({ projectId: proj.id, title: 'Child' });
459
-
460
- const res = await router.handleRequest(new Request(`${base}/projects/${proj.id}`, { method: 'DELETE' }));
461
- expect(res.status).toBe(200);
462
- expect(await router.engine.getTask(task.id)).toBeNull();
463
- });
464
-
465
- it('returns 400 for malformed JSON and hides internal error messages', async () => {
466
- const router = new CriticalPathRouter();
467
- const bad = await router.handleRequest(post('/projects', '{not json'));
468
- expect(bad.status).toBe(400);
469
-
470
- router.engine.getProjects = async () => {
471
- throw new Error('SQLITE_CORRUPT: secret internals');
472
- };
473
- const originalError = console.error;
474
- console.error = () => {};
475
- try {
476
- const res = await router.handleRequest(new Request(`${base}/projects`));
477
- expect(res.status).toBe(500);
478
- expect((await res.json()).error).toBe('Internal Server Error');
479
- } finally {
480
- console.error = originalError;
481
- }
482
- });
483
-
484
- it('answers CORS preflight requests, sending CORS headers only when configured', async () => {
485
- const preflight = () => new Request(`${base}/tasks`, { method: 'OPTIONS', headers: { Origin: 'https://x.test' } });
486
-
487
- const closed = await new CriticalPathRouter().handleRequest(preflight());
488
- expect(closed.status).toBe(204);
489
- expect(closed.headers.get('Access-Control-Allow-Origin')).toBeNull();
490
-
491
- const open = await new CriticalPathRouter(undefined, { cors: { origins: '*' } }).handleRequest(preflight());
492
- expect(open.headers.get('Access-Control-Allow-Origin')).toBe('*');
493
- expect(open.headers.get('Access-Control-Allow-Methods')).toContain('PATCH');
494
- });
495
- });
496
-
497
- it('createNextHandler supports both callable and destructured exports', async () => {
498
- const handler = createNextHandler({});
499
- const { GET } = handler;
500
- const req = () => new Request('http://localhost:3000/api/critical-path/projects');
501
-
502
- expect((await handler(req())).status).toBe(200);
503
- expect((await GET(req())).status).toBe(200);
504
- });
505
-
506
- it('updates projects via PATCH and rejects id or timestamp overrides', async () => {
507
- const router = new CriticalPathRouter();
508
- const proj = await router.engine.createProject({ key: 'UPD', name: 'Before' });
509
- const patch = (id: string, body: unknown) =>
510
- router.handleRequest(
511
- new Request(`http://localhost:3000/api/critical-path/projects/${id}`, {
512
- method: 'PATCH',
513
- headers: { 'Content-Type': 'application/json' },
514
- body: JSON.stringify(body)
515
- })
516
- );
517
-
518
- expect((await patch(proj.id, { name: 'After', id: 'hijacked' })).status).toBe(400);
519
- expect((await patch(proj.id, { name: 'After', createdAt: '1999-01-01T00:00:00.000Z' })).status).toBe(400);
520
-
521
- const res = await patch(proj.id, { name: 'After' });
522
- expect(res.status).toBe(200);
523
- const { project } = await res.json();
524
- expect(project.id).toBe(proj.id);
525
- expect(project.name).toBe('After');
526
- expect(project.createdAt).toBe(proj.createdAt);
527
-
528
- expect((await patch('missing', { name: 'x' })).status).toBe(404);
529
- });
530
-
531
- it('returns 404 when deleting a resource that does not exist', async () => {
532
- const router = new CriticalPathRouter();
533
- for (const path of ['projects', 'tasks', 'workflows', 'comments', 'attachments', 'deliverables', 'teams', 'containers', 'iterations']) {
534
- const res = await router.handleRequest(
535
- new Request(`http://localhost:3000/api/critical-path/${path}/missing`, { method: 'DELETE' })
536
- );
537
- expect(res.status, path).toBe(404);
538
- }
539
- });
540
-
541
- describe('unexpected error handling options', () => {
542
- const failingRouter = (options: ConstructorParameters<typeof CriticalPathRouter>[1]) => {
543
- const router = new CriticalPathRouter(undefined, options);
544
- router.engine.getProjects = async () => {
545
- throw new Error('SQLITE_CORRUPT: secret internals');
546
- };
547
- return router;
548
- };
549
- const listProjects = () => new Request('http://localhost:3000/api/critical-path/projects');
550
-
551
- it('passes unexpected errors and the request to onError instead of console.error', async () => {
552
- const seen: Array<{ message: string; url: string }> = [];
553
- const router = failingRouter({
554
- onError: (err, request) => {
555
- seen.push({ message: (err as Error).message, url: request.url });
556
- }
557
- });
558
-
559
- const res = await router.handleRequest(listProjects());
560
- expect(res.status).toBe(500);
561
- expect((await res.json()).error).toBe('Internal Server Error');
562
- expect(seen).toEqual([{ message: 'SQLITE_CORRUPT: secret internals', url: listProjects().url }]);
563
- });
564
-
565
- it('lets onError replace the response', async () => {
566
- const router = failingRouter({
567
- onError: () => new Response(JSON.stringify({ error: 'Try again later' }), { status: 503 })
568
- });
569
- const res = await router.handleRequest(listProjects());
570
- expect(res.status).toBe(503);
571
- });
572
-
573
- it('does not call onError for expected client errors', async () => {
574
- let calls = 0;
575
- const router = new CriticalPathRouter(undefined, { onError: () => void calls++ });
576
- const res = await router.handleRequest(
577
- new Request('http://localhost:3000/api/critical-path/projects', { method: 'POST', body: '{bad' })
578
- );
579
- expect(res.status).toBe(400);
580
- expect(calls).toBe(0);
581
- });
582
-
583
- it('exposes real messages only when exposeErrors is enabled', async () => {
584
- const exposed = failingRouter({ exposeErrors: true, onError: () => {} });
585
- expect((await (await exposed.handleRequest(listProjects())).json()).error).toBe('SQLITE_CORRUPT: secret internals');
586
-
587
- const hidden = failingRouter({ exposeErrors: false, onError: () => {} });
588
- expect((await (await hidden.handleRequest(listProjects())).json()).error).toBe('Internal Server Error');
589
- });
590
-
591
- it('defaults exposeErrors from NODE_ENV === development', async () => {
592
- const original = process.env.NODE_ENV;
593
- try {
594
- process.env.NODE_ENV = 'development';
595
- const dev = failingRouter({ onError: () => {} });
596
- expect((await (await dev.handleRequest(listProjects())).json()).error).toContain('SQLITE_CORRUPT');
597
-
598
- process.env.NODE_ENV = 'production';
599
- const prod = failingRouter({ onError: () => {} });
600
- expect((await (await prod.handleRequest(listProjects())).json()).error).toBe('Internal Server Error');
601
- } finally {
602
- process.env.NODE_ENV = original;
603
- }
604
- });
605
-
606
- it('passes options through createNextHandler', async () => {
607
- const store = new InMemoryStore();
608
- store.getProjects = async () => {
609
- throw new Error('adapter failure');
610
- };
611
- const errors: unknown[] = [];
612
- const handler = createNextHandler({ store }, { exposeErrors: true, onError: (err) => void errors.push(err) });
613
-
614
- const res = await handler(listProjects());
615
- expect(res.status).toBe(500);
616
- expect((await res.json()).error).toBe('adapter failure');
617
- expect(errors).toHaveLength(1);
618
- });
619
- });
620
-
621
- describe('request context, auth, mounting and CORS', () => {
622
- const tokenContext = (request: Request) => {
623
- const token = request.headers.get('Authorization')?.replace('Bearer ', '');
624
- return token ? { userId: token, userName: token.toUpperCase() } : null;
625
- };
626
- const call = (router: CriticalPathRouter, path: string, init: RequestInit & { token?: string } = {}) => {
627
- const headers = new Headers(init.headers);
628
- if (init.token) headers.set('Authorization', `Bearer ${init.token}`);
629
- if (init.body) headers.set('Content-Type', 'application/json');
630
- return router.handleRequest(new Request(`http://localhost:3000/api/critical-path${path}`, { ...init, headers }));
631
- };
632
-
633
- it('returns 401 when requireAuth is set and no user is resolved', async () => {
634
- const router = new CriticalPathRouter(undefined, { getContext: tokenContext, requireAuth: true });
635
- expect((await call(router, '/projects')).status).toBe(401);
636
- expect((await call(router, '/projects', { token: 'alice' })).status).toBe(200);
637
- // Preflight is answered without credentials
638
- expect((await call(router, '/projects', { method: 'OPTIONS' })).status).toBe(204);
639
- });
640
-
641
- it('attributes mutations to the context user', async () => {
642
- const router = new CriticalPathRouter(undefined, { getContext: tokenContext });
643
- const project = await router.engine.createProject({ key: 'CTX', name: 'Context' });
644
- const task = await router.engine.createTask({ projectId: project.id, title: 'T' });
645
-
646
- const patched = await call(router, `/tasks/${task.id}`, {
647
- method: 'PATCH',
648
- token: 'alice',
649
- body: JSON.stringify({ title: 'Renamed' })
650
- });
651
- expect(patched.status).toBe(200);
652
-
653
- const commented = await call(router, `/tasks/${task.id}/comments`, {
654
- method: 'POST',
655
- token: 'alice',
656
- body: JSON.stringify({ content: 'hi' })
657
- });
658
- const { comment } = await commented.json();
659
- expect(comment.authorId).toBe('alice');
660
-
661
- const reacted = await call(router, `/comments/${comment.id}/reactions`, {
662
- method: 'POST',
663
- token: 'alice',
664
- body: JSON.stringify({ emoji: '🚀' })
665
- });
666
- expect(reacted.status).toBe(200);
667
- expect((await reacted.json()).comment.reactions[0].userId).toBe('alice');
668
-
669
- const activities = await router.engine.store.getActivities({ taskId: task.id });
670
- const update = activities.find((a) => a.action !== 'task.created');
671
- expect(update?.actorId).toBe('alice');
672
- });
673
-
674
- it('treats getContext failures as unexpected errors', async () => {
675
- const errors: unknown[] = [];
676
- const router = new CriticalPathRouter(undefined, {
677
- getContext: () => {
678
- throw new Error('session store offline');
679
- },
680
- onError: (err) => void errors.push(err)
681
- });
682
- const res = await call(router, '/projects');
683
- expect(res.status).toBe(500);
684
- expect(errors).toHaveLength(1);
685
- });
686
-
687
- it('mounts at an explicit basePath', async () => {
688
- const router = new CriticalPathRouter(undefined, { basePath: '/api/pm/' });
689
- const ok = await router.handleRequest(new Request('http://localhost/api/pm/projects'));
690
- expect(ok.status).toBe(200);
691
- const outside = await router.handleRequest(new Request('http://localhost/api/critical-path/projects'));
692
- expect(outside.status).toBe(404);
693
- const lookalike = await router.handleRequest(new Request('http://localhost/api/pmx/projects'));
694
- expect(lookalike.status).toBe(404);
695
- });
696
-
697
- it('applies an origin allow-list with credentials', async () => {
698
- const router = new CriticalPathRouter(undefined, {
699
- cors: { origins: ['https://app.example.com'], credentials: true, maxAge: 600 }
700
- });
701
- const allowed = await call(router, '/projects', { headers: { Origin: 'https://app.example.com' } });
702
- expect(allowed.headers.get('Access-Control-Allow-Origin')).toBe('https://app.example.com');
703
- expect(allowed.headers.get('Access-Control-Allow-Credentials')).toBe('true');
704
- expect(allowed.headers.get('Vary')).toContain('Origin');
705
-
706
- const denied = await call(router, '/projects', { headers: { Origin: 'https://evil.example.com' } });
707
- expect(denied.headers.get('Access-Control-Allow-Origin')).toBeNull();
708
-
709
- const preflight = await call(router, '/projects', { method: 'OPTIONS', headers: { Origin: 'https://app.example.com' } });
710
- expect(preflight.headers.get('Access-Control-Max-Age')).toBe('600');
711
- });
712
-
713
- it('can disable CORS headers and rejects credentials with a wildcard origin', async () => {
714
- const router = new CriticalPathRouter(undefined, { cors: false });
715
- const res = await call(router, '/projects', { headers: { Origin: 'https://app.example.com' } });
716
- expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull();
717
-
718
- expect(() => new CriticalPathRouter(undefined, { cors: { origins: '*', credentials: true } })).toThrow(/credentials/);
719
- });
720
- });
721
-
722
- describe('framework adapters with context', () => {
723
- it('createSvelteKitHandler attributes writes to the user from event.locals', async () => {
724
- type Event = { request: Request; locals: { user?: { id: string } } };
725
- const router = new CriticalPathRouter(undefined, { requireAuth: true });
726
- const project = await router.engine.createProject({ key: 'SK', name: 'SvelteKit' });
727
- const task = await router.engine.createTask({ projectId: project.id, title: 'T' });
728
- const { POST } = createSvelteKitHandler<Event>(router, {
729
- getContext: (event) => (event.locals.user ? { userId: event.locals.user.id } : null)
730
- });
731
-
732
- const commentReq = () =>
733
- new Request(`http://localhost/api/critical-path/tasks/${task.id}/comments`, {
734
- method: 'POST',
735
- headers: { 'Content-Type': 'application/json' },
736
- body: JSON.stringify({ content: 'from svelte' })
737
- });
738
-
739
- expect((await POST({ request: commentReq(), locals: {} })).status).toBe(401);
740
- const res = await POST({ request: commentReq(), locals: { user: { id: 'svelte-user' } } });
741
- expect(res.status).toBe(201);
742
- expect((await res.json()).comment.authorId).toBe('svelte-user');
743
- });
744
-
745
- it('createSvelteKitHandler passes locals-derived context to its own router', async () => {
746
- const { GET } = createSvelteKitHandler<{ request: Request; locals: { userId?: string } }>(
747
- {},
748
- { requireAuth: true, getContext: (event) => ({ userId: event.locals.userId }) }
749
- );
750
- const req = () => new Request('http://localhost/api/critical-path/projects');
751
- expect((await GET({ request: req(), locals: {} })).status).toBe(401);
752
- expect((await GET({ request: req(), locals: { userId: 'u1' } })).status).toBe(200);
753
- });
754
-
755
- it('createUniversalHandler serves requests with router options', async () => {
756
- const handle = createUniversalHandler({}, { basePath: '/pm', requireAuth: true, getContext: () => ({ userId: 'u1' }) });
757
- expect((await handle(new Request('http://localhost/pm/projects'))).status).toBe(200);
758
- expect((await handle(new Request('http://localhost/other/projects'))).status).toBe(404);
759
- });
760
- });
761
-
762
- describe('request body validation', () => {
763
- const send = (router: CriticalPathRouter, method: string, path: string, body: unknown, token?: string) =>
764
- router.handleRequest(
765
- new Request(`http://localhost:3000/api/critical-path${path}`, {
766
- method,
767
- headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
768
- body: JSON.stringify(body)
769
- })
770
- );
771
-
772
- it('rejects attempts to move tasks or rewrite server-assigned fields', async () => {
773
- const router = new CriticalPathRouter();
774
- const home = await router.engine.createProject({ key: 'HOME', name: 'Home' });
775
- const other = await router.engine.createProject({ key: 'OTH', name: 'Other' });
776
- const task = await router.engine.createTask({ projectId: home.id, title: 'Stay' });
777
-
778
- const res = await send(router, 'PATCH', `/tasks/${task.id}`, {
779
- title: 'Renamed',
780
- projectId: other.id,
781
- id: 'hijacked',
782
- createdAt: '1999-01-01T00:00:00.000Z',
783
- injected: '<script>'
784
- });
785
- expect(res.status).toBe(400);
786
- const { issues } = await res.json();
787
- expect(issues[0].message).toMatch(/Unrecognized key/);
788
- expect(issues[0].message).toMatch(/projectId/);
789
-
790
- const stored = await router.engine.getTask(task.id);
791
- expect(stored?.title).toBe('Stay');
792
- expect(stored?.projectId).toBe(home.id);
793
- });
794
-
795
- it('returns 400 with field-level issues for invalid bodies', async () => {
796
- const router = new CriticalPathRouter();
797
- const project = await router.engine.createProject({ key: 'VAL', name: 'Validation' });
798
-
799
- const res = await send(router, 'POST', '/tasks', { projectId: project.id, title: '', progress: 'half' });
800
- expect(res.status).toBe(400);
801
- const data = await res.json();
802
- expect(data.issues.map((i: { path: string }) => i.path).sort()).toEqual(['progress', 'title']);
803
-
804
- expect((await send(router, 'POST', '/projects', [])).status).toBe(400);
805
- expect((await send(router, 'POST', '/iterations', { projectId: project.id, name: 'S1', status: 'later' })).status).toBe(400);
806
- });
807
-
808
- it('takes authorship from the caller (or anonymous), never the body, and path ids win', async () => {
809
- const router = new CriticalPathRouter(undefined, {
810
- getContext: (request) => {
811
- const token = request.headers.get('Authorization')?.replace('Bearer ', '');
812
- return token ? { userId: token } : null;
813
- }
814
- });
815
- const project = await router.engine.createProject({ key: 'AUT', name: 'Authors' });
816
- const task = await router.engine.createTask({ projectId: project.id, title: 'T' });
817
- const decoy = await router.engine.createTask({ projectId: project.id, title: 'Decoy' });
818
-
819
- const anonymous = await send(router, 'POST', `/tasks/${task.id}/comments`, { content: 'hi' });
820
- expect(anonymous.status).toBe(201);
821
- expect((await anonymous.json()).comment.authorId).toBe('anonymous');
822
-
823
- const spoofed = await send(router, 'POST', `/tasks/${task.id}/comments`, { content: 'hi', authorId: 'ceo' }, 'alice');
824
- expect(spoofed.status).toBe(400);
825
-
826
- const authed = await send(router, 'POST', `/tasks/${task.id}/comments`, { content: 'hi', taskId: decoy.id }, 'alice');
827
- expect(authed.status).toBe(201);
828
- const { comment } = await authed.json();
829
- expect(comment.authorId).toBe('alice');
830
- expect(comment.taskId).toBe(task.id);
831
- });
832
-
833
- it('attributes unauthenticated writes to the anonymous actor and rejects actor claims', async () => {
834
- const router = new CriticalPathRouter();
835
- const project = await router.engine.createProject({ key: 'ANO', name: 'Anonymous' });
836
- const task = await router.engine.createTask({ projectId: project.id, title: 'T' });
837
-
838
- const claimed = await send(router, 'PATCH', `/tasks/${task.id}`, { isBlocked: true, actorId: 'agent-7' });
839
- expect(claimed.status).toBe(400);
840
-
841
- const res = await send(router, 'PATCH', `/tasks/${task.id}`, { isBlocked: true });
842
- expect(res.status).toBe(200);
843
- const activities = await router.engine.store.getActivities({ taskId: task.id });
844
- expect(activities.some((a) => a.actorId === 'anonymous')).toBe(true);
845
- });
846
- });
847
-
848
- describe('authorization and tenancy over HTTP', () => {
849
- const users: Record<string, { userId: string; tenantId: string; roles?: string[] }> = {
850
- alice: { userId: 'alice', tenantId: 'acme' },
851
- vic: { userId: 'vic', tenantId: 'acme' },
852
- gus: { userId: 'gus', tenantId: 'globex' }
853
- };
854
- const router = new CriticalPathRouter(
855
- { authorize: createRolePolicy() },
856
- {
857
- requireAuth: true,
858
- getContext: (request) => users[request.headers.get('Authorization')?.replace('Bearer ', '') ?? ''] ?? null
859
- }
860
- );
861
- const call = (token: string, method: string, path: string, body?: unknown) =>
862
- router.handleRequest(
863
- new Request(`http://localhost/api/critical-path${path}`, {
864
- method,
865
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
866
- ...(body ? { body: JSON.stringify(body) } : {})
867
- })
868
- );
869
-
870
- it('enforces roles with 403 and hides other tenants with 404', async () => {
871
- const created = await call('alice', 'POST', '/projects', {
872
- name: 'Acme roadmap',
873
- members: [{ userId: 'vic', role: 'viewer' }]
874
- });
875
- expect(created.status).toBe(201);
876
- const { project } = await created.json();
877
- expect(project.members).toEqual(expect.arrayContaining([{ userId: 'alice', role: 'admin' }]));
878
-
879
- const viewerWrite = await call('vic', 'POST', '/tasks', { projectId: project.id, title: 'Nope' });
880
- expect(viewerWrite.status).toBe(403);
881
-
882
- const viewerRead = await call('vic', 'GET', `/projects/${project.id}`);
883
- expect(viewerRead.status).toBe(200);
884
-
885
- const otherTenant = await call('gus', 'GET', `/projects/${project.id}`);
886
- expect(otherTenant.status).toBe(404);
887
- const otherTenantList = await (await call('gus', 'GET', '/projects')).json();
888
- expect(otherTenantList.projects).toHaveLength(0);
889
-
890
- const tenantInBody = await call('alice', 'PATCH', `/projects/${project.id}`, { tenantId: 'globex' });
891
- expect(tenantInBody.status).toBe(400);
892
- });
893
- });
894
-
895
- it('manages webhooks over HTTP and delivers events they subscribe to', async () => {
896
- const delivered: string[] = [];
897
- const router = new CriticalPathRouter({
898
- webhookDelivery: {
899
- fetch: (async (_url: string, init: RequestInit) => {
900
- delivered.push(JSON.parse(init.body as string).event);
901
- return new Response(null, { status: 204 });
902
- }) as unknown as typeof fetch
903
- }
904
- });
905
- const call = (method: string, path: string, body?: unknown) =>
906
- router.handleRequest(
907
- new Request(`http://localhost/api/critical-path${path}`, {
908
- method,
909
- headers: { 'Content-Type': 'application/json' },
910
- ...(body ? { body: JSON.stringify(body) } : {})
911
- })
912
- );
913
-
914
- expect((await call('POST', '/webhooks', { name: 'Typo', url: 'https://hooks.example.com', events: ['task.craeted'] })).status).toBe(400);
915
-
916
- const created = await call('POST', '/webhooks', { name: 'CI', url: 'https://hooks.example.com/ci', events: ['project.created'] });
917
- expect(created.status).toBe(201);
918
- const { webhook, secret } = await created.json();
919
- expect(secret).toMatch(/^whsec_/);
920
-
921
- const listed = await (await call('GET', '/webhooks')).json();
922
- expect(listed.webhooks[0]).not.toHaveProperty('secret');
923
- expect(listed.webhooks[0].hasSecret).toBe(true);
924
-
925
- await call('POST', '/projects', { name: 'Triggers a delivery' });
926
- await router.engine.webhooks.idle();
927
- expect(delivered).toEqual(['project.created']);
928
-
929
- expect((await call('DELETE', `/webhooks/${webhook.id}`)).status).toBe(200);
930
- expect((await call('GET', `/webhooks/${webhook.id}`)).status).toBe(404);
931
- });
932
-
933
- describe('plugin routes and middleware', () => {
934
- const reports: CriticalPathPlugin = {
935
- id: 'reports',
936
- name: 'Reports',
937
- version: '1',
938
- init: async (engine) => {
939
- await engine.createProject({ key: 'SEED', name: 'Seeded during init' });
940
- },
941
- routes: [
942
- {
943
- method: 'GET',
944
- path: '/reports/:projectId/summary',
945
- handler: async (_request, { engine, params, context }) => {
946
- const tasks = await engine.getTasks(params.projectId);
947
- return Response.json({ projectId: params.projectId, taskCount: tasks.length, caller: context?.userId ?? null });
948
- }
949
- },
950
- {
951
- method: 'POST',
952
- path: '/reports/forbidden',
953
- handler: () => {
954
- throw new ForbiddenError('reports are read-only');
955
- }
956
- }
957
- ],
958
- middleware: async (request, _ctx, next) => {
959
- if (request.headers.get('X-Block') === 'yes') return Response.json({ error: 'blocked' }, { status: 429 });
960
- const response = await next();
961
- response.headers.set('X-Plugin', 'reports');
962
- return response;
963
- }
964
- };
965
- const router = new CriticalPathRouter(
966
- { plugins: [reports] },
967
- { getContext: (request) => (request.headers.get('X-User') ? { userId: request.headers.get('X-User')! } : null) }
968
- );
969
- const call = (path: string, init: RequestInit = {}) =>
970
- router.handleRequest(new Request(`http://localhost/api/critical-path${path}`, init));
971
-
972
- it('serves plugin routes with params, the caller and an actor-scoped engine, after init', async () => {
973
- const [project] = await router.engine.getProjects();
974
- expect(project.name).toBe('Seeded during init');
975
- await router.engine.createTask({ projectId: project.id, title: 'T' });
976
-
977
- const res = await call(`/reports/${project.id}/summary`, { headers: { 'X-User': 'ana' } });
978
- expect(res.status).toBe(200);
979
- expect(await res.json()).toEqual({ projectId: project.id, taskCount: 1, caller: 'ana' });
980
- });
981
-
982
- it('wraps built-in and plugin routes in middleware, which can short-circuit', async () => {
983
- const builtIn = await call('/projects');
984
- expect(builtIn.status).toBe(200);
985
- expect(builtIn.headers.get('X-Plugin')).toBe('reports');
986
-
987
- const blocked = await call('/projects', { headers: { 'X-Block': 'yes' } });
988
- expect(blocked.status).toBe(429);
989
- });
990
-
991
- it('maps errors thrown by plugin routes like built-in errors', async () => {
992
- const res = await call('/reports/forbidden', { method: 'POST' });
993
- expect(res.status).toBe(403);
994
- });
995
- });
996
-
997
- it('filters and paginates GET /tasks and GET /activities', async () => {
998
- const router = new CriticalPathRouter();
999
- const project = await router.engine.createProject({ name: 'Paged' });
1000
- for (let i = 0; i < 5; i++) {
1001
- await router.engine.createTask({ projectId: project.id, title: `T${i}`, priority: i % 2 ? 'high' : 'low' });
1002
- }
1003
- const get = async (qs: string) => {
1004
- const res = await router.handleRequest(new Request(`http://localhost/api/critical-path${qs}`));
1005
- return { status: res.status, body: await res.json() };
1006
- };
1007
-
1008
- const high = await get(`/tasks?projectId=${project.id}&priority=high,urgent`);
1009
- expect(high.body.tasks.map((t: { title: string }) => t.title).sort()).toEqual(['T1', 'T3']);
1010
-
1011
- const first = await get(`/tasks?projectId=${project.id}&limit=2`);
1012
- expect(first.body.tasks).toHaveLength(2);
1013
- expect(first.body.nextCursor).toBeTruthy();
1014
- const second = await get(`/tasks?projectId=${project.id}&limit=2&cursor=${encodeURIComponent(first.body.nextCursor)}`);
1015
- // Tasks created in the same millisecond tie on createdAt and are ordered by id, so check paging, not titles
1016
- const ids = [...first.body.tasks, ...second.body.tasks].map((t: { id: string }) => t.id);
1017
- expect(new Set(ids).size).toBe(4);
1018
-
1019
- expect((await get('/tasks?projectID=typo')).status).toBe(400);
1020
- expect((await get('/tasks?limit=0')).status).toBe(400);
1021
- expect((await get('/tasks?cursor=garbage')).status).toBe(400);
1022
-
1023
- const feed = await get(`/activities?projectId=${project.id}&limit=3`);
1024
- expect(feed.body.activities).toHaveLength(3);
1025
- expect(feed.body.nextCursor).toBeTruthy();
1026
- });
1027
-
1028
- it('rejects request bodies over maxBodyBytes with 413', async () => {
1029
- const router = new CriticalPathRouter(undefined, { maxBodyBytes: 64 });
1030
- const big = JSON.stringify({ name: 'x'.repeat(200) });
1031
-
1032
- const declared = await router.handleRequest(
1033
- new Request('http://localhost/api/critical-path/projects', { method: 'POST', body: big, headers: { 'Content-Type': 'application/json' } })
1034
- );
1035
- expect(declared.status).toBe(413);
1036
-
1037
- // A streamed body without Content-Length is cut off once it passes the limit
1038
- const stream = new ReadableStream({
1039
- start(controller) {
1040
- controller.enqueue(new TextEncoder().encode(big));
1041
- controller.close();
1042
- }
1043
- });
1044
- const streamed = await router.handleRequest(
1045
- new Request('http://localhost/api/critical-path/projects', { method: 'POST', body: stream, duplex: 'half' } as RequestInit)
1046
- );
1047
- expect(streamed.status).toBe(413);
1048
-
1049
- const small = await router.handleRequest(
1050
- new Request('http://localhost/api/critical-path/projects', { method: 'POST', body: JSON.stringify({ name: 'ok' }) })
1051
- );
1052
- expect(small.status).toBe(201);
1053
- });
1054
- });
1055
-