|
|
|
@ -1,11 +1,13 @@ |
|
|
|
"""Synthetic end-to-end checks for the owner reconciliation pages.""" |
|
|
|
"""Synthetic end-to-end checks for the owner reconciliation pages.""" |
|
|
|
|
|
|
|
|
|
|
|
from datetime import date |
|
|
|
from datetime import date, datetime |
|
|
|
from decimal import Decimal |
|
|
|
from decimal import Decimal |
|
|
|
|
|
|
|
import re |
|
|
|
|
|
|
|
|
|
|
|
import pytest |
|
|
|
import pytest |
|
|
|
from django.test import Client |
|
|
|
from django.test import Client |
|
|
|
from django.urls import reverse |
|
|
|
from django.urls import reverse |
|
|
|
|
|
|
|
from django.utils import timezone |
|
|
|
|
|
|
|
|
|
|
|
from finance.models import AuditEvent, ReconciliationCheckpoint |
|
|
|
from finance.models import AuditEvent, ReconciliationCheckpoint |
|
|
|
from finance.services import ( |
|
|
|
from finance.services import ( |
|
|
|
@ -50,6 +52,90 @@ def checkpoint(target, institution_balance): |
|
|
|
) |
|
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def preview_token(response): |
|
|
|
|
|
|
|
match = re.search(r'name="preview_token" value="([^"]+)"', response.content.decode()) |
|
|
|
|
|
|
|
return match.group(1) if match else "" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("field", ["account", "as_of_date", "institution_balance"]) |
|
|
|
|
|
|
|
def test_confirm_requires_new_preview_after_input_changes(client, field): |
|
|
|
|
|
|
|
bank = account() |
|
|
|
|
|
|
|
other = account("Synthetic other reconciliation bank") |
|
|
|
|
|
|
|
values = { |
|
|
|
|
|
|
|
"account": bank.pk, |
|
|
|
|
|
|
|
"as_of_date": "2026-09-01", |
|
|
|
|
|
|
|
"institution_balance": "0.00", |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
url = reverse("reconciliation-index") |
|
|
|
|
|
|
|
preview = client.post(url, {"action": "preview", **values}) |
|
|
|
|
|
|
|
changed = {**values, field: { |
|
|
|
|
|
|
|
"account": other.pk, |
|
|
|
|
|
|
|
"as_of_date": "2026-09-02", |
|
|
|
|
|
|
|
"institution_balance": "12.50", |
|
|
|
|
|
|
|
}[field]} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
response = client.post(url, { |
|
|
|
|
|
|
|
"action": "confirm", "preview_token": preview_token(preview), **changed, |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assert ReconciliationCheckpoint.objects.count() == 0 |
|
|
|
|
|
|
|
assert response.status_code == 200 |
|
|
|
|
|
|
|
assert response.context["form"].non_field_errors() |
|
|
|
|
|
|
|
assert 'name="action" value="confirm"' not in response.content.decode() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
refreshed = client.post(url, {"action": "preview", **changed}) |
|
|
|
|
|
|
|
confirmed = client.post(url, { |
|
|
|
|
|
|
|
"action": "confirm", "preview_token": preview_token(refreshed), **changed, |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
assert confirmed.status_code == 302 |
|
|
|
|
|
|
|
saved = ReconciliationCheckpoint.objects.get() |
|
|
|
|
|
|
|
assert str(saved.account_id) == str(changed["account"]) |
|
|
|
|
|
|
|
assert saved.as_of_date.isoformat() == changed["as_of_date"] |
|
|
|
|
|
|
|
assert saved.institution_balance == Decimal(changed["institution_balance"]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("token_kind", ["missing", "tampered"]) |
|
|
|
|
|
|
|
def test_confirm_rejects_missing_or_tampered_preview_token(client, token_kind): |
|
|
|
|
|
|
|
bank = account() |
|
|
|
|
|
|
|
values = { |
|
|
|
|
|
|
|
"account": bank.pk, |
|
|
|
|
|
|
|
"as_of_date": "2026-09-01", |
|
|
|
|
|
|
|
"institution_balance": "0.00", |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
url = reverse("reconciliation-index") |
|
|
|
|
|
|
|
preview = client.post(url, {"action": "preview", **values}) |
|
|
|
|
|
|
|
token = "" if token_kind == "missing" else preview_token(preview) + "tampered" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
response = client.post(url, { |
|
|
|
|
|
|
|
"action": "confirm", "preview_token": token, **values, |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assert ReconciliationCheckpoint.objects.count() == 0 |
|
|
|
|
|
|
|
assert response.status_code == 200 |
|
|
|
|
|
|
|
assert response.context["form"].non_field_errors() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_history_exposes_creation_timestamps_for_same_date_corrections(client): |
|
|
|
|
|
|
|
bank = account() |
|
|
|
|
|
|
|
first = checkpoint(bank, "0.00") |
|
|
|
|
|
|
|
correction = checkpoint(bank, "1.00") |
|
|
|
|
|
|
|
first_time = timezone.make_aware(datetime(2026, 9, 2, 10, 11, 12)) |
|
|
|
|
|
|
|
correction_time = timezone.make_aware(datetime(2026, 9, 2, 10, 11, 13)) |
|
|
|
|
|
|
|
ReconciliationCheckpoint.objects.filter(pk=first.pk).update(created_at=first_time) |
|
|
|
|
|
|
|
ReconciliationCheckpoint.objects.filter(pk=correction.pk).update(created_at=correction_time) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
response = client.get(reverse("reconciliation-history", args=[bank.pk])) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
body = response.content.decode() |
|
|
|
|
|
|
|
assert "Created at" in body |
|
|
|
|
|
|
|
assert "2026-09-02 10:11:12 UTC" in body |
|
|
|
|
|
|
|
assert "2026-09-02 10:11:13 UTC" in body |
|
|
|
|
|
|
|
assert body.index("10:11:13 UTC") < body.index("10:11:12 UTC") |
|
|
|
|
|
|
|
assert [row.created_at for row in response.context["history"]] == [ |
|
|
|
|
|
|
|
correction_time, first_time, |
|
|
|
|
|
|
|
] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reconciliation_is_linked_and_landing_form_lists_only_active_accounts(client): |
|
|
|
def test_reconciliation_is_linked_and_landing_form_lists_only_active_accounts(client): |
|
|
|
active = account("Synthetic active bank") |
|
|
|
active = account("Synthetic active bank") |
|
|
|
archived = account("Synthetic archived bank") |
|
|
|
archived = account("Synthetic archived bank") |
|
|
|
@ -121,6 +207,7 @@ def test_confirm_revalidates_and_service_snapshots_fresh_ledger_then_redirects(c |
|
|
|
"institution_balance": "-40.00", |
|
|
|
"institution_balance": "-40.00", |
|
|
|
"ledger_balance": "999999.00", |
|
|
|
"ledger_balance": "999999.00", |
|
|
|
"difference": "999999.00", |
|
|
|
"difference": "999999.00", |
|
|
|
|
|
|
|
"preview_token": preview_token(preview), |
|
|
|
}, |
|
|
|
}, |
|
|
|
) |
|
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|