mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-21 18:14:44 +02:00
fix(adapters): complete self-describing adapter attributes
- Replace settings_schema classmethod with Pydantic model class attribute - Add display_name, description, requires_api_key, wizard_order, default_cadence_s - Remove stream_name from adapters (JetStream routes by subject filter) - Define NWSSettings, FIRMSSettings, USGSQuakeSettings Pydantic models - Make discover_adapters() public with error handling - Move adapter registry to Supervisor instance (self._adapters) - Add subject_for tests for all 6 quake magnitude tiers - Fix test_supervisor_integration to use injected mock adapters Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4573bf6ee2
commit
4ee3d8bd14
7 changed files with 418 additions and 315 deletions
|
|
@ -200,76 +200,77 @@ class TestEnableDisableEnableIntegration:
|
|||
supervisor._js = mock_nats.jetstream()
|
||||
|
||||
# Patch NWSAdapter to use our mock
|
||||
with patch("central.supervisor.NWSAdapter", MockNWSAdapter):
|
||||
# Start supervisor (starts adapter)
|
||||
await supervisor._start_adapter(initial_config)
|
||||
# Inject mock adapter into supervisor's registry
|
||||
supervisor._adapters["nws"] = MockNWSAdapter
|
||||
# Start supervisor (starts adapter)
|
||||
await supervisor._start_adapter(initial_config)
|
||||
|
||||
state = supervisor._adapter_states.get("nws")
|
||||
assert state is not None
|
||||
assert adapter_is_running(state)
|
||||
state = supervisor._adapter_states.get("nws")
|
||||
assert state is not None
|
||||
assert adapter_is_running(state)
|
||||
|
||||
# Simulate completed poll 5 minutes ago
|
||||
state.last_completed_poll = datetime.now(timezone.utc) - timedelta(minutes=5)
|
||||
saved_last_poll = state.last_completed_poll
|
||||
# Simulate completed poll 5 minutes ago
|
||||
state.last_completed_poll = datetime.now(timezone.utc) - timedelta(minutes=5)
|
||||
saved_last_poll = state.last_completed_poll
|
||||
|
||||
# Disable adapter
|
||||
disabled_config = AdapterConfig(
|
||||
name="nws",
|
||||
enabled=False,
|
||||
cadence_s=60,
|
||||
settings={"states": ["ID"], "contact_email": "test@test.com"},
|
||||
paused_at=None,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
config_source.set_adapter(disabled_config)
|
||||
await supervisor._on_config_change("adapters", "nws")
|
||||
# Disable adapter
|
||||
disabled_config = AdapterConfig(
|
||||
name="nws",
|
||||
enabled=False,
|
||||
cadence_s=60,
|
||||
settings={"states": ["ID"], "contact_email": "test@test.com"},
|
||||
paused_at=None,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
config_source.set_adapter(disabled_config)
|
||||
await supervisor._on_config_change("adapters", "nws")
|
||||
|
||||
# Verify stopped but state preserved (THIS IS THE KEY CHECK)
|
||||
# On unfixed code, state will be NONE because pop() removes it
|
||||
# On fixed code, state still exists with is_running=False
|
||||
state = supervisor._adapter_states.get("nws")
|
||||
assert state is not None, (
|
||||
"State was removed on stop! This violates the rate-limit guarantee. "
|
||||
"State should be preserved to maintain last_completed_poll."
|
||||
)
|
||||
assert not adapter_is_running(state)
|
||||
assert state.last_completed_poll == saved_last_poll
|
||||
# Verify stopped but state preserved (THIS IS THE KEY CHECK)
|
||||
# On unfixed code, state will be NONE because pop() removes it
|
||||
# On fixed code, state still exists with is_running=False
|
||||
state = supervisor._adapter_states.get("nws")
|
||||
assert state is not None, (
|
||||
"State was removed on stop! This violates the rate-limit guarantee. "
|
||||
"State should be preserved to maintain last_completed_poll."
|
||||
)
|
||||
assert not adapter_is_running(state)
|
||||
assert state.last_completed_poll == saved_last_poll
|
||||
|
||||
# Re-enable adapter
|
||||
reenabled_config = AdapterConfig(
|
||||
name="nws",
|
||||
enabled=True,
|
||||
cadence_s=60,
|
||||
settings={"states": ["ID"], "contact_email": "test@test.com"},
|
||||
paused_at=None,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
config_source.set_adapter(reenabled_config)
|
||||
await supervisor._on_config_change("adapters", "nws")
|
||||
# Re-enable adapter
|
||||
reenabled_config = AdapterConfig(
|
||||
name="nws",
|
||||
enabled=True,
|
||||
cadence_s=60,
|
||||
settings={"states": ["ID"], "contact_email": "test@test.com"},
|
||||
paused_at=None,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
config_source.set_adapter(reenabled_config)
|
||||
await supervisor._on_config_change("adapters", "nws")
|
||||
|
||||
# Verify restarted with preserved last_completed_poll
|
||||
state = supervisor._adapter_states.get("nws")
|
||||
assert state is not None
|
||||
assert adapter_is_running(state)
|
||||
assert state.last_completed_poll == saved_last_poll
|
||||
# Verify restarted with preserved last_completed_poll
|
||||
state = supervisor._adapter_states.get("nws")
|
||||
assert state is not None
|
||||
assert adapter_is_running(state)
|
||||
assert state.last_completed_poll == saved_last_poll
|
||||
|
||||
# The loop should detect that last_poll + cadence is in the past
|
||||
# and poll immediately. Let's verify by checking the wait time logic.
|
||||
now = datetime.now(timezone.utc)
|
||||
next_poll_at = saved_last_poll.timestamp() + 60 # cadence = 60s
|
||||
wait_time = max(0, next_poll_at - now.timestamp())
|
||||
# The loop should detect that last_poll + cadence is in the past
|
||||
# and poll immediately. Let's verify by checking the wait time logic.
|
||||
now = datetime.now(timezone.utc)
|
||||
next_poll_at = saved_last_poll.timestamp() + 60 # cadence = 60s
|
||||
wait_time = max(0, next_poll_at - now.timestamp())
|
||||
|
||||
# last_poll was 5 minutes ago, cadence is 60s
|
||||
# next_poll_at = 5_minutes_ago + 60s = 4_minutes_ago
|
||||
# wait_time should be 0 (poll immediately)
|
||||
assert wait_time == 0, (
|
||||
f"Expected immediate poll (wait=0), got wait={wait_time}s. "
|
||||
f"last_poll was {saved_last_poll}, now is {now}"
|
||||
)
|
||||
# last_poll was 5 minutes ago, cadence is 60s
|
||||
# next_poll_at = 5_minutes_ago + 60s = 4_minutes_ago
|
||||
# wait_time should be 0 (poll immediately)
|
||||
assert wait_time == 0, (
|
||||
f"Expected immediate poll (wait=0), got wait={wait_time}s. "
|
||||
f"last_poll was {saved_last_poll}, now is {now}"
|
||||
)
|
||||
|
||||
# Cleanup
|
||||
supervisor._shutdown_event.set()
|
||||
await cleanup_adapter(supervisor, "nws")
|
||||
# Cleanup
|
||||
supervisor._shutdown_event.set()
|
||||
await cleanup_adapter(supervisor, "nws")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enable_disable_enable_gap_shorter_than_cadence(
|
||||
|
|
@ -308,75 +309,76 @@ class TestEnableDisableEnableIntegration:
|
|||
supervisor._nc = mock_nats
|
||||
supervisor._js = mock_nats.jetstream()
|
||||
|
||||
with patch("central.supervisor.NWSAdapter", MockNWSAdapter):
|
||||
# Start adapter
|
||||
await supervisor._start_adapter(initial_config)
|
||||
# Inject mock adapter into supervisor's registry
|
||||
supervisor._adapters["nws"] = MockNWSAdapter
|
||||
# Start adapter
|
||||
await supervisor._start_adapter(initial_config)
|
||||
|
||||
state = supervisor._adapter_states.get("nws")
|
||||
assert state is not None
|
||||
state = supervisor._adapter_states.get("nws")
|
||||
assert state is not None
|
||||
|
||||
# Simulate completed poll 10 seconds ago
|
||||
state.last_completed_poll = datetime.now(timezone.utc) - timedelta(seconds=10)
|
||||
saved_last_poll = state.last_completed_poll
|
||||
# Simulate completed poll 10 seconds ago
|
||||
state.last_completed_poll = datetime.now(timezone.utc) - timedelta(seconds=10)
|
||||
saved_last_poll = state.last_completed_poll
|
||||
|
||||
# Disable adapter
|
||||
disabled_config = AdapterConfig(
|
||||
name="nws",
|
||||
enabled=False,
|
||||
cadence_s=60,
|
||||
settings={"states": ["ID"], "contact_email": "test@test.com"},
|
||||
paused_at=None,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
config_source.set_adapter(disabled_config)
|
||||
await supervisor._on_config_change("adapters", "nws")
|
||||
# Disable adapter
|
||||
disabled_config = AdapterConfig(
|
||||
name="nws",
|
||||
enabled=False,
|
||||
cadence_s=60,
|
||||
settings={"states": ["ID"], "contact_email": "test@test.com"},
|
||||
paused_at=None,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
config_source.set_adapter(disabled_config)
|
||||
await supervisor._on_config_change("adapters", "nws")
|
||||
|
||||
# Verify stopped but state preserved (THIS IS THE KEY CHECK)
|
||||
# On unfixed code, state will be NONE because pop() removes it
|
||||
# On fixed code, state still exists with is_running=False
|
||||
state = supervisor._adapter_states.get("nws")
|
||||
assert state is not None, (
|
||||
"State was removed on stop! This violates the rate-limit guarantee. "
|
||||
"State should be preserved to maintain last_completed_poll."
|
||||
)
|
||||
assert not adapter_is_running(state)
|
||||
assert state.last_completed_poll == saved_last_poll
|
||||
# Verify stopped but state preserved (THIS IS THE KEY CHECK)
|
||||
# On unfixed code, state will be NONE because pop() removes it
|
||||
# On fixed code, state still exists with is_running=False
|
||||
state = supervisor._adapter_states.get("nws")
|
||||
assert state is not None, (
|
||||
"State was removed on stop! This violates the rate-limit guarantee. "
|
||||
"State should be preserved to maintain last_completed_poll."
|
||||
)
|
||||
assert not adapter_is_running(state)
|
||||
assert state.last_completed_poll == saved_last_poll
|
||||
|
||||
# Re-enable adapter (simulate 20 seconds later, but we're just
|
||||
# checking the rate limit logic)
|
||||
reenabled_config = AdapterConfig(
|
||||
name="nws",
|
||||
enabled=True,
|
||||
cadence_s=60,
|
||||
settings={"states": ["ID"], "contact_email": "test@test.com"},
|
||||
paused_at=None,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
config_source.set_adapter(reenabled_config)
|
||||
await supervisor._on_config_change("adapters", "nws")
|
||||
# Re-enable adapter (simulate 20 seconds later, but we're just
|
||||
# checking the rate limit logic)
|
||||
reenabled_config = AdapterConfig(
|
||||
name="nws",
|
||||
enabled=True,
|
||||
cadence_s=60,
|
||||
settings={"states": ["ID"], "contact_email": "test@test.com"},
|
||||
paused_at=None,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
config_source.set_adapter(reenabled_config)
|
||||
await supervisor._on_config_change("adapters", "nws")
|
||||
|
||||
# Verify restarted with preserved last_completed_poll
|
||||
state = supervisor._adapter_states.get("nws")
|
||||
assert state is not None
|
||||
assert adapter_is_running(state)
|
||||
assert state.last_completed_poll == saved_last_poll
|
||||
# Verify restarted with preserved last_completed_poll
|
||||
state = supervisor._adapter_states.get("nws")
|
||||
assert state is not None
|
||||
assert adapter_is_running(state)
|
||||
assert state.last_completed_poll == saved_last_poll
|
||||
|
||||
# The loop should detect that last_poll + cadence is still in the future
|
||||
# and wait until then.
|
||||
now = datetime.now(timezone.utc)
|
||||
next_poll_at = saved_last_poll.timestamp() + 60
|
||||
wait_time = max(0, next_poll_at - now.timestamp())
|
||||
# The loop should detect that last_poll + cadence is still in the future
|
||||
# and wait until then.
|
||||
now = datetime.now(timezone.utc)
|
||||
next_poll_at = saved_last_poll.timestamp() + 60
|
||||
wait_time = max(0, next_poll_at - now.timestamp())
|
||||
|
||||
# last_poll was ~10 seconds ago, cadence is 60s
|
||||
# wait_time should be ~50s (60 - 10 = 50)
|
||||
assert 45 < wait_time < 55, (
|
||||
f"Expected ~50s wait (respecting rate limit), got wait={wait_time}s. "
|
||||
f"Rate limit violated: poll would happen before last_poll + cadence"
|
||||
)
|
||||
# last_poll was ~10 seconds ago, cadence is 60s
|
||||
# wait_time should be ~50s (60 - 10 = 50)
|
||||
assert 45 < wait_time < 55, (
|
||||
f"Expected ~50s wait (respecting rate limit), got wait={wait_time}s. "
|
||||
f"Rate limit violated: poll would happen before last_poll + cadence"
|
||||
)
|
||||
|
||||
# Cleanup
|
||||
supervisor._shutdown_event.set()
|
||||
await cleanup_adapter(supervisor, "nws")
|
||||
# Cleanup
|
||||
supervisor._shutdown_event.set()
|
||||
await cleanup_adapter(supervisor, "nws")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enable_disable_delete_readd_fresh_state(
|
||||
|
|
@ -414,60 +416,61 @@ class TestEnableDisableEnableIntegration:
|
|||
supervisor._nc = mock_nats
|
||||
supervisor._js = mock_nats.jetstream()
|
||||
|
||||
with patch("central.supervisor.NWSAdapter", MockNWSAdapter):
|
||||
# Start adapter
|
||||
await supervisor._start_adapter(initial_config)
|
||||
# Inject mock adapter into supervisor's registry
|
||||
supervisor._adapters["nws"] = MockNWSAdapter
|
||||
# Start adapter
|
||||
await supervisor._start_adapter(initial_config)
|
||||
|
||||
state = supervisor._adapter_states.get("nws")
|
||||
assert state is not None
|
||||
state = supervisor._adapter_states.get("nws")
|
||||
assert state is not None
|
||||
|
||||
# Simulate completed poll 10 seconds ago
|
||||
state.last_completed_poll = datetime.now(timezone.utc) - timedelta(seconds=10)
|
||||
# Simulate completed poll 10 seconds ago
|
||||
state.last_completed_poll = datetime.now(timezone.utc) - timedelta(seconds=10)
|
||||
|
||||
# Disable adapter
|
||||
disabled_config = AdapterConfig(
|
||||
name="nws",
|
||||
enabled=False,
|
||||
cadence_s=60,
|
||||
settings={"states": ["ID"], "contact_email": "test@test.com"},
|
||||
paused_at=None,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
config_source.set_adapter(disabled_config)
|
||||
await supervisor._on_config_change("adapters", "nws")
|
||||
# Disable adapter
|
||||
disabled_config = AdapterConfig(
|
||||
name="nws",
|
||||
enabled=False,
|
||||
cadence_s=60,
|
||||
settings={"states": ["ID"], "contact_email": "test@test.com"},
|
||||
paused_at=None,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
config_source.set_adapter(disabled_config)
|
||||
await supervisor._on_config_change("adapters", "nws")
|
||||
|
||||
# DELETE adapter from DB (remove from config source)
|
||||
config_source.set_adapter(None, name="nws")
|
||||
await supervisor._on_config_change("adapters", "nws")
|
||||
# DELETE adapter from DB (remove from config source)
|
||||
config_source.set_adapter(None, name="nws")
|
||||
await supervisor._on_config_change("adapters", "nws")
|
||||
|
||||
# Verify adapter fully removed
|
||||
assert "nws" not in supervisor._adapter_states
|
||||
# Verify adapter fully removed
|
||||
assert "nws" not in supervisor._adapter_states
|
||||
|
||||
# Re-add adapter with same name
|
||||
new_config = AdapterConfig(
|
||||
name="nws",
|
||||
enabled=True,
|
||||
cadence_s=60,
|
||||
settings={"states": ["ID"], "contact_email": "test@test.com"},
|
||||
paused_at=None,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
config_source.set_adapter(new_config)
|
||||
await supervisor._on_config_change("adapters", "nws")
|
||||
# Re-add adapter with same name
|
||||
new_config = AdapterConfig(
|
||||
name="nws",
|
||||
enabled=True,
|
||||
cadence_s=60,
|
||||
settings={"states": ["ID"], "contact_email": "test@test.com"},
|
||||
paused_at=None,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
config_source.set_adapter(new_config)
|
||||
await supervisor._on_config_change("adapters", "nws")
|
||||
|
||||
# Verify new adapter started fresh
|
||||
state = supervisor._adapter_states.get("nws")
|
||||
assert state is not None
|
||||
assert adapter_is_running(state)
|
||||
# last_completed_poll should be None (fresh adapter)
|
||||
assert state.last_completed_poll is None, (
|
||||
f"Expected None (fresh adapter), got {state.last_completed_poll}. "
|
||||
f"Preserved state not cleared on delete."
|
||||
)
|
||||
# Verify new adapter started fresh
|
||||
state = supervisor._adapter_states.get("nws")
|
||||
assert state is not None
|
||||
assert adapter_is_running(state)
|
||||
# last_completed_poll should be None (fresh adapter)
|
||||
assert state.last_completed_poll is None, (
|
||||
f"Expected None (fresh adapter), got {state.last_completed_poll}. "
|
||||
f"Preserved state not cleared on delete."
|
||||
)
|
||||
|
||||
# Cleanup
|
||||
supervisor._shutdown_event.set()
|
||||
await cleanup_adapter(supervisor, "nws")
|
||||
# Cleanup
|
||||
supervisor._shutdown_event.set()
|
||||
await cleanup_adapter(supervisor, "nws")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_preserves_state_start_reuses_it(
|
||||
|
|
@ -497,34 +500,35 @@ class TestEnableDisableEnableIntegration:
|
|||
supervisor._nc = mock_nats
|
||||
supervisor._js = mock_nats.jetstream()
|
||||
|
||||
with patch("central.supervisor.NWSAdapter", MockNWSAdapter):
|
||||
# Start adapter
|
||||
await supervisor._start_adapter(config)
|
||||
# Inject mock adapter into supervisor's registry
|
||||
supervisor._adapters["nws"] = MockNWSAdapter
|
||||
# Start adapter
|
||||
await supervisor._start_adapter(config)
|
||||
|
||||
state = supervisor._adapter_states.get("nws")
|
||||
state.last_completed_poll = datetime.now(timezone.utc) - timedelta(seconds=30)
|
||||
saved_poll = state.last_completed_poll
|
||||
state = supervisor._adapter_states.get("nws")
|
||||
state.last_completed_poll = datetime.now(timezone.utc) - timedelta(seconds=30)
|
||||
saved_poll = state.last_completed_poll
|
||||
|
||||
# Stop adapter
|
||||
await supervisor._stop_adapter("nws")
|
||||
# Stop adapter
|
||||
await supervisor._stop_adapter("nws")
|
||||
|
||||
# State should still exist
|
||||
assert "nws" in supervisor._adapter_states
|
||||
state = supervisor._adapter_states["nws"]
|
||||
assert not adapter_is_running(state)
|
||||
assert state.last_completed_poll == saved_poll
|
||||
# State should still exist
|
||||
assert "nws" in supervisor._adapter_states
|
||||
state = supervisor._adapter_states["nws"]
|
||||
assert not adapter_is_running(state)
|
||||
assert state.last_completed_poll == saved_poll
|
||||
|
||||
# Restart adapter
|
||||
await supervisor._start_adapter(config)
|
||||
# Restart adapter
|
||||
await supervisor._start_adapter(config)
|
||||
|
||||
# Should reuse existing state
|
||||
state = supervisor._adapter_states.get("nws")
|
||||
assert adapter_is_running(state)
|
||||
assert state.last_completed_poll == saved_poll
|
||||
# Should reuse existing state
|
||||
state = supervisor._adapter_states.get("nws")
|
||||
assert adapter_is_running(state)
|
||||
assert state.last_completed_poll == saved_poll
|
||||
|
||||
# Cleanup
|
||||
supervisor._shutdown_event.set()
|
||||
await cleanup_adapter(supervisor, "nws")
|
||||
# Cleanup
|
||||
supervisor._shutdown_event.set()
|
||||
await cleanup_adapter(supervisor, "nws")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_adapter_clears_state(
|
||||
|
|
@ -554,14 +558,15 @@ class TestEnableDisableEnableIntegration:
|
|||
supervisor._nc = mock_nats
|
||||
supervisor._js = mock_nats.jetstream()
|
||||
|
||||
with patch("central.supervisor.NWSAdapter", MockNWSAdapter):
|
||||
await supervisor._start_adapter(config)
|
||||
# Inject mock adapter into supervisor's registry
|
||||
supervisor._adapters["nws"] = MockNWSAdapter
|
||||
await supervisor._start_adapter(config)
|
||||
|
||||
state = supervisor._adapter_states.get("nws")
|
||||
state.last_completed_poll = datetime.now(timezone.utc)
|
||||
state = supervisor._adapter_states.get("nws")
|
||||
state.last_completed_poll = datetime.now(timezone.utc)
|
||||
|
||||
# Remove adapter
|
||||
await cleanup_adapter(supervisor, "nws")
|
||||
# Remove adapter
|
||||
await cleanup_adapter(supervisor, "nws")
|
||||
|
||||
# State should be gone
|
||||
assert "nws" not in supervisor._adapter_states
|
||||
# State should be gone
|
||||
assert "nws" not in supervisor._adapter_states
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue