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
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
|
|
|