"""Issues #244, #245, #246 (tg12 external audit): carrier tracker quality + provenance + freshness. These tests pin the post-fix contract: - **#244**: dated editorial snapshot positions no longer live in the registry. They live in a one-shot seed file that is consumed once on first-ever startup. After that, the runtime cache reflects only what THIS install has actually observed. - **#245**: headline-derived positions (centroid of a region keyword) are stamped ``position_confidence = "approximate"`` so the UI can render them with appropriate uncertainty. - **#246**: freshness is a *labelling* decision, not an eviction decision. Positions older than the configurable freshness window flip from ``"recent"`` to ``"stale"`` but are NEVER replaced with the registry default — that would teleport the carrier. The user always sees the last position the system actually observed. """ from __future__ import annotations import json import os from datetime import datetime, timedelta, timezone from pathlib import Path from unittest.mock import patch import pytest @pytest.fixture def fresh_tracker(tmp_path, monkeypatch): """Isolated carrier_tracker with seed/cache paths redirected to tmp. Yields the module so tests can call its functions; resets globals between tests so position caches don't leak across cases. """ from services import carrier_tracker seed_path = tmp_path / "data" / "carrier_seed.json" cache_path = tmp_path / "carrier_cache.json" seed_path.parent.mkdir(parents=True, exist_ok=True) monkeypatch.setattr(carrier_tracker, "SEED_FILE", seed_path) monkeypatch.setattr(carrier_tracker, "CACHE_FILE", cache_path) monkeypatch.delenv("SHADOWBROKER_CARRIER_FRESHNESS_DAYS", raising=False) # Reset module-level mutable state. carrier_tracker._carrier_positions.clear() carrier_tracker._cached_gdelt_articles.clear() carrier_tracker._last_gdelt_fetch_at = 0.0 yield carrier_tracker # Clean up so subsequent tests start fresh. carrier_tracker._carrier_positions.clear() carrier_tracker._cached_gdelt_articles.clear() def _write_seed(path: Path, hull: str = "CVN-78", **overrides) -> None: payload = { "_meta": { "as_of": "2026-03-09", "source": "USNI News Fleet & Marine Tracker", "source_url": "https://news.usni.org/...", "note": "test", }, "carriers": { hull: { "lat": 18.0, "lng": 39.5, "heading": 0, "desc": "Red Sea — Operation Epic Fury (USNI Mar 9)", "source": "USNI News Fleet & Marine Tracker (seed, as of 2026-03-09)", "source_url": "https://news.usni.org/category/fleet-tracker", "position_source_at": "2026-03-09T00:00:00Z", "position_confidence": "seed", **overrides, } }, } path.write_text(json.dumps(payload), encoding="utf-8") # --------------------------------------------------------------------------- # #244 — first-run seed bootstrap, never re-seeds after that # --------------------------------------------------------------------------- class TestSeedBootstrap: def test_first_ever_startup_bootstraps_from_seed(self, fresh_tracker, tmp_path): _write_seed(fresh_tracker.SEED_FILE) # No cache exists yet. assert not fresh_tracker.CACHE_FILE.exists() positions = fresh_tracker._bootstrap_cache_if_missing() # The seed entry made it into the cache. assert "CVN-78" in positions assert positions["CVN-78"]["lat"] == 18.0 assert positions["CVN-78"]["position_confidence"] == "seed" # And the cache file is now on disk so subsequent runs skip the seed. assert fresh_tracker.CACHE_FILE.exists() def test_subsequent_startup_ignores_seed(self, fresh_tracker, tmp_path): # Pre-seed a different position into the cache; the seed file says Red Sea. cache_data = { "CVN-78": { "lat": 25.0, "lng": 55.0, "heading": 0, "desc": "Persian Gulf — operator-observed", "source": "Operator log", "source_url": "", "position_source_at": "2026-04-15T12:00:00Z", "position_confidence": "recent", } } fresh_tracker.CACHE_FILE.write_text(json.dumps(cache_data)) _write_seed(fresh_tracker.SEED_FILE) # seed is present but should NOT be used positions = fresh_tracker._bootstrap_cache_if_missing() assert positions["CVN-78"]["lat"] == 25.0 assert positions["CVN-78"]["desc"] == "Persian Gulf — operator-observed" def test_no_seed_no_cache_falls_back_to_homeport(self, fresh_tracker): # Neither seed nor cache. Must fall back to homeport defaults # (carrier never disappears). assert not fresh_tracker.SEED_FILE.exists() assert not fresh_tracker.CACHE_FILE.exists() positions = fresh_tracker._bootstrap_cache_if_missing() # Every registered carrier has SOMETHING. assert set(positions.keys()) == set(fresh_tracker.CARRIER_REGISTRY.keys()) # All entries are labelled as homeport defaults. for hull, entry in positions.items(): assert entry["position_confidence"] == "homeport_default" registry = fresh_tracker.CARRIER_REGISTRY[hull] assert entry["lat"] == registry["homeport_lat"] assert entry["lng"] == registry["homeport_lng"] # --------------------------------------------------------------------------- # #244 — no editorial fallbacks live in the registry # --------------------------------------------------------------------------- class TestRegistryShape: def test_registry_has_no_dated_fallback_fields(self, fresh_tracker): """The Mar 9 editorial coordinates are gone from the registry. They live only in the seed file.""" forbidden = {"fallback_lat", "fallback_lng", "fallback_heading", "fallback_desc"} for hull, entry in fresh_tracker.CARRIER_REGISTRY.items(): offending = forbidden & set(entry.keys()) assert not offending, f"{hull} still has dated registry fields: {offending}" def test_registry_keeps_homeport_for_every_hull(self, fresh_tracker): for hull, entry in fresh_tracker.CARRIER_REGISTRY.items(): assert "homeport_lat" in entry, f"{hull} missing homeport_lat" assert "homeport_lng" in entry, f"{hull} missing homeport_lng" assert "name" in entry assert "wiki" in entry # --------------------------------------------------------------------------- # #246 — freshness labelling, NOT eviction # --------------------------------------------------------------------------- class TestFreshnessLabelling: def test_recent_observation_labels_recent(self, fresh_tracker): now = datetime(2026, 6, 1, tzinfo=timezone.utc) entry = { "lat": 25.0, "lng": 55.0, "position_source_at": (now - timedelta(days=3)).isoformat(), } assert fresh_tracker._compute_position_confidence(entry, now=now) == "recent" def test_aged_observation_flips_to_stale(self, fresh_tracker): now = datetime(2026, 6, 1, tzinfo=timezone.utc) entry = { "lat": 25.0, "lng": 55.0, "position_source_at": (now - timedelta(days=30)).isoformat(), } assert fresh_tracker._compute_position_confidence(entry, now=now) == "stale" def test_seed_label_is_preserved_explicitly(self, fresh_tracker): now = datetime(2026, 6, 1, tzinfo=timezone.utc) entry = { "lat": 18.0, "lng": 39.5, "position_source_at": "2026-03-09T00:00:00Z", "position_confidence": "seed", } # Even though the source is months old, the explicit "seed" label wins # so the UI can render the seed-specific badge instead of generic "stale". assert fresh_tracker._compute_position_confidence(entry, now=now) == "seed" def test_homeport_default_label_is_preserved(self, fresh_tracker): now = datetime(2026, 6, 1, tzinfo=timezone.utc) entry = { "lat": 36.95, "lng": -76.32, "position_source_at": now.isoformat(), "position_confidence": "homeport_default", } assert fresh_tracker._compute_position_confidence(entry, now=now) == "homeport_default" def test_freshness_window_is_env_configurable(self, fresh_tracker, monkeypatch): now = datetime(2026, 6, 1, tzinfo=timezone.utc) entry = { "lat": 25.0, "lng": 55.0, "position_source_at": (now - timedelta(days=20)).isoformat(), } # Default window = 14 days → 20-day-old entry is stale. assert fresh_tracker._compute_position_confidence(entry, now=now) == "stale" # Stretch to 30 days → same entry is now "recent". monkeypatch.setenv("SHADOWBROKER_CARRIER_FRESHNESS_DAYS", "30") assert fresh_tracker._compute_position_confidence(entry, now=now) == "recent" def test_aged_cache_entry_keeps_its_position_never_reverts(self, fresh_tracker): """The core regression test for the user's intent: a year-old cache entry must NOT be replaced with the seed or homeport. The PHYSICAL position the user sees is the last one observed; only the freshness LABEL changes.""" a_year_ago = (datetime.now(timezone.utc) - timedelta(days=365)).isoformat() cache_data = { "CVN-78": { "lat": 25.0, "lng": 55.0, "heading": 0, "desc": "Persian Gulf", "source": "GDELT News API", "source_url": "https://news.example/...", "position_source_at": a_year_ago, "position_confidence": "recent", # was recent when written } } fresh_tracker.CACHE_FILE.write_text(json.dumps(cache_data)) positions = fresh_tracker._bootstrap_cache_if_missing() enriched = fresh_tracker._enrich_for_rendering("CVN-78", positions["CVN-78"]) # The position is preserved exactly. assert enriched["lat"] == 25.0 assert enriched["lng"] == 55.0 # But the live label has flipped to stale. assert enriched["position_confidence"] == "stale" assert enriched["is_fallback"] is True # --------------------------------------------------------------------------- # #245 — approximate confidence for region-centroid positions # --------------------------------------------------------------------------- class TestApproximateConfidenceForNewsDerivedPositions: def test_news_parsing_stamps_approximate_confidence(self, fresh_tracker): articles = [ { "title": "USS Ford carrier deployed in Mediterranean for joint exercise", "url": "https://news.example/ford-mediterranean", "seendate": "20260415120000", } ] updates = fresh_tracker._parse_carrier_positions_from_news(articles) assert "CVN-78" in updates entry = updates["CVN-78"] assert entry["position_confidence"] == "approximate" # And the source_at is the article's seen date, not now(). assert entry["position_source_at"].startswith("2026-04-15") def test_gdelt_seendate_parser_handles_well_formed_input(self, fresh_tracker): iso = fresh_tracker._gdelt_seendate_to_iso("20260415120000") assert iso is not None assert iso.startswith("2026-04-15T12:00:00") def test_gdelt_seendate_parser_returns_none_on_garbage(self, fresh_tracker): assert fresh_tracker._gdelt_seendate_to_iso("") is None assert fresh_tracker._gdelt_seendate_to_iso("not-a-date") is None assert fresh_tracker._gdelt_seendate_to_iso("2026") is None # --------------------------------------------------------------------------- # Full enrichment → public API shape # --------------------------------------------------------------------------- class TestEnrichForRendering: def test_seed_entry_produces_expected_public_fields(self, fresh_tracker): seed_entry = { "lat": 18.0, "lng": 39.5, "heading": 0, "desc": "Red Sea (USNI Mar 9)", "source": "USNI News Fleet & Marine Tracker (seed, as of 2026-03-09)", "source_url": "https://news.usni.org/category/fleet-tracker", "position_source_at": "2026-03-09T00:00:00Z", "position_confidence": "seed", } enriched = fresh_tracker._enrich_for_rendering("CVN-78", seed_entry) # Existing UI fields preserved. assert enriched["lat"] == 18.0 assert enriched["lng"] == 39.5 assert enriched["source"].startswith("USNI") assert enriched["last_osint_update"] == "2026-03-09T00:00:00Z" # New audit-required fields. assert enriched["position_confidence"] == "seed" assert enriched["position_source_at"] == "2026-03-09T00:00:00Z" assert enriched["is_fallback"] is True def test_recent_observation_is_not_fallback(self, fresh_tracker): now = datetime.now(timezone.utc) recent_entry = { "lat": 25.0, "lng": 55.0, "heading": 0, "desc": "Persian Gulf", "source": "GDELT News API", "source_url": "https://news.example/...", "position_source_at": (now - timedelta(days=2)).isoformat(), "position_confidence": "approximate", } enriched = fresh_tracker._enrich_for_rendering("CVN-78", recent_entry, now=now) assert enriched["position_confidence"] == "approximate" # Approximate (from a recent headline) is honest precision, but the UI # treats it as live data — is_fallback only flips True for explicit # fallback categories (seed / stale / homeport_default). assert enriched["is_fallback"] is False # --------------------------------------------------------------------------- # Regression: existing frontend fields are preserved # --------------------------------------------------------------------------- class TestPublicResponseShapeBackwardCompat: """The frontend ShipPopup expects `estimated`, `source`, `source_url`, `last_osint_update`. The new fields are additive and existing fields keep their meaning so the UI does not need updating to keep working.""" def test_get_carrier_positions_preserves_existing_keys(self, fresh_tracker): _write_seed(fresh_tracker.SEED_FILE) fresh_tracker._bootstrap_cache_if_missing() with fresh_tracker._positions_lock: fresh_tracker._carrier_positions.update( { "CVN-78": { "lat": 18.0, "lng": 39.5, "heading": 0, "desc": "Red Sea (seed)", "source": "Seed", "source_url": "", "position_source_at": "2026-03-09T00:00:00Z", "position_confidence": "seed", } } ) out = fresh_tracker.get_carrier_positions() assert len(out) == 1 c = out[0] # Old fields the frontend uses. for key in ( "name", "type", "lat", "lng", "country", "desc", "wiki", "estimated", "source", "source_url", "last_osint_update", ): assert key in c, f"missing legacy field {key!r}" # New fields. for key in ("position_confidence", "position_source_at", "is_fallback"): assert key in c, f"missing audit-required field {key!r}" assert c["type"] == "carrier" assert c["estimated"] is True