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.
 
 
 
 
 

215 lines
7.2 KiB

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 "<h1>Transactions</h1>" 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