mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-22 02:24:38 +02:00
Compare commits
No commits in common. "main" and "v0.6.2" have entirely different histories.
6 changed files with 43 additions and 209 deletions
|
|
@ -1,7 +1,6 @@
|
||||||
"""Route handlers for Central GUI."""
|
"""Route handlers for Central GUI."""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import html
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
@ -2728,24 +2727,6 @@ def _parse_events_params(params) -> tuple[dict | None, str | None]:
|
||||||
}, None
|
}, None
|
||||||
|
|
||||||
|
|
||||||
def _derive_subject(event: dict) -> str | None:
|
|
||||||
"""Derive an event's plain-text subject for the JSON API.
|
|
||||||
|
|
||||||
Renders the same per-adapter ``_event_summaries/{adapter}.html`` partial
|
|
||||||
the /events table uses (falling back to ``_default.html``), so the JSON
|
|
||||||
subject carries the same human text as the GUI's Subject cell with no
|
|
||||||
duplicated derivation logic. The partials are HTML-autoescaped for the
|
|
||||||
table (e.g. ``>`` -> ``>``); we ``html.unescape`` so JSON consumers get
|
|
||||||
plain text. Returns ``None`` when the partial yields no text -- an unknown
|
|
||||||
adapter, or an event whose source fields don't support a subject (e.g. a
|
|
||||||
wfigs row with neither county nor state).
|
|
||||||
"""
|
|
||||||
template = _get_templates().env.select_template(
|
|
||||||
[f"_event_summaries/{event.get('adapter')}.html", "_event_summaries/_default.html"]
|
|
||||||
)
|
|
||||||
return html.unescape(template.render(event=event)).strip() or None
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
|
async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
|
||||||
"""
|
"""
|
||||||
Fetch events from database using parsed parameters.
|
Fetch events from database using parsed parameters.
|
||||||
|
|
@ -2813,6 +2794,7 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
|
||||||
received,
|
received,
|
||||||
adapter,
|
adapter,
|
||||||
category,
|
category,
|
||||||
|
payload->>'subject' as subject,
|
||||||
ST_AsGeoJSON(geom) as geometry,
|
ST_AsGeoJSON(geom) as geometry,
|
||||||
payload as data,
|
payload as data,
|
||||||
regions
|
regions
|
||||||
|
|
@ -2842,23 +2824,17 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
|
||||||
if row["geometry"]:
|
if row["geometry"]:
|
||||||
geometry = json.loads(row["geometry"])
|
geometry = json.loads(row["geometry"])
|
||||||
|
|
||||||
event = {
|
events.append({
|
||||||
"id": row["id"],
|
"id": row["id"],
|
||||||
"time": row["time"].isoformat(),
|
"time": row["time"].isoformat(),
|
||||||
"received": row["received"].isoformat(),
|
"received": row["received"].isoformat(),
|
||||||
"adapter": row["adapter"],
|
"adapter": row["adapter"],
|
||||||
"category": row["category"],
|
"category": row["category"],
|
||||||
|
"subject": row["subject"],
|
||||||
"geometry": geometry,
|
"geometry": geometry,
|
||||||
"data": dict(row["data"]) if row["data"] else {},
|
"data": dict(row["data"]) if row["data"] else {},
|
||||||
"regions": list(row["regions"]) if row["regions"] else [],
|
"regions": list(row["regions"]) if row["regions"] else [],
|
||||||
}
|
})
|
||||||
# Subject is derived from the inner adapter payload by rendering the
|
|
||||||
# same _event_summaries partial the /events table uses, so the JSON
|
|
||||||
# `subject` matches the GUI's Subject cell. (The CloudEvents envelope
|
|
||||||
# has no top-level `subject`; the old `payload->>'subject'` was always
|
|
||||||
# null for every consumer.)
|
|
||||||
event["subject"] = _derive_subject(event)
|
|
||||||
events.append(event)
|
|
||||||
|
|
||||||
# Build next_cursor if there are more results
|
# Build next_cursor if there are more results
|
||||||
next_cursor = None
|
next_cursor = None
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ from central.config_source import (
|
||||||
ConfigSource,
|
ConfigSource,
|
||||||
DbConfigSource,
|
DbConfigSource,
|
||||||
)
|
)
|
||||||
from central.bootstrap_config import get_settings
|
|
||||||
from central.crypto import KEY_SIZE, clear_key_cache
|
from central.crypto import KEY_SIZE, clear_key_cache
|
||||||
|
|
||||||
# Test database DSN
|
# Test database DSN
|
||||||
|
|
@ -32,20 +31,11 @@ def master_key_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch):
|
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""Configure master key path for all tests.
|
"""Configure master key path for all tests."""
|
||||||
|
clear_key_cache()
|
||||||
Clear get_settings (and the crypto key cache) AFTER setting the env so
|
|
||||||
crypto rebuilds from the test key regardless of suite order, and again on
|
|
||||||
teardown so the test key never leaks into a later test. See PR M-b.
|
|
||||||
"""
|
|
||||||
monkeypatch.setenv("CENTRAL_DB_DSN", TEST_DB_DSN)
|
monkeypatch.setenv("CENTRAL_DB_DSN", TEST_DB_DSN)
|
||||||
monkeypatch.setenv("CENTRAL_MASTER_KEY_PATH", str(master_key_path))
|
monkeypatch.setenv("CENTRAL_MASTER_KEY_PATH", str(master_key_path))
|
||||||
clear_key_cache()
|
|
||||||
get_settings.cache_clear()
|
|
||||||
yield
|
|
||||||
clear_key_cache()
|
|
||||||
get_settings.cache_clear()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import asyncpg
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
|
||||||
from central.bootstrap_config import get_settings
|
|
||||||
from central.config_store import ConfigStore
|
from central.config_store import ConfigStore
|
||||||
from central.crypto import KEY_SIZE, clear_key_cache
|
from central.crypto import KEY_SIZE, clear_key_cache
|
||||||
|
|
||||||
|
|
@ -35,24 +34,12 @@ def master_key_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch):
|
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""Configure master key path for all tests.
|
"""Configure master key path for all tests."""
|
||||||
|
clear_key_cache()
|
||||||
CENTRAL_MASTER_KEY_PATH feeds Settings, which get_settings() lru-caches. An
|
|
||||||
earlier test can warm that cache with the default /etc/central/master.key
|
|
||||||
before this fixture runs, so the env change alone is not enough — clear
|
|
||||||
get_settings (and the crypto key cache) AFTER setting the env so crypto
|
|
||||||
rebuilds from the test key regardless of suite order, and again on teardown
|
|
||||||
so the test key never leaks into a later test.
|
|
||||||
"""
|
|
||||||
monkeypatch.setenv("CENTRAL_DB_DSN", TEST_DB_DSN)
|
monkeypatch.setenv("CENTRAL_DB_DSN", TEST_DB_DSN)
|
||||||
monkeypatch.setenv("CENTRAL_MASTER_KEY_PATH", str(master_key_path))
|
monkeypatch.setenv("CENTRAL_MASTER_KEY_PATH", str(master_key_path))
|
||||||
monkeypatch.setenv("CENTRAL_CSRF_SECRET", "test-csrf-secret-for-testing-only-32chars")
|
monkeypatch.setenv("CENTRAL_CSRF_SECRET", "test-csrf-secret-for-testing-only-32chars")
|
||||||
clear_key_cache()
|
|
||||||
get_settings.cache_clear()
|
|
||||||
yield
|
|
||||||
clear_key_cache()
|
|
||||||
get_settings.cache_clear()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
|
|
@ -351,13 +338,3 @@ class TestListenerReconnect:
|
||||||
pytest.fail("Listener did not stop after cancellation")
|
pytest.fail("Listener did not stop after cancellation")
|
||||||
|
|
||||||
assert listen_task.cancelled() or listen_task.done()
|
assert listen_task.cancelled() or listen_task.done()
|
||||||
|
|
||||||
|
|
||||||
def test_master_key_path_is_isolated(master_key_path: Path) -> None:
|
|
||||||
"""Contract: after setup_master_key runs, get_settings() resolves the master
|
|
||||||
key to the per-session test key — never the production /etc/central path —
|
|
||||||
regardless of suite order. Fails on the pre-fix code in a full-suite run
|
|
||||||
where get_settings was warmed with the default path by an earlier test.
|
|
||||||
"""
|
|
||||||
assert get_settings().master_key_path == master_key_path
|
|
||||||
assert get_settings().master_key_path != Path("/etc/central/master.key")
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
"""Tests for events feed frontend routes."""
|
"""Tests for events feed frontend routes."""
|
||||||
|
|
||||||
import html
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from central.adapter_discovery import discover_adapters
|
from central.gui.routes import events_list, events_rows, events_json
|
||||||
from central.gui import templates as _gui_templates
|
|
||||||
from central.gui.routes import events_list, events_rows, events_json, _derive_subject
|
|
||||||
|
|
||||||
|
|
||||||
class TestEventsFeedFrontendAuthenticated:
|
class TestEventsFeedFrontendAuthenticated:
|
||||||
|
|
@ -686,11 +683,7 @@ class TestEventRowDataAttributes:
|
||||||
assert len(context["events"]) == 1
|
assert len(context["events"]) == 1
|
||||||
assert context["events"][0]["adapter"] == "usgs_quake"
|
assert context["events"][0]["adapter"] == "usgs_quake"
|
||||||
assert context["events"][0]["category"] == "quake.event"
|
assert context["events"][0]["category"] == "quake.event"
|
||||||
# `subject` is now derived from the inner payload (rendered partial),
|
assert context["events"][0]["subject"] == "M4.2 Earthquake"
|
||||||
# not a DB pass-through, so the mock's input value is no longer echoed;
|
|
||||||
# just confirm the field is present. See TestEventsJsonSubject for the
|
|
||||||
# derivation contract.
|
|
||||||
assert "subject" in context["events"][0]
|
|
||||||
|
|
||||||
|
|
||||||
# --- PR L-b: operator /events tab polish ---------------------------------
|
# --- PR L-b: operator /events tab polish ---------------------------------
|
||||||
|
|
@ -1092,106 +1085,3 @@ class TestTableRendersThroughHTTP:
|
||||||
cells = _first_row_cells(resp.text)
|
cells = _first_row_cells(resp.text)
|
||||||
assert cells[1].endswith("UTC") and "2026" in cells[1] # Time non-empty
|
assert cells[1].endswith("UTC") and "2026" in cells[1] # Time non-empty
|
||||||
assert cells[4] == self._expected_adapter_display() # Adapter display_name
|
assert cells[4] == self._expected_adapter_display() # Adapter display_name
|
||||||
|
|
||||||
|
|
||||||
# --- feat(events-json-subject): JSON subject derivation ------------------
|
|
||||||
|
|
||||||
# Representative inner adapter payloads (payload->'data'->'data'), captured from
|
|
||||||
# production -- one per registered adapter. Keyed by adapter name so the
|
|
||||||
# coverage test below fails loudly if a new adapter ships without a sample.
|
|
||||||
_SAMPLE_INNER = {
|
|
||||||
"eonet": {"title": "Kress Wildfire, Swisher, Texas"},
|
|
||||||
"firms": {"frp": 0.34, "confidence": "nominal"},
|
|
||||||
"gdacs": {"title": "Green flood alert in Austria", "alertlevel": "Green"},
|
|
||||||
"inciweb": {"title": "MTHLF Jericho Creek"},
|
|
||||||
"nwis": {"value": 93.2, "unit_of_measure": "ft^3/s"},
|
|
||||||
"nws": {"event": "Special Weather Statement", "severity": "Moderate"},
|
|
||||||
"swpc_alerts": {
|
|
||||||
"product_id": "EF3A",
|
|
||||||
"message": (
|
|
||||||
"Space Weather Message Code: ALTEF3\r\nSerial Number: 3691\r\n"
|
|
||||||
"Issue Time: 2026 May 21 0509 UTC\r\n\r\nCONTINUED ALERT: "
|
|
||||||
"Electron 2MeV Integral Flux exceeded 1000pfu"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"swpc_kindex": {"Kp": 1.0},
|
|
||||||
"swpc_protons": {"flux": 15.06399917602539, "energy": ">=1 MeV"},
|
|
||||||
"usgs_quake": {"magnitude": 1.009682538298, "place": "17 km W of Searles Valley, CA"},
|
|
||||||
"wfigs_incidents": {"county": "Montezuma", "state": "CO"},
|
|
||||||
"wfigs_perimeters": {"county": "Carbon", "state": "MT"},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Exact expected subjects for the deterministic adapters. swpc_alerts is omitted
|
|
||||||
# (its message runs through Jinja's truncate(80)) and is checked separately.
|
|
||||||
# swpc_protons expects unescaped ">=" -- _derive_subject html.unescapes the
|
|
||||||
# autoescaped partial output so JSON consumers get plain text.
|
|
||||||
_EXPECTED_SUBJECT = {
|
|
||||||
"eonet": "Kress Wildfire, Swisher, Texas",
|
|
||||||
"firms": "Fire detected — 0.34 MW radiative power",
|
|
||||||
"gdacs": "Green flood alert in Austria — Green alert",
|
|
||||||
"inciweb": "MTHLF Jericho Creek",
|
|
||||||
"nwis": "Water reading: 93.2 ft^3/s",
|
|
||||||
"nws": "Special Weather Statement — Moderate",
|
|
||||||
"swpc_kindex": "Geomagnetic activity (Kp index): 1.0",
|
|
||||||
"swpc_protons": "Solar proton flux: 15.06 pfu at >=1 MeV",
|
|
||||||
"usgs_quake": "Magnitude 1.0 — 17 km W of Searles Valley, CA",
|
|
||||||
"wfigs_incidents": "Wildfire incident — Montezuma, CO",
|
|
||||||
"wfigs_perimeters": "Wildfire perimeter — Carbon, MT",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _subject_event(adapter: str, inner: dict) -> dict:
|
|
||||||
"""Build a minimal event dict shaped like _fetch_events output."""
|
|
||||||
return {"adapter": adapter, "data": {"data": {"data": inner}}}
|
|
||||||
|
|
||||||
|
|
||||||
class TestEventsJsonSubject:
|
|
||||||
"""/events.json `subject` is derived from the inner payload and carries the
|
|
||||||
same human text as the GUI's per-adapter Subject cell (feat/events-json-subject).
|
|
||||||
|
|
||||||
The old `payload->>'subject'` SQL was always null (the CloudEvents envelope
|
|
||||||
has no top-level subject). Parameterized over discover_adapters() -- no
|
|
||||||
hardcoded adapter list.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_sample_covers_every_registered_adapter(self):
|
|
||||||
"""No hardcoded list: samples must track the live registry exactly."""
|
|
||||||
assert set(_SAMPLE_INNER) == set(discover_adapters())
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("adapter", sorted(discover_adapters()))
|
|
||||||
def test_subject_non_null_per_adapter(self, adapter):
|
|
||||||
"""Every registered adapter derives a non-null subject for a real event."""
|
|
||||||
event = _subject_event(adapter, _SAMPLE_INNER[adapter])
|
|
||||||
assert _derive_subject(event) is not None
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("adapter", sorted(discover_adapters()))
|
|
||||||
def test_subject_matches_rendered_partial(self, adapter):
|
|
||||||
"""Derived subject equals the adapter's own partial (unescaped) -- the
|
|
||||||
JSON path and the GUI Subject cell never diverge."""
|
|
||||||
event = _subject_event(adapter, _SAMPLE_INNER[adapter])
|
|
||||||
oracle = html.unescape(
|
|
||||||
_gui_templates.env.get_template(f"_event_summaries/{adapter}.html").render(event=event)
|
|
||||||
).strip()
|
|
||||||
assert _derive_subject(event) == oracle
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("adapter", sorted(_EXPECTED_SUBJECT))
|
|
||||||
def test_subject_exact_human_text(self, adapter):
|
|
||||||
"""Pin the human-readable subject for the deterministic adapters."""
|
|
||||||
event = _subject_event(adapter, _SAMPLE_INNER[adapter])
|
|
||||||
assert _derive_subject(event) == _EXPECTED_SUBJECT[adapter]
|
|
||||||
|
|
||||||
def test_swpc_alerts_prefixes_id_and_truncates_message(self):
|
|
||||||
"""swpc_alerts subject prefixes the product id and truncates the body."""
|
|
||||||
event = _subject_event("swpc_alerts", _SAMPLE_INNER["swpc_alerts"])
|
|
||||||
subject = _derive_subject(event)
|
|
||||||
assert subject is not None
|
|
||||||
assert subject.startswith("Space weather alert EF3A: ")
|
|
||||||
assert subject.endswith("...")
|
|
||||||
|
|
||||||
def test_unknown_adapter_yields_none(self):
|
|
||||||
"""Unknown adapters fall back to _default.html -> no subject."""
|
|
||||||
assert _derive_subject(_subject_event("does_not_exist", {"x": 1})) is None
|
|
||||||
|
|
||||||
def test_missing_source_fields_yields_none(self):
|
|
||||||
"""An event lacking its adapter's source fields derives no subject."""
|
|
||||||
assert _derive_subject(_subject_event("usgs_quake", {})) is None
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import base64
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -14,7 +14,6 @@ import pytest_asyncio
|
||||||
from central.config_models import AdapterConfig
|
from central.config_models import AdapterConfig
|
||||||
from central.config_source import DbConfigSource
|
from central.config_source import DbConfigSource
|
||||||
from central.config_store import ConfigStore
|
from central.config_store import ConfigStore
|
||||||
from central.bootstrap_config import get_settings
|
|
||||||
from central.crypto import KEY_SIZE, clear_key_cache
|
from central.crypto import KEY_SIZE, clear_key_cache
|
||||||
|
|
||||||
# Test database DSN
|
# Test database DSN
|
||||||
|
|
@ -34,20 +33,11 @@ def master_key_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch):
|
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""Configure master key path for all tests.
|
"""Configure master key path for all tests."""
|
||||||
|
clear_key_cache()
|
||||||
Clear get_settings (and the crypto key cache) AFTER setting the env so
|
|
||||||
crypto rebuilds from the test key regardless of suite order, and again on
|
|
||||||
teardown so the test key never leaks into a later test. See PR M-b.
|
|
||||||
"""
|
|
||||||
monkeypatch.setenv("CENTRAL_DB_DSN", TEST_DB_DSN)
|
monkeypatch.setenv("CENTRAL_DB_DSN", TEST_DB_DSN)
|
||||||
monkeypatch.setenv("CENTRAL_MASTER_KEY_PATH", str(master_key_path))
|
monkeypatch.setenv("CENTRAL_MASTER_KEY_PATH", str(master_key_path))
|
||||||
clear_key_cache()
|
|
||||||
get_settings.cache_clear()
|
|
||||||
yield
|
|
||||||
clear_key_cache()
|
|
||||||
get_settings.cache_clear()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
|
|
@ -206,7 +196,10 @@ class TestRateLimitGuarantee:
|
||||||
state.config = new_config
|
state.config = new_config
|
||||||
state.adapter.cadence_s = 90
|
state.adapter.cadence_s = 90
|
||||||
|
|
||||||
|
# Calculate expected next poll time
|
||||||
|
expected_next_poll = last_poll + timedelta(seconds=90)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
expected_wait = max(0, (expected_next_poll - now).total_seconds())
|
||||||
|
|
||||||
# The wait time should be based on last_poll + new_cadence
|
# The wait time should be based on last_poll + new_cadence
|
||||||
# Since last_poll was 30 seconds ago and new cadence is 90,
|
# Since last_poll was 30 seconds ago and new cadence is 90,
|
||||||
|
|
@ -227,6 +220,7 @@ class TestRateLimitGuarantee:
|
||||||
If operator increases cadence to 120s after a gap of 150s,
|
If operator increases cadence to 120s after a gap of 150s,
|
||||||
the poll should happen now (not wait another 120s).
|
the poll should happen now (not wait another 120s).
|
||||||
"""
|
"""
|
||||||
|
from central.supervisor import AdapterState
|
||||||
|
|
||||||
mock_adapter = MagicMock()
|
mock_adapter = MagicMock()
|
||||||
mock_adapter.name = "test"
|
mock_adapter.name = "test"
|
||||||
|
|
@ -244,6 +238,13 @@ class TestRateLimitGuarantee:
|
||||||
updated_at=datetime.now(timezone.utc),
|
updated_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
state = AdapterState(
|
||||||
|
name="test",
|
||||||
|
adapter=mock_adapter,
|
||||||
|
config=config,
|
||||||
|
last_completed_poll=last_poll,
|
||||||
|
)
|
||||||
|
|
||||||
# Calculate next poll time
|
# Calculate next poll time
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
next_poll_at = last_poll.timestamp() + config.cadence_s
|
next_poll_at = last_poll.timestamp() + config.cadence_s
|
||||||
|
|
@ -263,6 +264,7 @@ class TestRateLimitGuarantee:
|
||||||
poll should be at (last_completed_poll + cadence_s), not immediately
|
poll should be at (last_completed_poll + cadence_s), not immediately
|
||||||
(unless that time has already passed).
|
(unless that time has already passed).
|
||||||
"""
|
"""
|
||||||
|
from central.supervisor import AdapterState
|
||||||
|
|
||||||
mock_adapter = MagicMock()
|
mock_adapter = MagicMock()
|
||||||
mock_adapter.name = "test"
|
mock_adapter.name = "test"
|
||||||
|
|
@ -281,6 +283,13 @@ class TestRateLimitGuarantee:
|
||||||
updated_at=datetime.now(timezone.utc),
|
updated_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
state = AdapterState(
|
||||||
|
name="test",
|
||||||
|
adapter=mock_adapter,
|
||||||
|
config=config,
|
||||||
|
last_completed_poll=last_poll,
|
||||||
|
)
|
||||||
|
|
||||||
# Calculate next poll time
|
# Calculate next poll time
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
next_poll_at = last_poll.timestamp() + config.cadence_s
|
next_poll_at = last_poll.timestamp() + config.cadence_s
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,17 @@ IMPORTANT: These tests are designed to:
|
||||||
- PASS on fixed code (last_completed_poll is preserved across disable/enable)
|
- PASS on fixed code (last_completed_poll is preserved across disable/enable)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
from central.config_models import AdapterConfig
|
from central.config_models import AdapterConfig
|
||||||
from central.bootstrap_config import get_settings
|
|
||||||
from central.crypto import KEY_SIZE, clear_key_cache
|
from central.crypto import KEY_SIZE, clear_key_cache
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -55,20 +56,11 @@ def master_key_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch):
|
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""Configure master key path for all tests.
|
"""Configure master key path for all tests."""
|
||||||
|
clear_key_cache()
|
||||||
Clear get_settings (and the crypto key cache) AFTER setting the env so
|
|
||||||
crypto rebuilds from the test key regardless of suite order, and again on
|
|
||||||
teardown so the test key never leaks into a later test. See PR M-b.
|
|
||||||
"""
|
|
||||||
monkeypatch.setenv("CENTRAL_DB_DSN", TEST_DB_DSN)
|
monkeypatch.setenv("CENTRAL_DB_DSN", TEST_DB_DSN)
|
||||||
monkeypatch.setenv("CENTRAL_MASTER_KEY_PATH", str(master_key_path))
|
monkeypatch.setenv("CENTRAL_MASTER_KEY_PATH", str(master_key_path))
|
||||||
clear_key_cache()
|
|
||||||
get_settings.cache_clear()
|
|
||||||
yield
|
|
||||||
clear_key_cache()
|
|
||||||
get_settings.cache_clear()
|
|
||||||
|
|
||||||
|
|
||||||
class MockConfigSource:
|
class MockConfigSource:
|
||||||
|
|
@ -193,7 +185,7 @@ class TestEnableDisableEnableIntegration:
|
||||||
- Assert next poll fires immediately (last+cadence is in past)
|
- Assert next poll fires immediately (last+cadence is in past)
|
||||||
- Assert exactly ONE poll happens, not multiple catch-up
|
- Assert exactly ONE poll happens, not multiple catch-up
|
||||||
"""
|
"""
|
||||||
from central.supervisor import Supervisor
|
from central.supervisor import Supervisor, AdapterState
|
||||||
|
|
||||||
config_source = MockConfigSource()
|
config_source = MockConfigSource()
|
||||||
initial_config = AdapterConfig(
|
initial_config = AdapterConfig(
|
||||||
|
|
@ -304,7 +296,7 @@ class TestEnableDisableEnableIntegration:
|
||||||
- Re-enable adapter 20 seconds later (still within cadence window)
|
- Re-enable adapter 20 seconds later (still within cadence window)
|
||||||
- Assert next poll fires at last_poll + 60s, NOT immediately
|
- Assert next poll fires at last_poll + 60s, NOT immediately
|
||||||
"""
|
"""
|
||||||
from central.supervisor import Supervisor
|
from central.supervisor import Supervisor, AdapterState
|
||||||
|
|
||||||
config_source = MockConfigSource()
|
config_source = MockConfigSource()
|
||||||
initial_config = AdapterConfig(
|
initial_config = AdapterConfig(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue