168 lines
7.0 KiB
Python
168 lines
7.0 KiB
Python
from argparse import Namespace
|
|
import importlib.util
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
PATH = Path("scripts/simulate_blif_flow_v2_authoritative.py")
|
|
SPEC = importlib.util.spec_from_file_location("authoritative_simulation", PATH)
|
|
sim = importlib.util.module_from_spec(SPEC)
|
|
SPEC.loader.exec_module(sim)
|
|
|
|
|
|
class Result:
|
|
def __init__(self, row): self.row = row
|
|
def mappings(self): return self
|
|
def one(self): return self.row
|
|
def all(self): return self.row if isinstance(self.row, list) else [self.row]
|
|
|
|
|
|
class Connection:
|
|
def __init__(self, database="clientflow", readonly="on"):
|
|
self.database, self.readonly, self.sql = database, readonly, []
|
|
def exec_driver_sql(self, sql):
|
|
self.sql.append(sql.strip())
|
|
if "current_database" in sql:
|
|
return Result({"database": self.database, "user": "audit", "transaction_read_only": self.readonly})
|
|
if "count(*) FROM opportunities" in sql:
|
|
return Result({"opportunities": 328, "projections": 328})
|
|
if "GROUP BY business_state" in sql:
|
|
return Result([])
|
|
return Result({})
|
|
def close(self): pass
|
|
|
|
|
|
class Engine:
|
|
def __init__(self, connection): self.connection = connection
|
|
def connect(self): return self.connection
|
|
|
|
|
|
def named_decisions(fail=False):
|
|
result = {}
|
|
for name, (oid, business_action, action, queue) in sim.NAMED.items():
|
|
result[oid] = {"business_next_action": business_action, "effective_action": action,
|
|
"operational_queue": "not_current" if queue == "suppressed" else queue,
|
|
"suppress_current_card": queue == "suppressed"}
|
|
if fail:
|
|
result[sim.NAMED["Instalbeira"][0]]["effective_action"] = "SEND_INVOICE"
|
|
return result
|
|
|
|
|
|
def runtime(connection, *, fail_named=False, modes=None):
|
|
empty = {"work_items": [], "waiting_items": [], "backlog_items": [], "not_current_items": []}
|
|
def operations(**kwargs):
|
|
assert kwargs["connection"] is connection
|
|
if modes is not None:
|
|
modes.append(kwargs["flow_mode"])
|
|
return empty
|
|
def decisions(ids, **kwargs):
|
|
assert kwargs == {"flow_mode": "authoritative", "connection": connection}
|
|
return named_decisions(fail_named)
|
|
return lambda: (Engine(connection), operations, decisions)
|
|
|
|
|
|
def test_help_performs_zero_db_work():
|
|
called = False
|
|
def loader():
|
|
nonlocal called
|
|
called = True
|
|
raise AssertionError("DB/application runtime must not load")
|
|
with pytest.raises(SystemExit) as exc:
|
|
sim.main(["--help"], runtime_loader=loader)
|
|
assert exc.value.code == 0 and called is False
|
|
|
|
|
|
def test_production_requires_explicit_flag(monkeypatch):
|
|
monkeypatch.setenv("BLIF_FLOW_V2_MODE", "compare")
|
|
with pytest.raises(RuntimeError, match="requires --production"):
|
|
sim.run(Namespace(production_readonly_simulation=False), runtime_loader=runtime(Connection()))
|
|
|
|
|
|
@pytest.mark.parametrize("database,readonly,mode,message", [
|
|
("test_db", "on", "compare", "current_database"),
|
|
("clientflow", "off", "compare", "transaction_read_only"),
|
|
("clientflow", "on", None, "explicit BLIF_FLOW"),
|
|
("clientflow", "on", "off", "BLIF_FLOW_V2_MODE=compare"),
|
|
("clientflow", "on", "shadow", "BLIF_FLOW_V2_MODE=compare"),
|
|
("clientflow", "on", "authoritative", "BLIF_FLOW_V2_MODE=compare"),
|
|
])
|
|
def test_production_guards(monkeypatch, database, readonly, mode, message):
|
|
if mode is None:
|
|
monkeypatch.delenv("BLIF_FLOW_V2_MODE", raising=False)
|
|
else:
|
|
monkeypatch.setenv("BLIF_FLOW_V2_MODE", mode)
|
|
with pytest.raises(RuntimeError, match=message):
|
|
sim.run(Namespace(production_readonly_simulation=True),
|
|
runtime_loader=runtime(Connection(database, readonly)))
|
|
|
|
|
|
def test_safe_simulation_uses_explicit_modes_without_changing_config_and_has_no_db_writes(monkeypatch, tmp_path):
|
|
monkeypatch.setenv("BLIF_FLOW_V2_MODE", "compare")
|
|
monkeypatch.setattr(sim, "JSON_PATH", tmp_path / "report.json")
|
|
monkeypatch.setattr(sim, "TEXT_PATH", tmp_path / "report.txt")
|
|
connection = Connection()
|
|
modes = []
|
|
report = sim.run(Namespace(production_readonly_simulation=True), runtime_loader=runtime(connection, modes=modes))
|
|
assert report["configured_mode"] == "compare"
|
|
assert sim.exit_code(report) == 0
|
|
assert [sql for sql in connection.sql if sql.split(None, 1)[0].upper() in {"INSERT", "UPDATE", "DELETE", "MERGE"}] == []
|
|
assert connection.sql[0] == "BEGIN READ ONLY" and connection.sql[-1] == "ROLLBACK"
|
|
assert modes == ["off", "authoritative"]
|
|
|
|
|
|
def safe_report():
|
|
return sim.build_report(identity={}, configured_mode="compare",
|
|
v1={"work_items": []}, v2={"work_items": []},
|
|
named_decisions=named_decisions(), missing_projections=0)
|
|
|
|
|
|
def test_unsafe_false_negative_is_nonzero():
|
|
report = safe_report()
|
|
report["unsafe_false_negatives"] = 1
|
|
assert sim.exit_code(report) != 0
|
|
|
|
|
|
def test_missing_projection_is_nonzero():
|
|
report = safe_report()
|
|
report["missing_projections"] = 1
|
|
assert sim.exit_code(report) != 0
|
|
|
|
|
|
def test_duplicate_current_card_is_nonzero():
|
|
report = safe_report()
|
|
report["duplicate_current_cards"] = 1
|
|
assert sim.exit_code(report) != 0
|
|
|
|
|
|
def test_named_case_failure_is_nonzero():
|
|
report = sim.build_report(identity={}, configured_mode="compare", v1={"work_items": []},
|
|
v2={"work_items": []}, named_decisions=named_decisions(True), missing_projections=0)
|
|
assert sim.exit_code(report) != 0
|
|
|
|
|
|
def test_action_changes_correlate_by_material_process_not_action_key():
|
|
v1_item = {"work_item_key": "opportunity:o:action:OLD", "opportunity_id": "o",
|
|
"action_code": "OLD", "operational_queue": "do_now"}
|
|
v2_item = {"work_item_key": "opportunity:o:action:NEW", "opportunity_id": "o",
|
|
"canonical_opportunity_id": "o", "action_code": "NEW", "operational_queue": "do_now"}
|
|
report = sim.build_report(identity={}, configured_mode="compare",
|
|
v1={"work_items": [v1_item]}, v2={"work_items": [v2_item]},
|
|
named_decisions=named_decisions(), missing_projections=0)
|
|
assert len(report["changed_actions"]) == 1
|
|
assert report["removed_cards"] == [] and report["added_cards"] == []
|
|
|
|
|
|
def test_authoritative_material_collapse_selects_one_card_and_retains_evidence():
|
|
from app.operations_service import _collapse_authoritative_material_processes
|
|
rows = [
|
|
{"process_key": "conversation:chatwoot:1863", "work_item_key": "x:SEND_INFO",
|
|
"action_code": "SEND_INFO", "operational_queue": "do_now", "source_refs": [{"source": "task", "id": "1"}]},
|
|
{"process_key": "conversation:chatwoot:1863", "work_item_key": "x:REVIEW_MANUALLY",
|
|
"action_code": "REVIEW_MANUALLY", "operational_queue": "review", "source_refs": [{"source": "communication", "id": "2"}]},
|
|
]
|
|
result = _collapse_authoritative_material_processes(rows)
|
|
assert len(result) == 1
|
|
assert result[0]["action_code"] == "REVIEW_MANUALLY"
|
|
assert {ref["id"] for ref in result[0]["source_refs"]} == {"1", "2"}
|