fix(ac-0024): injective C identity via SHA-256 length-prefix digest + PostgreSQL migration/round-trip tests
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
c246fca206
commit
21f8fdd4b6
@@ -27,6 +27,7 @@ _EXPECTED_TABLES = {
|
||||
"snapshot_pause", # BAT-008 / REQ-032
|
||||
"broker_account_snapshot", # dcy-041 / GitHub #45
|
||||
"ledger_skip", # GitHub #68 skipped-ledger durable audit
|
||||
"reconcile_notification_state", # req-0007 / ac-0024
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""PostgreSQLReconcileNotificationStore integration tests (req-0007, ac-0024).
|
||||
|
||||
Verifies migration 026 schema creation (idempotent replay), full CRUD
|
||||
round-trip, and lifecycle semantics against a real PostgreSQL 16 instance.
|
||||
|
||||
Gated on TREX_POSTGRESQL_TEST_DSN — skipped in unit-test-only CI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.application.reconciliation import _c_identity_key
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pg_notify_store(pg_clean):
|
||||
from trex_core.adapters.portfolio.reconcile_notification_store import (
|
||||
PostgreSQLReconcileNotificationStore,
|
||||
)
|
||||
return PostgreSQLReconcileNotificationStore(pg_clean)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Migration 026 schema
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMigration026:
|
||||
def test_table_exists_after_migration(self, pg_clean):
|
||||
"""Migration 026 creates reconcile_notification_state (IF NOT EXISTS)."""
|
||||
import psycopg
|
||||
with psycopg.connect(pg_clean) as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT table_name FROM information_schema.tables "
|
||||
"WHERE table_schema = 'public' AND table_name = 'reconcile_notification_state'"
|
||||
)
|
||||
assert cur.fetchone() is not None, "reconcile_notification_state table not found"
|
||||
|
||||
def test_table_has_expected_columns(self, pg_clean):
|
||||
"""Migration 026 creates all required columns."""
|
||||
import psycopg
|
||||
with psycopg.connect(pg_clean) as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_schema = 'public' AND table_name = 'reconcile_notification_state'"
|
||||
)
|
||||
cols = {r[0] for r in cur.fetchall()}
|
||||
expected = {
|
||||
"identity_key", "case_code", "instance_key", "symbol",
|
||||
"quantity_a", "quantity_b", "lifecycle_state", "created_at", "updated_at",
|
||||
}
|
||||
assert expected <= cols, f"Missing columns: {expected - cols}"
|
||||
|
||||
def test_migration_is_idempotent(self, pg_clean):
|
||||
"""Running migration 026 twice does not fail (IF NOT EXISTS guard)."""
|
||||
from trex_core.adapters.persistence.migrate import apply_migrations
|
||||
# pg_clean already ran apply_migrations once; run again to verify idempotency.
|
||||
apply_migrations(pg_clean) # must not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCRUDRoundTrip:
|
||||
def test_set_and_get_state_b_case(self, pg_notify_store):
|
||||
"""set_state → get_state round-trip for B-case (slot_id identity)."""
|
||||
pg_notify_store.set_state(
|
||||
"slot-uuid-001",
|
||||
case_code="B",
|
||||
instance_key="inst-A",
|
||||
symbol="005930",
|
||||
quantity_a=10,
|
||||
quantity_b=7,
|
||||
lifecycle_state="notified",
|
||||
)
|
||||
state = pg_notify_store.get_state("slot-uuid-001")
|
||||
assert state is not None
|
||||
assert state["case_code"] == "B"
|
||||
assert state["instance_key"] == "inst-A"
|
||||
assert state["symbol"] == "005930"
|
||||
assert state["quantity_a"] == 10
|
||||
assert state["quantity_b"] == 7
|
||||
assert state["lifecycle_state"] == "notified"
|
||||
|
||||
def test_set_and_get_state_c_case(self, pg_notify_store):
|
||||
"""set_state → get_state round-trip for C-case (SHA-256 digest identity)."""
|
||||
c_key = _c_identity_key("inst-A", "005930")
|
||||
pg_notify_store.set_state(
|
||||
c_key,
|
||||
case_code="C",
|
||||
instance_key="inst-A",
|
||||
symbol="005930",
|
||||
quantity_a=50,
|
||||
quantity_b=42,
|
||||
lifecycle_state="notified",
|
||||
)
|
||||
state = pg_notify_store.get_state(c_key)
|
||||
assert state is not None
|
||||
assert state["case_code"] == "C"
|
||||
assert state["symbol"] == "005930"
|
||||
assert state["quantity_a"] == 50
|
||||
|
||||
def test_set_state_upsert(self, pg_notify_store):
|
||||
"""set_state is an upsert — second call with same key updates the row."""
|
||||
pg_notify_store.set_state(
|
||||
"slot-uuid-002",
|
||||
case_code="B",
|
||||
instance_key="inst-A",
|
||||
symbol="005930",
|
||||
quantity_a=10,
|
||||
quantity_b=7,
|
||||
lifecycle_state="notified",
|
||||
)
|
||||
pg_notify_store.set_state(
|
||||
"slot-uuid-002",
|
||||
case_code="B",
|
||||
instance_key="inst-A",
|
||||
symbol="005930",
|
||||
quantity_a=10,
|
||||
quantity_b=9, # quantity changed
|
||||
lifecycle_state="notified",
|
||||
)
|
||||
state = pg_notify_store.get_state("slot-uuid-002")
|
||||
assert state["quantity_b"] == 9
|
||||
|
||||
def test_get_state_returns_none_for_missing_key(self, pg_notify_store):
|
||||
assert pg_notify_store.get_state("does-not-exist") is None
|
||||
|
||||
def test_clear_state_removes_row(self, pg_notify_store):
|
||||
"""clear_state deletes the row; subsequent get_state returns None."""
|
||||
pg_notify_store.set_state(
|
||||
"slot-uuid-003",
|
||||
case_code="B",
|
||||
instance_key="inst-A",
|
||||
symbol="005930",
|
||||
quantity_a=10,
|
||||
quantity_b=7,
|
||||
lifecycle_state="notified",
|
||||
)
|
||||
pg_notify_store.clear_state("slot-uuid-003")
|
||||
assert pg_notify_store.get_state("slot-uuid-003") is None
|
||||
|
||||
def test_list_states_by_lifecycle(self, pg_notify_store):
|
||||
"""list_states_by_lifecycle returns only rows with the given state."""
|
||||
pg_notify_store.set_state(
|
||||
"slot-A",
|
||||
case_code="B", instance_key="inst-A", symbol="005930",
|
||||
quantity_a=10, quantity_b=7, lifecycle_state="notified",
|
||||
)
|
||||
pg_notify_store.set_state(
|
||||
"slot-B",
|
||||
case_code="B", instance_key="inst-A", symbol="005930",
|
||||
quantity_a=20, quantity_b=15, lifecycle_state="pending_resolved",
|
||||
)
|
||||
notified = pg_notify_store.list_states_by_lifecycle("notified")
|
||||
pending = pg_notify_store.list_states_by_lifecycle("pending_resolved")
|
||||
assert len(notified) == 1
|
||||
assert notified[0]["identity_key"] == "slot-A"
|
||||
assert len(pending) == 1
|
||||
assert pending[0]["identity_key"] == "slot-B"
|
||||
|
||||
def test_durability_across_store_instances(self, pg_clean):
|
||||
"""State survives a simulated process restart (new store instance, same DSN)."""
|
||||
from trex_core.adapters.portfolio.reconcile_notification_store import (
|
||||
PostgreSQLReconcileNotificationStore,
|
||||
)
|
||||
a = PostgreSQLReconcileNotificationStore(pg_clean)
|
||||
a.set_state(
|
||||
"slot-restart",
|
||||
case_code="B", instance_key="inst-A", symbol="005930",
|
||||
quantity_a=10, quantity_b=7, lifecycle_state="notified",
|
||||
)
|
||||
# "restart" — fresh store instance
|
||||
b = PostgreSQLReconcileNotificationStore(pg_clean)
|
||||
state = b.get_state("slot-restart")
|
||||
assert state is not None
|
||||
assert state["lifecycle_state"] == "notified"
|
||||
|
||||
def test_c_identity_keys_for_distinct_inputs_are_distinct(self, pg_notify_store):
|
||||
"""Two distinct (instance_key, symbol) pairs must yield distinct DB rows."""
|
||||
key_a = _c_identity_key("inst-A", "005930")
|
||||
key_b = _c_identity_key("inst-A", "005931")
|
||||
assert key_a != key_b
|
||||
pg_notify_store.set_state(
|
||||
key_a, case_code="C", instance_key="inst-A", symbol="005930",
|
||||
quantity_a=10, quantity_b=8, lifecycle_state="notified",
|
||||
)
|
||||
pg_notify_store.set_state(
|
||||
key_b, case_code="C", instance_key="inst-A", symbol="005931",
|
||||
quantity_a=20, quantity_b=15, lifecycle_state="notified",
|
||||
)
|
||||
state_a = pg_notify_store.get_state(key_a)
|
||||
state_b = pg_notify_store.get_state(key_b)
|
||||
assert state_a["symbol"] == "005930"
|
||||
assert state_b["symbol"] == "005931"
|
||||
|
||||
def test_crafted_separator_collision_absent(self, pg_notify_store):
|
||||
"""Crafted-separator inputs ('a\\x1fC','b') vs ('a','C\\x1fb') map to distinct rows."""
|
||||
key_a = _c_identity_key("a\x1fC", "b")
|
||||
key_b = _c_identity_key("a", "C\x1fb")
|
||||
assert key_a != key_b
|
||||
pg_notify_store.set_state(
|
||||
key_a, case_code="C", instance_key="a\x1fC", symbol="b",
|
||||
quantity_a=1, quantity_b=2, lifecycle_state="notified",
|
||||
)
|
||||
pg_notify_store.set_state(
|
||||
key_b, case_code="C", instance_key="a", symbol="C\x1fb",
|
||||
quantity_a=3, quantity_b=4, lifecycle_state="notified",
|
||||
)
|
||||
state_a = pg_notify_store.get_state(key_a)
|
||||
state_b = pg_notify_store.get_state(key_b)
|
||||
assert state_a["instance_key"] == "a\x1fC"
|
||||
assert state_b["instance_key"] == "a"
|
||||
Reference in New Issue
Block a user