|
|
|
|
@ -1,5 +1,6 @@
|
|
|
|
|
import csv |
|
|
|
|
import json |
|
|
|
|
import threading |
|
|
|
|
from datetime import date, datetime, timezone as datetime_timezone |
|
|
|
|
from decimal import Decimal |
|
|
|
|
from hashlib import sha256 |
|
|
|
|
@ -8,7 +9,9 @@ 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, |
|
|
|
|
@ -35,11 +38,26 @@ EXPECTED_FILES = [
|
|
|
|
|
"transfers.csv", |
|
|
|
|
"source_records.csv", |
|
|
|
|
] |
|
|
|
|
EXPECTED_TRANSACTION_HEADER = ( |
|
|
|
|
"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" |
|
|
|
|
) |
|
|
|
|
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): |
|
|
|
|
@ -156,9 +174,9 @@ def test_export_has_versioned_manifest_and_stable_csv_contract():
|
|
|
|
|
assert manifest["version"] == 1 |
|
|
|
|
assert manifest["application_version"] == "0.1.0" |
|
|
|
|
assert list(manifest["files"]) == EXPECTED_FILES |
|
|
|
|
assert package.read("transactions.csv").decode("utf-8").splitlines()[0] == EXPECTED_TRANSACTION_HEADER |
|
|
|
|
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)) |
|
|
|
|
|
|
|
|
|
@ -218,3 +236,77 @@ def test_export_is_byte_stable_for_the_same_snapshot(monkeypatch):
|
|
|
|
|
|
|
|
|
|
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") |
|
|
|
|
} |
|
|
|
|
|