"""Tests for Helix-native close signals + task signal bindings (PR5).""" from __future__ import annotations import pytest from app.domains.finance import close_signals from app.domains.finance import close_task_service as svc from app.domains.finance.close_signal_keys import ( ALL_SIGNAL_KEYS, SIG_FX_FROZEN, SIG_RECON_CLEAN, ) @pytest.mark.asyncio async def test_templates_seed_signal_bindings(db_session): await svc.ensure_seeded(db_session) templates = {t.source_key: t for t in await svc.list_templates(db_session)} # Curated bindings land on the right tasks. assert templates["ap-16"].signal_key == SIG_RECON_CLEAN assert templates["rep-03"].signal_key == SIG_FX_FROZEN # Most tasks are unbound. assert templates["pay-02"].signal_key is None @pytest.mark.asyncio async def test_instances_carry_signal_key(db_session): await svc.instantiate_from_templates(db_session, "2026-06") tasks = {t.instance_key: t for t in await svc.list_tasks(db_session, "2026-06")} assert tasks["ap-16"].signal_key == SIG_RECON_CLEAN assert tasks["rep-03"].signal_key == SIG_FX_FROZEN @pytest.mark.asyncio async def test_evaluate_signals_shape(db_session): sigs = await close_signals.evaluate_signals(db_session, "2026-06") # Every registered signal is present with the right shape. assert set(sigs.keys()) == set(ALL_SIGNAL_KEYS) for key, sig in sigs.items(): assert sig["key"] == key assert sig["status"] in ("ok", "warn", "fail", "na") assert sig["evidence_link"].startswith("/admin/finance/") assert isinstance(sig["detail"], str) # With an empty test DB, FX has no rates → a warn, not a crash. assert sigs[SIG_FX_FROZEN]["status"] == "warn" @pytest.mark.asyncio async def test_signals_degrade_when_cockpit_raises(db_session, monkeypatch): """A cockpit-query failure must degrade the 5 cockpit signals to 'na', not propagate — signals are advisory and ride along with the task list.""" from app.domains.finance import read_service async def _boom(*a, **k): raise RuntimeError("cockpit exploded") monkeypatch.setattr(read_service, "close_cockpit", _boom) sigs = await close_signals.evaluate_signals(db_session, "2026-06") # Still returns every signal; the 5 cockpit-derived ones are 'na'. assert set(sigs.keys()) == set(ALL_SIGNAL_KEYS) assert sigs["recon_clean"]["status"] == "na" assert sigs["gl_synced"]["status"] == "na" # The independent finance-data signals still evaluated. assert sigs[SIG_FX_FROZEN]["status"] in ("ok", "warn") @pytest.mark.asyncio async def test_http_tasks_include_signals(client, finance_headers): await client.post("/api/v1/finance/close/restore-defaults", headers=finance_headers) await client.post("/api/v1/finance/close/runs/2026-06/instantiate", headers=finance_headers) r = await client.get("/api/v1/finance/close/runs/2026-06/tasks", headers=finance_headers) data = r.json()["data"] assert "signals" in data assert set(data["signals"].keys()) == set(ALL_SIGNAL_KEYS) # A bound task surfaces its signal_key. ap16 = next(t for t in data["tasks"] if t["template_source_key"] == "ap-16") assert ap16["signal_key"] == SIG_RECON_CLEAN # Dedicated signals endpoint too. r = await client.get("/api/v1/finance/close/runs/2026-06/signals", headers=finance_headers) assert set(r.json()["data"].keys()) == set(ALL_SIGNAL_KEYS)