Add ADS-B, AIS, and ALPR map layers with live CoT streaming.
PR / lint (pull_request) Failing after 31s
PR / test (pull_request) Successful in 45s
PR / docker-build (pull_request) Successful in 1m3s
PR / e2e (pull_request) Successful in 1m33s

Ingest aircraft and vessel tracks via OSINT feeds and tactical CoT, expose viewport-filtered SSE to the map, and add an OSM ALPR layer with tiled caching and performant marker sync.
This commit is contained in:
Madison Grubb
2026-06-24 16:24:41 -04:00
parent a6b87305a1
commit aa8a0bd83f
64 changed files with 5761 additions and 2119 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ const ensureDevCerts = () => {
)
}
catch (error) {
throw new Error(`Failed to generate dev certificates: ${error.message}`)
throw new Error(`Failed to generate dev certificates: ${error.message}`, { cause: error })
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ export function ensureDevCerts() {
console.log('[test] Generated .dev-certs/key.pem and .dev-certs/cert.pem')
}
catch (error) {
throw new Error(`Failed to generate dev certificates: ${error.message}`)
throw new Error(`Failed to generate dev certificates: ${error.message}`, { cause: error })
}
}
+27 -10
View File
@@ -7,6 +7,7 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { spawn, execSync } from 'node:child_process'
import { connect } from 'node:tls'
import { createServer } from 'node:net'
import { existsSync, mkdirSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -21,11 +22,21 @@ const devCertsDir = join(projectRoot, '.dev-certs')
const devKey = join(devCertsDir, 'key.pem')
const devCert = join(devCertsDir, 'cert.pem')
const API_PORT = 3000
const COT_PORT = 8089
const COT_AUTH_USER = 'test'
const COT_AUTH_PASS = 'test'
function getFreePort() {
return new Promise((resolve, reject) => {
const server = createServer()
server.listen(0, () => {
const address = server.address()
const port = typeof address === 'object' && address ? address.port : 0
server.close(() => resolve(port))
})
server.on('error', reject)
})
}
function ensureDevCerts() {
if (existsSync(devKey) && existsSync(devCert)) return
mkdirSync(devCertsDir, { recursive: true })
@@ -37,12 +48,12 @@ function ensureDevCerts() {
const FETCH_TIMEOUT_MS = 5000
async function waitForHealth(timeoutMs = 90000) {
async function waitForHealth(apiPort, timeoutMs = 90000) {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
for (const protocol of ['https', 'http']) {
try {
const baseURL = `${protocol}://localhost:${API_PORT}`
const baseURL = `${protocol}://localhost:${apiPort}`
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS)
const res = await fetch(`${baseURL}/health`, { method: 'GET', signal: ctrl.signal })
@@ -55,16 +66,20 @@ async function waitForHealth(timeoutMs = 90000) {
}
await new Promise(r => setTimeout(r, 1000))
}
throw new Error(`Health not OK on ${API_PORT} within ${timeoutMs}ms`)
throw new Error(`Health not OK on ${apiPort} within ${timeoutMs}ms`)
}
describe('Server and CoT integration', () => {
const testState = {
serverProcess: null,
apiPort: 0,
cotPort: 0,
}
beforeAll(async () => {
ensureDevCerts()
testState.apiPort = await getFreePort()
testState.cotPort = await getFreePort()
const serverPath = join(projectRoot, '.output', 'server', 'index.mjs')
if (!existsSync(serverPath)) {
execSync('npm run build', { cwd: projectRoot, stdio: 'pipe' })
@@ -72,6 +87,8 @@ describe('Server and CoT integration', () => {
const dbPath = join(tmpdir(), `kestrelos-it-${process.pid}-${Date.now()}.db`)
const env = {
...process.env,
PORT: String(testState.apiPort),
COT_PORT: String(testState.cotPort),
DB_PATH: dbPath,
BOOTSTRAP_EMAIL: COT_AUTH_USER,
BOOTSTRAP_PASSWORD: COT_AUTH_PASS,
@@ -83,7 +100,7 @@ describe('Server and CoT integration', () => {
})
testState.serverProcess.stdout?.on('data', d => process.stdout.write(d))
testState.serverProcess.stderr?.on('data', d => process.stderr.write(d))
await waitForHealth(90000)
await waitForHealth(testState.apiPort, 90000)
}, 120000)
afterAll(() => {
@@ -92,11 +109,11 @@ describe('Server and CoT integration', () => {
}
})
it('serves health on port 3000', async () => {
it('serves health on the configured API port', async () => {
const tryProtocols = async (protocols) => {
if (protocols.length === 0) throw new Error('No protocol succeeded')
try {
const res = await fetch(`${protocols[0]}://localhost:${API_PORT}/health`, { method: 'GET', headers: { Accept: 'application/json' } })
const res = await fetch(`${protocols[0]}://localhost:${testState.apiPort}/health`, { method: 'GET', headers: { Accept: 'application/json' } })
if (res?.ok) return res
return tryProtocols(protocols.slice(1))
}
@@ -112,10 +129,10 @@ describe('Server and CoT integration', () => {
expect(body.endpoints).toHaveProperty('ready', '/health/ready')
})
it('CoT on 8089: TAK client auth with username/password succeeds (socket stays open)', async () => {
it('CoT: TAK client auth with username/password succeeds (socket stays open)', async () => {
const payload = buildAuthCotXml({ username: COT_AUTH_USER, password: COT_AUTH_PASS })
const socket = await new Promise((resolve, reject) => {
const s = connect(COT_PORT, '127.0.0.1', { rejectUnauthorized: false }, () => {
const s = connect(testState.cotPort, '127.0.0.1', { rejectUnauthorized: false }, () => {
s.write(payload, () => resolve(s))
})
s.on('error', reject)
+23 -24
View File
@@ -1,27 +1,24 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { SHUTDOWN_TIMEOUT_MS } from '../../server/utils/constants.js'
import { registerCleanup, graceful, initShutdownHandlers, clearCleanup } from '../../server/utils/shutdown.js'
describe('shutdown integration', () => {
const testState = {
originalExit: null,
exitCalls: [],
originalOn: null,
}
/** @type {import('vitest').MockInstance} */
let exitSpy
/** @type {typeof process.on} */
let originalOn
beforeEach(() => {
clearCleanup()
testState.exitCalls = []
testState.originalExit = process.exit
process.exit = vi.fn((code) => {
testState.exitCalls.push(code)
})
testState.originalOn = process.on
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {})
originalOn = process.on
})
afterEach(() => {
process.exit = testState.originalExit
process.on = testState.originalOn
exitSpy.mockRestore()
process.on = originalOn
clearCleanup()
vi.useRealTimers()
})
it('initializes signal handlers', () => {
@@ -32,7 +29,6 @@ describe('shutdown integration', () => {
initShutdownHandlers()
expect(process.on).toHaveBeenCalledWith('SIGTERM', expect.any(Function))
expect(process.on).toHaveBeenCalledWith('SIGINT', expect.any(Function))
process.on = testState.originalOn
})
it('signal handler calls graceful', async () => {
@@ -43,9 +39,10 @@ describe('shutdown integration', () => {
initShutdownHandlers()
const sigtermHandler = handlers.SIGTERM
expect(sigtermHandler).toBeDefined()
await sigtermHandler()
expect(testState.exitCalls.length).toBeGreaterThan(0)
process.on = testState.originalOn
sigtermHandler()
await vi.waitFor(() => {
expect(exitSpy).toHaveBeenCalled()
})
})
it('signal handler handles graceful error', async () => {
@@ -59,18 +56,20 @@ describe('shutdown integration', () => {
registerCleanup(async () => {
throw new Error('Force error')
})
await sigintHandler()
expect(testState.exitCalls.length).toBeGreaterThan(0)
process.on = testState.originalOn
sigintHandler()
await vi.waitFor(() => {
expect(exitSpy).toHaveBeenCalled()
})
})
it('covers timeout path in graceful', async () => {
vi.useFakeTimers()
registerCleanup(async () => {
await new Promise(resolve => setTimeout(resolve, 40000))
await new Promise(resolve => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS + 5_000))
})
graceful()
await new Promise(resolve => setTimeout(resolve, 100))
expect(testState.exitCalls.length).toBeGreaterThan(0)
await vi.advanceTimersByTimeAsync(SHUTDOWN_TIMEOUT_MS + 1)
expect(exitSpy).toHaveBeenCalledWith(1)
})
it('covers graceful catch block', async () => {
@@ -78,6 +77,6 @@ describe('shutdown integration', () => {
throw new Error('Test error')
})
await graceful()
expect(testState.exitCalls.length).toBeGreaterThan(0)
expect(exitSpy).toHaveBeenCalled()
})
})
+8
View File
@@ -75,4 +75,12 @@ describe('KestrelMap', () => {
expect(wrapper.props('pois')).toHaveLength(1)
expect(wrapper.props('canEditPois')).toBe(false)
})
it('includes CoT layer toggles and supercluster layer', async () => {
const componentPath = resolve(__dirname, '../../app/components/KestrelMap.vue')
const source = readFileSync(componentPath, 'utf-8')
expect(source).toContain('cot-layer-toggles')
expect(source).toContain('cotMapLayer')
expect(source).toContain('syncCotLayer')
})
})
-14
View File
@@ -15,26 +15,12 @@ describe('useCameras', () => {
setupEndpoints(() => ({
devices: [{ id: '1', name: 'Test', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg', device_type: 'feed' }],
liveSessions: [],
cotEntities: [],
}))
const wrapper = await mountSuspended(Index)
await wait()
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
})
it('exposes cotEntities from API', async () => {
const cotEntities = [{ id: 'cot-1', lat: 38, lng: -123, label: 'ATAK1' }]
setupEndpoints(() => ({
devices: [],
liveSessions: [],
cotEntities,
}))
const wrapper = await mountSuspended(Index)
await wait()
const map = wrapper.findComponent({ name: 'KestrelMap' })
expect(map.props('cotEntities')).toEqual(cotEntities)
})
it('handles API error and falls back to empty devices and liveSessions', async () => {
setupEndpoints(() => {
throw new Error('network')
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { useCotLayers } from '../../app/composables/useCotLayers.js'
describe('useCotLayers', () => {
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
localStorage.clear()
})
it('defaults all layers on', () => {
const { layers, layerQuery } = useCotLayers()
expect(layers.value).toEqual({ air: true, surface: true, ground: true })
expect(layerQuery.value).toBe('air,surface,ground')
})
it('toggles layers and persists to localStorage', () => {
const { layers, toggleLayer, layerQuery } = useCotLayers()
toggleLayer('air')
expect(layers.value.air).toBe(false)
expect(layerQuery.value).toBe('surface,ground')
const stored = JSON.parse(localStorage.getItem('kestrel-cot-layers'))
expect(stored.air).toBe(false)
})
})
describe('useCotStream helpers', () => {
it('layer query none when all off', () => {
localStorage.clear()
const { toggleLayer, layerQuery } = useCotLayers()
toggleLayer('air')
toggleLayer('surface')
toggleLayer('ground')
expect(layerQuery.value).toBe('none')
})
})
+154
View File
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
parseOverpassElement,
parseBbox,
tileKeysForBbox,
getAlprCameras,
markTilesFetched,
} from '../../server/utils/alpr.js'
import { cameraToFeature, identifyingProperties, inferModelFromTags, isGenericCameraName } from '../../server/utils/alprGeo.js'
import { getDb, setDbPathForTest, closeDb } from '../../server/utils/db.js'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { randomUUID } from 'node:crypto'
describe('alpr utils', () => {
describe('parseOverpassElement', () => {
it('parses a valid OSM node', () => {
const out = parseOverpassElement({
type: 'node',
id: 123,
lat: 33.75,
lon: -84.39,
tags: { 'manufacturer': 'Flock Safety', 'camera:direction': '90' },
})
expect(out?.osmId).toBe(123)
expect(out?.manufacturer).toBe('Flock Safety')
})
})
describe('cameraToFeature', () => {
it('returns GeoJSON Point feature with identifying tags', () => {
const feature = cameraToFeature({
osmId: 1,
lat: 33.5,
lng: -84.5,
manufacturer: 'Flock Safety',
direction: 90,
fov: 45,
tags: {
'model': 'Falcon',
'operator': 'City PD',
'ref': 'CAM-12',
'operator:wikidata': 'Q123',
'fixme': 'verify mount',
},
})
expect(feature.properties.manufacturer).toBe('Flock Safety')
expect(feature.properties.model).toBe('Falcon')
expect(feature.properties.operator).toBe('City PD')
expect(feature.properties.ref).toBe('CAM-12')
expect(feature.properties.operatorWikidata).toBe('Q123')
expect(feature.properties.tags).toEqual({ fixme: 'verify mount' })
})
})
describe('identifyingProperties', () => {
it('omits brand when same as manufacturer', () => {
const props = identifyingProperties({ manufacturer: 'Flock Safety', brand: 'Flock Safety', model: 'Falcon' })
expect(props.manufacturer).toBe('Flock Safety')
expect(props.model).toBe('Falcon')
expect(props.brand).toBeUndefined()
})
it('infers model from lowercase name tag', () => {
const props = identifyingProperties({
manufacturer: 'Flock Safety',
name: 'falcon',
})
expect(props.model).toBe('Falcon')
expect(props.name).toBeUndefined()
expect(props.modelUnknown).toBeUndefined()
})
it('drops generic camera names and flags unknown model', () => {
const props = identifyingProperties({
manufacturer: 'Flock Safety',
name: 'Flock ALPR camera',
})
expect(props.name).toBeUndefined()
expect(props.model).toBeUndefined()
expect(props.modelUnknown).toBe(true)
})
})
describe('inferModelFromTags', () => {
it('reads model tag and mis-tagged Flock Falcon name', () => {
expect(inferModelFromTags({ model: 'Sparrow' })).toBe('Sparrow')
expect(inferModelFromTags({ name: 'Flock Falcon' })).toBe('Falcon')
expect(isGenericCameraName('Flock ALPR camera')).toBe(true)
expect(isGenericCameraName('Peachtree & 5th')).toBe(false)
})
})
describe('parseBbox', () => {
it('parses valid bbox', () => {
expect(parseBbox({ south: 33.0, west: -85.0, north: 33.4, east: -84.6 })).toEqual({
south: 33.0,
west: -85.0,
north: 33.4,
east: -84.6,
})
})
it('rejects oversized bbox', () => {
expect(() => parseBbox({ south: 0, west: 0, north: 2, east: 1 })).toThrow(/bbox exceeds/)
})
})
describe('cache and fetch', () => {
let dbPath
beforeEach(async () => {
dbPath = join(tmpdir(), `kestrelos-alpr-${randomUUID()}.db`)
setDbPathForTest(dbPath)
await getDb()
})
afterEach(() => {
closeDb()
setDbPathForTest(null)
})
it('returns GeoJSON FeatureCollection from cache', async () => {
const db = await getDb()
const bbox = { south: 33.4, west: -85.0, north: 33.6, east: -84.0 }
await db.run(
`INSERT INTO alpr_nodes (osm_id, lat, lng, manufacturer, direction, tags, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[99, 33.5, -84.5, 'Acme', 180, '{}', new Date().toISOString()],
)
await markTilesFetched(db, tileKeysForBbox(bbox))
vi.stubGlobal('fetch', vi.fn())
const result = await getAlprCameras(db, bbox)
expect(result.type).toBe('FeatureCollection')
expect(result.features).toHaveLength(1)
expect(result.source).toBe('cache')
vi.unstubAllGlobals()
})
it('falls back to cache when Overpass fails', async () => {
const db = await getDb()
await db.run(
`INSERT INTO alpr_nodes (osm_id, lat, lng, manufacturer, direction, tags, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[42, 33.5, -84.5, null, null, '{}', new Date().toISOString()],
)
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')))
const result = await getAlprCameras(db, { south: 33.4, west: -85.0, north: 33.6, east: -84.0 })
expect(result.type).toBe('FeatureCollection')
expect(result.features).toHaveLength(1)
vi.unstubAllGlobals()
})
})
})
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
import { formatAlprPopup } from '../../app/utils/alprMapLayer.js'
describe('formatAlprPopup', () => {
it('summarizes manufacturer, model, and operator', () => {
const html = formatAlprPopup({
osmId: 1,
manufacturer: 'Flock Safety',
model: 'Falcon',
operator: 'City PD',
direction: 90,
fov: 60,
})
expect(html).toContain('Flock Safety')
expect(html).toContain('Falcon')
expect(html).toContain('License plate reader')
expect(html).toContain('City PD')
expect(html).toContain('Facing E (90°)')
expect(html).toContain('~60° view')
expect(html).not.toContain('Manufacturer')
expect(html).not.toContain('fixme')
})
it('uses name as title with make/model below', () => {
const html = formatAlprPopup({
osmId: 2,
name: 'Peachtree & 5th',
manufacturer: 'Flock Safety',
model: 'Falcon',
})
expect(html).toContain('<strong>Peachtree &amp; 5th</strong>')
expect(html).toContain('Model</span> <strong>Falcon</strong>')
expect(html).toContain('Flock Safety Falcon')
})
it('links operator wikidata inline', () => {
const html = formatAlprPopup({
osmId: 3,
operator: 'Atlanta Police',
operatorWikidata: 'Q123',
})
expect(html).toContain('<strong>')
expect(html).toContain('href="https://www.wikidata.org/wiki/Q123"')
expect(html).toContain('Atlanta Police')
expect(html).not.toContain('Wikidata')
})
it('shows model unknown when OSM lacks model tag', () => {
const html = formatAlprPopup({
osmId: 4,
manufacturer: 'Flock Safety',
modelUnknown: true,
})
expect(html).toContain('Flock Safety')
expect(html).toContain('Model not recorded in OpenStreetMap')
})
})
+10
View File
@@ -0,0 +1,10 @@
import { describe, it, expect } from 'vitest'
import { tilesNearCenter } from '../../app/utils/alprViewport.js'
describe('tilesNearCenter', () => {
it('returns a single tile for a small viewport', () => {
const tiles = tilesNearCenter({ south: 37.7, west: -122.5, north: 37.8, east: -122.4 }, 16)
expect(tiles).toHaveLength(1)
expect(tiles[0]).toEqual({ south: 37.5, west: -122.5, north: 38, east: -122 })
})
})
+1
View File
@@ -10,6 +10,7 @@ describe('authSkipPaths', () => {
it('does not skip any protected path', () => {
const protectedPaths = [
...PROTECTED_PATH_PREFIXES,
'/api/alpr',
'/api/cameras',
'/api/devices',
'/api/devices/any-id',
+6
View File
@@ -2,7 +2,10 @@ import { describe, it, expect } from 'vitest'
import {
COT_AUTH_TIMEOUT_MS,
LIVE_SESSION_TTL_MS,
COT_TTL_MS,
COT_ENTITY_TTL_MS,
COT_OSINT_TTL_MS,
COT_PRUNE_INTERVAL_MS,
POLL_INTERVAL_MS,
SHUTDOWN_TIMEOUT_MS,
COT_PORT,
@@ -18,7 +21,10 @@ describe('constants', () => {
it('uses default values when env vars not set', () => {
expect(COT_AUTH_TIMEOUT_MS).toBe(15000)
expect(LIVE_SESSION_TTL_MS).toBe(60000)
expect(COT_TTL_MS).toBe(90000)
expect(COT_ENTITY_TTL_MS).toBe(90000)
expect(COT_OSINT_TTL_MS).toBe(30000)
expect(COT_PRUNE_INTERVAL_MS).toBe(15000)
expect(POLL_INTERVAL_MS).toBe(1500)
expect(SHUTDOWN_TIMEOUT_MS).toBe(30000)
expect(COT_PORT).toBe(8089)
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest'
import {
cotAirIconKind,
cotCategory,
formatCotPopup,
getCotIconHtml,
} from '../../app/utils/cotDisplay.js'
const esc = s => String(s)
describe('cotDisplay', () => {
it('detects helicopter from CoT type', () => {
expect(cotAirIconKind({ type: 'a-f-A-C-H' })).toBe('helicopter')
expect(cotAirIconKind({ type: 'a-f-A-C-F' })).toBe('fixedWing')
})
it('renders distinct air icon kinds', () => {
const plane = getCotIconHtml({ type: 'a-f-A-C-F', heading: 90 })
const heli = getCotIconHtml({ type: 'a-f-A-C-H', heading: 180 })
expect(plane.className).toBe('cot-entity-fixedWing')
expect(heli.className).toBe('cot-entity-helicopter')
expect(plane.html).toContain('rotate(90deg)')
expect(heli.html).toContain('rotate(180deg)')
})
it('formats rich ADS-B popup', () => {
const html = formatCotPopup({
source: 'adsb',
type: 'a-f-A-C-F',
label: 'UAL123',
icao: 'abc123',
originCountry: 'United States',
altitude: 10000,
speed: 200,
heading: 270,
verticalRate: 5,
squawk: '1200',
}, esc)
expect(html).toContain('UAL123')
expect(html).toContain('Aircraft')
expect(html).toContain('ICAO ABC123')
expect(html).toContain('United States')
expect(html).toContain('ft')
expect(html).toContain('kt')
expect(html).toContain('270°')
expect(html).toContain('Squawk 1200')
})
it('formats vessel popup with MMSI', () => {
const html = formatCotPopup({
source: 'ais',
type: 'a-f-S-C',
label: 'TEST SHIP',
id: 'MMSI.366123456',
speed: 12,
heading: 90,
}, esc)
expect(html).toContain('Vessel')
expect(html).toContain('MMSI 366123456')
})
it('formats team popup', () => {
expect(cotCategory('a-f-G-U-C')).toBe('ground')
const html = formatCotPopup({ type: 'a-f-G-U-C', label: 'Alpha 1' }, esc)
expect(html).toContain('Team')
expect(html).toContain('Alpha 1')
})
})
+111
View File
@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest'
import {
inferSourceFromId,
cotCategoryFromType,
isInBbox,
matchesLayerFilter,
openSkyStateToCot,
aisStreamMessageToCot,
unionBboxes,
clampBbox,
parseBboxParam,
parseLayersParam,
} from '../../../server/utils/cotEntityUtils.js'
describe('cotEntityUtils', () => {
it('infers source from UID prefix', () => {
expect(inferSourceFromId('ICAO.abc123')).toBe('adsb')
expect(inferSourceFromId('MMSI.366123456')).toBe('ais')
expect(inferSourceFromId('ANDROID-deadbeef')).toBe('tak')
})
it('maps CoT type to category', () => {
expect(cotCategoryFromType('a-f-A-C-F')).toBe('air')
expect(cotCategoryFromType('a-f-S-C')).toBe('surface')
expect(cotCategoryFromType('a-f-G-U-C')).toBe('ground')
})
it('checks bbox membership', () => {
const bbox = { west: -123, south: 37, east: -122, north: 38 }
expect(isInBbox({ lat: 37.5, lng: -122.5 }, bbox)).toBe(true)
expect(isInBbox({ lat: 40, lng: -122.5 }, bbox)).toBe(false)
})
it('filters by layer set', () => {
const airOnly = new Set(['air'])
expect(matchesLayerFilter(airOnly, { type: 'a-f-A-C-F' })).toBe(true)
expect(matchesLayerFilter(airOnly, { type: 'a-f-S-C' })).toBe(false)
})
it('maps OpenSky state vector to CoT', () => {
const state = ['abc123', 'UAL123 ', 'United States', 1, 2, -122.4, 37.7, 10000, false, 200, 90, 5, null, null, 1200, false, 0, 0]
const cot = openSkyStateToCot(state)
expect(cot).toMatchObject({
id: 'ICAO.abc123',
lat: 37.7,
lng: -122.4,
label: 'UAL123',
source: 'adsb',
type: 'a-f-A-C-F',
icao: 'abc123',
originCountry: 'United States',
heading: 90,
speed: 200,
altitude: 10000,
verticalRate: 5,
squawk: '1200',
})
})
it('maps OpenSky rotorcraft to helicopter CoT type', () => {
const state = ['heli01', 'N123HC ', 'United States', 1, 2, -122.4, 37.7, 500, false, 50, 180, 0, null, null, null, false, 0, 8]
const cot = openSkyStateToCot(state)
expect(cot?.type).toBe('a-f-A-C-H')
})
it('maps AISStream message to CoT', () => {
const cot = aisStreamMessageToCot({
MetaData: { MMSI: 366123456, ShipName: 'TEST SHIP' },
Message: {
PositionReport: {
UserID: 366123456,
Latitude: 37.8,
Longitude: -122.3,
Sog: 12.5,
Cog: 180,
},
},
})
expect(cot).toMatchObject({
id: 'MMSI.366123456',
lat: 37.8,
lng: -122.3,
label: 'TEST SHIP',
source: 'ais',
type: 'a-f-S-C',
})
})
it('unions bboxes', () => {
expect(unionBboxes([
{ west: -123, south: 37, east: -122, north: 38 },
{ west: -124, south: 36, east: -121, north: 39 },
])).toEqual({ west: -124, south: 36, east: -121, north: 39 })
})
it('clamps oversized bbox to max span', () => {
const huge = { west: -125, south: 32, east: -115, north: 42 }
const clamped = clampBbox(huge, 10)
expect(clamped.north - clamped.south).toBeCloseTo(10)
expect(clamped.east - clamped.west).toBeCloseTo(10)
expect((clamped.north + clamped.south) / 2).toBeCloseTo(37)
expect((clamped.east + clamped.west) / 2).toBeCloseTo(-120)
})
it('parses bbox and layers query params', () => {
expect(parseBboxParam('-123,37,-122,38')).toEqual({ west: -123, south: 37, east: -122, north: 38 })
expect(parseBboxParam('bad')).toBeNull()
expect(parseLayersParam('air,surface')).toEqual(new Set(['air', 'surface']))
expect(parseLayersParam('none').size).toBe(0)
})
})
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import {
entitiesToFeatures,
getCotClusters,
loadCotCluster,
} from '../../app/utils/cotMapLayer.js'
function makeEntities(n, centerLat = 37.7, centerLng = -122.4) {
return Array.from({ length: n }, (_, i) => ({
id: `ICAO.${i}`,
lat: centerLat + (i % 10) * 0.01,
lng: centerLng + Math.floor(i / 10) * 0.01,
type: 'a-f-A-C-F',
label: `AC${i}`,
}))
}
describe('cotMapLayer', () => {
it('converts entities to GeoJSON points', () => {
const features = entitiesToFeatures([
{ id: 'ICAO.1', lat: 37.7, lng: -122.4, type: 'a-f-A-C-F' },
{ id: 'bad', lat: 'x', lng: 0 },
])
expect(features).toHaveLength(1)
expect(features[0].geometry.coordinates).toEqual([-122.4, 37.7])
expect(features[0].properties.entity.id).toBe('ICAO.1')
})
it('clusters dense tracks at low zoom', () => {
loadCotCluster(makeEntities(50))
const view = { west: -123, south: 37, east: -122, north: 38, zoom: 6 }
const clusters = getCotClusters(view)
const clusterCount = clusters.filter(f => f.properties?.cluster).length
const pointCount = clusters.filter(f => !f.properties?.cluster).length
expect(clusterCount).toBeGreaterThan(0)
expect(clusterCount + pointCount).toBeLessThan(50)
})
it('shows individual markers at high zoom', () => {
loadCotCluster(makeEntities(20))
const view = { west: -123, south: 37, east: -122, north: 38, zoom: 15 }
const clusters = getCotClusters(view)
expect(clusters.every(f => !f.properties?.cluster)).toBe(true)
expect(clusters).toHaveLength(20)
})
})
+13
View File
@@ -44,4 +44,17 @@ describe('cotServer (parse-and-store path)', () => {
expect(active[0].lng).toBe(4)
expect(active[0].label).toBe('Updated')
})
it('infers adsb source from ICAO uid when ingesting CoT position', async () => {
await updateFromCot({
id: 'ICAO.abc123',
lat: 37.7,
lng: -122.4,
label: 'N12345',
eventType: 'a-f-A-C-F',
})
const active = await getActiveEntities()
expect(active[0].source).toBe('adsb')
expect(active[0].type).toBe('a-f-A-C-F')
})
})
+84 -3
View File
@@ -1,5 +1,12 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { updateFromCot, getActiveEntities, clearCotStore } from '../../../server/utils/cotStore.js'
import {
updateFromCot,
getActiveEntities,
getActiveEntitiesInBbox,
clearCotStore,
onCotChange,
pruneStaleEntities,
} from '../../../server/utils/cotStore.js'
describe('cotStore', () => {
beforeEach(() => {
@@ -14,6 +21,28 @@ describe('cotStore', () => {
expect(active[0].lat).toBe(37.7)
expect(active[0].lng).toBe(-122.4)
expect(active[0].label).toBe('Alpha')
expect(active[0].source).toBe('tak')
})
it('stores enriched ADS-B fields and infers source', async () => {
await updateFromCot({
id: 'ICAO.abc123',
lat: 37.7,
lng: -122.4,
label: 'UAL1',
type: 'a-f-A-C-F',
heading: 90,
speed: 200,
altitude: 10000,
})
const active = await getActiveEntities()
expect(active[0]).toMatchObject({
source: 'adsb',
heading: 90,
speed: 200,
altitude: 10000,
type: 'a-f-A-C-F',
})
})
it('updates same uid', async () => {
@@ -41,10 +70,10 @@ describe('cotStore', () => {
it('prunes expired entities after getActiveEntities', async () => {
await updateFromCot({ id: 'uid-1', lat: 37, lng: -122 })
const active1 = await getActiveEntities(100)
const active1 = await getActiveEntities({ ttlMs: 100 })
expect(active1).toHaveLength(1)
await new Promise(r => setTimeout(r, 150))
const active2 = await getActiveEntities(100)
const active2 = await getActiveEntities({ ttlMs: 100 })
expect(active2).toHaveLength(0)
})
@@ -55,4 +84,56 @@ describe('cotStore', () => {
expect(active).toHaveLength(2)
expect(active.map(e => e.id).sort()).toEqual(['a', 'b'])
})
it('filters OSINT entities by bbox but keeps team globally', async () => {
await updateFromCot({ id: 'ICAO.abc', lat: 37.5, lng: -122.5, type: 'a-f-A-C-F' })
await updateFromCot({ id: 'MMSI.123', lat: 40, lng: -100, type: 'a-f-S-C' })
await updateFromCot({ id: 'ANDROID-1', lat: 50, lng: 10, source: 'tak' })
const bbox = { west: -123, south: 37, east: -122, north: 38 }
const active = await getActiveEntitiesInBbox(bbox, { takFilterBbox: false })
const ids = active.map(e => e.id).sort()
expect(ids).toEqual(['ANDROID-1', 'ICAO.abc'])
})
it('filters team by bbox when COT_TAK_FILTER_BBOX enabled', async () => {
await updateFromCot({ id: 'ANDROID-1', lat: 50, lng: 10, source: 'tak' })
const bbox = { west: -123, south: 37, east: -122, north: 38 }
const active = await getActiveEntitiesInBbox(bbox, { takFilterBbox: true })
expect(active).toHaveLength(0)
})
it('caps bbox query results at maxEntities', async () => {
for (let i = 0; i < 5; i++) {
await updateFromCot({ id: `ICAO.${i}`, lat: 37.5 + i * 0.01, lng: -122.4, type: 'a-f-A-C-F' })
}
const bbox = { west: -123, south: 37, east: -122, north: 38 }
const active = await getActiveEntitiesInBbox(bbox, { maxEntities: 3 })
expect(active).toHaveLength(3)
})
it('skips emit when silent upsert', async () => {
const updates = []
const off = onCotChange((event) => {
if (event === 'update') updates.push(event)
})
await updateFromCot({ id: 'ICAO.silent', lat: 37, lng: -122, type: 'a-f-A-C-F' }, { silent: true })
off()
expect(updates).toHaveLength(0)
const active = await getActiveEntities()
expect(active.some(e => e.id === 'ICAO.silent')).toBe(true)
})
it('pruneStaleEntities uses shorter TTL for OSINT sources', async () => {
await updateFromCot({ id: 'ICAO.old', lat: 37, lng: -122, source: 'adsb', type: 'a-f-A-C-F' })
await updateFromCot({ id: 'ANDROID-1', lat: 37, lng: -122, source: 'tak' })
const removed = []
const off = onCotChange((event, payload) => {
if (event === 'remove') removed.push(payload.id)
})
await new Promise(r => setTimeout(r, 60))
await pruneStaleEntities({ ttlMs: 10_000, osintTtlMs: 50 })
off()
expect(removed).toContain('ICAO.old')
expect(removed).not.toContain('ANDROID-1')
})
})
+96
View File
@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
registerSubscriber,
getSubscriberBboxUnion,
clearSubscribers,
notifySubscribersForEntity,
notifySubscribersRemove,
broadcastSubscriberSnapshots,
} from '../../../server/utils/cotSubscribers.js'
import { updateFromCot, clearCotStore } from '../../../server/utils/cotStore.js'
describe('cotSubscribers', () => {
beforeEach(() => {
clearSubscribers()
})
it('unions subscriber bboxes', () => {
registerSubscriber({
bbox: { west: -123, south: 37, east: -122, north: 38 },
layers: new Set(['air']),
push: vi.fn(),
})
registerSubscriber({
bbox: { west: -124, south: 36, east: -121, north: 39 },
layers: new Set(['surface']),
push: vi.fn(),
})
expect(getSubscriberBboxUnion()).toEqual({ west: -124, south: 36, east: -121, north: 39 })
})
it('notifies subscribers inside bbox and matching layer', async () => {
const push = vi.fn()
registerSubscriber({
bbox: { west: -123, south: 37, east: -122, north: 38 },
layers: new Set(['air']),
push,
})
await notifySubscribersForEntity('update', { entity: { id: 'ICAO.x' } }, {
id: 'ICAO.x',
lat: 37.5,
lng: -122.5,
type: 'a-f-A-C-F',
})
expect(push).toHaveBeenCalledWith('update', expect.any(String))
})
it('skips subscribers when entity outside bbox', async () => {
const push = vi.fn()
registerSubscriber({
bbox: { west: -123, south: 37, east: -122, north: 38 },
layers: new Set(['air']),
push,
})
await notifySubscribersForEntity('update', { entity: { id: 'ICAO.x' } }, {
id: 'ICAO.x',
lat: 40,
lng: -122.5,
type: 'a-f-A-C-F',
})
expect(push).not.toHaveBeenCalled()
})
it('notifySubscribersRemove pushes to all subscribers', async () => {
const pushA = vi.fn()
const pushB = vi.fn()
registerSubscriber({
bbox: { west: -123, south: 37, east: -122, north: 38 },
layers: new Set(['air']),
push: pushA,
})
registerSubscriber({
bbox: { west: -125, south: 35, east: -120, north: 40 },
layers: new Set(['surface']),
push: pushB,
})
await notifySubscribersRemove('ICAO.removed')
expect(pushA).toHaveBeenCalledWith('remove', JSON.stringify({ id: 'ICAO.removed' }))
expect(pushB).toHaveBeenCalledWith('remove', JSON.stringify({ id: 'ICAO.removed' }))
})
it('broadcastSubscriberSnapshots sends per-subscriber filtered snapshot', async () => {
clearCotStore()
await updateFromCot({ id: 'ICAO.in', lat: 37.5, lng: -122.5, type: 'a-f-A-C-F' })
await updateFromCot({ id: 'ICAO.out', lat: 40, lng: -100, type: 'a-f-A-C-F' })
const push = vi.fn()
registerSubscriber({
bbox: { west: -123, south: 37, east: -122, north: 38 },
layers: new Set(['air']),
push,
})
await broadcastSubscriberSnapshots({ ttlMs: 90_000, osintTtlMs: 30_000, takFilterBbox: false })
expect(push).toHaveBeenCalledWith('snapshot', expect.any(String))
const payload = JSON.parse(push.mock.calls[0][1])
expect(payload.entities.map(e => e.id)).toEqual(['ICAO.in'])
})
})
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect, vi } from 'vitest'
import { createClusterIndex } from '../../app/utils/mapCluster.js'
import { clearFeatureMarkers, syncFeatureMarkers } from '../../app/utils/mapMarkerSync.js'
import { bboxFetchKey, tilesNearCenter } from '../../app/utils/alprViewport.js'
describe('mapCluster', () => {
it('loads once and queries by viewport', () => {
const index = createClusterIndex({ radius: 50, maxZoom: 14, minPoints: 2 })
const features = Array.from({ length: 20 }, (_, i) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [-122.4 + i * 0.01, 37.7] },
properties: { id: i },
}))
index.load(features)
const zoomedOut = index.query({ west: -123, south: 37, east: -122, north: 38, zoom: 6 })
expect(zoomedOut.some(f => f.properties?.cluster)).toBe(true)
const zoomedIn = index.query({ west: -123, south: 37, east: -122, north: 38, zoom: 15 })
expect(zoomedIn).toHaveLength(20)
})
})
describe('mapMarkerSync', () => {
it('reuses markers by key', () => {
const layer = {
_layers: [],
addLayer(m) { this._layers.push(m) },
removeLayer(m) { this._layers = this._layers.filter(x => x !== m) },
}
const create = vi.fn(f => ({ id: f.properties.id }))
const update = vi.fn()
const opts = { keyFor: f => f.properties.id, create, update }
const pt = (id, lng, lat) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [lng, lat] }, properties: { id } })
syncFeatureMarkers(layer, [pt(1, -122, 37)], opts)
syncFeatureMarkers(layer, [pt(1, -121.9, 37.1)], opts)
expect(create).toHaveBeenCalledTimes(1)
expect(update).toHaveBeenCalledTimes(1)
clearFeatureMarkers(layer)
expect(layer._layers).toHaveLength(0)
})
})
describe('alprViewport', () => {
it('selects nearby tiles without scanning the world', () => {
const world = { south: -85, west: -180, north: 85, east: 180 }
expect(tilesNearCenter(world, 16)).toHaveLength(16)
expect(tilesNearCenter(world, 1)[0]).toEqual({ south: 0, west: 0, north: 0.5, east: 0.5 })
})
it('coarsens fetch keys when zoomed out', () => {
const bounds = { south: 37.01, west: -122.51, north: 37.99, east: -122.01, zoom: 6 }
expect(bboxFetchKey(bounds)).toBe(bboxFetchKey({ ...bounds, south: bounds.south + 0.05 }))
expect(bboxFetchKey({ ...bounds, zoom: 14 })).not.toBe(bboxFetchKey(bounds))
})
})
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { clearSubscribers, registerSubscriber } from '../../../server/utils/cotSubscribers.js'
import {
isOpenSkyPollActive,
resetTrackingFeedForTests,
scheduleOpenSkyPollForTests,
} from '../../../server/utils/trackingFeed.js'
describe('trackingFeed', () => {
beforeEach(() => {
clearSubscribers()
resetTrackingFeedForTests()
})
it('does not start OpenSky poll without SSE subscribers', () => {
scheduleOpenSkyPollForTests()
expect(isOpenSkyPollActive()).toBe(false)
})
it('starts OpenSky poll when a subscriber is registered', () => {
registerSubscriber({
bbox: { west: -123, south: 37, east: -122, north: 38 },
layers: new Set(['air']),
push: vi.fn(),
})
scheduleOpenSkyPollForTests()
expect(isOpenSkyPollActive()).toBe(true)
})
})