"""`decision add-batch`: N decisioni sostanziali in un'unica scrittura atomica. Contratto: o TUTTE le decisioni entrano nel ledger o NESSUNA — un item invalido rigetta l'intero batch (il retry del gate non può creare duplicati parziali). """ import json from typer.testing import CliRunner from tht.cli.decision_cmd import decision_app from tht.decisions import append_decision, append_decisions, list_decisions from tht.phase import current_phase def _walk_to_phase(session, target): while current_phase(session) < target: append_decision(session, type="phase_approved", subject=f"phase:{current_phase(session)}") def _configure_command(monkeypatch, sessions): import tht.cli.decision_cmd as mod import tht.cli.session_cmd as session_mod from tht.session.models import SessionManifest, SessionSnapshot class _Repository: def get(self, session_id): return SessionSnapshot( manifest=SessionManifest( id=session_id, created_at="2026-01-01T00:00:00Z", question="q", database="d", schema="s", ), decisions=list_decisions(sessions / session_id), ) def append_decisions(self, session_id, decisions): return append_decisions(sessions / session_id, list(decisions)) class _Cfg: pass repository = _Repository() monkeypatch.setattr(mod, "_load_config_or_exit", lambda _c: _Cfg()) monkeypatch.setattr(mod, "load_session_or_exit", lambda _cfg, _s: None) monkeypatch.setattr(mod, "load_snapshot_or_exit", lambda _cfg, sid: repository.get(sid)) monkeypatch.setattr(mod, "session_repository", lambda _cfg: repository) monkeypatch.setattr(session_mod, "load_snapshot_or_exit", lambda _cfg, sid: repository.get(sid)) SCHEMA_LINKING_BATCH = [ {"type": "table_promoted", "subject": "fact_impianto_pmk", "detail": "impianti PMK"}, {"type": "column_promoted", "subject": "fact_impianto_pmk.cod_paz"}, {"type": "column_excluded", "subject": "fact_impianto_pmk.note"}, {"type": "table_excluded", "subject": "dim_obsoleta", "rationale": "non pertinente"}, ] def test_add_batch_appends_all_decisions_in_one_write(tmp_path, monkeypatch): session = tmp_path / "s1" session.mkdir() _walk_to_phase(session, 4) _configure_command(monkeypatch, tmp_path) result = CliRunner().invoke( decision_app, ["add-batch", "--session", "s1", "--doc", "-"], input=json.dumps(SCHEMA_LINKING_BATCH), ) assert result.exit_code == 0, result.output recorded = [d for d in list_decisions(session) if not d.type.startswith("phase_")] assert [(d.type, d.subject) for d in recorded] == [ (item["type"], item["subject"]) for item in SCHEMA_LINKING_BATCH ] def test_add_batch_rejects_the_whole_batch_when_one_item_is_invalid(tmp_path, monkeypatch): session = tmp_path / "s1" session.mkdir() _walk_to_phase(session, 4) _configure_command(monkeypatch, tmp_path) before = len(list_decisions(session)) bad = SCHEMA_LINKING_BATCH + [{"type": "tipo_inesistente", "subject": "x"}] result = CliRunner().invoke( decision_app, ["add-batch", "--session", "s1", "--doc", "-"], input=json.dumps(bad), ) assert result.exit_code == 1 assert len(list_decisions(session)) == before def test_add_batch_rejects_meta_and_cte_approved_types(tmp_path, monkeypatch): session = tmp_path / "s1" session.mkdir() _walk_to_phase(session, 4) _configure_command(monkeypatch, tmp_path) before = len(list_decisions(session)) for forbidden in ("phase_approved", "decision_retracted", "cte_approved"): result = CliRunner().invoke( decision_app, ["add-batch", "--session", "s1", "--doc", "-"], input=json.dumps([{"type": forbidden, "subject": "x"}]), ) assert result.exit_code == 1, forbidden assert len(list_decisions(session)) == before def test_add_batch_enforces_the_strictest_min_phase(tmp_path, monkeypatch): session = tmp_path / "s1" session.mkdir() _walk_to_phase(session, 2) # column_* richiede la fase di schema linking (4) _configure_command(monkeypatch, tmp_path) before = len(list_decisions(session)) result = CliRunner().invoke( decision_app, ["add-batch", "--session", "s1", "--doc", "-"], input=json.dumps([{"type": "column_promoted", "subject": "t.c"}]), ) assert result.exit_code != 0 assert len(list_decisions(session)) == before