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.
217 lines
7.1 KiB
217 lines
7.1 KiB
"""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_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]
|
|
|