diff --git a/service/src/nats/parsers/aircraft.ts b/service/src/nats/parsers/aircraft.ts index 6ede4a8..a7edf15 100644 --- a/service/src/nats/parsers/aircraft.ts +++ b/service/src/nats/parsers/aircraft.ts @@ -1,4 +1,5 @@ import type { AircraftMessagePayload, CoverageEvent } from '../../types/events.js'; +import { toStationId } from './station-id.js'; /** * Parse an aircraft message NATS event into a CoverageEvent. @@ -9,7 +10,8 @@ export function parseAircraftMessage(data: AircraftMessagePayload): CoverageEven const station = data.station; const message = data.message; - if (!station?.id || !station?.uuid) return null; + const stationId = toStationId(station?.id); + if (stationId === null || !station?.uuid) return null; // Try to get target position from message first, then flight let targetLat: number | null = null; @@ -29,7 +31,7 @@ export function parseAircraftMessage(data: AircraftMessagePayload): CoverageEven const hasTarget = targetLat !== null && targetLon !== null; return { - stationId: station.id, + stationId, stationUuid: station.uuid, targetLat, targetLon, @@ -55,11 +57,12 @@ export function parseAircraftFlight(data: AircraftMessagePayload): CoverageEvent const station = data.station; const flight = data.flight; - if (!station?.id || !station?.uuid) return null; + const stationId = toStationId(station?.id); + if (stationId === null || !station?.uuid) return null; if (!flight?.latitude || !flight?.longitude) return null; return { - stationId: station.id, + stationId, stationUuid: station.uuid, targetLat: flight.latitude, targetLon: flight.longitude, diff --git a/service/src/nats/parsers/marine.ts b/service/src/nats/parsers/marine.ts index b9805a1..16e8240 100644 --- a/service/src/nats/parsers/marine.ts +++ b/service/src/nats/parsers/marine.ts @@ -1,3 +1,4 @@ +import { toStationId } from './station-id.js'; import type { MarineVoyagePositionPayload, MarineAisMessagePayload, @@ -12,7 +13,8 @@ export function parseVoyagePosition(data: MarineVoyagePositionPayload): Coverage const station = data.station; const pos = data.voyagePosition; - if (!station?.id || !station?.uuid) return null; + const stationId = toStationId(station?.id); + if (stationId === null || !station?.uuid) return null; let targetLat: number | null = null; let targetLon: number | null = null; @@ -28,7 +30,7 @@ export function parseVoyagePosition(data: MarineVoyagePositionPayload): Coverage const hasTarget = targetLat !== null && targetLon !== null; return { - stationId: station.id, + stationId, stationUuid: station.uuid, targetLat, targetLon, @@ -53,14 +55,15 @@ export function parseVoyage(data: MarineVoyagePositionPayload): CoverageEvent | const station = data.station; const voyage = data.voyage; - if (!station?.id || !station?.uuid) return null; + const stationId = toStationId(station?.id); + if (stationId === null || !station?.uuid) return null; const targetLat = voyage?.last_latitude ?? null; const targetLon = voyage?.last_longitude ?? null; const hasTarget = targetLat !== null && targetLon !== null; return { - stationId: station.id, + stationId, stationUuid: station.uuid, targetLat, targetLon, @@ -85,10 +88,11 @@ export function parseVoyage(data: MarineVoyagePositionPayload): CoverageEvent | export function parseAisMessage(data: MarineAisMessagePayload): CoverageEvent | null { const station = data.station; - if (!station?.id || !station?.uuid) return null; + const stationId = toStationId(station?.id); + if (stationId === null || !station?.uuid) return null; return { - stationId: station.id, + stationId, stationUuid: station.uuid, targetLat: null, targetLon: null, diff --git a/service/src/nats/parsers/station-id.ts b/service/src/nats/parsers/station-id.ts new file mode 100644 index 0000000..a8d59b1 --- /dev/null +++ b/service/src/nats/parsers/station-id.ts @@ -0,0 +1,15 @@ +/** + * The aggregation server publishes row ids as JSON strings ("1234"), while the + * main API returns them as numbers. Station metadata is keyed by number, so + * normalize here or every event looks like it came from an unknown station. + */ +export function toStationId(value: unknown): number | null { + if (typeof value === 'number') { + return Number.isSafeInteger(value) && value > 0 ? value : null; + } + if (typeof value === 'string' && /^\d+$/.test(value)) { + const id = Number(value); + return Number.isSafeInteger(id) && id > 0 ? id : null; + } + return null; +} diff --git a/service/src/sync/station-sync.ts b/service/src/sync/station-sync.ts index 8f06bf8..dcbdf87 100644 --- a/service/src/sync/station-sync.ts +++ b/service/src/sync/station-sync.ts @@ -1,5 +1,6 @@ import { config } from '../config.js'; import type { StationMeta } from '../types/events.js'; +import { toStationId } from '../nats/parsers/station-id.js'; /** * Periodically syncs station metadata from the main API. @@ -84,7 +85,8 @@ export class StationSync { let enriched = 0; for (const s of stations) { - if (!s.id) continue; + const id = toStationId(s.id); + if (id === null) continue; let lat = parseFloat(s.latitude) || parseFloat(s.fuzzedLatitude) || 0; let lon = parseFloat(s.longitude) || parseFloat(s.fuzzedLongitude) || 0; @@ -99,8 +101,8 @@ export class StationSync { } } - this.stationMap.set(s.id, { - id: s.id, + this.stationMap.set(id, { + id, uuid: s.uuid ?? '', ident: s.ident ?? '', latitude: lat, diff --git a/service/src/types/events.ts b/service/src/types/events.ts index 2cb42d4..1f22f1b 100644 --- a/service/src/types/events.ts +++ b/service/src/types/events.ts @@ -52,7 +52,7 @@ export interface AircraftMessagePayload { transmissionType?: string; }; station?: { - id?: number; + id?: number | string; uuid?: string; ident?: string; latitude?: number; @@ -63,7 +63,7 @@ export interface AircraftMessagePayload { source_type?: string; }; message?: { - id?: number; + id?: number | string; latitude?: number; longitude?: number; altitude?: number; @@ -76,18 +76,18 @@ export interface AircraftMessagePayload { source_type?: string; }; airframe?: { - id?: number; + id?: number | string; tail?: string; }; flight?: { - id?: number; + id?: number | string; latitude?: number; longitude?: number; altitude?: number; track?: number; }; messageReport?: { - id?: number; + id?: number | string; }; } @@ -99,7 +99,7 @@ export interface MarineVoyagePositionPayload { transmissionType?: string; }; station?: { - id?: number; + id?: number | string; uuid?: string; latitude?: number; longitude?: number; @@ -114,12 +114,12 @@ export interface MarineVoyagePositionPayload { position_accuracy?: boolean; }; voyage?: { - id?: number; + id?: number | string; last_latitude?: number; last_longitude?: number; }; vessel?: { - id?: number; + id?: number | string; mmsi?: string; name?: string; }; @@ -132,13 +132,13 @@ export interface MarineAisMessagePayload { application?: string; }; station?: { - id?: number; + id?: number | string; uuid?: string; latitude?: number; longitude?: number; source_type?: string; }; aisMessage?: { - id?: number; + id?: number | string; }; } diff --git a/service/test/parsers.test.ts b/service/test/parsers.test.ts new file mode 100644 index 0000000..d5ab36f --- /dev/null +++ b/service/test/parsers.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { CoverageStore } from '../src/aggregation/coverage-store.js'; +import { parseAircraftMessage, parseAircraftFlight } from '../src/nats/parsers/aircraft.js'; +import { toStationId } from '../src/nats/parsers/station-id.js'; +import type { StationMeta } from '../src/types/events.js'; + +describe('toStationId', () => { + it('accepts numbers and digit strings', () => { + expect(toStationId(1234)).toBe(1234); + expect(toStationId('1234')).toBe(1234); + }); + + it('rejects anything that is not a positive integer id', () => { + for (const bad of [0, -1, 1.5, '', '0', '12a', ' 12', '1e3', null, undefined, {}, NaN]) { + expect(toStationId(bad)).toBeNull(); + } + }); +}); + +describe('aircraft parsers with production payloads', () => { + // The aggregation server serializes ids as strings. + const payload = { + source: { name: 'dumpvdl2' }, + station: { id: '1234', uuid: 'uuid-1234', ident: 'TEST-1234' }, + message: { id: '987654321', latitude: 48.5, longitude: 11.2, altitude: 36000, timestamp: new Date().toISOString() }, + flight: { id: '555', latitude: 48.6, longitude: 11.3, altitude: 36000 }, + }; + + it('normalizes a string station id to a number', () => { + expect(parseAircraftMessage(payload)?.stationId).toBe(1234); + expect(parseAircraftFlight(payload)?.stationId).toBe(1234); + }); + + it('drops events with no usable station id', () => { + expect(parseAircraftMessage({ ...payload, station: { ...payload.station, id: 'abc' } })).toBeNull(); + expect(parseAircraftMessage({ ...payload, station: { uuid: 'uuid-1234' } })).toBeNull(); + }); + + describe('ingest', () => { + let store: CoverageStore; + afterEach(() => store.destroy()); + + it('accepts an event whose station id arrived as a string', () => { + const meta: StationMeta = { + id: 1234, uuid: 'uuid-1234', ident: 'TEST-1234', latitude: 48.0, longitude: 11.0, + sourceType: 'vdl', status: 'active', flagged: false, blocked: false, trustScore: 0.5, + lastSyncedAt: new Date(), + }; + store = new CoverageStore(); + store.stationMap.set(1234, meta); + store.ingest(parseAircraftMessage(payload)!); + expect(store.eventsProcessed).toBe(1); + expect(store.eventsRejected).toBe(0); + }); + }); +});