4 changed files with 513 additions and 14 deletions
@ -0,0 +1,310 @@
|
||||
"""Synthetic integration checks for deterministic guided imports.""" |
||||
|
||||
from dataclasses import asdict, replace |
||||
from datetime import date |
||||
from uuid import uuid4 |
||||
|
||||
import pytest |
||||
|
||||
from finance.models import Account, AuditEvent, SourceRecord, Transaction |
||||
from finance.services import Actor, DomainError, archive, create_account, record_transaction |
||||
from ingestion import guided_imports, services |
||||
from ingestion.models import ImportBatch |
||||
|
||||
pytestmark = pytest.mark.django_db(transaction=True) |
||||
ACTOR = Actor(kind="HUMAN", identifier="synthetic-guided-owner") |
||||
HEADER = "Data,Documento,Historico,Agencia,Lote,Valor\n" |
||||
ROW = "15/01/2026,1,Synthetic purchase,0,0,-12.50\n" |
||||
DATA = (HEADER + ROW + "16/01/2026,2,Synthetic second purchase,0,0,-7.25\n").encode() |
||||
|
||||
|
||||
@pytest.fixture |
||||
def bank(): |
||||
return create_account(name="Synthetic guided bank", kind="BANK", actor=ACTOR) |
||||
|
||||
|
||||
@pytest.fixture |
||||
def source(tmp_path, settings): |
||||
settings.IMPORT_ROOT = tmp_path |
||||
|
||||
def write(data=DATA, name="bank.csv"): |
||||
(tmp_path / name).write_bytes(data) |
||||
return next(row for row in guided_imports.discover_imports() if row.name == name) |
||||
|
||||
return write |
||||
|
||||
|
||||
def prepare(discovered, bank): |
||||
return guided_imports.prepare_import(discovered=discovered, section_mapping={"1": str(bank.pk)}, actor=ACTOR) |
||||
|
||||
|
||||
def commit(prepared): |
||||
return guided_imports.commit_prepared_import(prepared=prepared, actor=ACTOR) |
||||
|
||||
|
||||
def assert_no_open_preview(): |
||||
assert not ImportBatch.objects.filter(state="PREVIEW").exists() |
||||
|
||||
|
||||
def test_clean_preflight_is_inert_and_complete_commit_preserves_audit_and_provenance(source, bank): |
||||
discovered = source() |
||||
prepared = prepare(discovered, bank) |
||||
assert prepared.state == "READY" |
||||
assert prepared.code is None |
||||
assert prepared.document_hash == discovered.document_hash |
||||
assert prepared.counts == {"created": 2, "matched": 0, "skipped": 0} |
||||
assert not Transaction.objects.exists() |
||||
assert not SourceRecord.objects.exists() |
||||
batch = ImportBatch.objects.get(pk=prepared.batch_id) |
||||
assert batch.revision == prepared.revision == 2 |
||||
assert list(batch.items.values_list("decision", flat=True)) == [{"action": "CREATE"}] * 2 |
||||
result = commit(prepared) |
||||
assert result.state == "COMMITTED" |
||||
assert result.counts == {"created": 2, "matched": 0, "skipped": 0} |
||||
assert Transaction.objects.count() == SourceRecord.objects.count() == 2 |
||||
assert set(SourceRecord.objects.values_list("document_hash", flat=True)) == {discovered.document_hash} |
||||
assert set(AuditEvent.objects.filter(operation__startswith="import_").values_list("actor_identifier", flat=True)) == {ACTOR.identifier} |
||||
assert "Synthetic purchase" not in str(asdict(result)) |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
def test_exact_second_import_only_skips_and_commit_is_idempotent(source, bank): |
||||
discovered = source() |
||||
first = prepare(discovered, bank) |
||||
assert commit(first).counts["created"] == 2 |
||||
assert commit(first).counts["created"] == 2 |
||||
second = prepare(discovered, bank) |
||||
assert second.state == "ALREADY_IMPORTED" |
||||
assert second.counts == {"created": 0, "matched": 0, "skipped": 2} |
||||
assert all(item.decision == {"action": "SKIP", "reason": "ALREADY_IMPORTED"} |
||||
for item in ImportBatch.objects.get(pk=second.batch_id).items.all()) |
||||
assert commit(second).counts["skipped"] == 2 |
||||
assert Transaction.objects.count() == SourceRecord.objects.count() == 2 |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
def test_ambiguous_existing_duplicate_cancels_whole_file(source, bank): |
||||
commit(prepare(source(), bank)) |
||||
ambiguous = prepare(source(DATA.replace(b",1,", b",3,")), bank) |
||||
assert ambiguous.state == "NEEDS_ATTENTION" |
||||
assert ambiguous.code == "REVIEW_REQUIRED" |
||||
assert Transaction.objects.count() == 2 |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
@pytest.mark.parametrize("data", [ |
||||
(HEADER + ROW + ROW).encode(), |
||||
(HEADER + ROW + "31/02/2026,2,Synthetic invalid date,0,0,-2.00\n").encode(), |
||||
(HEADER + ROW + "synthetic malformed row\n").encode(), |
||||
]) |
||||
def test_uncertain_or_malformed_rows_cancel_without_any_decisions(source, bank, data): |
||||
prepared = prepare(source(data), bank) |
||||
assert prepared.state == "NEEDS_ATTENTION" |
||||
assert prepared.code == "REVIEW_REQUIRED" |
||||
assert not Transaction.objects.exists() |
||||
assert all(not item.decision for item in ImportBatch.objects.get().items.all()) |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
def test_exceptional_file_does_not_block_an_unrelated_ready_file(source, bank): |
||||
exceptional = source((HEADER + ROW + ROW).encode(), "ambiguous.csv") |
||||
ready = source() |
||||
results = [prepare(row, bank) for row in (exceptional, ready)] |
||||
assert [row.state for row in results] == ["NEEDS_ATTENTION", "READY"] |
||||
assert commit(results[1]).counts["created"] == 2 |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
@pytest.mark.parametrize("mapping", [{}, {"2": "ACCOUNT"}, {"1": "ACCOUNT", "2": "ACCOUNT"}, |
||||
{"1": None}, {"1": "not-an-id"}, {"1": str(uuid4())}]) |
||||
def test_bad_mapping_is_rejected_before_preview(source, bank, mapping): |
||||
mapping = {key: str(bank.pk) if value == "ACCOUNT" else value for key, value in mapping.items()} |
||||
prepared = guided_imports.prepare_import(discovered=source(), section_mapping=mapping, actor=ACTOR) |
||||
assert prepared.state == "NEEDS_ATTENTION" |
||||
assert prepared.code == "INVALID_SECTION_MAPPING" |
||||
assert not ImportBatch.objects.exists() |
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["archived", "currency"]) |
||||
def test_only_active_brl_accounts_can_be_mapped(source, bank, change): |
||||
if change == "archived": |
||||
archive(bank, actor=ACTOR) |
||||
else: |
||||
Account.objects.filter(pk=bank.pk).update(currency="USD") |
||||
prepared = prepare(source(), bank) |
||||
assert prepared.state == "NEEDS_ATTENTION" |
||||
assert prepared.code == "INVALID_SECTION_MAPPING" |
||||
assert not ImportBatch.objects.exists() |
||||
|
||||
|
||||
def test_wrong_account_kind_requires_attention_and_cancels(source): |
||||
card = create_account(name="Synthetic card", kind="CREDIT_CARD", actor=ACTOR) |
||||
prepared = prepare(source(), card) |
||||
assert prepared.state == "NEEDS_ATTENTION" |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
@pytest.mark.parametrize("stage", ["prepare", "commit"]) |
||||
def test_changed_file_cannot_use_scan_hash(source, bank, tmp_path, stage): |
||||
discovered = source() |
||||
prepared = prepare(discovered, bank) if stage == "commit" else None |
||||
(tmp_path / discovered.name).write_bytes(DATA.replace(b"-12.50", b"-13.50")) |
||||
result = commit(prepared) if prepared else prepare(discovered, bank) |
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert result.code == "FILE_CHANGED" |
||||
assert not Transaction.objects.exists() |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["../outside.csv", "/outside.csv", "nested/file.csv", "..\\outside.csv"]) |
||||
def test_tampered_filename_is_rejected(source, bank, name): |
||||
result = prepare(replace(source(), name=name), bank) |
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert result.code == "UNREADABLE_FILE" |
||||
assert not ImportBatch.objects.exists() |
||||
|
||||
|
||||
def test_symlink_replacement_is_rejected_before_commit(source, bank, tmp_path): |
||||
discovered = source() |
||||
prepared = prepare(discovered, bank) |
||||
original = tmp_path / discovered.name |
||||
other = tmp_path / "other.csv" |
||||
other.write_bytes(DATA) |
||||
original.unlink() |
||||
original.symlink_to(other) |
||||
result = commit(prepared) |
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert result.code == "UNREADABLE_FILE" |
||||
assert not Transaction.objects.exists() |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
@pytest.mark.parametrize("field,value", [("profile", "OUROCARD_FULL_V1"), ("sections", ("2",))]) |
||||
def test_altered_scan_metadata_is_rejected(source, bank, field, value): |
||||
result = prepare(replace(source(), **{field: value}), bank) |
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert result.code == "FILE_CHANGED" |
||||
assert not ImportBatch.objects.exists() |
||||
|
||||
|
||||
def test_stale_revision_cancels_unfinished_preview(source, bank): |
||||
prepared = prepare(source(), bank) |
||||
services.refresh_preview(batch_id=prepared.batch_id, revision=prepared.revision, actor=ACTOR) |
||||
result = commit(prepared) |
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert result.code == "STALE_REVISION" |
||||
assert not Transaction.objects.exists() |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
def test_new_duplicate_after_preparation_requires_new_review(source, bank): |
||||
prepared = prepare(source(), bank) |
||||
record_transaction(account_id=bank.pk, amount="-12.50", kind="EXPENSE", |
||||
posting_date=date(2026, 1, 15), occurrence_date=date(2026, 1, 15), |
||||
description="Synthetic purchase", actor=ACTOR) |
||||
result = commit(prepared) |
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert result.code == "STALE_PREVIEW" |
||||
assert Transaction.objects.count() == 1 |
||||
assert not SourceRecord.objects.exists() |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
def test_domain_failure_is_safe_and_rolls_back_then_cancels(source, bank, monkeypatch): |
||||
prepared = prepare(source(), bank) |
||||
original = services.record |
||||
calls = 0 |
||||
|
||||
def fail_second(**kwargs): |
||||
nonlocal calls |
||||
calls += 1 |
||||
if calls == 2: |
||||
raise DomainError("Synthetic source text must never be disclosed") |
||||
return original(**kwargs) |
||||
|
||||
monkeypatch.setattr(services, "record", fail_second) |
||||
result = commit(prepared) |
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert result.code == "IMPORT_FAILED" |
||||
assert not Transaction.objects.exists() |
||||
assert not SourceRecord.objects.exists() |
||||
assert "Synthetic source text" not in str(asdict(result)) |
||||
assert_no_open_preview() |
||||
|
||||
|
||||
def test_multi_section_card_creates_only_after_every_mapping_is_complete(source): |
||||
first = create_account(name="Synthetic first card", kind="CREDIT_CARD", actor=ACTOR) |
||||
second = create_account(name="Synthetic second card", kind="CREDIT_CARD", actor=ACTOR) |
||||
data = ("1 - SYNTHETIC FIRST\n" + f"15.01.2026{'Synthetic first':37}BR 12,50 0,00\n" |
||||
+ "2 - SYNTHETIC SECOND\n" + f"16.01.2026{'Synthetic second':37}BR 7,25 0,00\n").encode() |
||||
discovered = source(data, "cards.txt") |
||||
result = guided_imports.prepare_import(discovered=discovered, |
||||
section_mapping={"1": str(first.pk), "2": str(second.pk)}, actor=ACTOR) |
||||
assert result.state == "READY" |
||||
assert commit(result).counts["created"] == 2 |
||||
assert set(Transaction.objects.values_list("account_id", flat=True)) == {first.pk, second.pk} |
||||
|
||||
|
||||
def test_partial_missing_context_has_safe_code_and_no_preview(source): |
||||
card = create_account(name="Synthetic partial card", kind="CREDIT_CARD", actor=ACTOR) |
||||
discovered = source(f"31/12{'Synthetic card row':43}BR 12,50 0,00\n".encode(), "partial.txt") |
||||
result = prepare(discovered, card) |
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert result.code == "MISSING_CREATION_DATE" |
||||
assert not ImportBatch.objects.exists() |
||||
|
||||
|
||||
def test_partial_valid_context_resolves_new_year_and_commits(source): |
||||
card = create_account(name="Synthetic partial card", kind="CREDIT_CARD", actor=ACTOR) |
||||
data = ("\n" * 20 + "Data de emissão: 20/01/2026\n" |
||||
+ f"31/12{'Synthetic prior year':43}BR 12,50 0,00\n" |
||||
+ f"05/01{'Synthetic current year':43}BR 7,25 0,00\n").encode() |
||||
result = prepare(source(data, "partial.txt"), card) |
||||
assert result.state == "READY" |
||||
batch = ImportBatch.objects.get(pk=result.batch_id) |
||||
assert batch.context == {"start_date": "2025-02-01", "end_date": "2026-01-31"} |
||||
assert commit(result).counts["created"] == 2 |
||||
assert set(Transaction.objects.values_list("posting_date", flat=True)) == {date(2025, 12, 31), date(2026, 1, 5)} |
||||
|
||||
|
||||
def test_tampered_batch_identifier_returns_safe_result_without_cancelling_another_batch(source, bank): |
||||
first = prepare(source(), bank) |
||||
different = prepare(source(DATA.replace(b"-12.50", b"-13.50"), "second.csv"), bank) |
||||
result = commit(replace(first, batch_id=different.batch_id)) |
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert result.code == "INVALID_PREPARED_IMPORT" |
||||
assert ImportBatch.objects.get(pk=different.batch_id).state == "PREVIEW" |
||||
assert not Transaction.objects.exists() |
||||
|
||||
|
||||
def test_invalid_batch_identifier_is_safe(source, bank): |
||||
result = commit(replace(prepare(source(), bank), batch_id="invalid-id")) |
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert result.code == "INVALID_PREPARED_IMPORT" |
||||
|
||||
|
||||
def test_altered_prepared_source_cannot_cancel_unrelated_preview(source, bank, tmp_path): |
||||
first = prepare(source(), bank) |
||||
different = prepare(source(DATA.replace(b"-12.50", b"-13.50"), "second.csv"), bank) |
||||
(tmp_path / first.name).unlink() |
||||
result = commit(replace(first, batch_id=different.batch_id)) |
||||
assert result.state == "NEEDS_ATTENTION" |
||||
assert result.code == "INVALID_PREPARED_IMPORT" |
||||
assert ImportBatch.objects.get(pk=different.batch_id).state == "PREVIEW" |
||||
|
||||
|
||||
def test_partially_imported_source_skips_exact_rows_and_creates_only_new_rows(source, bank): |
||||
discovered = source() |
||||
batch = services.create_preview(data=DATA, profile=discovered.profile, |
||||
section_mapping={"1": str(bank.pk)}, actor=ACTOR) |
||||
first, second = batch.items.order_by("locator") |
||||
batch = services.apply_decisions(batch_id=batch.pk, revision=batch.revision, actor=ACTOR, decisions=[ |
||||
{"locator": first.locator, "action": "CREATE"}, |
||||
{"locator": second.locator, "action": "SKIP", "reason": "USER_EXCLUDED"}, |
||||
]) |
||||
services.commit_preview(batch_id=batch.pk, revision=batch.revision, actor=ACTOR) |
||||
prepared = prepare(discovered, bank) |
||||
assert prepared.state == "READY" |
||||
assert commit(prepared).counts == {"created": 1, "matched": 0, "skipped": 1} |
||||
assert Transaction.objects.count() == SourceRecord.objects.count() == 2 |
||||
assert_no_open_preview() |
||||
Loading…
Reference in new issue