Phase 1: data pipeline (Postgres+Redis with in-memory fallback, mock telemetry publisher, WebSocket fan-out, hub /debug page)
CI / Lint, typecheck, test, build (pull_request) Failing after 10s
CI / Lint, typecheck, test, build (pull_request) Failing after 10s
- packages/db: postgres.js + ioredis wrapper, StateStore interface with InMemory + Postgres implementations, schema migration runner - apps/api: refactored to use @kerbal-rt/db; live WebSocket hub subscribes to store changes and broadcasts to all clients - apps/tools/mock-telemetry: Node script that generates realistic KSP state and POSTs to /api/v1/ingest at 1Hz (uses same keplerian math as the live-map renderer) - apps/hub: new /debug page that connects to /api/v1/live and shows the live state - ksp/README.md: documents Phase 1c (real kRPC bridge) and the two implementation options - Tests: 17 total (5 kepler math, 5 db store, 5 API health/state, 2 API WebSocket fan-out) End-to-end verified: mock publisher → API → hub /debug page, 11 snapshots/5s over WebSocket, vessels advancing in mean anomaly.
This commit is contained in:
@@ -2,10 +2,102 @@ import { describe, it, expect } from 'vitest';
|
||||
import { app } from '../src/test-app.js';
|
||||
|
||||
describe('health', () => {
|
||||
it('returns ok', async () => {
|
||||
it('returns ok with service info', async () => {
|
||||
const res = await app.request('/health');
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.service).toBe('kerbal-rt-api');
|
||||
expect(body.store).toBe('InMemoryStateStore');
|
||||
});
|
||||
|
||||
it('returns 503 NO_DATA when no snapshot has been ingested', async () => {
|
||||
const res = await app.request('/api/v1/state');
|
||||
expect(res.status).toBe(503);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe(true);
|
||||
expect(body.code).toBe('NO_DATA');
|
||||
});
|
||||
|
||||
it('accepts a valid snapshot and returns it on /api/v1/state', async () => {
|
||||
const snap = {
|
||||
ut: 1,
|
||||
capturedAt: '2026-01-01T00:00:00Z',
|
||||
activeVesselId: null,
|
||||
bodies: [],
|
||||
vessels: [],
|
||||
groundStations: [],
|
||||
};
|
||||
const ingest = await app.request('/api/v1/ingest', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(snap),
|
||||
});
|
||||
expect(ingest.status).toBe(200);
|
||||
const ingestBody = await ingest.json();
|
||||
expect(ingestBody.error).toBe(false);
|
||||
expect(ingestBody.data.ok).toBe(true);
|
||||
|
||||
const state = await app.request('/api/v1/state');
|
||||
expect(state.status).toBe(200);
|
||||
const stateBody = await state.json();
|
||||
expect(stateBody.data.ut).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects malformed snapshots with 400', async () => {
|
||||
const res = await app.request('/api/v1/ingest', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ broken: 'payload' }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe(true);
|
||||
expect(body.code).toBe('BAD_PAYLOAD');
|
||||
});
|
||||
|
||||
it('looks up vessels by id', async () => {
|
||||
const snap = {
|
||||
ut: 1,
|
||||
capturedAt: '2026-01-01T00:00:00Z',
|
||||
activeVesselId: 'v-1',
|
||||
bodies: [],
|
||||
vessels: [
|
||||
{
|
||||
id: 'v-1',
|
||||
name: 'Test',
|
||||
type: 'Probe',
|
||||
owner: 'KASA',
|
||||
situation: 'ORBITING',
|
||||
status: 'ACTIVE',
|
||||
orbit: {
|
||||
semiMajorAxis: 7e6,
|
||||
eccentricity: 0,
|
||||
inclination: 0,
|
||||
longitudeOfAscendingNode: 0,
|
||||
argumentOfPeriapsis: 0,
|
||||
meanAnomalyAtEpoch: 0,
|
||||
epoch: 0,
|
||||
},
|
||||
referenceBodyId: 'kerbin',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
retiredAt: null,
|
||||
},
|
||||
],
|
||||
groundStations: [],
|
||||
};
|
||||
await app.request('/api/v1/ingest', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(snap),
|
||||
});
|
||||
|
||||
const found = await app.request('/api/v1/vessels/v-1');
|
||||
expect(found.status).toBe(200);
|
||||
const body = await found.json();
|
||||
expect(body.data.name).toBe('Test');
|
||||
|
||||
const missing = await app.request('/api/v1/vessels/does-not-exist');
|
||||
expect(missing.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { WebSocket } from 'ws';
|
||||
import { createNodeWebSocket } from '@hono/node-ws';
|
||||
import { InMemoryStateStore } from '@kerbal-rt/db';
|
||||
import { buildApp } from '../src/app.js';
|
||||
import { LiveSocketHub } from '../src/ws.js';
|
||||
|
||||
/**
|
||||
* Integration test: the API exposes /api/v1/live over WebSocket.
|
||||
* When a snapshot is applied to the store, the WS hub broadcasts it
|
||||
* to every connected client.
|
||||
*/
|
||||
describe('WebSocket live feed', () => {
|
||||
async function startTestServer(): Promise<{
|
||||
server: Server;
|
||||
port: number;
|
||||
store: InMemoryStateStore;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const store = new InMemoryStateStore();
|
||||
const hub = new LiveSocketHub({ store, channel: 'test' });
|
||||
await hub.start();
|
||||
|
||||
const app = buildApp({ store, ingestApiKey: null });
|
||||
const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app });
|
||||
|
||||
app.get(
|
||||
'/api/v1/live',
|
||||
upgradeWebSocket(() => ({
|
||||
onOpen: (_evt, ws) => hub.onOpen(ws),
|
||||
onMessage: (evt, ws) => hub.onMessage(evt, ws),
|
||||
onClose: (_evt, ws) => hub.onClose(ws),
|
||||
})),
|
||||
);
|
||||
|
||||
const server = createServer();
|
||||
injectWebSocket(server);
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const port = (server.address() as { port: number }).port;
|
||||
|
||||
return {
|
||||
server,
|
||||
port,
|
||||
store,
|
||||
async close() {
|
||||
await hub.stop();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('sends the current snapshot to a newly connected client', async () => {
|
||||
const { port, store, close } = await startTestServer();
|
||||
try {
|
||||
await store.applySnapshot({
|
||||
ut: 42,
|
||||
capturedAt: 'x',
|
||||
activeVesselId: null,
|
||||
bodies: [],
|
||||
vessels: [],
|
||||
groundStations: [],
|
||||
});
|
||||
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}/api/v1/live`);
|
||||
const firstMessage = await new Promise<string>((resolve, reject) => {
|
||||
ws.once('open', () => {});
|
||||
ws.once('message', (data) => {
|
||||
resolve(data.toString());
|
||||
ws.close();
|
||||
});
|
||||
ws.once('error', reject);
|
||||
setTimeout(() => reject(new Error('timeout')), 5000);
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(firstMessage);
|
||||
expect(parsed.type).toBe('snapshot');
|
||||
expect(parsed.snapshot.ut).toBe(42);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
it('broadcasts new snapshots to all connected clients', async () => {
|
||||
const { port, store, close } = await startTestServer();
|
||||
try {
|
||||
const ws1 = new WebSocket(`ws://127.0.0.1:${port}/api/v1/live`);
|
||||
const ws2 = new WebSocket(`ws://127.0.0.1:${port}/api/v1/live`);
|
||||
|
||||
// Wait for both to open
|
||||
await Promise.all([
|
||||
new Promise<void>((r) => ws1.once('open', () => r())),
|
||||
new Promise<void>((r) => ws2.once('open', () => r())),
|
||||
]);
|
||||
|
||||
// Attach persistent handlers FIRST (so we don't miss any message
|
||||
// during the drain→wait window), then drain the initial snapshot
|
||||
// messages and wait for the broadcast.
|
||||
const received = { ws1: false, ws2: false };
|
||||
const waiters: Promise<void>[] = [];
|
||||
const makeWaiter = (key: 'ws1' | 'ws2', ws: WebSocket) =>
|
||||
new Promise<void>((resolve) => {
|
||||
ws.on('message', (data) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === 'snapshot' && msg.snapshot.ut === 99) {
|
||||
received[key] = true;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
// Safety timeout so a failing test doesn't hang forever
|
||||
setTimeout(() => resolve(), 3000);
|
||||
});
|
||||
waiters.push(makeWaiter('ws1', ws1));
|
||||
waiters.push(makeWaiter('ws2', ws2));
|
||||
|
||||
// Wait for the initial snapshot to land on both before we apply
|
||||
// the new one — otherwise we may set up the waiter after it
|
||||
// arrives (race condition with `once`).
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
await store.applySnapshot({
|
||||
ut: 99,
|
||||
capturedAt: 'x',
|
||||
activeVesselId: null,
|
||||
bodies: [],
|
||||
vessels: [],
|
||||
groundStations: [],
|
||||
});
|
||||
|
||||
await Promise.all(waiters);
|
||||
|
||||
expect(received.ws1).toBe(true);
|
||||
expect(received.ws2).toBe(true);
|
||||
|
||||
ws1.close();
|
||||
ws2.close();
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user