UNPKG

@unito/integration-sdk

Version:

Integration SDK

298 lines (297 loc) 15.7 kB
import assert from 'node:assert/strict'; import { afterEach, beforeEach, describe, it, mock } from 'node:test'; import { Handler, } 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 = [ ['', 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 = (() => { }); const updateItem = (() => { }); const deleteItem = (() => { }); const getCollection = (() => { }); const createItem = (() => { }); const paths = [ ['/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, request = {}) { let body = {}; let statusCode = 0; const response = { status: (receivedStatusCode) => ({ send: (receivedBody) => { body = receivedBody; statusCode = receivedStatusCode; }, }), statusCode: 0, locals: { credentials: { foo: 'bar' }, logger: { decorate: () => { } }, }, }; await handler(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.methods.get, true); assert.equal(routes[1].route.path, '/foo'); assert.equal(routes[1].route.methods.post, true); assert.equal(routes[2].route.path, '/foo/:bar'); assert.equal(routes[2].route.methods.get, true); assert.equal(routes[3].route.path, '/foo/:bar'); assert.equal(routes[3].route.methods.patch, true); assert.equal(routes[4].route.path, '/foo/:bar'); assert.equal(routes[4].route.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.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', 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.methods.post, true); const validBody = { itemPath: '/projects/P1', relationName: 'tasks', fieldName: 'description', value: '<p>hello</p>', }; 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', 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: '<p>hi</p>', }; 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: '<p>hello</p>' }), }); 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.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: '<p>hello</p>' }, 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 = []; const logger = { decorate: (meta) => decorateCalls.push(meta) }; const response = { status: () => ({ send: () => { } }), locals: { credentials: { foo: 'bar' }, logger }, }; await createRoute({ body: { title: 'x', description: 'y', assignee: 'u1' } }, response, (() => { })); 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 = []; const logger = { decorate: (meta) => decorateCalls.push(meta) }; const response = { status: () => ({ send: () => { } }), locals: { credentials: { foo: 'bar' }, logger }, }; await updateRoute({ body: { title: 'x', description: 'y', __meta: { additionalField: { relationName: 'tasks', field: { name: 'note', type: 'string' } } }, }, }, response, (() => { })); assert.equal(decorateCalls.length, 1); assert.deepEqual(decorateCalls[0], { fieldNames: ['title', 'description'] }); }); }); });