from datetime import datetime, timedelta, timezone from inspect import getsource from types import SimpleNamespace import pytest import app.opportunity_next_action_service as service from app.admin_ui.pages.opportunities import _opportunity_lifecycle_state from app.domain.opportunity_flow.v2 import EffectiveOperationalDecision, suppress_duplicate_representation class Decision: def to_dict(self): return {"action_code": "V1_ACTION", "label": "V1", "description": "legacy"} def test_compare_mode_returns_v1_behavior(monkeypatch, caplog): caplog.set_level("INFO") monkeypatch.setattr(service.settings, "blif_flow_v2_mode", "compare") monkeypatch.setattr(service, "_build_db_evidence", lambda *args, **kwargs: SimpleNamespace(company_profile="blif")) monkeypatch.setattr(service, "load_company_profile", lambda *_: object()) monkeypatch.setattr(service, "decide_opportunity_next_action", lambda *_: Decision()) monkeypatch.setattr(service, "_load_v2_comparison_rows", lambda ids: { "opp": {"business_state": "COMPLETED", "business_next_action": None, "reason_code": "BUSINESS_TRANSITION"} }) assert service.get_opportunity_next_action("opp", preloaded={})["action_code"] == "V1_ACTION" assert "blif_flow_v2_compare" in caplog.text def test_compare_mode_has_no_business_mutation_sql(): source = getsource(service._load_v2_comparison_rows).upper() + getsource(service._observe_flow_v2).upper() assert "UPDATE " not in source assert "INSERT " not in source assert "DELETE " not in source def test_stale_legacy_stage_cannot_override_v2_factual_state(): rows = service.compare_v1_v2_decisions( {"opp": {"action_code": "CREATE_JASMIN_QUOTE"}}, {"opp": {"business_state": "PROFORMA_REQUIRED", "business_next_action": "CREATE_PROFORMA", "reason_code": "BUSINESS_TRANSITION"}}, ) assert rows[0]["v2_business_state"] == "PROFORMA_REQUIRED" assert rows[0]["returned_source"] == "v1" def test_historical_next_followup_without_active_task_creates_no_work(): assert _opportunity_lifecycle_state({ "lifecycle_state": "active", "next_follow_up_at": "2020-01-01T00:00:00+00:00", "pending_follow_up_action_code": None, }) == "active" def test_due_active_followup_still_creates_work(): assert _opportunity_lifecycle_state({ "lifecycle_state": "awaiting_customer", "next_follow_up_at": "2020-01-01T00:00:00+00:00", "pending_follow_up_action_code": "FOLLOW_UP_CUSTOMER_REVIEW", "pending_follow_up_due_at": datetime.now(timezone.utc) - timedelta(days=1), }) == "follow_up_due" def test_future_active_followup_remains_scheduled(): assert _opportunity_lifecycle_state({ "lifecycle_state": "awaiting_customer", "pending_follow_up_action_code": "FOLLOW_UP_CUSTOMER_REVIEW", "pending_follow_up_due_at": datetime.now(timezone.utc) + timedelta(days=1), }) == "scheduled_follow_up" def test_duplicate_representation_stays_suppressed(): raw = EffectiveOperationalDecision("REVIEW_REQUIRED", "REVIEW_REQUIRED", "REVIEW_REQUIRED", "review", "review", "medium", "business_transition") suppressed = suppress_duplicate_representation(raw, canonical_process_id="canonical") assert suppressed.effective_operational_action is None assert suppressed.effective_operational_queue == "not_current" def test_authoritative_mode_is_fail_closed(monkeypatch): monkeypatch.setattr(service.settings, "blif_flow_v2_mode", "authoritative") with pytest.raises(RuntimeError, match="disabled"): service.get_opportunity_next_actions([]) def test_shadow_mode_has_no_decision_side_effect(monkeypatch): monkeypatch.setattr(service.settings, "blif_flow_v2_mode", "shadow") monkeypatch.setattr(service, "_load_v2_comparison_rows", lambda ids: pytest.fail("shadow must not read comparison projection")) service._observe_flow_v2({"opp": {"action_code": "V1"}})