You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
358 lines
16 KiB
358 lines
16 KiB
"""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(source(), 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 |
|
directory = tmp_path / "staging" if stage == "commit" else tmp_path |
|
(directory / 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 / "staging" / 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_context_year_underflow_does_not_interrupt_another_ready_file(source, bank): |
|
card = create_account(name="Synthetic boundary card", kind="CREDIT_CARD", actor=ACTOR) |
|
data = ("\n" * 20 + "Data de emissão: 20/01/0001\n" |
|
+ f"05/01{'Synthetic boundary row':43}BR 12,50 0,00\n").encode() |
|
exceptional = source(data, "partial.txt") |
|
ready = source() |
|
results = [prepare(discovered, account) for discovered, account in ((exceptional, card), (ready, bank))] |
|
assert results[0].state == "NEEDS_ATTENTION" |
|
assert results[0].code == "INVALID_CREATION_DATE" |
|
assert results[0].batch_id is None |
|
assert results[1].state == "READY" |
|
assert commit(results[1]).counts["created"] == 2 |
|
assert Transaction.objects.count() == SourceRecord.objects.count() == 2 |
|
assert not ImportBatch.objects.filter(profile="OUROCARD_PARTIAL_V1").exists() |
|
assert_no_open_preview() |
|
|
|
|
|
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() |
|
|
|
|
|
def test_guided_import_stages_then_processes_committed_source(source, bank, tmp_path): |
|
discovered = source() |
|
prepared = prepare(discovered, bank) |
|
assert prepared.state == "READY" |
|
assert not (tmp_path / "bank.csv").exists() |
|
assert (tmp_path / "staging" / "bank.csv").read_bytes() == DATA |
|
processed = tmp_path / "processed" |
|
processed.mkdir(exist_ok=True) |
|
(processed / "bank.csv").write_bytes(b"synthetic previous source") |
|
|
|
committed = commit(prepared) |
|
|
|
assert committed.state == "COMMITTED" |
|
assert not (tmp_path / "bank.csv").exists() |
|
assert not (tmp_path / "staging" / "bank.csv").exists() |
|
assert (processed / "bank.csv").read_bytes() == DATA |
|
|
|
|
|
def test_guided_import_returns_failure_and_recovers_leftover_staging(source, bank, tmp_path): |
|
discovered = source(b"unsupported", "bad.csv") |
|
result = prepare(discovered, bank) |
|
|
|
assert result.state == "NEEDS_ATTENTION" |
|
assert (tmp_path / "bad.csv").exists() |
|
staging = tmp_path / "staging" |
|
staging.mkdir(exist_ok=True) |
|
(staging / "interrupted.csv").write_bytes(DATA) |
|
assert [row.name for row in guided_imports.discover_imports()] == ["bad.csv", "interrupted.csv"]
|
|
|