from datetime import date from decimal import Decimal import pytest from django.test import Client from django.urls import reverse from finance.models import Account, AuditEvent, Category, Payee, SourceRecord, Transaction from finance.services import Actor, create_account, create_category, create_payee, record_transaction pytestmark = pytest.mark.django_db(transaction=True) SETUP_ACTOR = Actor(kind="AGENT", identifier="synthetic-web-test") def account(name="Synthetic Bank"): return create_account(name=name, kind="BANK", currency="BRL", actor=SETUP_ACTOR) def transaction(target, description="Synthetic purchase"): return record_transaction( account_id=target.pk, amount=Decimal("-24.50"), posting_date=date(2026, 1, 15), kind="EXPENSE", description=description, actor=SETUP_ACTOR, ) def test_root_and_transaction_pages_are_discoverable_and_filter_rows(client): bank = account() expected = transaction(bank) transaction(bank, "Other month").__class__.objects.filter(description="Other month").update( posting_date=date(2026, 2, 1) ) home = client.get(reverse("home")) page = client.get( reverse("transaction-list"), {"currency": "BRL", "date_from": "2026-01-01", "date_to": "2026-01-31"}, ) assert home.status_code == 200 assert page.status_code == 200 content = page.content.decode() assert "Synthetic purchase" in content assert "Other month" not in content assert reverse("transaction-detail", args=[expected.pk]) in content assert "

Transactions

" in content assert "Accounts" in content and "Categories" in content and "Payees" in content def test_invalid_transaction_filters_are_shown_without_querying(client): response = client.get(reverse("transaction-list"), {"date_from": "not-a-date"}) assert response.status_code == 200 assert "Enter a valid date." in response.content.decode() def test_transaction_detail_shows_relationships_and_provenance(client): bank = account() row = transaction(bank) SourceRecord.objects.create( transaction=row, document_hash="a" * 64, source_format="BANK_CSV", parser_version="synthetic-v1", item_locator="row:2", ) response = client.get(reverse("transaction-detail", args=[row.pk])) content = response.content.decode() assert response.status_code == 200 assert "Synthetic purchase" in content assert "Synthetic Bank" in content assert "BANK_CSV" in content assert "row:2" in content @pytest.mark.parametrize( "route,args", [ ("transaction-create", ()), ("transaction-annotate", ("00000000-0000-0000-0000-000000000001",)), ("account-create", ()), ("account-archive", ("00000000-0000-0000-0000-000000000001",)), ("category-create", ()), ("category-archive", ("00000000-0000-0000-0000-000000000001",)), ("payee-create", ()), ("payee-archive", ("00000000-0000-0000-0000-000000000001",)), ], ) def test_mutations_require_csrf(route, args): guarded = Client(enforce_csrf_checks=True) response = guarded.post(reverse(route, args=args), {}) assert response.status_code == 403 def test_manual_transaction_and_annotation_use_local_owner_audit(client): bank = account() category = create_category(name="Synthetic Food", kind="EXPENSE", actor=SETUP_ACTOR) payee = create_payee(name="Synthetic Merchant", actor=SETUP_ACTOR) created = client.post( reverse("transaction-create"), { "account": bank.pk, "amount": "-24.50", "posting_date": "2026-01-15", "occurrence_date": "", "kind": "EXPENSE", "description": "Synthetic purchase", "payee": "", "category": "", }, ) row = Transaction.objects.get(description="Synthetic purchase") assert created.status_code == 302 assert created.url == reverse("transaction-detail", args=[row.pk]) create_audit = AuditEvent.objects.get(operation="record_transaction", target_id=row.pk) assert (create_audit.actor_kind, create_audit.actor_identifier) == ("HUMAN", "local-owner") annotated = client.post( reverse("transaction-annotate", args=[row.pk]), {"description": "Synthetic revised", "payee": payee.pk, "category": category.pk}, ) row.refresh_from_db() assert annotated.status_code == 302 assert row.description == "Synthetic revised" assert row.payee_id == payee.pk and row.category_id == category.pk assert row.amount == Decimal("-24.50") and row.posting_date == date(2026, 1, 15) annotation_audit = AuditEvent.objects.get(operation="annotate_transaction", target_id=row.pk) assert (annotation_audit.actor_kind, annotation_audit.actor_identifier) == ("HUMAN", "local-owner") @pytest.mark.parametrize( "kind,create_route,list_route,archive_route,payload,model", [ ( "account", "account-create", "account-list", "account-archive", {"name": "Synthetic Savings", "kind": "BANK", "currency": "BRL"}, Account, ), ( "category", "category-create", "category-list", "category-archive", {"name": "Synthetic Dining", "kind": "EXPENSE"}, Category, ), ( "payee", "payee-create", "payee-list", "payee-archive", {"name": "Synthetic Shop"}, Payee, ), ], ) def test_reference_create_list_and_archive_are_audited( client, kind, create_route, list_route, archive_route, payload, model ): created = client.post(reverse(create_route), payload) obj = model.objects.get(name=payload["name"]) assert created.status_code == 302 assert created.url == reverse(list_route) assert payload["name"] in client.get(reverse(list_route)).content.decode() create_audit = AuditEvent.objects.get(operation=f"create_{kind}", target_id=obj.pk) assert create_audit.actor_identifier == "local-owner" archived = client.post(reverse(archive_route, args=[obj.pk])) obj.refresh_from_db() assert archived.status_code == 302 assert obj.archived is True assert "Archived" in client.get(reverse(list_route)).content.decode() archive_audit = AuditEvent.objects.get(operation="archive", target_id=obj.pk) assert archive_audit.actor_identifier == "local-owner" assert client.get(reverse(archive_route, args=[obj.pk])).status_code == 405 def test_domain_errors_render_as_safe_fixed_messages(client): bank = account() response = client.post( reverse("transaction-create"), { "account": bank.pk, "amount": "24.50", "posting_date": "2026-01-15", "occurrence_date": "", "kind": "EXPENSE", "description": "Synthetic invalid sign", "payee": "", "category": "", }, ) content = response.content.decode() assert response.status_code == 200 assert "Expense amounts must be negative." in content assert "Synthetic invalid sign" in content assert Transaction.objects.filter(description="Synthetic invalid sign").count() == 0