@critical-path/server 0.13.5 → 0.14.0
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/CHANGELOG.md +39 -0
- package/README.md +37 -0
- package/dist/adapters/next.d.ts +18 -8
- package/dist/adapters/next.d.ts.map +1 -1
- package/dist/adapters/next.js +11 -4
- package/dist/adapters/next.js.map +1 -1
- package/dist/adapters/sveltekit.d.ts +2 -2
- package/dist/adapters/sveltekit.d.ts.map +1 -1
- package/dist/adapters/sveltekit.js +2 -2
- package/dist/adapters/sveltekit.js.map +1 -1
- package/dist/index.test.js +163 -0
- package/dist/index.test.js.map +1 -1
- package/dist/router.d.ts +18 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +132 -52
- package/dist/router.js.map +1 -1
- package/package.json +2 -2
- package/src/adapters/next.ts +27 -6
- package/src/adapters/sveltekit.ts +6 -3
- package/src/index.test.ts +199 -0
- package/src/router.ts +141 -52
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { CriticalPathRouter } from '../router.js';
|
|
1
|
+
import { CriticalPathRouter, type CriticalPathRouterOptions } from '../router.js';
|
|
2
2
|
import type { CriticalPathConfig } from '@critical-path/core';
|
|
3
3
|
|
|
4
|
-
export function createSvelteKitHandler(
|
|
4
|
+
export function createSvelteKitHandler(
|
|
5
|
+
configOrRouter: CriticalPathConfig | CriticalPathRouter,
|
|
6
|
+
options?: CriticalPathRouterOptions
|
|
7
|
+
) {
|
|
5
8
|
const router = configOrRouter instanceof CriticalPathRouter
|
|
6
9
|
? configOrRouter
|
|
7
|
-
: new CriticalPathRouter(configOrRouter);
|
|
10
|
+
: new CriticalPathRouter(configOrRouter, options);
|
|
8
11
|
|
|
9
12
|
const handler = async ({ request }: { request: Request }) => {
|
|
10
13
|
return router.handleRequest(request);
|
package/src/index.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { CriticalPathRouter } from './router.js';
|
|
3
|
+
import { createNextHandler } from './adapters/next.js';
|
|
4
|
+
import { InMemoryStore } from '@critical-path/core';
|
|
3
5
|
|
|
4
6
|
describe('@critical-path/server Router Tests', () => {
|
|
5
7
|
it('handles project creation and retrieval over HTTP Fetch Requests', async () => {
|
|
@@ -410,5 +412,202 @@ describe('@critical-path/server Router Tests', () => {
|
|
|
410
412
|
expect(data.status).toBe('Executing compilation step');
|
|
411
413
|
expect(typeof data.timestamp).toBe('number');
|
|
412
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', async () => {
|
|
485
|
+
const router = new CriticalPathRouter();
|
|
486
|
+
const res = await router.handleRequest(new Request(`${base}/tasks`, { method: 'OPTIONS' }));
|
|
487
|
+
expect(res.status).toBe(204);
|
|
488
|
+
expect(res.headers.get('Access-Control-Allow-Methods')).toContain('PATCH');
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('createNextHandler supports both callable and destructured exports', async () => {
|
|
493
|
+
const handler = createNextHandler({});
|
|
494
|
+
const { GET } = handler;
|
|
495
|
+
const req = () => new Request('http://localhost:3000/api/critical-path/projects');
|
|
496
|
+
|
|
497
|
+
expect((await handler(req())).status).toBe(200);
|
|
498
|
+
expect((await GET(req())).status).toBe(200);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('updates projects via PATCH without allowing id or timestamp overrides', async () => {
|
|
502
|
+
const router = new CriticalPathRouter();
|
|
503
|
+
const proj = await router.engine.createProject({ key: 'UPD', name: 'Before' });
|
|
504
|
+
const patch = (id: string, body: unknown) =>
|
|
505
|
+
router.handleRequest(
|
|
506
|
+
new Request(`http://localhost:3000/api/critical-path/projects/${id}`, {
|
|
507
|
+
method: 'PATCH',
|
|
508
|
+
headers: { 'Content-Type': 'application/json' },
|
|
509
|
+
body: JSON.stringify(body)
|
|
510
|
+
})
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
const res = await patch(proj.id, { name: 'After', id: 'hijacked', createdAt: '1999-01-01T00:00:00.000Z' });
|
|
514
|
+
expect(res.status).toBe(200);
|
|
515
|
+
const { project } = await res.json();
|
|
516
|
+
expect(project.id).toBe(proj.id);
|
|
517
|
+
expect(project.name).toBe('After');
|
|
518
|
+
expect(project.createdAt).toBe(proj.createdAt);
|
|
519
|
+
|
|
520
|
+
expect((await patch('missing', { name: 'x' })).status).toBe(404);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it('returns 404 when deleting a resource that does not exist', async () => {
|
|
524
|
+
const router = new CriticalPathRouter();
|
|
525
|
+
for (const path of ['projects', 'tasks', 'workflows', 'comments', 'attachments', 'deliverables', 'teams', 'containers', 'iterations']) {
|
|
526
|
+
const res = await router.handleRequest(
|
|
527
|
+
new Request(`http://localhost:3000/api/critical-path/${path}/missing`, { method: 'DELETE' })
|
|
528
|
+
);
|
|
529
|
+
expect(res.status, path).toBe(404);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
describe('unexpected error handling options', () => {
|
|
534
|
+
const failingRouter = (options: ConstructorParameters<typeof CriticalPathRouter>[1]) => {
|
|
535
|
+
const router = new CriticalPathRouter(undefined, options);
|
|
536
|
+
router.engine.getProjects = async () => {
|
|
537
|
+
throw new Error('SQLITE_CORRUPT: secret internals');
|
|
538
|
+
};
|
|
539
|
+
return router;
|
|
540
|
+
};
|
|
541
|
+
const listProjects = () => new Request('http://localhost:3000/api/critical-path/projects');
|
|
542
|
+
|
|
543
|
+
it('passes unexpected errors and the request to onError instead of console.error', async () => {
|
|
544
|
+
const seen: Array<{ message: string; url: string }> = [];
|
|
545
|
+
const router = failingRouter({
|
|
546
|
+
onError: (err, request) => {
|
|
547
|
+
seen.push({ message: (err as Error).message, url: request.url });
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
const res = await router.handleRequest(listProjects());
|
|
552
|
+
expect(res.status).toBe(500);
|
|
553
|
+
expect((await res.json()).error).toBe('Internal Server Error');
|
|
554
|
+
expect(seen).toEqual([{ message: 'SQLITE_CORRUPT: secret internals', url: listProjects().url }]);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it('lets onError replace the response', async () => {
|
|
558
|
+
const router = failingRouter({
|
|
559
|
+
onError: () => new Response(JSON.stringify({ error: 'Try again later' }), { status: 503 })
|
|
560
|
+
});
|
|
561
|
+
const res = await router.handleRequest(listProjects());
|
|
562
|
+
expect(res.status).toBe(503);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('does not call onError for expected client errors', async () => {
|
|
566
|
+
let calls = 0;
|
|
567
|
+
const router = new CriticalPathRouter(undefined, { onError: () => void calls++ });
|
|
568
|
+
const res = await router.handleRequest(
|
|
569
|
+
new Request('http://localhost:3000/api/critical-path/projects', { method: 'POST', body: '{bad' })
|
|
570
|
+
);
|
|
571
|
+
expect(res.status).toBe(400);
|
|
572
|
+
expect(calls).toBe(0);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('exposes real messages only when exposeErrors is enabled', async () => {
|
|
576
|
+
const exposed = failingRouter({ exposeErrors: true, onError: () => {} });
|
|
577
|
+
expect((await (await exposed.handleRequest(listProjects())).json()).error).toBe('SQLITE_CORRUPT: secret internals');
|
|
578
|
+
|
|
579
|
+
const hidden = failingRouter({ exposeErrors: false, onError: () => {} });
|
|
580
|
+
expect((await (await hidden.handleRequest(listProjects())).json()).error).toBe('Internal Server Error');
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it('defaults exposeErrors from NODE_ENV === development', async () => {
|
|
584
|
+
const original = process.env.NODE_ENV;
|
|
585
|
+
try {
|
|
586
|
+
process.env.NODE_ENV = 'development';
|
|
587
|
+
const dev = failingRouter({ onError: () => {} });
|
|
588
|
+
expect((await (await dev.handleRequest(listProjects())).json()).error).toContain('SQLITE_CORRUPT');
|
|
589
|
+
|
|
590
|
+
process.env.NODE_ENV = 'production';
|
|
591
|
+
const prod = failingRouter({ onError: () => {} });
|
|
592
|
+
expect((await (await prod.handleRequest(listProjects())).json()).error).toBe('Internal Server Error');
|
|
593
|
+
} finally {
|
|
594
|
+
process.env.NODE_ENV = original;
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('passes options through createNextHandler', async () => {
|
|
599
|
+
const store = new InMemoryStore();
|
|
600
|
+
store.getProjects = async () => {
|
|
601
|
+
throw new Error('adapter failure');
|
|
602
|
+
};
|
|
603
|
+
const errors: unknown[] = [];
|
|
604
|
+
const handler = createNextHandler({ store }, { exposeErrors: true, onError: (err) => void errors.push(err) });
|
|
605
|
+
|
|
606
|
+
const res = await handler(listProjects());
|
|
607
|
+
expect(res.status).toBe(500);
|
|
608
|
+
expect((await res.json()).error).toBe('adapter failure');
|
|
609
|
+
expect(errors).toHaveLength(1);
|
|
610
|
+
});
|
|
611
|
+
});
|
|
413
612
|
});
|
|
414
613
|
|