247 lines
7.1 KiB
TypeScript
247 lines
7.1 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { EventEmitter } from 'node:events';
|
|
import express from 'express';
|
|
import test from 'node:test';
|
|
import type { RequestHandler } from 'express';
|
|
import { createRequest, createResponse } from 'node-mocks-http';
|
|
import type { Body, Headers, RequestMethod, RequestOptions } from 'node-mocks-http';
|
|
|
|
import { createEntityRouter } from '../../src/factories/router.factory.ts';
|
|
import { commonErrorHandler } from '../../src/helpers.ts';
|
|
import type {
|
|
EntityRouterDbApi,
|
|
EntityRouterService,
|
|
} from '../../src/types/index.ts';
|
|
import {
|
|
setCurrentUser,
|
|
setRuntimeContext,
|
|
} from '../../src/utils/request-context.ts';
|
|
|
|
const USER_ID = '0f5cb907-73d3-45da-9e42-bf3e4b499efb';
|
|
const ENTITY_ID = 'aa0b89ab-b0db-4d6c-a59c-c7785fc3eae5';
|
|
|
|
interface RecordedCall {
|
|
name: string;
|
|
payload: unknown;
|
|
}
|
|
|
|
function contextMiddleware(): RequestHandler {
|
|
return (req, _res, next) => {
|
|
setCurrentUser(req, {
|
|
id: USER_ID,
|
|
app_role: {
|
|
name: 'Test Role',
|
|
permissions: [
|
|
{ name: 'READ_TEST_ENTITIES' },
|
|
{ name: 'CREATE_TEST_ENTITIES' },
|
|
{ name: 'UPDATE_TEST_ENTITIES' },
|
|
{ name: 'DELETE_TEST_ENTITIES' },
|
|
],
|
|
},
|
|
});
|
|
setRuntimeContext(req, {
|
|
mode: 'admin',
|
|
projectSlug: null,
|
|
headerEnvironment: 'stage',
|
|
headerProjectSlug: 'demo-tour',
|
|
});
|
|
next();
|
|
};
|
|
}
|
|
|
|
function buildHarness() {
|
|
const calls: RecordedCall[] = [];
|
|
const service: EntityRouterService<Record<string, unknown>, Record<string, unknown>> = {
|
|
create: (options) => {
|
|
calls.push({ name: 'create', payload: options });
|
|
return Promise.resolve({ id: ENTITY_ID, ...options.data });
|
|
},
|
|
update: (options) => {
|
|
calls.push({ name: 'update', payload: options });
|
|
return Promise.resolve(true);
|
|
},
|
|
remove: (options) => {
|
|
calls.push({ name: 'remove', payload: options });
|
|
return Promise.resolve(true);
|
|
},
|
|
deleteByIds: (options) => {
|
|
calls.push({ name: 'deleteByIds', payload: options });
|
|
return Promise.resolve(true);
|
|
},
|
|
bulkImport: () => {
|
|
calls.push({ name: 'bulkImport', payload: null });
|
|
return Promise.resolve();
|
|
},
|
|
};
|
|
const dbApi: EntityRouterDbApi = {
|
|
MODEL: {
|
|
rawAttributes: {
|
|
id: {},
|
|
name: {},
|
|
createdAt: {},
|
|
},
|
|
},
|
|
findAll: (query, options) => {
|
|
calls.push({ name: 'findAll', payload: { query, options } });
|
|
return Promise.resolve({
|
|
rows: [{ id: ENTITY_ID, name: 'Lobby' }],
|
|
count: 1,
|
|
});
|
|
},
|
|
findAllAutocomplete: (options) => {
|
|
calls.push({ name: 'findAllAutocomplete', payload: options });
|
|
return Promise.resolve([{ id: ENTITY_ID, label: 'Lobby' }]);
|
|
},
|
|
findBy: (options) => {
|
|
calls.push({ name: 'findBy', payload: options });
|
|
return Promise.resolve({ id: ENTITY_ID, name: 'Lobby' });
|
|
},
|
|
};
|
|
|
|
const app = express();
|
|
app.use(contextMiddleware());
|
|
app.use('/entities', createEntityRouter('test_entities', service, dbApi));
|
|
app.use(commonErrorHandler);
|
|
|
|
return { app, calls };
|
|
}
|
|
|
|
interface AppRequestOptions {
|
|
method: RequestMethod;
|
|
url: string;
|
|
headers?: Headers;
|
|
body?: Body;
|
|
}
|
|
|
|
function dispatchApp(app: express.Express, options: AppRequestOptions) {
|
|
const requestOptions: RequestOptions = {
|
|
method: options.method,
|
|
url: options.url,
|
|
};
|
|
if (options.headers !== undefined) {
|
|
requestOptions.headers = options.headers;
|
|
}
|
|
if (options.body !== undefined) {
|
|
requestOptions.body = options.body;
|
|
}
|
|
|
|
const req = createRequest(requestOptions);
|
|
const res = createResponse({ eventEmitter: EventEmitter });
|
|
|
|
return new Promise<typeof res>((resolve, reject) => {
|
|
res.on('end', () => resolve(res));
|
|
app(req, res, (error?: unknown) => {
|
|
if (error) {
|
|
reject(
|
|
error instanceof Error
|
|
? error
|
|
: new Error('Express app returned a non-Error callback value.'),
|
|
);
|
|
return;
|
|
}
|
|
resolve(res);
|
|
});
|
|
});
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null;
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> {
|
|
if (isRecord(value)) return value;
|
|
throw new TypeError('Expected value to be an object record.');
|
|
}
|
|
|
|
void test('createEntityRouter passes current user, runtime context, and host into create service calls', async () => {
|
|
const { app, calls } = buildHarness();
|
|
const response = await dispatchApp(app, {
|
|
method: 'POST',
|
|
url: '/entities',
|
|
headers: {
|
|
referer: 'https://builder.example.test/projects',
|
|
},
|
|
body: { data: { name: 'Lobby' } },
|
|
});
|
|
|
|
assert.equal(response.statusCode, 200);
|
|
assert.deepEqual(response._getData(), { id: ENTITY_ID, name: 'Lobby' });
|
|
|
|
const call = calls.find((item) => item.name === 'create');
|
|
assert.ok(call);
|
|
const payload = asRecord(call.payload);
|
|
assert.equal(payload.host, 'https://builder.example.test');
|
|
assert.equal(asRecord(payload.currentUser).id, USER_ID);
|
|
assert.equal(
|
|
asRecord(payload.runtimeContext).headerProjectSlug,
|
|
'demo-tour',
|
|
);
|
|
});
|
|
|
|
void test('createEntityRouter normalizes list query controls and preserves filters', async () => {
|
|
const { app, calls } = buildHarness();
|
|
const response = await dispatchApp(app, {
|
|
method: 'GET',
|
|
url: '/entities?limit=1000&page=2&sort=asc&field=name&unknown=keep',
|
|
});
|
|
|
|
assert.equal(response.statusCode, 200);
|
|
assert.deepEqual(response._getData(), {
|
|
rows: [{ id: ENTITY_ID, name: 'Lobby' }],
|
|
count: 1,
|
|
});
|
|
|
|
const call = calls.find((item) => item.name === 'findAll');
|
|
assert.ok(call);
|
|
const payload = asRecord(call.payload);
|
|
assert.deepEqual(payload.query, {
|
|
limit: 1000,
|
|
page: 2,
|
|
unknown: 'keep',
|
|
sort: 'ASC',
|
|
field: 'name',
|
|
});
|
|
assert.equal(asRecord(payload.options).currentUser !== undefined, true);
|
|
});
|
|
|
|
void test('createEntityRouter rejects mismatched body ids before update service call', async () => {
|
|
const { app, calls } = buildHarness();
|
|
const response = await dispatchApp(app, {
|
|
method: 'PUT',
|
|
url: `/entities/${ENTITY_ID}`,
|
|
body: {
|
|
data: {
|
|
id: '6a373ead-7ff4-4f1c-9a69-29ff1e97420d',
|
|
name: 'Mismatch',
|
|
},
|
|
},
|
|
});
|
|
|
|
assert.equal(response.statusCode, 400);
|
|
assert.equal(response._getData(), 'Request body id does not match route id');
|
|
assert.equal(calls.some((item) => item.name === 'update'), false);
|
|
});
|
|
|
|
void test('createEntityRouter emits CSV exports with configured fields', async () => {
|
|
const { app } = buildHarness();
|
|
const response = await dispatchApp(app, {
|
|
method: 'GET',
|
|
url: '/entities?filetype=csv',
|
|
});
|
|
|
|
assert.equal(response.statusCode, 200);
|
|
const disposition: unknown = response.getHeader('content-disposition');
|
|
if (typeof disposition !== 'string') {
|
|
throw new TypeError('Expected content-disposition response header.');
|
|
}
|
|
assert.match(disposition, /export\.csv/);
|
|
const csvPayload: unknown = response._getData();
|
|
if (typeof csvPayload !== 'string') {
|
|
throw new TypeError('Expected CSV response body.');
|
|
}
|
|
assert.equal(
|
|
csvPayload.trim(),
|
|
'"id","createdAt"\n"aa0b89ab-b0db-4d6c-a59c-c7785fc3eae5",',
|
|
);
|
|
});
|