Files
trex-core/tests/adapters/persistence/test_ops_checks.py
T

163 lines
6.7 KiB
Python

"""Ledger-gap operational check tests (REQ-055 / dcy-011 FR-5, issue #11).
PostgreSQL-gated (TREX_POSTGRESQL_TEST_DSN). dcy-020 (BAT-030): the detector
keys off slot_transition_log sell fills (the `closed` status no longer
exists). Inserts sold slots + outbox rows directly and asserts
find_ledger_gap_slots detects only the gaps.
"""
from __future__ import annotations
import subprocess
import uuid
from datetime import UTC, date, datetime
from pathlib import Path
from trex_core.adapters.persistence.ops_checks import (
collect_release_provenance,
find_ledger_gap_slots,
isolate_dirty_checkout_files,
)
_DAY = date(2026, 6, 10)
_TS = datetime(2026, 6, 10, 6, 0, tzinfo=UTC)
def _insert_slot(conn, *, status, portfolio_id, symbol="005930"):
sid = str(uuid.uuid4())
with conn.cursor() as cur:
cur.execute(
"INSERT INTO daily_slot (slot_id, status, symbol, portfolio_id) "
"VALUES (%s, %s, %s, %s)",
(sid, status, symbol, portfolio_id),
)
return sid
def _insert_sell_fill(conn, slot_id, *, reason="sell_filled", ts=_TS):
with conn.cursor() as cur:
cur.execute(
"INSERT INTO slot_transition_log (slot_id, from_state, to_state, ts, reason, actor) "
"VALUES (%s, 'pending_sell', 'inactive', %s, %s, 'execution_loop')",
(slot_id, ts, reason),
)
def _insert_outbox(conn, *, asset_id, order_id="O1", execution_id="E1"):
with conn.cursor() as cur:
cur.execute(
"INSERT INTO ledger_outbox (correlation_id, asset_id, order_id, execution_id, "
"side, qty, price) VALUES (%s, %s, %s, %s, 'sell', 1, 1000)",
(f"{asset_id}:{order_id}:{execution_id}", asset_id, order_id, execution_id),
)
def test_detects_only_gap_slots(pg_clean):
import psycopg
dsn = pg_clean
with psycopg.connect(dsn, autocommit=True) as conn:
gap_null = _insert_slot(conn, status="inactive", portfolio_id=None) # gap (no pf)
_insert_sell_fill(conn, gap_null)
gap_no_outbox = _insert_slot(conn, status="inactive", portfolio_id="PF-GAP")
_insert_sell_fill(conn, gap_no_outbox) # gap (no outbox row)
ok = _insert_slot(conn, status="inactive", portfolio_id="PF-OK") # not a gap
_insert_sell_fill(conn, ok)
_insert_outbox(conn, asset_id="PF-OK")
never_sold = _insert_slot(conn, status="holding", portfolio_id=None) # no sell fill → ignored
gaps = find_ledger_gap_slots(dsn, trading_date=_DAY)
gap_ids = {str(g["slot_id"]) for g in gaps}
assert gap_null in gap_ids
assert gap_no_outbox in gap_ids
assert ok not in gap_ids
assert never_sold not in gap_ids
def test_no_gaps_when_all_recorded(pg_clean):
import psycopg
dsn = pg_clean
with psycopg.connect(dsn, autocommit=True) as conn:
sid = _insert_slot(conn, status="inactive", portfolio_id="PF-1")
_insert_sell_fill(conn, sid)
_insert_outbox(conn, asset_id="PF-1")
assert find_ledger_gap_slots(dsn, trading_date=_DAY) == []
def test_date_scope_filters_by_sell_fill_day(pg_clean):
import psycopg
dsn = pg_clean
other_day_ts = datetime(2026, 6, 9, 6, 0, tzinfo=UTC)
with psycopg.connect(dsn, autocommit=True) as conn:
sid = _insert_slot(conn, status="inactive", portfolio_id="PF-OLD")
_insert_sell_fill(conn, sid, ts=other_day_ts)
assert find_ledger_gap_slots(dsn, trading_date=_DAY) == []
gaps = find_ledger_gap_slots(dsn, trading_date=date(2026, 6, 9))
assert {str(g["slot_id"]) for g in gaps} == {sid}
def _init_git_repo(tmp_path: Path) -> tuple[Path, str]:
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
subprocess.run(["git", "config", "user.name", "test"], cwd=repo, check=True, capture_output=True)
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=repo, check=True, capture_output=True)
(repo / "tracked.txt").write_text("v1\n", encoding="utf-8")
subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True, capture_output=True)
subprocess.run(["git", "commit", "-m", "init"], cwd=repo, check=True, capture_output=True)
head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip()
return repo, head
def test_collect_release_provenance_passes_on_clean_checkout(tmp_path):
repo, head = _init_git_repo(tmp_path)
report = collect_release_provenance(
actor="operator-a",
reason="deploy",
instance="inst-a",
command_class="manual-start",
checkout_dir=repo,
expected_oci_revision=head,
image_digest="sha256:" + ("a" * 64),
runtime_files=["tracked.txt"],
capture_commands={},
broker_log_path=None,
)
assert report["checkout"]["git_clean"] is True
assert report["artifact_identity"]["oci_revision_matches_head"] is True
assert report["runtime_file_verification"][0]["matches_expected_revision"] is True
assert report["gate"]["passed"] is True
def test_collect_release_provenance_blocks_on_revision_or_runtime_mismatch(tmp_path):
repo, old_head = _init_git_repo(tmp_path)
(repo / "tracked.txt").write_text("v2\n", encoding="utf-8")
subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True, capture_output=True)
subprocess.run(["git", "commit", "-m", "v2"], cwd=repo, check=True, capture_output=True)
report = collect_release_provenance(
actor="operator-a",
reason="deploy",
instance="inst-a",
command_class="manual-start",
checkout_dir=repo,
expected_oci_revision=old_head,
runtime_files=["tracked.txt"],
capture_commands={},
broker_log_path=None,
)
assert report["checkout"]["git_clean"] is True
assert report["artifact_identity"]["oci_revision_matches_head"] is False
assert report["runtime_file_verification"][0]["matches_expected_revision"] is False
assert report["gate"]["passed"] is False
def test_isolate_dirty_checkout_files_copies_dirty_and_untracked(tmp_path):
repo, _ = _init_git_repo(tmp_path)
(repo / "tracked.txt").write_text("dirty\n", encoding="utf-8")
(repo / "new.txt").write_text("new\n", encoding="utf-8")
isolated = tmp_path / "isolated"
manifest = isolate_dirty_checkout_files(checkout_dir=repo, isolate_dir=isolated)
assert set(manifest["dirty_paths"]) == {"tracked.txt", "new.txt"}
assert set(manifest["copied_paths"]) == {"tracked.txt", "new.txt"}
assert (isolated / "tracked.txt").read_text(encoding="utf-8") == "dirty\n"
assert (isolated / "new.txt").read_text(encoding="utf-8") == "new\n"
assert (isolated / "manifest.json").is_file()