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

- 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:
Mavis
2026-06-02 18:54:46 +00:00
parent 938ba042b3
commit a457b9d96f
28 changed files with 2165 additions and 98 deletions
+93 -1
View File
@@ -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);
});
});
+141
View File
@@ -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();
}
});
});