added base test coverage
This commit is contained in:
parent
6085443549
commit
e1711b42c2
@ -9,6 +9,8 @@
|
||||
"build": "tsc -p tsconfig.json && node scripts/copy-runtime-assets.ts",
|
||||
"test": "node --test tests/*.test.ts",
|
||||
"test:integration": "node --test tests/integration/*.test.ts",
|
||||
"test:e2e": "node --test tests/e2e/*.test.ts",
|
||||
"test:all": "npm run test && npm run test:integration && npm run test:e2e",
|
||||
"verify": "npm run typecheck && npm run lint && npm run check:esm-boundaries && npm run test",
|
||||
"check:esm-boundaries": "node scripts/check-esm-boundaries.ts",
|
||||
"check:public-access": "node scripts/check-public-access-hardening.ts",
|
||||
|
||||
72
backend/tests/e2e/runtime-context.test.ts
Normal file
72
backend/tests/e2e/runtime-context.test.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import express from 'express';
|
||||
import test from 'node:test';
|
||||
|
||||
import { runtimeContextMiddleware } from '../../src/middlewares/runtime-context.ts';
|
||||
import runtimeContextRoutes from '../../src/routes/runtime-context.ts';
|
||||
import { startTestServer } from '../http-test-utils.ts';
|
||||
|
||||
function buildRuntimeContextApp() {
|
||||
const app = express();
|
||||
app.use(runtimeContextMiddleware);
|
||||
app.use('/api/runtime-context', runtimeContextRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
void test('GET /api/runtime-context returns default admin runtime context', async () => {
|
||||
const server = await startTestServer(buildRuntimeContextApp());
|
||||
try {
|
||||
const response = await fetch(`${server.baseUrl}/api/runtime-context`);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), {
|
||||
mode: 'admin',
|
||||
projectSlug: null,
|
||||
});
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('GET /api/runtime-context exposes valid runtime headers over HTTP', async () => {
|
||||
const server = await startTestServer(buildRuntimeContextApp());
|
||||
try {
|
||||
const response = await fetch(`${server.baseUrl}/api/runtime-context`, {
|
||||
headers: {
|
||||
'x-runtime-environment': 'production',
|
||||
'x-runtime-project-slug': 'museum-tour',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), {
|
||||
mode: 'admin',
|
||||
projectSlug: null,
|
||||
headerEnvironment: 'production',
|
||||
headerProjectSlug: 'museum-tour',
|
||||
});
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('GET /api/runtime-context ignores unsupported runtime environment headers', async () => {
|
||||
const server = await startTestServer(buildRuntimeContextApp());
|
||||
try {
|
||||
const response = await fetch(`${server.baseUrl}/api/runtime-context`, {
|
||||
headers: {
|
||||
'x-runtime-environment': 'qa',
|
||||
'x-runtime-project-slug': 'museum-tour',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), {
|
||||
mode: 'admin',
|
||||
projectSlug: null,
|
||||
headerProjectSlug: 'museum-tour',
|
||||
});
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
39
backend/tests/http-test-utils.ts
Normal file
39
backend/tests/http-test-utils.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import http from 'node:http';
|
||||
import type { Express } from 'express';
|
||||
|
||||
export interface RunningTestServer {
|
||||
baseUrl: string;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function startTestServer(app: Express): Promise<RunningTestServer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer(app);
|
||||
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.off('error', reject);
|
||||
const address = server.address();
|
||||
|
||||
if (typeof address !== 'object' || address === null) {
|
||||
server.close();
|
||||
reject(new Error('Test server did not expose a listening address.'));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
close: () =>
|
||||
new Promise((closeResolve, closeReject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
closeReject(error);
|
||||
return;
|
||||
}
|
||||
closeResolve();
|
||||
});
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
246
backend/tests/integration/router-factory.test.ts
Normal file
246
backend/tests/integration/router-factory.test.ts
Normal file
@ -0,0 +1,246 @@
|
||||
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",',
|
||||
);
|
||||
});
|
||||
102
backend/tests/upload-session-manager.test.ts
Normal file
102
backend/tests/upload-session-manager.test.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import UploadSessionManager from '../src/services/file/UploadSessionManager.ts';
|
||||
|
||||
function makeSessionManager(ttlMs = 60_000): {
|
||||
manager: UploadSessionManager;
|
||||
rootDir: string;
|
||||
} {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'upload-sessions-'));
|
||||
return {
|
||||
manager: new UploadSessionManager({ sessionDir: rootDir, ttlMs }),
|
||||
rootDir,
|
||||
};
|
||||
}
|
||||
|
||||
void test('UploadSessionManager creates metadata and tracks uploaded chunks', async () => {
|
||||
const { manager, rootDir } = makeSessionManager();
|
||||
try {
|
||||
const sessionId = manager.createSession({
|
||||
filename: 'tour.mp4',
|
||||
folder: 'assets',
|
||||
totalChunks: 2,
|
||||
totalSize: 11,
|
||||
userId: 'user-1',
|
||||
contentType: 'video/mp4',
|
||||
});
|
||||
|
||||
let meta = manager.readMeta(sessionId);
|
||||
assert.equal(meta?.filename, 'tour.mp4');
|
||||
assert.equal(meta?.uploadedChunks !== undefined, true);
|
||||
assert.equal(manager.isComplete(sessionId), false);
|
||||
|
||||
await manager.saveChunk(sessionId, 0, Buffer.from('hello '));
|
||||
await manager.saveChunk(sessionId, 1, Buffer.from('world'));
|
||||
|
||||
meta = manager.readMeta(sessionId);
|
||||
assert.equal(meta?.uploadedChunks[0]?.size, 6);
|
||||
assert.equal(meta?.uploadedChunks[1]?.size, 5);
|
||||
assert.equal(manager.chunkExists(sessionId, 0), true);
|
||||
assert.equal(manager.isComplete(sessionId), true);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
void test('UploadSessionManager assembles chunks in index order', async () => {
|
||||
const { manager, rootDir } = makeSessionManager();
|
||||
try {
|
||||
const sessionId = manager.createSession({
|
||||
filename: 'image.bin',
|
||||
folder: 'assets',
|
||||
totalChunks: 3,
|
||||
totalSize: 9,
|
||||
});
|
||||
await manager.saveChunk(sessionId, 2, Buffer.from('ghi'));
|
||||
await manager.saveChunk(sessionId, 0, Buffer.from('abc'));
|
||||
await manager.saveChunk(sessionId, 1, Buffer.from('def'));
|
||||
|
||||
const targetPath = path.join(rootDir, 'assembled', 'image.bin');
|
||||
await manager.assembleChunks(sessionId, targetPath);
|
||||
|
||||
assert.equal(fs.readFileSync(targetPath, 'utf8'), 'abcdefghi');
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
void test('UploadSessionManager removes expired and invalid sessions during cleanup', () => {
|
||||
const { manager, rootDir } = makeSessionManager(1);
|
||||
try {
|
||||
const expiredSessionId = manager.createSession({
|
||||
filename: 'expired.txt',
|
||||
folder: 'assets',
|
||||
totalChunks: 1,
|
||||
totalSize: 1,
|
||||
});
|
||||
const meta = manager.readMeta(expiredSessionId);
|
||||
assert.ok(meta);
|
||||
meta.updatedAt = new Date(Date.now() - 10_000).toISOString();
|
||||
manager.writeMeta(expiredSessionId, meta);
|
||||
|
||||
const invalidSessionId = 'invalid-session';
|
||||
const invalidSessionDir = manager.getSessionDir(invalidSessionId);
|
||||
fs.mkdirSync(invalidSessionDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
manager.getMetaPath(invalidSessionId),
|
||||
JSON.stringify({ sessionId: invalidSessionId }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
manager.cleanupExpiredSessions();
|
||||
|
||||
assert.equal(fs.existsSync(manager.getSessionDir(expiredSessionId)), false);
|
||||
assert.equal(fs.existsSync(invalidSessionDir), false);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user