@unito/integration-sdk
Version:
Integration SDK
298 lines (297 loc) • 15.7 kB
JavaScript
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'] });
});
});
});