"""Synthetic acceptance workflow for the complete local MVP.""" import json from contextlib import contextmanager from datetime import date from decimal import Decimal from pathlib import Path from types import SimpleNamespace from zipfile import ZipFile import pytest from django.test import Client from django.urls import reverse from core.backup import ( DatabaseSnapshot, DatabaseTarget, create_backup, verify_backup, ) from finance.exports import build_export from finance.models import ReconciliationCheckpoint, SourceRecord, Transaction from finance.services import Actor, create_account, create_category, record_transaction from ingestion import services as import_services pytestmark = pytest.mark.django_db(transaction=True) ACTOR = Actor(kind="AGENT", identifier="synthetic-mvp-acceptance") FIXTURE = Path("tests/fixtures/synthetic/ingestion/bank.csv") def test_owner_guided_import_then_exact_reimport_skip(tmp_path, settings): settings.IMPORT_ROOT = tmp_path (tmp_path / "owner-bank.csv").write_bytes(FIXTURE.read_bytes()) account = create_account(name="Synthetic guided acceptance bank", kind="BANK", actor=ACTOR) browser = Client(enforce_csrf_checks=True) page = browser.get(reverse("imports-index")) csrf = page.cookies["csrftoken"].value for index, expected in enumerate(({"created": 2, "matched": 0, "skipped": 0}, {"created": 0, "matched": 0, "skipped": 2})): if index: (tmp_path / "owner-bank.csv").write_bytes(FIXTURE.read_bytes()) scanned = browser.post(reverse("imports-index"), { "action": "scan", "csrfmiddlewaretoken": csrf, }, follow=True) assert scanned.status_code == 200 assert "Synthetic purchase" not in scanned.content.decode() field = next(iter(scanned.context["files"][0]["form"])) prepared = browser.post(reverse("imports-index"), { "action": "prepare", "csrfmiddlewaretoken": csrf, "scan_token": scanned.context["scan_token"], field.html_name: str(account.pk), }, follow=True) result = browser.post(reverse("imports-index"), { "action": "commit", "csrfmiddlewaretoken": csrf, "prepared_token": prepared.context["prepared_token"], }, follow=True) assert result.status_code == 200 assert result.context["files"][0]["state"] == "COMMITTED" assert result.context["files"][0]["counts"] == expected assert Transaction.objects.count() == SourceRecord.objects.count() == 2 def test_owner_reconciliation_workflow_reports_backdated_activity(): account = create_account( name="Synthetic acceptance reconciliation account", kind="BANK", currency="BRL", actor=ACTOR, ) record_transaction( account_id=account.pk, amount=Decimal("-50.00"), posting_date=date(2026, 4, 10), kind="EXPENSE", actor=ACTOR, ) browser = Client() checkpoint = { "account": account.pk, "as_of_date": "2026-04-30", "institution_balance": "-50.00", } preview = browser.post( reverse("reconciliation-index"), {"action": "preview", **checkpoint}, ) assert preview.status_code == 200 assert "Preview" in preview.content.decode() assert "0.00 BRL" in preview.content.decode() assert ReconciliationCheckpoint.objects.count() == 0 confirmed = browser.post( reverse("reconciliation-index"), {"action": "confirm", "preview_token": preview.context["preview_token"], **checkpoint}, ) assert confirmed.status_code == 302 assert confirmed.url == reverse("reconciliation-history", args=[account.pk]) assert ReconciliationCheckpoint.objects.count() == 1 record_transaction( account_id=account.pk, amount=Decimal("-5.00"), posting_date=date(2026, 4, 15), kind="EXPENSE", actor=ACTOR, ) dashboard = browser.get( reverse("home"), {"month": "2026-04", "currency": "BRL"}, ) assert dashboard.status_code == 200 assert "Synthetic acceptance reconciliation account" in dashboard.content.decode() assert "Changed since reconciliation" in dashboard.content.decode() def test_synthetic_import_to_report_export_and_backup_workflow( tmp_path, settings, monkeypatch ): account = create_account( name="Synthetic acceptance bank", kind="BANK", currency="BRL", actor=ACTOR, ) category = create_category( name="Synthetic acceptance spending", kind="EXPENSE", actor=ACTOR, ) batch = import_services.create_preview( data=FIXTURE.read_bytes(), profile="BANK_CSV_V1", section_mapping={"1": str(account.pk)}, actor=ACTOR, ) assert Transaction.objects.count() == 0 decisions = [ {"locator": item.locator, "action": "CREATE"} for item in batch.items.order_by("locator") ] batch = import_services.apply_decisions( batch_id=batch.pk, revision=batch.revision, decisions=decisions, actor=ACTOR, ) batch = import_services.commit_preview( batch_id=batch.pk, revision=batch.revision, actor=ACTOR, ) assert batch.result["created"] == 2 assert Transaction.objects.count() == SourceRecord.objects.count() == 2 expense = Transaction.objects.get(kind=Transaction.Kind.EXPENSE) browser = Client(enforce_csrf_checks=True) form = browser.get(reverse("transaction-annotate", args=[expense.pk])) csrf_token = form.cookies["csrftoken"].value response = browser.post( reverse("transaction-annotate", args=[expense.pk]), { "csrfmiddlewaretoken": csrf_token, "description": expense.description, "payee": "", "category": str(category.pk), }, ) assert response.status_code == 302 expense.refresh_from_db() assert expense.category_id == category.pk report = browser.get( reverse("monthly-report"), {"month": "2026-02", "currency": "BRL"}, ) assert report.status_code == 200 assert report.context["summary"].inflow == Decimal("100.00") assert report.context["summary"].expense == Decimal("12.34") assert report.context["summary"].net_activity == Decimal("87.66") assert report.context["spending_rows"][0].category_name == category.name export = build_export() export_path = tmp_path / export.filename export_path.write_bytes(export.content) with ZipFile(export_path) as archive: manifest = json.loads(archive.read("manifest.json")) assert manifest["format"] == "gizmoduck-export" assert manifest["files"]["transactions.csv"]["rows"] == 2 assert manifest["files"]["source_records.csv"]["rows"] == 2 assert category.name.encode() in archive.read("categories.csv") settings.BACKUP_ROOT = tmp_path settings.BACKUP_VERIFY_ALLOWED = True snapshot = DatabaseSnapshot( migrations=(("finance", "0001_initial"), ("ingestion", "0001_initial")), row_counts={"finance_transaction": 2, "finance_sourcerecord": 2}, ) @contextmanager def exported_snapshot(_target): yield "synthetic-mvp-snapshot", snapshot class Runner: def __init__(self): self.calls = [] def __call__(self, argv, **kwargs): self.calls.append(list(argv)) if argv[0] == "pg_dump" and argv != ["pg_dump", "--version"]: dump_name = next( value.removeprefix("--file=") for value in argv if value.startswith("--file=") ) Path(dump_name).write_bytes(b"PGDMP synthetic acceptance") return SimpleNamespace(stdout=b"pg_dump (PostgreSQL) 17.6\n") monkeypatch.setattr("core.backup._exported_snapshot", exported_snapshot) backup_runner = Runner() backup = create_backup("synthetic-mvp.dump", runner=backup_runner) assert any(call[0] == "pg_dump" for call in backup_runner.calls) assert json.loads(backup.manifest_path.read_text(encoding="utf-8"))[ "row_counts" ] == snapshot.row_counts target = DatabaseTarget( host="restore-db", port="5432", name="gizmoduck_restore", user="gizmoduck_restore", password="synthetic-restore-password", ) observed = iter( [ DatabaseSnapshot(migrations=(), row_counts={}), snapshot, ] ) monkeypatch.setattr("core.backup._read_snapshot", lambda _target: next(observed)) restore_runner = Runner() result = verify_backup(backup.name, target, runner=restore_runner) assert result.row_counts == snapshot.row_counts assert restore_runner.calls[0][0] == "pg_restore" assert "--single-transaction" in restore_runner.calls[0]