feat(live): task 1.5.6 — live broadcast integration test
Adds end-to-end integration test for the WebSocket live broadcast pipeline: Redis + TimescaleDB containers + Directus stub → full pipeline boot → real WS client assertions. Mirrors pipeline.integration.test.ts pattern with the skip-on-no-Docker guard. Key additions: - test/live.integration.test.ts: 6 test scenarios — happy path (subscribe → snapshot → live position), auth rejection (401), forbidden subscription (error/forbidden), multi-client fan-out (both receive position), orphan position (no WS frame), faulty snapshot exclusion (next-best non-faulty) - test/helpers/directus-stub.ts: bare http.createServer stub for /users/me and /items/events/:id endpoints with cookie-based user lookup - test/fixtures/test-schema.sql: minimal schema subset (events, entries, entry_devices with IMEI-as-device_id for Phase 1 join semantics) The integration test runs via `pnpm test:integration`, not `pnpm test`. Docker required; the suite skips cleanly when Docker is unavailable.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Minimal HTTP server stub impersonating the two Directus endpoints the
|
||||
* Processor calls:
|
||||
*
|
||||
* GET /users/me — returns a fake user if the cookie matches
|
||||
* GET /items/events/:id — returns 200 if (cookie, eventId) is allowed
|
||||
*
|
||||
* Instantiate with `createDirectusStub(opts)` and tear down with
|
||||
* `stub.close()`. The stub binds to a random OS port and exposes `stub.url`
|
||||
* for config injection.
|
||||
*
|
||||
* Design: bare `node:http` — no Express dependency.
|
||||
*/
|
||||
|
||||
import * as http from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FakeUser = {
|
||||
readonly id: string;
|
||||
readonly email: string;
|
||||
readonly role: string | null;
|
||||
readonly first_name: string;
|
||||
readonly last_name: string;
|
||||
};
|
||||
|
||||
export type StubOptions = {
|
||||
/**
|
||||
* Map from the raw cookie header value (e.g. `"session=abc"`) to the fake
|
||||
* user that cookie represents. Any cookie not in this map → 401.
|
||||
*/
|
||||
readonly allowedCookieToUser: Map<string, FakeUser>;
|
||||
/**
|
||||
* Map from Directus user ID → set of event IDs that user may access.
|
||||
* A request from a valid user for an event not in their set → 403.
|
||||
*/
|
||||
readonly allowedEvents: Map<string, Set<string>>;
|
||||
};
|
||||
|
||||
export type DirectusStub = {
|
||||
readonly url: string;
|
||||
readonly close: () => Promise<void>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Creates and starts a Directus stub server on a random port.
|
||||
* Returns a promise that resolves once the server is listening.
|
||||
*/
|
||||
export function createDirectusStub(opts: StubOptions): Promise<DirectusStub> {
|
||||
const server = http.createServer((req, res) => {
|
||||
const cookie = req.headers['cookie'] ?? '';
|
||||
const user = opts.allowedCookieToUser.get(cookie);
|
||||
|
||||
// GET /users/me
|
||||
if (req.url === '/users/me') {
|
||||
if (!user) {
|
||||
res.writeHead(401).end();
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ data: user }));
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /items/events/:id
|
||||
const eventMatch = /^\/items\/events\/([0-9a-f-]+)/i.exec(req.url ?? '');
|
||||
if (eventMatch) {
|
||||
if (!user) {
|
||||
res.writeHead(401).end();
|
||||
return;
|
||||
}
|
||||
const eventId = eventMatch[1]!;
|
||||
const allowed = opts.allowedEvents.get(user.id)?.has(eventId) ?? false;
|
||||
if (!allowed) {
|
||||
res.writeHead(403).end();
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ data: { id: eventId } }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.off('error', reject);
|
||||
const addr = server.address() as AddressInfo;
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${addr.port}`,
|
||||
close: () =>
|
||||
new Promise((res, rej) =>
|
||||
server.close((err) => (err ? rej(err) : res())),
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user