Implement document reconciliation v2
This commit is contained in:
370
tests/test_document_reconciliation_v2.py
Normal file
370
tests/test_document_reconciliation_v2.py
Normal file
@@ -0,0 +1,370 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from app.document_reconciliation_backfill import classify_group, select_complete_group_batch
|
||||
from app.document_reconciliation_service import (
|
||||
_command_fingerprint,
|
||||
resolve_document_links,
|
||||
select_valid_primary,
|
||||
)
|
||||
from scripts.migrate_document_reconciliation_v2 import actions_for_only_unambiguous
|
||||
from scripts.migrate_document_reconciliation_v2 import apply_actions
|
||||
from scripts.preflight_document_reconciliation_v2 import run_preflight
|
||||
|
||||
|
||||
def _doc(doc_id: str, *, role="related", primary=False, status="OPEN", external_id=None, payload=None):
|
||||
return {"id": doc_id, "opportunity_id": "00000000-0000-0000-0000-000000000001",
|
||||
"document_kind": "quotation", "role": role, "is_primary": primary,
|
||||
"status": status, "external_id": external_id or doc_id, "payload": payload or {}}
|
||||
|
||||
|
||||
class _Rows:
|
||||
def __init__(self, rows=None, scalar=None):
|
||||
self._rows, self._scalar = rows or [], scalar
|
||||
def scalar(self): return self._scalar
|
||||
def mappings(self): return self
|
||||
def all(self): return self._rows
|
||||
|
||||
|
||||
class _ResolverConnection:
|
||||
def __init__(self, legacy, v2=None, schema=True):
|
||||
self.legacy, self.v2, self.schema = legacy, v2 or [], schema
|
||||
def execute(self, statement, params=None):
|
||||
sql = str(statement)
|
||||
if "to_regclass" in sql:
|
||||
return _Rows(scalar=self.schema)
|
||||
if "FROM commercial_documents d" in sql and "opportunity_document_links" not in sql:
|
||||
return _Rows(self.legacy)
|
||||
if "FROM opportunity_document_links l" in sql:
|
||||
return _Rows(self.v2)
|
||||
raise AssertionError(sql)
|
||||
|
||||
|
||||
def _legacy_row(doc_id, kind="quotation", primary=True):
|
||||
return {"id": doc_id, "opportunity_id": "o", "document_kind": kind,
|
||||
"role": "current" if primary else "related", "is_primary": primary,
|
||||
"is_active": True, "status": "OPEN", "updated_at": "1"}
|
||||
|
||||
|
||||
def test_resolver_works_without_v2_schema_and_with_empty_schema():
|
||||
legacy = [_legacy_row("a")]
|
||||
without = resolve_document_links("o", conn=_ResolverConnection(legacy, schema=False))
|
||||
empty = resolve_document_links("o", conn=_ResolverConnection(legacy, v2=[], schema=True))
|
||||
assert without[0]["resolution_source"] == "legacy_rollout"
|
||||
assert empty[0]["resolution_source"] == "legacy_rollout"
|
||||
|
||||
|
||||
def test_resolver_uses_legacy_for_partial_group_and_v2_after_complete_group():
|
||||
legacy = [_legacy_row("a"), _legacy_row("b", primary=False)]
|
||||
partial_v2 = [{"document_id": "a", "document_kind": "quotation", "relationship": "PRIMARY",
|
||||
"ended_at": None, "updated_at": "2"}]
|
||||
partial = resolve_document_links("o", conn=_ResolverConnection(legacy, partial_v2))
|
||||
assert len(partial) == 2 and {row["resolution_source"] for row in partial} == {"legacy_rollout"}
|
||||
complete_v2 = partial_v2 + [{"document_id": "b", "document_kind": "quotation",
|
||||
"relationship": "SECONDARY", "ended_at": None, "updated_at": "2"}]
|
||||
complete = resolve_document_links("o", conn=_ResolverConnection(legacy, complete_v2))
|
||||
assert len(complete) == 2 and {row["resolution_source"] for row in complete} == {"v2"}
|
||||
|
||||
|
||||
def test_resolver_contract_keeps_document_id_separate_from_link_id():
|
||||
legacy = [_legacy_row("document-a")]
|
||||
v2 = [{"id": "document-a", "document_id": "document-a", "link_id": "link-z",
|
||||
"document_kind": "quotation", "relationship": "PRIMARY",
|
||||
"ended_at": None, "updated_at": "2"}]
|
||||
resolved = resolve_document_links("o", conn=_ResolverConnection(legacy, v2))
|
||||
assert resolved[0]["id"] == "document-a"
|
||||
assert resolved[0]["document_id"] == "document-a"
|
||||
assert resolved[0]["link_id"] == "link-z"
|
||||
|
||||
fallback = resolve_document_links("o", conn=_ResolverConnection(legacy, schema=False))
|
||||
assert fallback[0]["id"] == fallback[0]["document_id"] == "document-a"
|
||||
assert fallback[0]["link_id"] is None
|
||||
|
||||
|
||||
def test_v2_projection_contains_ui_document_fields_and_explicit_ids():
|
||||
source = Path("app/document_reconciliation_service.py").read_text()
|
||||
projection = source[source.index('_LINK_SELECT = """'):source.index('def list_document_links')]
|
||||
assert "d.id::text AS id" in projection
|
||||
assert "d.id::text AS document_id" in projection
|
||||
assert "l.id::text AS link_id" in projection
|
||||
for field in ("customer_id", "company", "document_type", "serie", "series_number",
|
||||
"document_number", "version_number", "external_id", "external_url", "role",
|
||||
"is_primary", "is_active", "status", "amount", "tax_amount", "total_amount",
|
||||
"currency", "document_date", "due_date", "payload"):
|
||||
assert f"d.{field}" in projection, field
|
||||
|
||||
|
||||
def test_backfill_single_primary_is_unambiguous():
|
||||
result = classify_group([_doc("a", role="current", primary=True), _doc("b")])
|
||||
assert [r["relationship"] for r in result] == ["PRIMARY", "SECONDARY"]
|
||||
|
||||
|
||||
def test_backfill_two_primaries_requires_review():
|
||||
result = classify_group([_doc("a", role="current", primary=True), _doc("b", role="accepted", primary=True)])
|
||||
assert {r["relationship"] for r in result} == {"REVIEW_REQUIRED"}
|
||||
|
||||
|
||||
def test_backfill_cancelled_primary_requires_review_and_never_ignored():
|
||||
result = classify_group([_doc("a", role="current", primary=True, status="cancelled")])
|
||||
assert result[0]["relationship"] == "REVIEW_REQUIRED"
|
||||
assert result[0]["cancelled"] is True
|
||||
|
||||
|
||||
def test_detached_without_evidence_is_not_invented_removed():
|
||||
assert classify_group([_doc("a", role="detached")])[0]["relationship"] == "REVIEW_REQUIRED"
|
||||
assert classify_group([_doc("a", role="detached", payload={"removed_reason": "manual"})])[0]["relationship"] == "REMOVED"
|
||||
|
||||
|
||||
def test_classifier_import_is_isolated_from_application_configuration():
|
||||
env = os.environ.copy()
|
||||
env.pop("DATABASE_URL", None)
|
||||
env.pop("OPENROUTER_API_KEY", None)
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import sys; from app.document_reconciliation_backfill import classify_group; "
|
||||
"assert callable(classify_group); "
|
||||
"assert 'app.config' not in sys.modules; "
|
||||
"assert 'app.db' not in sys.modules; "
|
||||
"assert 'sqlalchemy' not in sys.modules",
|
||||
],
|
||||
cwd=Path(__file__).resolve().parents[1],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_schema_has_database_guards_and_append_only_audit():
|
||||
sql = Path("migrations/007_document_reconciliation_v2.sql").read_text()
|
||||
assert "ux_document_link_primary_kind" in sql and "WHERE ended_at IS NULL AND relationship = 'PRIMARY'" in sql
|
||||
assert "ux_document_link_current" in sql
|
||||
assert "BEFORE UPDATE OR DELETE" in sql
|
||||
assert "ux_document_link_events_idempotency" in sql
|
||||
|
||||
|
||||
def test_active_router_owns_v2_handlers_and_htmx_panel_target():
|
||||
router = Path("app/admin_ui/router.py").read_text()
|
||||
page = Path("app/admin_ui/pages/opportunities.py").read_text()
|
||||
assert "router.include_router(opportunities.router)" in router
|
||||
assert "document_reconciliation_v2_action" in page
|
||||
assert "HTMLResponse(jasmin_documents_html(opportunity_id" in page
|
||||
assert "_authenticated_actor(request)" in page
|
||||
|
||||
|
||||
def test_sync_does_not_assign_legacy_primary_by_import_order():
|
||||
source = Path("app/reconciliation_service.py").read_text()
|
||||
function = source[source.index("def _upsert_jasmin_document_from_item"):source.index("def _", source.index("def _upsert_jasmin_document_from_item") + 10)]
|
||||
assert '"role": "related"' in function
|
||||
assert '"is_primary": False' in function
|
||||
assert "classify_sync_document" in function
|
||||
canonical = Path("app/document_reconciliation_service.py").read_text()
|
||||
assert "multiple Jasmin candidates require review" in canonical
|
||||
assert "SYNC_AMBIGUITY_DEMOTED" in canonical
|
||||
|
||||
|
||||
def test_total_never_falls_back_to_non_primary_or_cancelled_primary():
|
||||
rows = [{"id": "secondary", "relationship": "SECONDARY", "total_amount": 999}]
|
||||
assert select_valid_primary(rows) is None
|
||||
rows.insert(0, {"id": "cancelled", "relationship": "PRIMARY", "status": "CANCELLED"})
|
||||
assert select_valid_primary(rows) is None
|
||||
|
||||
|
||||
def test_only_unambiguous_keeps_ambiguous_group_fully_represented():
|
||||
actions = classify_group([_doc("a", role="current", primary=True),
|
||||
_doc("b", role="accepted", primary=True)])
|
||||
selected = actions_for_only_unambiguous(actions)
|
||||
assert len(selected) == 2
|
||||
assert {row["relationship"] for row in selected} == {"REVIEW_REQUIRED"}
|
||||
|
||||
|
||||
def test_backfill_paginates_complete_groups_with_composite_cursor():
|
||||
source = Path("scripts/migrate_document_reconciliation_v2.py").read_text()
|
||||
assert "WITH selected_groups AS" in source
|
||||
assert "GROUP BY d.opportunity_id, d.document_kind" in source
|
||||
assert "LIMIT :limit" in source
|
||||
assert "OPPORTUNITY_ID|DOCUMENT_KIND" in source
|
||||
assert "JOIN commercial_documents d ON d.opportunity_id=g.opportunity_id" in source
|
||||
|
||||
|
||||
def test_group_larger_than_batch_size_is_never_split():
|
||||
rows = [{"id": str(index), "opportunity_id": "a", "document_kind": "quotation"}
|
||||
for index in range(5)] + [{"id": "z", "opportunity_id": "b", "document_kind": "quotation"}]
|
||||
batch = select_complete_group_batch(rows, batch_size=1)
|
||||
assert len(batch) == 5 and {row["opportunity_id"] for row in batch} == {"a"}
|
||||
|
||||
|
||||
def test_composite_resume_keeps_other_kind_on_opportunity_boundary():
|
||||
rows = [
|
||||
{"id": "1", "opportunity_id": "a", "document_kind": "invoice"},
|
||||
{"id": "2", "opportunity_id": "a", "document_kind": "quotation"},
|
||||
{"id": "3", "opportunity_id": "b", "document_kind": "quotation"},
|
||||
]
|
||||
first = select_complete_group_batch(rows, batch_size=1)
|
||||
cursor = (first[-1]["opportunity_id"], first[-1]["document_kind"])
|
||||
second = select_complete_group_batch(rows, batch_size=1, resume_from=cursor)
|
||||
assert [(row["opportunity_id"], row["document_kind"]) for row in second] == [("a", "quotation")]
|
||||
|
||||
|
||||
def test_idempotency_fingerprint_retry_and_conflicting_reuse_contract():
|
||||
first, payload = _command_fingerprint(opportunity_id="o", document_id="d",
|
||||
target_relationship="PRIMARY", reason="approved")
|
||||
retry, _ = _command_fingerprint(opportunity_id="o", document_id="d",
|
||||
target_relationship="PRIMARY", reason="approved")
|
||||
conflict, _ = _command_fingerprint(opportunity_id="o", document_id="d",
|
||||
target_relationship="SECONDARY", reason="approved")
|
||||
assert first == retry and first != conflict
|
||||
source = Path("app/document_reconciliation_service.py").read_text()
|
||||
function = source[source.index("def set_document_relationship"):source.index("def set_primary_document")]
|
||||
assert function.index("_claim_idempotency") < function.index("_lock_context")
|
||||
assert "already used with a different command" in source
|
||||
|
||||
|
||||
def test_idempotency_fingerprint_covers_every_material_command_field():
|
||||
base = dict(opportunity_id="o", document_id="d", target_relationship="PRIMARY",
|
||||
reason="approved", source="api", event_type="CHANGED", metadata={"a": 1},
|
||||
is_manual=True, actor="alice", document_kind="invoice")
|
||||
fingerprint, _ = _command_fingerprint(**base)
|
||||
for field, value in (("source", "sync"), ("event_type", "BACKFILLED"),
|
||||
("metadata", {"a": 2}), ("is_manual", False), ("actor", "bob"),
|
||||
("correlation_id", "corr-1"), ("request_id", "req-1")):
|
||||
changed, _ = _command_fingerprint(**(base | {field: value}))
|
||||
assert changed != fingerprint, field
|
||||
reordered, _ = _command_fingerprint(**(base | {"metadata": {"z": 2, "a": 1}}))
|
||||
same_reordered, _ = _command_fingerprint(**(base | {"metadata": {"a": 1, "z": 2}}))
|
||||
assert reordered == same_reordered
|
||||
|
||||
|
||||
def test_generic_document_writer_registers_quotation_and_invoice_through_canonical_service():
|
||||
source = Path("app/commercial_service.py").read_text()
|
||||
function = source[source.index("def create_commercial_document"):source.index("def add_document_lines")]
|
||||
assert "register_created_document(" in function
|
||||
assert function.index("INSERT INTO commercial_documents") < function.index("register_created_document(")
|
||||
assert "and not v2_available" in function
|
||||
assert "UPDATE opportunity_document_links" not in function
|
||||
assert "INSERT INTO opportunity_document_links" not in function
|
||||
canonical = Path("app/document_reconciliation_service.py").read_text()
|
||||
register = canonical[canonical.index("def register_created_document"):canonical.index("def record_jasmin_status_change")]
|
||||
assert "document_reconciliation_v2_available(conn)" in register
|
||||
assert "classify_sync_document(" in register
|
||||
|
||||
|
||||
def test_new_group_member_preserves_manual_primary_and_uses_secondary_or_review():
|
||||
source = Path("app/document_reconciliation_service.py").read_text()
|
||||
classify = source[source.index("def classify_sync_document"):source.index("def register_created_document")]
|
||||
assert 'relationship = "SECONDARY" if manual_primary' in classify
|
||||
assert 'else "REVIEW_REQUIRED"' in classify
|
||||
assert "multiple Jasmin candidates require review" in classify
|
||||
assert 'and not previous["is_manual"]' in classify
|
||||
|
||||
|
||||
def test_all_commercial_document_producers_register_v2_relationships():
|
||||
commercial = Path("app/commercial_service.py").read_text()
|
||||
reconciliation = Path("app/reconciliation_service.py").read_text()
|
||||
assert commercial.count("INSERT INTO commercial_documents") == 1
|
||||
assert "register_created_document(" in commercial
|
||||
assert reconciliation.count("INSERT INTO commercial_documents") == 1
|
||||
assert "classify_sync_document(" in reconciliation
|
||||
|
||||
|
||||
def test_backfill_applies_one_transaction_per_complete_group():
|
||||
source = Path("scripts/migrate_document_reconciliation_v2.py").read_text()
|
||||
function = source[source.index("def apply_actions"):source.index("def write_reports")]
|
||||
assert "groups.setdefault" in function
|
||||
assert function.count("with engine.begin() as conn") == 1
|
||||
assert "_conn=conn" in function
|
||||
|
||||
|
||||
def test_backfill_second_document_failure_rolls_back_whole_group(monkeypatch):
|
||||
import app.db
|
||||
import app.document_reconciliation_service as service
|
||||
|
||||
committed = {"links": [], "events": [], "legacy": []}
|
||||
class Transaction:
|
||||
def __enter__(self):
|
||||
self.pending = {key: [] for key in committed}
|
||||
return self
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
if exc_type is None:
|
||||
for key in committed:
|
||||
committed[key].extend(self.pending[key])
|
||||
return False
|
||||
class FakeEngine:
|
||||
def begin(self): return Transaction()
|
||||
calls = 0
|
||||
def fake_set(*args, _conn=None, **kwargs):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
_conn.pending["links"].append(args[1])
|
||||
_conn.pending["events"].append(args[1])
|
||||
_conn.pending["legacy"].append(args[1])
|
||||
if calls == 2:
|
||||
raise RuntimeError("simulated second document failure")
|
||||
monkeypatch.setattr(app.db, "engine", FakeEngine())
|
||||
monkeypatch.setattr(service, "set_document_relationship", fake_set)
|
||||
actions = [dict(_doc("a"), relationship="PRIMARY", decision_reason="one"),
|
||||
dict(_doc("b"), relationship="SECONDARY", decision_reason="two")]
|
||||
with pytest.raises(RuntimeError, match="second document"):
|
||||
apply_actions(actions)
|
||||
assert committed == {"links": [], "events": [], "legacy": []}
|
||||
|
||||
|
||||
def test_generic_line_writer_is_schema_compatible_and_keeps_ids_coherent():
|
||||
source = Path("app/commercial_service.py").read_text()
|
||||
function = source[source.index("def add_document_lines"):source.index("def list_commercial_documents")]
|
||||
assert "to_regclass('commercial_document_lines')" in function
|
||||
assert "FROM pg_attribute" in function
|
||||
assert "attname = 'commercial_document_id'" in function
|
||||
assert "table_schema='public'" not in function
|
||||
assert 'CAST(:document_id AS UUID), CAST(:document_id AS UUID)' in function
|
||||
|
||||
|
||||
def test_startup_does_not_partially_apply_migration_007():
|
||||
source = Path("app/commercial_service.py").read_text()
|
||||
function = source[source.index("def ensure_commercial_schema"):source.index("def get_customer_by_tax_id")]
|
||||
assert "ADD COLUMN IF NOT EXISTS commercial_document_id" not in function
|
||||
assert "ADD COLUMN IF NOT EXISTS opportunity_document_link_id" not in function
|
||||
|
||||
|
||||
def test_no_new_authoritative_v2_reader_outside_canonical_service():
|
||||
allowlist = {"document_reconciliation_service.py"}
|
||||
offenders = []
|
||||
for path in Path("app").rglob("*.py"):
|
||||
if path.name in allowlist:
|
||||
continue
|
||||
if "opportunity_document_links" in path.read_text(encoding="utf-8"):
|
||||
offenders.append(str(path))
|
||||
# Remaining consumers are deliberately tracked until migrated; additions
|
||||
# outside this explicit inventory fail the review gate.
|
||||
assert offenders == []
|
||||
|
||||
|
||||
def test_membership_and_manual_reason_are_domain_guards():
|
||||
source = Path("app/document_reconciliation_service.py").read_text()
|
||||
assert "document does not belong to this opportunity" in source
|
||||
assert "reason is required for every manual relationship change" in source
|
||||
assert "commercial_documents WHERE id=CAST(:did AS UUID)" in source
|
||||
|
||||
|
||||
def test_preflight_and_down_migration_cover_blockers_and_dependency_order():
|
||||
assert callable(run_preflight)
|
||||
preflight = Path("scripts/preflight_document_reconciliation_v2.py").read_text()
|
||||
assert "null_document_id" in preflight and "orphan_document_id" in preflight
|
||||
assert "multiple_legacy_primaries" in preflight and "duplicate_document_groups" in preflight
|
||||
down = Path("migrations/007_document_reconciliation_v2_down.sql").read_text()
|
||||
assert down.index("DROP COLUMN IF EXISTS opportunity_document_link_id") < down.index("DROP TABLE IF EXISTS opportunity_document_links")
|
||||
assert down.index("DROP TRIGGER") < down.index("DROP FUNCTION") < down.index("DROP TABLE IF EXISTS opportunity_document_link_events")
|
||||
assert "document_ledger_exported" in down and "v2_consumers_active" in down
|
||||
|
||||
|
||||
def test_jasmin_items_keep_document_and_link_provenance():
|
||||
source = Path("app/reconciliation_service.py").read_text()
|
||||
assert '"commercial_document_id": origin.get("commercial_document_id")' in source
|
||||
assert '"opportunity_document_link_id": origin.get("opportunity_document_link_id")' in source
|
||||
104
tests/test_document_reconciliation_v2_postgres.py
Normal file
104
tests/test_document_reconciliation_v2_postgres.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Optional PostgreSQL contract tests.
|
||||
|
||||
These tests are intentionally gated by a dedicated disposable-test URL. They
|
||||
never fall back to DATABASE_URL.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
from contextlib import nullcontext
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
|
||||
TEST_URL = os.getenv("CLIENTFLOW_TEST_DATABASE_URL")
|
||||
pytestmark = pytest.mark.skipif(not TEST_URL, reason="CLIENTFLOW_TEST_DATABASE_URL not set")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def pg_conn():
|
||||
engine = create_engine(TEST_URL)
|
||||
schema = "document_reconciliation_test_" + uuid.uuid4().hex
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text(f'CREATE SCHEMA "{schema}"'))
|
||||
conn.commit()
|
||||
conn.execute(text(f'SET search_path TO "{schema}"'))
|
||||
conn.execute(text("""CREATE TABLE opportunities(id UUID PRIMARY KEY);
|
||||
CREATE TABLE commercial_documents(
|
||||
id UUID PRIMARY KEY, opportunity_id UUID REFERENCES opportunities(id),
|
||||
document_kind TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'related',
|
||||
is_primary BOOLEAN NOT NULL DEFAULT FALSE, is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
updated_at TIMESTAMPTZ DEFAULT now());
|
||||
CREATE TABLE commercial_document_lines(
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), document_id UUID REFERENCES commercial_documents(id),
|
||||
opportunity_item_id UUID, line_index INTEGER, local_product_id UUID,
|
||||
jasmin_sales_item TEXT, description TEXT, quantity NUMERIC, unit TEXT,
|
||||
unit_price NUMERIC, tax_schema TEXT, total_amount NUMERIC, payload JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT now());
|
||||
CREATE TABLE schema_migrations(version TEXT PRIMARY KEY);"""))
|
||||
conn.commit()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.rollback()
|
||||
conn.execute(text("SET search_path TO public"))
|
||||
conn.execute(text(f'DROP SCHEMA "{schema}" CASCADE'))
|
||||
conn.commit()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_dedicated_url_is_the_only_postgres_test_source(pg_conn):
|
||||
assert TEST_URL
|
||||
assert str(pg_conn.execute(text("SELECT current_database()" )).scalar())
|
||||
|
||||
|
||||
def test_migration_007_postgres_contracts_and_generic_writer(pg_conn, monkeypatch):
|
||||
sql = Path("migrations/007_document_reconciliation_v2.sql").read_text()
|
||||
pg_conn.exec_driver_sql(sql)
|
||||
pg_conn.commit()
|
||||
pg_conn.exec_driver_sql("SET search_path TO " + pg_conn.exec_driver_sql("SELECT current_schema()").scalar())
|
||||
oid, first, second = [str(uuid.uuid4()) for _ in range(3)]
|
||||
pg_conn.execute(text("INSERT INTO opportunities(id) VALUES(CAST(:id AS UUID))"), {"id": oid})
|
||||
pg_conn.execute(text("""INSERT INTO commercial_documents(id,opportunity_id,document_kind)
|
||||
VALUES(CAST(:a AS UUID),CAST(:o AS UUID),'invoice'),
|
||||
(CAST(:b AS UUID),CAST(:o AS UUID),'invoice')"""), {"a": first, "b": second, "o": oid})
|
||||
pg_conn.execute(text("""INSERT INTO opportunity_document_links
|
||||
(opportunity_id,document_id,document_kind,relationship,source)
|
||||
VALUES(CAST(:o AS UUID),CAST(:d AS UUID),'invoice','PRIMARY','test')"""), {"o": oid, "d": first})
|
||||
pg_conn.commit()
|
||||
with pytest.raises(Exception):
|
||||
pg_conn.execute(text("""INSERT INTO opportunity_document_links
|
||||
(opportunity_id,document_id,document_kind,relationship,source)
|
||||
VALUES(CAST(:o AS UUID),CAST(:d AS UUID),'invoice','PRIMARY','test')"""), {"o": oid, "d": second})
|
||||
pg_conn.rollback()
|
||||
|
||||
import app.commercial_service as commercial
|
||||
class BoundEngine:
|
||||
def begin(self): return nullcontext(pg_conn)
|
||||
monkeypatch.setattr(commercial, "engine", BoundEngine())
|
||||
monkeypatch.setattr(commercial, "ensure_commercial_schema", lambda: None)
|
||||
commercial.add_document_lines(first, [{"description": "line"}])
|
||||
ids = pg_conn.execute(text("""SELECT document_id::text, commercial_document_id::text
|
||||
FROM commercial_document_lines""")).first()
|
||||
assert ids == (first, first)
|
||||
|
||||
|
||||
def test_migration_007_append_only_and_down(pg_conn):
|
||||
pg_conn.exec_driver_sql(Path("migrations/007_document_reconciliation_v2.sql").read_text())
|
||||
oid, did = str(uuid.uuid4()), str(uuid.uuid4())
|
||||
pg_conn.execute(text("INSERT INTO opportunities(id) VALUES(CAST(:id AS UUID))"), {"id": oid})
|
||||
pg_conn.execute(text("""INSERT INTO commercial_documents(id,opportunity_id,document_kind)
|
||||
VALUES(CAST(:d AS UUID),CAST(:o AS UUID),'invoice')"""), {"d": did, "o": oid})
|
||||
event = pg_conn.execute(text("""INSERT INTO opportunity_document_link_events
|
||||
(opportunity_id,document_id,event_type,actor) VALUES(CAST(:o AS UUID),CAST(:d AS UUID),'TEST','test')
|
||||
RETURNING id::text"""), {"o": oid, "d": did}).scalar()
|
||||
with pytest.raises(Exception):
|
||||
pg_conn.execute(text("UPDATE opportunity_document_link_events SET actor='changed' WHERE id=CAST(:id AS UUID)"), {"id": event})
|
||||
pg_conn.rollback()
|
||||
pg_conn.execute(text("SET clientflow.v2_consumers_active='off'"))
|
||||
pg_conn.execute(text("SET clientflow.document_ledger_exported='on'"))
|
||||
pg_conn.exec_driver_sql(Path("migrations/007_document_reconciliation_v2_down.sql").read_text())
|
||||
assert pg_conn.execute(text("SELECT to_regclass('opportunity_document_links')")).scalar() is None
|
||||
Reference in New Issue
Block a user