"""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_existing_signature_duplicate_skips_without_creating_transactions(source, bank): commit(prepare(source(), bank)) duplicate = prepare(source(DATA.replace(b",1,", b",3,")), bank) assert duplicate.state == "ALREADY_IMPORTED" assert duplicate.counts == {"created": 0, "matched": 0, "skipped": 2} assert all(item.decision == {"action": "SKIP", "reason": "ALREADY_IMPORTED"} for item in ImportBatch.objects.get(pk=duplicate.batch_id).items.all()) assert commit(duplicate).counts == {"created": 0, "matched": 0, "skipped": 2} assert Transaction.objects.count() == SourceRecord.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"]