Files
fin-tracker/backend/tests/api/test_links_api.py
T
Dmitry ae82df6fa7 feat(ledger): резолв несматченных линков ZenMoney↔брокер
GET /links/unmatched, POST /links, DELETE /links/{id}: ручная привязка того,
что ledger/matching.py не смог сопоставить автоматически.
2026-09-19 10:38:39 +03:00

105 lines
4.0 KiB
Python

"""Manual review of ZenMoney <-> broker cash-flow links, over the API."""
from datetime import timedelta
import pytest
from httpx import AsyncClient
from factories import make_account, make_event, make_txn
from fintracker.analytics import today_local
from fintracker.models import AccountKind, AccountRole, EventKind
@pytest.fixture
async def leftover(app) -> dict[str, int]:
"""A transfer and a deposit too far apart in amount to auto-match: both stay unmatched."""
card = await make_account(name="Карта")
broker_account = await make_account(
name="ИИС", kind=AccountKind.broker, role=AccountRole.investment, source="tinvest"
)
mirror = await make_account(
name="ИИС (зеркало)", include_in_net_worth=False, mirror_of_account_id=broker_account
)
d = today_local() - timedelta(days=10)
txn_id = await make_txn(
d, income="10000", income_account_id=mirror, outcome="10000", outcome_account_id=card
)
event_id = await make_event(d, account_id=broker_account, kind=EventKind.deposit, amount="9500")
return {"txn": txn_id, "event": event_id, "broker_account": broker_account}
async def test_unmatched_lists_both_sides_of_a_leftover(
client: AsyncClient, auth_headers: dict[str, str], leftover: dict[str, int]
):
r = await client.get("/api/v1/links/unmatched", headers=auth_headers)
assert r.status_code == 200, r.text
body = r.json()
assert [c["cash_txn_id"] for c in body["zm"]] == [leftover["txn"]]
assert [c["event_id"] for c in body["broker"]] == [leftover["event"]]
async def test_manual_link_reclassifies_the_transfer_and_leaves_unmatched(
client: AsyncClient, auth_headers: dict[str, str], leftover: dict[str, int]
):
r = await client.post(
"/api/v1/links",
headers=auth_headers,
json={"cash_txn_id": leftover["txn"], "event_id": leftover["event"]},
)
assert r.status_code == 201, r.text
link = r.json()
assert link["kind"] == "manual"
assert link["amount_delta"] == "500.0000000000"
r = await client.get("/api/v1/links/unmatched", headers=auth_headers)
assert r.json() == {"zm": [], "broker": []}
r = await client.get("/api/v1/transactions", headers=auth_headers)
txn = next(t for t in r.json()["items"] if t["id"] == leftover["txn"])
assert txn["flow_type"] == "internal_transfer"
async def test_a_linked_side_cannot_be_linked_again(
client: AsyncClient, auth_headers: dict[str, str], leftover: dict[str, int]
):
body = {"cash_txn_id": leftover["txn"], "event_id": leftover["event"]}
r = await client.post("/api/v1/links", headers=auth_headers, json=body)
assert r.status_code == 201, r.text
other_event = await make_event(
today_local(), account_id=leftover["broker_account"], kind=EventKind.deposit, amount="1"
)
r = await client.post(
"/api/v1/links",
headers=auth_headers,
json={"cash_txn_id": leftover["txn"], "event_id": other_event},
)
assert r.status_code == 409
async def test_delete_link_brings_the_pair_back_to_unmatched(
client: AsyncClient, auth_headers: dict[str, str], leftover: dict[str, int]
):
body = {"cash_txn_id": leftover["txn"], "event_id": leftover["event"]}
r = await client.post("/api/v1/links", headers=auth_headers, json=body)
link_id = r.json()["id"]
r = await client.delete(f"/api/v1/links/{link_id}", headers=auth_headers)
assert r.status_code == 204
r = await client.get("/api/v1/links/unmatched", headers=auth_headers)
body = r.json()
assert [c["cash_txn_id"] for c in body["zm"]] == [leftover["txn"]]
assert [c["event_id"] for c in body["broker"]] == [leftover["event"]]
async def test_unknown_ids_are_a_problem(client: AsyncClient, auth_headers: dict[str, str]):
r = await client.post(
"/api/v1/links", headers=auth_headers, json={"cash_txn_id": 999, "event_id": 999}
)
assert r.status_code == 404
r = await client.delete("/api/v1/links/999", headers=auth_headers)
assert r.status_code == 404