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.
 
 
 
 
 

361 lines
12 KiB

from datetime import date
from decimal import Decimal
from html import unescape
import re
from urllib.parse import parse_qs, urlsplit
import pytest
from django.conf import settings
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,
DomainError,
create_account,
create_category,
create_payee,
create_plan,
create_statement,
create_transfer,
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 "<h1>Transactions</h1>" in content
assert "Accounts" in content and "Categories" in content and "Payees" in content
def test_loopback_runtime_serves_the_application_stylesheet(client):
assert settings.DEBUG is False
assert settings.LOCAL_SERVE_STATIC is True
response = client.get("/static/core/app.css")
assert response.status_code == 200
assert b"font-family: system-ui" in b"".join(response.streaming_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_pagination_preserves_filters_and_selected_controls(client):
bank = account()
category = create_category(name="Synthetic Food", kind="EXPENSE", actor=SETUP_ACTOR)
payee = create_payee(name="Synthetic Merchant", actor=SETUP_ACTOR)
Transaction.objects.bulk_create([
Transaction(
account=bank,
amount=Decimal("-1.00"),
posting_date=date(2026, 1, 15),
kind="EXPENSE",
description=f"Synthetic row {number}",
category=category,
payee=payee,
)
for number in range(51)
])
response = client.get(reverse("transaction-list"), {
"currency": "BRL",
"payee": str(payee.pk),
"category": str(category.pk),
"uncategorized": "no",
"page": "1",
})
content = response.content.decode()
next_href = unescape(re.search(r'href="([^"]+)">Next', content).group(1))
assert parse_qs(urlsplit(next_href).query) == {
"currency": ["BRL"],
"payee": [str(payee.pk)],
"category": [str(category.pk)],
"uncategorized": ["no"],
"page": ["2"],
}
assert f'<option value="{payee.pk}" selected>' in content
assert f'<option value="{category.pk}" selected>' in content
assert '<option value="no" selected>Categorized</option>' in content
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
def test_transaction_details_show_all_financial_relationships(client):
bank = account()
other = account("Synthetic Other")
card = create_account(name="Synthetic Card", kind="CREDIT_CARD", currency="BRL", actor=SETUP_ACTOR)
statement = create_statement(
account_id=card.pk,
period_start=date(2026, 1, 1),
period_end=date(2026, 1, 31),
due_date=date(2026, 2, 10),
actor=SETUP_ACTOR,
)
plan = create_plan(
account_id=card.pk,
description="Synthetic appliance plan",
total_installments=3,
actor=SETUP_ACTOR,
)
installment = record_transaction(
account_id=card.pk,
amount="-30.00",
posting_date=date(2026, 1, 15),
kind="EXPENSE",
description="Synthetic installment",
statement_id=statement.pk,
installment_plan_id=plan.pk,
installment_number=2,
actor=SETUP_ACTOR,
)
refund = record_transaction(
account_id=bank.pk,
amount="10.00",
posting_date=date(2026, 1, 20),
kind="REFUND",
description="Synthetic refund",
refund_of_id=installment.pk,
actor=SETUP_ACTOR,
)
transfer = create_transfer(
outgoing_account_id=bank.pk,
incoming_account_id=other.pk,
amount="15.00",
outgoing_date=date(2026, 1, 21),
incoming_date=date(2026, 1, 22),
actor=SETUP_ACTOR,
)
installment_page = client.get(reverse("transaction-detail", args=[installment.pk])).content.decode()
refund_page = client.get(reverse("transaction-detail", args=[refund.pk])).content.decode()
outgoing_page = client.get(reverse("transaction-detail", args=[transfer.outgoing_id])).content.decode()
incoming_page = client.get(reverse("transaction-detail", args=[transfer.incoming_id])).content.decode()
assert "Statement" in installment_page and "2026-01-31" in installment_page
assert "Synthetic appliance plan" in installment_page and "2 of 3" in installment_page
assert "Refunds" in installment_page and "Synthetic refund" in installment_page
assert "Refund of" in refund_page and "Synthetic installment" in refund_page
assert "Transfer counterpart" in outgoing_page and "Synthetic Other" in outgoing_page
assert "Transfer counterpart" in incoming_page and "Synthetic Bank" in incoming_page
@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
def test_unknown_domain_error_uses_safe_fallback_without_exposing_code(client, monkeypatch):
bank = account()
def rejected_write(**_values):
raise DomainError("synthetic_private_error_code")
monkeypatch.setattr("core.views.record_transaction", rejected_write)
response = client.post(reverse("transaction-create"), {
"account": bank.pk,
"amount": "-24.50",
"posting_date": "2026-01-15",
"occurrence_date": "",
"kind": "EXPENSE",
"description": "Synthetic rejected transaction",
"payee": "",
"category": "",
})
content = response.content.decode()
assert response.status_code == 200
assert "Unable to save this change." in content
assert "synthetic_private_error_code" not in content