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.
312 lines
11 KiB
312 lines
11 KiB
import csv |
|
import json |
|
import threading |
|
from datetime import date, datetime, timezone as datetime_timezone |
|
from decimal import Decimal |
|
from hashlib import sha256 |
|
from io import BytesIO, StringIO |
|
from uuid import UUID |
|
from zipfile import ZipFile |
|
|
|
import pytest |
|
from django.db import connections, transaction |
|
|
|
import finance.exports as exports |
|
from finance.exports import build_export |
|
from finance.models import ( |
|
Account, |
|
AuditEvent, |
|
CardStatement, |
|
Category, |
|
InstallmentPlan, |
|
Payee, |
|
SourceRecord, |
|
Transaction, |
|
Transfer, |
|
) |
|
|
|
|
|
pytestmark = pytest.mark.django_db(transaction=True) |
|
|
|
EXPECTED_FILES = [ |
|
"accounts.csv", |
|
"payees.csv", |
|
"categories.csv", |
|
"statements.csv", |
|
"installment_plans.csv", |
|
"transactions.csv", |
|
"transfers.csv", |
|
"source_records.csv", |
|
] |
|
EXPECTED_HEADERS = { |
|
"accounts.csv": "id,name,kind,currency,archived,created_at,updated_at", |
|
"payees.csv": "id,name,archived,created_at,updated_at", |
|
"categories.csv": "id,name,kind,archived,created_at,updated_at", |
|
"statements.csv": ( |
|
"id,account_id,period_start,period_end,due_date,statement_balance,created_at,updated_at" |
|
), |
|
"installment_plans.csv": ( |
|
"id,account_id,description,total_installments,created_at,updated_at" |
|
), |
|
"transactions.csv": ( |
|
"id,account_id,amount,posting_date,occurrence_date,description,kind,payee_id," |
|
"category_id,statement_id,refund_of_id,installment_plan_id,installment_number," |
|
"created_at,updated_at" |
|
), |
|
"transfers.csv": "id,outgoing_id,incoming_id,created_at,updated_at", |
|
"source_records.csv": ( |
|
"id,transaction_id,document_hash,source_format,parser_version,item_locator,created_at" |
|
), |
|
} |
|
|
|
|
|
def rows(package, name): |
|
content = package.read(name).decode("utf-8") |
|
return list(csv.DictReader(StringIO(content))) |
|
|
|
|
|
def seed_complete_export(): |
|
account = Account.objects.create( |
|
id=UUID("10000000-0000-4000-8000-000000000002"), |
|
name="Synthetic Card", |
|
kind=Account.Kind.CREDIT_CARD, |
|
currency="BRL", |
|
archived=True, |
|
) |
|
Account.objects.create( |
|
id=UUID("10000000-0000-4000-8000-000000000001"), |
|
name="Synthetic Bank", |
|
kind=Account.Kind.BANK, |
|
currency="USD", |
|
) |
|
payee = Payee.objects.create( |
|
id=UUID("20000000-0000-4000-8000-000000000001"), |
|
name="Synthetic Merchant", |
|
) |
|
category = Category.objects.create( |
|
id=UUID("30000000-0000-4000-8000-000000000001"), |
|
name="Synthetic Food", |
|
kind=Category.Kind.EXPENSE, |
|
) |
|
statement = CardStatement.objects.create( |
|
id=UUID("40000000-0000-4000-8000-000000000001"), |
|
account=account, |
|
period_start=date(2026, 1, 1), |
|
period_end=date(2026, 1, 31), |
|
due_date=date(2026, 2, 10), |
|
statement_balance=Decimal("1234.50"), |
|
) |
|
plan = InstallmentPlan.objects.create( |
|
id=UUID("50000000-0000-4000-8000-000000000001"), |
|
account=account, |
|
description="Synthetic appliance", |
|
total_installments=3, |
|
) |
|
purchase = Transaction.objects.create( |
|
id=UUID("60000000-0000-4000-8000-000000000001"), |
|
account=account, |
|
amount=Decimal("-411.50"), |
|
posting_date=date(2026, 1, 12), |
|
occurrence_date=date(2026, 1, 10), |
|
description="Synthetic purchase, first part", |
|
kind=Transaction.Kind.EXPENSE, |
|
payee=payee, |
|
category=category, |
|
statement=statement, |
|
installment_plan=plan, |
|
installment_number=1, |
|
) |
|
refund = Transaction.objects.create( |
|
id=UUID("60000000-0000-4000-8000-000000000002"), |
|
account=account, |
|
amount=Decimal("11.50"), |
|
posting_date=date(2026, 2, 1), |
|
description="", |
|
kind=Transaction.Kind.REFUND, |
|
category=category, |
|
refund_of=purchase, |
|
) |
|
transfer_out = Transaction.objects.create( |
|
id=UUID("60000000-0000-4000-8000-000000000003"), |
|
account_id=UUID("10000000-0000-4000-8000-000000000001"), |
|
amount=Decimal("-20.00"), |
|
posting_date=date(2026, 2, 2), |
|
kind=Transaction.Kind.TRANSFER, |
|
) |
|
transfer_in = Transaction.objects.create( |
|
id=UUID("60000000-0000-4000-8000-000000000004"), |
|
account=account, |
|
amount=Decimal("20.00"), |
|
posting_date=date(2026, 2, 2), |
|
kind=Transaction.Kind.TRANSFER, |
|
) |
|
Transfer.objects.create( |
|
id=UUID("70000000-0000-4000-8000-000000000001"), |
|
outgoing=transfer_out, |
|
incoming=transfer_in, |
|
) |
|
SourceRecord.objects.create( |
|
id=UUID("80000000-0000-4000-8000-000000000001"), |
|
transaction=purchase, |
|
document_hash="a" * 64, |
|
source_format=SourceRecord.Format.OUROCARD_TXT, |
|
parser_version="synthetic-v1", |
|
item_locator="line:2", |
|
) |
|
AuditEvent.objects.create( |
|
actor_kind=AuditEvent.ActorKind.HUMAN, |
|
actor_identifier="synthetic-owner", |
|
operation="synthetic_operation", |
|
outcome=AuditEvent.Outcome.SUCCESS, |
|
) |
|
return account, purchase, refund |
|
|
|
|
|
def test_export_has_versioned_manifest_and_stable_csv_contract(): |
|
seed_complete_export() |
|
|
|
artifact = build_export() |
|
|
|
with ZipFile(BytesIO(artifact.content)) as package: |
|
assert package.namelist() == [*EXPECTED_FILES, "manifest.json"] |
|
manifest = json.loads(package.read("manifest.json")) |
|
assert manifest["format"] == "gizmoduck-export" |
|
assert manifest["version"] == 1 |
|
assert manifest["application_version"] == "0.1.0" |
|
assert list(manifest["files"]) == EXPECTED_FILES |
|
for name in EXPECTED_FILES: |
|
content = package.read(name) |
|
assert content.decode("utf-8").splitlines()[0] == EXPECTED_HEADERS[name] |
|
assert manifest["files"][name]["sha256"] == sha256(content).hexdigest() |
|
assert manifest["files"][name]["rows"] == len(rows(package, name)) |
|
|
|
|
|
def test_export_retains_portable_values_relationships_and_provenance(): |
|
account, purchase, refund = seed_complete_export() |
|
|
|
artifact = build_export() |
|
|
|
with ZipFile(BytesIO(artifact.content)) as package: |
|
account_rows = rows(package, "accounts.csv") |
|
assert [row["id"] for row in account_rows] == [ |
|
"10000000-0000-4000-8000-000000000001", |
|
"10000000-0000-4000-8000-000000000002", |
|
] |
|
assert account_rows[1]["archived"] == "true" |
|
assert account_rows[1]["currency"] == "BRL" |
|
|
|
statement_row = rows(package, "statements.csv")[0] |
|
assert statement_row["statement_balance"] == "1234.50" |
|
assert statement_row["due_date"] == "2026-02-10" |
|
|
|
transaction_rows = rows(package, "transactions.csv") |
|
purchase_row = next(row for row in transaction_rows if row["id"] == str(purchase.pk)) |
|
assert purchase_row["amount"] == "-411.50" |
|
assert purchase_row["occurrence_date"] == "2026-01-10" |
|
assert purchase_row["description"] == "Synthetic purchase, first part" |
|
assert purchase_row["account_id"] == str(account.pk) |
|
assert purchase_row["installment_number"] == "1" |
|
assert "T" in purchase_row["created_at"] |
|
|
|
refund_row = next(row for row in transaction_rows if row["id"] == str(refund.pk)) |
|
assert refund_row["occurrence_date"] == "" |
|
assert refund_row["payee_id"] == "" |
|
assert refund_row["refund_of_id"] == str(purchase.pk) |
|
|
|
source_row = rows(package, "source_records.csv")[0] |
|
assert source_row == { |
|
"id": "80000000-0000-4000-8000-000000000001", |
|
"transaction_id": str(purchase.pk), |
|
"document_hash": "a" * 64, |
|
"source_format": "OUROCARD_TXT", |
|
"parser_version": "synthetic-v1", |
|
"item_locator": "line:2", |
|
"created_at": source_row["created_at"], |
|
} |
|
assert "audit_events.csv" not in package.namelist() |
|
|
|
|
|
def test_export_is_byte_stable_for_the_same_snapshot(monkeypatch): |
|
seed_complete_export() |
|
fixed_now = datetime(2026, 9, 7, 12, 30, tzinfo=datetime_timezone.utc) |
|
monkeypatch.setattr("finance.exports.timezone.now", lambda: fixed_now) |
|
|
|
first = build_export() |
|
second = build_export() |
|
|
|
assert first.filename == second.filename == "gizmoduck-export-20260907T123000Z.zip" |
|
assert first.content == second.content |
|
|
|
|
|
def test_export_uses_one_repeatable_read_snapshot_across_related_files(monkeypatch): |
|
seed_complete_export() |
|
accounts_read = threading.Event() |
|
writer_finished = threading.Event() |
|
writer_errors = [] |
|
new_account_id = UUID("10000000-0000-4000-8000-000000000003") |
|
new_payee_id = UUID("20000000-0000-4000-8000-000000000002") |
|
new_transaction_id = UUID("60000000-0000-4000-8000-000000000005") |
|
new_source_id = UUID("80000000-0000-4000-8000-000000000002") |
|
original_csv_content = exports._csv_content |
|
|
|
def pause_after_accounts(model, fields): |
|
content = original_csv_content(model, fields) |
|
if model is Account: |
|
accounts_read.set() |
|
assert writer_finished.wait(10), "separate writer did not finish" |
|
return content |
|
|
|
def commit_related_rows(): |
|
try: |
|
assert accounts_read.wait(10), "export did not read accounts" |
|
with transaction.atomic(): |
|
account = Account.objects.create( |
|
id=new_account_id, |
|
name="Synthetic Concurrent Account", |
|
kind=Account.Kind.BANK, |
|
currency="BRL", |
|
) |
|
payee = Payee.objects.create( |
|
id=new_payee_id, |
|
name="Synthetic Concurrent Payee", |
|
) |
|
row = Transaction.objects.create( |
|
id=new_transaction_id, |
|
account=account, |
|
amount=Decimal("-7.25"), |
|
posting_date=date(2026, 3, 1), |
|
description="Synthetic concurrent transaction", |
|
kind=Transaction.Kind.EXPENSE, |
|
payee=payee, |
|
) |
|
SourceRecord.objects.create( |
|
id=new_source_id, |
|
transaction=row, |
|
document_hash="b" * 64, |
|
source_format=SourceRecord.Format.BANK_CSV, |
|
parser_version="synthetic-v2", |
|
item_locator="line:3", |
|
) |
|
except BaseException as exc: |
|
writer_errors.append(exc) |
|
finally: |
|
connections.close_all() |
|
writer_finished.set() |
|
|
|
monkeypatch.setattr(exports, "_csv_content", pause_after_accounts) |
|
writer = threading.Thread(target=commit_related_rows) |
|
writer.start() |
|
artifact = build_export() |
|
writer.join(10) |
|
|
|
assert not writer.is_alive() |
|
assert writer_errors == [] |
|
with ZipFile(BytesIO(artifact.content)) as package: |
|
assert str(new_account_id) not in {row["id"] for row in rows(package, "accounts.csv")} |
|
assert str(new_payee_id) not in {row["id"] for row in rows(package, "payees.csv")} |
|
assert str(new_transaction_id) not in { |
|
row["id"] for row in rows(package, "transactions.csv") |
|
} |
|
assert str(new_source_id) not in { |
|
row["id"] for row in rows(package, "source_records.csv") |
|
}
|
|
|