Files
gyxx-flow/tests/test_effect_ledger.py
T

233 lines
6.8 KiB
Python

from __future__ import annotations
import json
from io import StringIO
from pathlib import Path
import pytest
from gyxx_flow.cli import EXIT_CONFIGURATION, EXIT_SUCCESS, main
from gyxx_flow.core.config import Settings
from gyxx_flow.ops.effects import (
EffectAlreadyApplied,
EffectLedger,
EffectStateAmbiguous,
)
def test_effect_ledger_blocks_applied_and_ambiguous_replays(tmp_path: Path) -> None:
ledger = EffectLedger(tmp_path)
first = ledger.begin(
workflow_id="product.daily",
business_date="2026-07-27",
step_id="legacy_execute",
run_id="run-001",
)
ledger.mark_applied(first)
with pytest.raises(EffectAlreadyApplied):
ledger.begin(
workflow_id="product.daily",
business_date="2026-07-27",
step_id="legacy_execute",
run_id="run-002",
)
ambiguous = ledger.begin(
workflow_id="product.daily",
business_date="2026-07-28",
step_id="legacy_execute",
run_id="run-003",
)
ledger.mark_ambiguous(ambiguous, reason="nonzero-exit")
with pytest.raises(EffectStateAmbiguous):
ledger.begin(
workflow_id="product.daily",
business_date="2026-07-28",
step_id="legacy_execute",
run_id="run-004",
)
def test_effect_ledger_leaves_in_progress_claim_ambiguous_after_crash(
tmp_path: Path,
) -> None:
ledger = EffectLedger(tmp_path)
ledger.begin(
workflow_id="shop.metrics.weekly",
business_date="2026-07-27",
step_id="legacy_collect",
run_id="run-001",
)
with pytest.raises(EffectStateAmbiguous):
ledger.begin(
workflow_id="shop.metrics.weekly",
business_date="2026-07-27",
step_id="legacy_collect",
run_id="run-002",
)
def test_effect_ledger_reconcile_retry_preserves_audit_and_allows_one_new_claim(
tmp_path: Path,
) -> None:
ledger = EffectLedger(tmp_path)
failed = ledger.begin(
workflow_id="product.daily",
business_date="2026-08-01",
step_id="collect",
run_id="run-original",
)
ledger.mark_ambiguous(failed, reason="preflight-failed")
result = ledger.reconcile(
workflow_id="product.daily",
business_date="2026-08-01",
step_id="collect",
expected_run_id="run-original",
action="retry",
operator="operator@example",
reason="verified command did not reach the external sink",
evidence="journal:run-original",
)
retry = ledger.begin(
workflow_id="product.daily",
business_date="2026-08-01",
step_id="collect",
run_id="run-retry",
)
assert result["status"] == "retry_allowed"
assert retry.run_id == "run-retry"
receipt_path = next((tmp_path / "state" / "ops" / "effects").glob("*.json"))
receipt = json.loads(receipt_path.read_text(encoding="utf-8"))
assert receipt["status"] == "in_progress"
assert receipt["run_id"] == "run-retry"
assert receipt["reconciliations"] == [
{
"action": "retry",
"operator": "operator@example",
"reason": "verified command did not reach the external sink",
"evidence": "journal:run-original",
"expected_run_id": "run-original",
"reconciled_at": result["reconciled_at"],
}
]
def test_effect_ledger_reconcile_can_mark_verified_effect_applied(
tmp_path: Path,
) -> None:
ledger = EffectLedger(tmp_path)
claim = ledger.begin(
workflow_id="product.alert.daily",
business_date="2026-08-01",
step_id="notify",
run_id="run-original",
)
ledger.mark_ambiguous(claim, reason="response-lost")
result = ledger.reconcile(
workflow_id="product.alert.daily",
business_date="2026-08-01",
step_id="notify",
expected_run_id="run-original",
action="applied",
operator="operator@example",
reason="verified the message receipt in Feishu",
evidence="message:verified-receipt",
)
assert result["status"] == "applied"
with pytest.raises(EffectAlreadyApplied):
ledger.begin(
workflow_id="product.alert.daily",
business_date="2026-08-01",
step_id="notify",
run_id="run-second",
)
def test_effect_ledger_reconcile_rejects_active_or_changed_claims(
tmp_path: Path,
) -> None:
ledger = EffectLedger(tmp_path)
claim = ledger.begin(
workflow_id="product.daily",
business_date="2026-08-02",
step_id="collect",
run_id="run-active",
)
with pytest.raises(EffectStateAmbiguous, match="only ambiguous"):
ledger.reconcile(
workflow_id="product.daily",
business_date="2026-08-02",
step_id="collect",
expected_run_id="run-active",
action="retry",
operator="operator@example",
reason="should not unlock a live claim",
evidence="journal:run-active",
)
ledger.mark_ambiguous(claim, reason="failed")
with pytest.raises(EffectStateAmbiguous, match="run_id changed"):
ledger.reconcile(
workflow_id="product.daily",
business_date="2026-08-02",
step_id="collect",
expected_run_id="run-other",
action="retry",
operator="operator@example",
reason="stale operator view",
evidence="journal:run-other",
)
def test_effect_reconcile_cli_requires_confirmation_and_returns_safe_projection(
tmp_path: Path,
) -> None:
data_root = tmp_path / "data"
settings = Settings(project_root=tmp_path, data_root=data_root)
ledger = EffectLedger(data_root)
claim = ledger.begin(
workflow_id="product.daily",
business_date="2026-08-03",
step_id="collect",
run_id="run-original",
)
ledger.mark_ambiguous(claim, reason="preflight-failed")
base_args = [
"effects",
"reconcile",
"product.daily",
"--date",
"2026-08-03",
"--step",
"collect",
"--expected-run-id",
"run-original",
"--action",
"retry",
"--operator",
"operator@example",
"--reason",
"verified no external write",
"--evidence",
"journal:run-original",
]
errors = StringIO()
assert main(base_args, settings=settings, stderr=errors) == EXIT_CONFIGURATION
assert "requires --execute" in errors.getvalue()
output = StringIO()
assert main([*base_args, "--execute"], settings=settings, stdout=output) == EXIT_SUCCESS
result = json.loads(output.getvalue())
assert result["status"] == "retry_allowed"
assert "operator" not in result
assert "reason" not in result
assert "evidence" not in result