import { Request, Response, NextFunction } from 'express'; import assert from 'node:assert/strict'; import { afterEach, beforeEach, describe, it, mock } from 'node:test'; import { Handler, HandlersInput, GetItemHandler, GetCollectionHandler, UpdateItemHandler, DeleteItemHandler, CreateItemHandler, } from '../src/handler.js'; import { BadRequestError, UnprocessableEntityError } from '../src/httpErrors.js'; describe('Handler', () => { beforeEach(() => { // Disable debug logging to keep the test output clean. mock.method(global.console, 'debug', () => {}); }); afterEach(() => { mock.reset(); }); describe('constructor', () => { it('returns a Handler', () => { const itemHandler = new Handler('/', {}); assert.ok(itemHandler instanceof Handler); }); it('validates path', () => { const paths: [string, boolean][] = [ ['', false], ['invalid', false], ['/', true], ['/bar', true], ['/bar/', false], ['/foo/:bar', true], ['/foo/:bar/', false], ['/foo/:bar/spam/:baz', true], ['/foo/:bar/:spam', true], ]; for (const [path, valid] of paths) { if (valid) { assert.doesNotThrow(() => new Handler(path, {})); } else { assert.throws(() => new Handler(path, {})); } } }); it('validates configuration', () => { const getItem = (() => {}) as unknown as GetItemHandler; const updateItem = (() => {}) as unknown as UpdateItemHandler; const deleteItem = (() => {}) as unknown as DeleteItemHandler; const getCollection = (() => {}) as unknown as GetCollectionHandler; const createItem = (() => {}) as unknown as CreateItemHandler; const paths: [string, HandlersInput, boolean][] = [ ['/foo', { getItem }, true], ['/foo', { getCollection }, true], ['/foo', { getItem, getCollection }, false], ['/foo', { updateItem, createItem }, false], ['/foo', { deleteItem, createItem }, false], ['/foo/:bar', { getItem, getCollection }, true], ['/foo/:bar', { getItem, updateItem, deleteItem, createItem, getCollection }, true], ]; for (const [path, handlers, valid] of paths) { if (valid) { assert.doesNotThrow(() => new Handler(path, handlers)); } else { assert.throws(() => new Handler(path, handlers)); } } }); }); describe('generate', () => { async function executeHandler( handler: (req: Request, res: Response, callback: NextFunction) => Promise, request: Record = {}, ) { let body: Record = {}; let statusCode = 0; const response = { status: (receivedStatusCode: number) => ({ send: (receivedBody: Record) => { body = receivedBody; statusCode = receivedStatusCode; }, }), statusCode: 0, locals: { credentials: { foo: 'bar' }, logger: { decorate: () => {} }, }, } as unknown as Response; await handler(request as unknown as Request, response, () => {}); return { body, statusCode }; } it('returns a router', async () => { const handler = new Handler('/foo/:bar', { getCollection: () => Promise.resolve({ info: {}, data: [] }), getItem: () => Promise.resolve({ fields: {}, relations: [], canonicalPath: '/foo/1' }), createItem: () => Promise.resolve({ fields: {}, path: '/', canonicalPath: '/foo/1' }), updateItem: () => Promise.resolve({ fields: {}, relations: [], canonicalPath: '/foo/1' }), deleteItem: () => Promise.resolve(), }); const router = handler.generate(); const routes = router.stack; assert.equal(routes.length, 5); assert.equal(routes[0]!.route!.path, '/foo'); assert.equal((routes[0]!.route! as any).methods.get, true); assert.equal(routes[1]!.route!.path, '/foo'); assert.equal((routes[1]!.route! as any).methods.post, true); assert.equal(routes[2]!.route!.path, '/foo/:bar'); assert.equal((routes[2]!.route! as any).methods.get, true); assert.equal(routes[3]!.route!.path, '/foo/:bar'); assert.equal((routes[3]!.route! as any).methods.patch, true); assert.equal(routes[4]!.route!.path, '/foo/:bar'); assert.equal((routes[4]!.route! as any).methods.delete, true); // GetCollection. assert.deepEqual(await executeHandler(routes[0]!.route!.stack[0]!.handle), { body: { info: {}, data: [] }, statusCode: 200, }); // CreateItem. assert.deepEqual(await executeHandler(routes[1]!.route!.stack[0]!.handle, { body: { foo: 'bar' } }), { body: { fields: {}, path: '/', canonicalPath: '/foo/1' }, statusCode: 201, }); // GetItem. assert.deepEqual(await executeHandler(routes[2]!.route!.stack[0]!.handle), { body: { fields: {}, relations: [], canonicalPath: '/foo/1' }, statusCode: 200, }); // UpdateItem. assert.deepEqual(await executeHandler(routes[3]!.route!.stack[0]!.handle, { body: { foo: 'bar' } }), { body: { fields: {}, relations: [], canonicalPath: '/foo/1' }, statusCode: 200, }); // DeleteItem. assert.deepEqual(await executeHandler(routes[4]!.route!.stack[0]!.handle), { body: null, statusCode: 204, }); }); it('uses pathWithIdentifier as is when appropriate', async () => { const handler = new Handler('/foo/:bar/baz', { getCollection: () => Promise.resolve({ info: {}, data: [] }), }); const router = handler.generate(); const routes = router.stack; assert.equal(routes.length, 1); assert.equal(routes[0]!.route!.path, '/foo/:bar/baz'); assert.equal((routes[0]!.route as any).methods.get, true); // GetCollection. assert.deepEqual(await executeHandler(routes[0]!.route!.stack[0]!.handle), { body: { info: {}, data: [] }, statusCode: 200, }); }); it('undefined handlers', async () => { const handler = new Handler('/foo/:bar', {}); const router = handler.generate(); const routes = router.stack; assert.equal(routes.length, 0); }); it('returns a 400 for malformed request payloads and a 422 for empty ones', async () => { const handler = new Handler('/foo/:bar', { createItem: () => Promise.resolve({ fields: {}, path: '/', canonicalPath: '/foo/1' }), updateItem: () => Promise.resolve({ fields: {}, relations: [], canonicalPath: '/foo/1' }), }); const router = handler.generate(); const routes = router.stack; // CreateItemRequestPayload. const createHandler = routes[0]!.route!.stack[0]!.handle; await assert.doesNotReject(async () => await executeHandler(createHandler, { body: { foo: 'bar' } })); await assert.rejects(async () => await executeHandler(createHandler, { body: null }), BadRequestError); await assert.rejects(async () => await executeHandler(createHandler, { body: 'not json' }), BadRequestError); await assert.rejects(async () => await executeHandler(createHandler, { body: {} }), UnprocessableEntityError); // UpdateItemRequestPayload. const updateHandler = routes[1]!.route!.stack[0]!.handle; await assert.doesNotReject(async () => await executeHandler(updateHandler, { body: { foo: 'bar' } })); await assert.rejects(async () => await executeHandler(updateHandler, { body: null }), BadRequestError); await assert.rejects(async () => await executeHandler(updateHandler, { body: 'not json' }), BadRequestError); await assert.rejects(async () => await executeHandler(updateHandler, { body: {} }), UnprocessableEntityError); }); it('mounts and executes parseRichText', async () => { const root = { type: 'root' as const, children: [{ type: 'text', value: 'hello' }] }; const handler = new Handler('/richtext/parse', { parseRichText: () => Promise.resolve({ root }), }); const router = handler.generate(); const routes = router.stack; assert.equal(routes.length, 1); assert.equal(routes[0]!.route!.path, '/richtext/parse'); assert.equal((routes[0]!.route as any).methods.post, true); const validBody = { itemPath: '/projects/P1', relationName: 'tasks', fieldName: 'description', value: '

hello

', }; assert.deepEqual(await executeHandler(routes[0]!.route!.stack[0]!.handle, { body: validBody }), { body: { root }, statusCode: 200, }); }); it('rejects invalid parseRichText request payloads', async () => { const root = { type: 'root' as const, children: [] }; const handler = new Handler('/richtext/parse', { parseRichText: () => Promise.resolve({ root }), }); const router = handler.generate(); const parseHandler = router.stack[0]!.route!.stack[0]!.handle; const valid = { itemPath: '/projects/P1', relationName: 'tasks', fieldName: 'description', value: '

hi

', }; await assert.doesNotReject(async () => await executeHandler(parseHandler, { body: valid })); await assert.rejects(async () => await executeHandler(parseHandler, { body: null }), BadRequestError); await assert.rejects(async () => await executeHandler(parseHandler, { body: 'not json' }), BadRequestError); await assert.rejects(async () => await executeHandler(parseHandler, { body: {} }), BadRequestError); await assert.rejects( async () => await executeHandler(parseHandler, { body: { ...valid, itemPath: undefined } }), BadRequestError, ); await assert.rejects( async () => await executeHandler(parseHandler, { body: { ...valid, relationName: undefined } }), BadRequestError, ); await assert.rejects( async () => await executeHandler(parseHandler, { body: { ...valid, fieldName: undefined } }), BadRequestError, ); await assert.rejects( async () => await executeHandler(parseHandler, { body: { ...valid, value: undefined } }), BadRequestError, ); await assert.rejects( async () => await executeHandler(parseHandler, { body: { ...valid, value: 42 } }), BadRequestError, ); }); it('mounts and executes dumpRichText', async () => { const handler = new Handler('/richtext/dump', { dumpRichText: () => Promise.resolve({ value: '

hello

' }), }); const router = handler.generate(); const routes = router.stack; assert.equal(routes.length, 1); assert.equal(routes[0]!.route!.path, '/richtext/dump'); assert.equal((routes[0]!.route as any).methods.post, true); const validBody = { itemPath: '/projects/P1', relationName: 'tasks', fieldName: 'description', root: { type: 'root', children: [{ type: 'text', value: 'hello' }] }, }; assert.deepEqual(await executeHandler(routes[0]!.route!.stack[0]!.handle, { body: validBody }), { body: { value: '

hello

' }, statusCode: 200, }); }); it('rejects invalid dumpRichText request payloads', async () => { const handler = new Handler('/richtext/dump', { dumpRichText: () => Promise.resolve({ value: '' }), }); const router = handler.generate(); const dumpHandler = router.stack[0]!.route!.stack[0]!.handle; const valid = { itemPath: '/projects/P1', relationName: 'tasks', fieldName: 'description', root: { type: 'root', children: [] }, }; await assert.doesNotReject(async () => await executeHandler(dumpHandler, { body: valid })); await assert.rejects(async () => await executeHandler(dumpHandler, { body: null }), BadRequestError); await assert.rejects(async () => await executeHandler(dumpHandler, { body: 'not json' }), BadRequestError); await assert.rejects(async () => await executeHandler(dumpHandler, { body: {} }), BadRequestError); await assert.rejects( async () => await executeHandler(dumpHandler, { body: { ...valid, itemPath: undefined } }), BadRequestError, ); await assert.rejects( async () => await executeHandler(dumpHandler, { body: { ...valid, root: undefined } }), BadRequestError, ); await assert.rejects( async () => await executeHandler(dumpHandler, { body: { ...valid, root: 'not an object' } }), BadRequestError, ); }); it('decorates the request logger with fieldNames on createItem', async () => { const handler = new Handler('/foo/:bar', { createItem: () => Promise.resolve({ fields: {}, path: '/', canonicalPath: '/foo/1' }), }); const router = handler.generate(); // /foo/:bar with only createItem → createItem mounts at POST /foo (one route). const createRoute = router.stack[0]!.route!.stack[0]!.handle; const decorateCalls: unknown[] = []; const logger = { decorate: (meta: unknown) => decorateCalls.push(meta) }; const response = { status: () => ({ send: () => {} }), locals: { credentials: { foo: 'bar' }, logger }, } as unknown as Response; await createRoute( { body: { title: 'x', description: 'y', assignee: 'u1' } } as unknown as Request, response, (() => {}) as NextFunction, ); assert.equal(decorateCalls.length, 1); assert.deepEqual(decorateCalls[0], { fieldNames: ['title', 'description', 'assignee'] }); }); it('decorates the request logger with fieldNames on updateItem, stripping __meta', async () => { const handler = new Handler('/foo/:bar', { updateItem: () => Promise.resolve({ fields: {}, relations: [], canonicalPath: '/foo/1' }), }); const router = handler.generate(); // /foo/:bar with only updateItem → updateItem mounts at PATCH /foo/:bar (one route). const updateRoute = router.stack[0]!.route!.stack[0]!.handle; const decorateCalls: unknown[] = []; const logger = { decorate: (meta: unknown) => decorateCalls.push(meta) }; const response = { status: () => ({ send: () => {} }), locals: { credentials: { foo: 'bar' }, logger }, } as unknown as Response; await updateRoute( { body: { title: 'x', description: 'y', __meta: { additionalField: { relationName: 'tasks', field: { name: 'note', type: 'string' } } }, }, } as unknown as Request, response, (() => {}) as NextFunction, ); assert.equal(decorateCalls.length, 1); assert.deepEqual(decorateCalls[0], { fieldNames: ['title', 'description'] }); }); }); });