diff --git a/.github/workflows/backend-integration.yml b/.github/workflows/backend-integration.yml new file mode 100644 index 0000000..d22c716 --- /dev/null +++ b/.github/workflows/backend-integration.yml @@ -0,0 +1,48 @@ +name: backend-integration + +on: + push: + branches: [main, master] + paths: + - "track-service/**" + - "acars-service/**" + - "nginx/**" + - "frontend/**" + - "Dockerfile" + - "entrypoint.sh" + - "docker-healthcheck.sh" + - "tests/integration/**" + - ".github/workflows/backend-integration.yml" + pull_request: + paths: + - "track-service/**" + - "acars-service/**" + - "nginx/**" + - "frontend/**" + - "Dockerfile" + - "entrypoint.sh" + - "docker-healthcheck.sh" + - "tests/integration/**" + - ".github/workflows/backend-integration.yml" + +permissions: + contents: read + +concurrency: + group: backend-integration-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + compose-smoke: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Show Docker versions + run: | + docker version + docker compose version + + - name: Run full-stack integration suite + run: tests/integration/run.sh diff --git a/tests/README.md b/tests/README.md index 06a1dc8..4cea42c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -22,6 +22,29 @@ npm run test -- --watch # watch mode CI runs `npm run typecheck && npm run test && npm run build` on every PR. +## Backend and deployment integration tests + +`integration/` boots a deterministic public test stack with Docker Compose: + +- a moving readsb-compatible `aircraft.json` fixture; +- a TCP ACARS fixture; +- TimescaleDB, track-service, and acars-service; +- the production viewer/nginx image in both live-only and full multi-feed modes. + +The verifier exercises track ingestion and history, heatmap aggregation, ACARS +ingestion, schema creation, compression and retention policies, nginx routing, +feature flags, and multi-feed proxy generation. It then restarts both backend +services and repeats the suite to catch non-idempotent database startup. + +Run it locally anywhere Docker Compose v2 is available: + +```sh +tests/integration/run.sh +``` + +No receiver, radio hardware, credentials, or external service is required. +GitHub Actions runs the same command on public `ubuntu-latest` runners. + ## Browser smoke test `tests/smoke-test.html` is a legacy browser-based test page from the early diff --git a/tests/integration/compose.yml b/tests/integration/compose.yml new file mode 100644 index 0000000..5750e97 --- /dev/null +++ b/tests/integration/compose.yml @@ -0,0 +1,186 @@ +name: adsb3d-integration + +services: + timescaledb: + image: timescale/timescaledb:2.17.2-pg16 + environment: + POSTGRES_DB: adsb_tracks + POSTGRES_USER: adsb + POSTGRES_PASSWORD: ci-only-password + healthcheck: + test: ["CMD-SHELL", "pg_isready -U adsb -d adsb_tracks"] + interval: 2s + timeout: 3s + retries: 30 + + fixture-feeder: + image: python:3.11-alpine + command: ["python", "/fixtures/server.py", "http"] + volumes: + - ./fixtures:/fixtures:ro + healthcheck: + test: + - CMD + - python + - -c + - >- + import urllib.request; + urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=2) + interval: 2s + timeout: 3s + retries: 20 + + fixture-acars: + image: python:3.11-alpine + command: ["python", "/fixtures/server.py", "acars"] + volumes: + - ./fixtures:/fixtures:ro + healthcheck: + test: + - CMD + - python + - -c + - >- + import socket; + socket.create_connection(('127.0.0.1', 15550), timeout=2).close() + interval: 2s + timeout: 3s + retries: 20 + + track-service: + build: + context: ../../track-service + environment: + FEEDER_URL: http://fixture-feeder:8080 + COLLECTION_INTERVAL: "1" + RETENTION_DAYS: "14" + DB_HOST: timescaledb + DB_PORT: "5432" + DB_NAME: adsb_tracks + DB_USER: adsb + DB_PASSWORD: ci-only-password + depends_on: + timescaledb: + condition: service_healthy + fixture-feeder: + condition: service_healthy + healthcheck: + test: + - CMD + - python + - -c + - >- + import urllib.request; + urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2) + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s + + acars-service: + build: + context: ../../acars-service + environment: + ACARS_HOST: fixture-acars + ACARS_PORT: "15550" + STATION_ID: CI_STATION + DB_HOST: timescaledb + DB_PORT: "5432" + DB_NAME: adsb_tracks + DB_USER: adsb + DB_PASSWORD: ci-only-password + depends_on: + track-service: + condition: service_healthy + fixture-acars: + condition: service_healthy + healthcheck: + test: + - CMD + - python + - -c + - >- + import urllib.request; + urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2) + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s + + viewer: + image: adsb3d-integration-viewer:local + build: + context: ../.. + environment: + LATITUDE: "51.5000" + LONGITUDE: "-0.1200" + ALTITUDE: "100" + LOCATION_NAME: CI Local + FEEDER_URL: http://fixture-feeder:8080 + TRACK_API_HOST: track-service:8000 + ACARS_API_HOST: acars-service:8000 + ENABLE_HISTORICAL: "true" + ENABLE_ACARS: "true" + ENABLE_VOICE: "false" + FEED1_NAME: CI Local + FEED1_LAT: "51.5000" + FEED1_LON: "-0.1200" + FEED1_ALT: "100" + FEED1_ACARS: "true" + FEED2_NAME: CI Remote + FEED2_URL: fixture-feeder:8080 + FEED2_LAT: "51.6000" + FEED2_LON: "-0.2200" + FEED2_ALT: "200" + depends_on: + track-service: + condition: service_healthy + acars-service: + condition: service_healthy + healthcheck: + test: ["CMD", "/docker-healthcheck.sh"] + interval: 2s + timeout: 5s + retries: 30 + start_period: 5s + + viewer-live-only: + image: adsb3d-integration-viewer:local + environment: + LATITUDE: "40.0000" + LONGITUDE: "-75.0000" + ALTITUDE: "50" + LOCATION_NAME: CI Live Only + FEEDER_URL: http://fixture-feeder:8080 + ENABLE_HISTORICAL: "false" + ENABLE_ACARS: "false" + ENABLE_VOICE: "false" + depends_on: + fixture-feeder: + condition: service_healthy + healthcheck: + test: ["CMD", "/docker-healthcheck.sh"] + interval: 2s + timeout: 5s + retries: 30 + start_period: 5s + + integration-tests: + build: + context: ../../track-service + command: ["python", "/tests/verify.py"] + environment: + DB_HOST: timescaledb + DB_PORT: "5432" + DB_NAME: adsb_tracks + DB_USER: adsb + DB_PASSWORD: ci-only-password + VIEWER_URL: http://viewer + LIVE_VIEWER_URL: http://viewer-live-only + volumes: + - .:/tests:ro + depends_on: + viewer: + condition: service_healthy + viewer-live-only: + condition: service_healthy diff --git a/tests/integration/fixtures/server.py b/tests/integration/fixtures/server.py new file mode 100755 index 0000000..4702803 --- /dev/null +++ b/tests/integration/fixtures/server.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Deterministic readsb HTTP and ACARS TCP fixtures for public CI.""" + +from __future__ import annotations + +import json +import socketserver +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +class FeederHandler(BaseHTTPRequestHandler): + request_count = 0 + lock = threading.Lock() + + def log_message(self, fmt: str, *args: object) -> None: + print(f"[fixture-http] {fmt % args}", flush=True) + + def _send_json(self, payload: dict, status: int = 200) -> None: + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: # noqa: N802 - stdlib handler API + if self.path == "/health": + self._send_json({"status": "fixture-healthy"}) + return + + if self.path == "/data/aircraft.json": + with self.lock: + type(self).request_count += 1 + tick = type(self).request_count + offset = min(tick, 1000) * 0.0001 + self._send_json( + { + "now": time.time(), + "messages": 1000 + tick, + "aircraft": [ + { + "hex": "abc123", + "flight": "TST123 ", + "lat": 51.5000 + offset, + "lon": -0.1200 + offset, + "alt_baro": 12000 + tick, + "alt_geom": 12100 + tick, + "gs": 250.0, + "track": 90.0, + "baro_rate": 64, + "squawk": "7000", + "category": "A3", + "r": "G-TEST", + "t": "A320", + "desc": "CI fixture aircraft", + "messages": 500 + tick, + "seen": 0.1, + }, + { + "hex": "def456", + "flight": "TST456 ", + "lat": 51.5100 - offset, + "lon": -0.1100 - offset, + "alt_baro": 8000, + "gs": 180.0, + "track": 270.0, + "category": "A2", + "messages": 200 + tick, + "seen": 0.2, + }, + ], + } + ) + return + + if self.path == "/tracks/abc123": + self._send_json( + { + "source": "remote-fixture", + "icao": "abc123", + "positions": [], + } + ) + return + + self._send_json({"detail": "not found", "path": self.path}, 404) + + +class AcarsHandler(socketserver.BaseRequestHandler): + messages = ( + { + "flight": "TST123", + "tail": "G-TEST", + "icao": "abc123", + "label": "Q0", + "block_id": "1", + "msg_num": "001", + "text": "CI OOOI fixture", + "freq": 131.55, + "level": -25, + "error": 0, + "dsta": "EGLL", + "lat": 51.5, + "lon": -0.12, + "alt": 12000, + }, + { + "flight": "TST456", + "tail": "G-CI02", + "icao": "def456", + "label": "H1", + "msg_num": "002", + "text": "CI operational fixture", + "freq": 136.975, + "level": -30, + "error": 0, + }, + ) + + def handle(self) -> None: + for message in self.messages: + try: + self.request.sendall(json.dumps(message).encode() + b"\n") + except (BrokenPipeError, ConnectionResetError): + break + time.sleep(0.1) + + +class ReusableThreadingTCPServer(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + +def main() -> None: + mode = sys.argv[1] if len(sys.argv) > 1 else "http" + if mode == "http": + print("[fixture-http] listening on :8080", flush=True) + ThreadingHTTPServer(("0.0.0.0", 8080), FeederHandler).serve_forever() + elif mode == "acars": + print("[fixture-acars] listening on :15550", flush=True) + ReusableThreadingTCPServer(("0.0.0.0", 15550), AcarsHandler).serve_forever() + else: + raise SystemExit(f"unknown fixture mode: {mode}") + + +if __name__ == "__main__": + main() diff --git a/tests/integration/run.sh b/tests/integration/run.sh new file mode 100755 index 0000000..ef6cb65 --- /dev/null +++ b/tests/integration/run.sh @@ -0,0 +1,31 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +COMPOSE_FILE="$SCRIPT_DIR/compose.yml" +PROJECT_NAME="adsb3d-integration-${GITHUB_RUN_ID:-local}" + +cleanup() { + status=$? + if [ "$status" -ne 0 ]; then + docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" ps || true + docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" logs --no-color || true + fi + docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" down --volumes --remove-orphans || true + exit "$status" +} +trap cleanup EXIT INT TERM + +docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" build +docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" up -d --wait \ + timescaledb fixture-feeder fixture-acars track-service acars-service viewer viewer-live-only + +echo "Running full-stack verification..." +docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" run --rm integration-tests + +echo "Restarting backend services to verify idempotent schema startup..." +docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" restart track-service acars-service +docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" up -d --wait track-service acars-service viewer + +echo "Re-running verification after backend restart..." +docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" run --rm integration-tests diff --git a/tests/integration/verify.py b/tests/integration/verify.py new file mode 100755 index 0000000..1763cb6 --- /dev/null +++ b/tests/integration/verify.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""End-to-end assertions for the containerized ADS-B 3D stack.""" + +from __future__ import annotations + +import asyncio +import json +import os +import re +import sys +import time +from datetime import datetime, timedelta, timezone +from urllib.parse import urlencode + +import aiohttp +import asyncpg + + +VIEWER_URL = os.getenv("VIEWER_URL", "http://viewer").rstrip("/") +LIVE_VIEWER_URL = os.getenv("LIVE_VIEWER_URL", "http://viewer-live-only").rstrip("/") + + +def check(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + print(f"PASS: {message}", flush=True) + + +async def get_json(session: aiohttp.ClientSession, url: str) -> dict: + async with session.get(url) as response: + body = await response.text() + check(response.status == 200, f"GET {url} returns 200 (got {response.status}: {body[:200]})") + return json.loads(body) + + +async def eventually_json( + session: aiohttp.ClientSession, + url: str, + predicate, + description: str, + timeout: float = 45.0, +) -> dict: + deadline = time.monotonic() + timeout + last: object = None + while time.monotonic() < deadline: + try: + async with session.get(url) as response: + last = await response.json(content_type=None) + if response.status == 200 and predicate(last): + check(True, description) + return last + except (aiohttp.ClientError, json.JSONDecodeError) as exc: + last = exc + await asyncio.sleep(1) + raise AssertionError(f"timed out waiting for {description}; last response: {last!r}") + + +async def verify_nginx_and_config(session: aiohttp.ClientSession) -> None: + async with session.get(f"{VIEWER_URL}/health") as response: + check(response.status == 200 and (await response.text()).strip() == "OK", "viewer nginx health route") + + live = await get_json(session, f"{VIEWER_URL}/data/aircraft.json") + check(any(a.get("hex") == "abc123" for a in live["aircraft"]), "local readsb route passes fixture aircraft") + + remote_live = await get_json(session, f"{VIEWER_URL}/data/feeds/2/aircraft.json") + check(any(a.get("hex") == "def456" for a in remote_live["aircraft"]), "multi-feed data route reaches remote feed") + + remote_health = await get_json(session, f"{VIEWER_URL}/api/feeds/2/health") + check(remote_health["status"] == "fixture-healthy", "multi-feed API route rewrites to remote service") + + remote_track = await get_json(session, f"{VIEWER_URL}/api/feeds/2/tracks/abc123") + check(remote_track.get("source") == "remote-fixture", "multi-feed nested API route preserves the backend path") + + async with session.get(f"{VIEWER_URL}/config.js") as response: + config = await response.text() + check("window.HISTORICAL_CONFIG = {\n enabled: true" in config, "historical feature flag is rendered") + check("window.ACARS_CONFIG = {\n enabled: true" in config, "ACARS feature flag is rendered") + check("mode: 'multi'" in config, "multi-feed mode is rendered") + match = re.search(r"window\.FEEDS_CONFIG = (\[.*\]);", config) + check(match is not None, "generated multi-feed JSON is present") + feeds = json.loads(match.group(1)) + check([feed["name"] for feed in feeds] == ["CI Local", "CI Remote"], "generated feed order and names") + check(feeds[1]["liveUrl"] == "/data/feeds/2/aircraft.json", "generated remote live URL") + check(feeds[1]["apiBase"] == "/api/feeds/2", "generated remote API base") + + async with session.get(f"{LIVE_VIEWER_URL}/config.js") as response: + live_config = await response.text() + check("window.HISTORICAL_CONFIG = {\n enabled: false" in live_config, "live-only historical flag is disabled") + check("window.ACARS_CONFIG = {\n enabled: false" in live_config, "live-only ACARS flag is disabled") + check("mode: 'single'" in live_config, "live-only viewer remains single-feed") + live_only_data = await get_json(session, f"{LIVE_VIEWER_URL}/data/aircraft.json") + check(len(live_only_data["aircraft"]) == 2, "live-only deployment proxies readsb independently") + + +async def verify_track_api(session: aiohttp.ClientSession) -> None: + health = await get_json(session, f"{VIEWER_URL}/api/health") + check(health == {"status": "healthy", "database": "connected", "collector": "running"}, "track health through nginx") + + track = await eventually_json( + session, + f"{VIEWER_URL}/api/tracks/abc123", + lambda body: len(body.get("positions", [])) >= 2, + "collector ingests multiple readsb samples and history returns them", + ) + check(track["icao"] == "abc123", "history response identifies the requested ICAO") + check(track["positions"][0]["time"] <= track["positions"][-1]["time"], "history positions are chronological") + + end = datetime.now(timezone.utc) + timedelta(minutes=1) + start = end - timedelta(hours=1) + query = urlencode( + { + "start": start.isoformat(), + "end": end.isoformat(), + "cell": "0.01", + "bbox": "51.0,-1.0,52.0,0.5", + } + ) + heatmap = await eventually_json( + session, + f"{VIEWER_URL}/api/heatmap?{query}", + lambda body: len(body.get("cells", [])) >= 1, + "heatmap aggregates ingested positions", + ) + check(heatmap["cell_deg"] == 0.01, "heatmap preserves requested grid size") + check(sum(cell["count"] for cell in heatmap["cells"]) >= 1, "heatmap reports aircraft density") + + +async def verify_acars_api(session: aiohttp.ClientSession) -> None: + health = await get_json(session, f"{VIEWER_URL}/acars-api/health") + check(health["status"] == "healthy" and health["collector"] == "running", "ACARS health through nginx") + recent = await eventually_json( + session, + f"{VIEWER_URL}/acars-api/messages/recent?minutes=60&limit=20", + lambda body: body.get("count", 0) >= 2, + "ACARS TCP messages are parsed, persisted, and queryable", + ) + messages = recent["messages"] + check(any(m["flight"] == "TST123" and m["label"] == "Q0" for m in messages), "ACARS OOOI fixture fields survive ingestion") + check(any(m["station_id"] == "CI_STATION" for m in messages), "ACARS station identity is attached") + + +async def verify_database() -> None: + conn = await asyncpg.connect( + host=os.getenv("DB_HOST", "timescaledb"), + port=int(os.getenv("DB_PORT", "5432")), + database=os.getenv("DB_NAME", "adsb_tracks"), + user=os.getenv("DB_USER", "adsb"), + password=os.getenv("DB_PASSWORD", "ci-only-password"), + ) + try: + tables = await conn.fetch( + """ + SELECT table_name FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ('aircraft_positions', 'aircraft_metadata', 'acars_messages') + """ + ) + table_names = {row["table_name"] for row in tables} + check(table_names == {"aircraft_positions", "aircraft_metadata", "acars_messages"}, "clean startup creates every backend table") + + hypertables = { + row["hypertable_name"]: row["compression_enabled"] + for row in await conn.fetch( + """ + SELECT hypertable_name, compression_enabled + FROM timescaledb_information.hypertables + WHERE hypertable_name IN ('aircraft_positions', 'acars_messages') + """ + ) + } + check(set(hypertables) == {"aircraft_positions", "acars_messages"}, "position and ACARS tables are Timescale hypertables") + check(all(hypertables.values()), "compression is enabled for both hypertables") + + jobs = await conn.fetch( + """ + SELECT proc_name, hypertable_name, config::text AS config + FROM timescaledb_information.jobs + WHERE hypertable_name IN ('aircraft_positions', 'acars_messages') + """ + ) + job_map = {(row["hypertable_name"], row["proc_name"]): row["config"] for row in jobs} + check(("aircraft_positions", "policy_compression") in job_map, "track compression policy exists") + check(("acars_messages", "policy_compression") in job_map, "ACARS compression policy exists") + check(("aircraft_positions", "policy_retention") in job_map, "track retention policy exists") + check(("acars_messages", "policy_retention") in job_map, "ACARS retention policy exists") + check("14 days" in job_map[("aircraft_positions", "policy_retention")], "track retention honours RETENTION_DAYS=14") + check("30 days" in job_map[("acars_messages", "policy_retention")], "ACARS retention policy is 30 days") + + has_military = await conn.fetchval( + """ + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'aircraft_metadata' AND column_name = 'is_military' + ) + """ + ) + check(has_military, "current aircraft_metadata migration state is present") + check(await conn.fetchval("SELECT COUNT(*) FROM aircraft_positions") >= 2, "position rows reached TimescaleDB") + check(await conn.fetchval("SELECT COUNT(*) FROM acars_messages") >= 2, "ACARS rows reached TimescaleDB") + check(await conn.fetchval("SELECT COUNT(*) FROM aircraft_metadata WHERE icao = 'abc123'") == 1, "aircraft metadata upsert ran") + finally: + await conn.close() + + +async def main() -> None: + timeout = aiohttp.ClientTimeout(total=10) + async with aiohttp.ClientSession(timeout=timeout) as session: + await verify_nginx_and_config(session) + await verify_track_api(session) + await verify_acars_api(session) + await verify_database() + print("All ADS-B 3D integration checks passed.", flush=True) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except Exception as exc: + print(f"FAIL: {exc}", file=sys.stderr, flush=True) + raise diff --git a/track-service/main.py b/track-service/main.py index c8ca85d..394b1bb 100644 --- a/track-service/main.py +++ b/track-service/main.py @@ -230,7 +230,7 @@ async def initialize_database_schema(db_pool): retention_days = 90 await conn.execute( "SELECT add_retention_policy('aircraft_positions', $1::interval, if_not_exists => TRUE)", - f"{retention_days} days" + timedelta(days=retention_days) ) logger.info(f"✓ Added retention policy ({retention_days} days)")