Files
fin/cli/tests/test_idempotency.py
T
EislM0203andClaude Opus 5 c9a1375fb9 fix: close the orphan-leg hole and add idempotency keys (frontier A1 + B3)
Two long-standing correctness gaps, both flagged in fin-research-frontier.

A1 — POST /api/transactions could write half a linked pair

  TransactionCreate inherited the full six-value type Literal and accepted a
  free-standing linked_account_id, so any client could create a lone
  `transfer` row with no partner. That is exactly the balance corruption
  invariant #1 exists to prevent: the orphan counts toward SUM(amount - fee)
  and paired-delete can never find a partner to remove it with.

  TransactionCreate now narrows type to the single-leg kinds and rejects a
  caller-supplied linked_account_id. TransactionBase stays wide on purpose —
  reads must keep describing historical rows of every type.

  splitwise_pay deliberately stays allowed. The two-leg creator uses that
  type, but a standalone row carrying splitwise_partner_share is also a
  legitimate record, and is what `fin tx add --type splitwise_pay
  --partner-share` writes. Narrowing to expense/income would have broken it.

B3 — no idempotency, so any retry double-booked

  Migration i9d0e1f2a3b4 adds a nullable client_ref to transactions.
  POST /api/transactions, /batch, /transfer and /settlement accept it, and
  crud.find_by_client_ref is checked before writing, so retrying a timed-out
  request replays the original rows instead of booking them again. NULL means
  "no idempotency requested" and never collides.

  Deviation from the frontier's sketch, which called for unique-when-not-null:
  the index is NOT unique. A transfer writes two rows and a batch writes many,
  all under one key, so uniqueness cannot live on the column. Suppression is a
  check-then-write inside the creators instead — sound here because SQLite
  serialises writers and the scheduler never writes transactions.

  Exposed on the MCP create_transaction/create_transfer/create_settlement
  tools, since a retrying agent is the case B3 was written about. The server
  instructions no longer tell agents that writes are non-idempotent.

Tests: 60 passing, up from 47. Thirteen new cover retries collapsing to one
write across all four endpoints, differing keys staying separate, NULL keys
not colliding, and the orphan-leg guard refusing transfer/settlement/manual
linkage while the four legitimate single-leg types still work.

Migration verified both directions on a scratch DB seeded at the previous
head: existing rows survive with NULL client_ref, index is created, and
downgrade drops cleanly with rows intact. Verified live over HTTP that a
transfer submitted three times with one client_ref yields one pair of legs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3qYLuu9jgXsCRXmJ18YGY
2026-08-22 22:08:54 +00:00

178 lines
6.7 KiB
Python

"""Idempotency keys — retrying a write must not double-book.
Without these, an agent or a flaky network that retries a timed-out create
books the money twice and nothing detects it. A caller-supplied client_ref
makes the retry replay the original result instead.
"""
import pytest
from fin_cli.client import FinClientError
def _bank(client, name="Checking"):
return client.create_account({"name": name, "type": "bank", "currency": "EUR"})
def _expense(account_id, client_ref=None, amount=-12.40):
data = {
"account_id": account_id,
"date": "2026-08-22",
"amount": amount,
"currency": "EUR",
"type": "expense",
}
if client_ref is not None:
data["client_ref"] = client_ref
return data
class TestSingleCreate:
def test_repeat_with_same_key_writes_once(self, client):
acct = _bank(client)
first = client.create_transaction(_expense(acct["id"], "trip-001"))
second = client.create_transaction(_expense(acct["id"], "trip-001"))
assert first["id"] == second["id"]
assert len(client.list_transactions(account_id=acct["id"])) == 1
def test_retry_replays_the_original_row_not_the_new_payload(self, client):
"""The key identifies the operation; a differing body does not create a second row."""
acct = _bank(client)
first = client.create_transaction(_expense(acct["id"], "trip-002", amount=-12.40))
second = client.create_transaction(_expense(acct["id"], "trip-002", amount=-999.00))
assert second["id"] == first["id"]
assert second["amount"] == -12.40
assert len(client.list_transactions(account_id=acct["id"])) == 1
def test_different_keys_write_separately(self, client):
acct = _bank(client)
client.create_transaction(_expense(acct["id"], "trip-003"))
client.create_transaction(_expense(acct["id"], "trip-004"))
assert len(client.list_transactions(account_id=acct["id"])) == 2
def test_no_key_means_no_deduplication(self, client):
"""NULL client_ref must not collide with other NULLs."""
acct = _bank(client)
client.create_transaction(_expense(acct["id"]))
client.create_transaction(_expense(acct["id"]))
assert len(client.list_transactions(account_id=acct["id"])) == 2
def test_client_ref_is_returned_on_reads(self, client):
acct = _bank(client)
client.create_transaction(_expense(acct["id"], "trip-005"))
stored = client.list_transactions(account_id=acct["id"])[0]
assert stored["client_ref"] == "trip-005"
class TestMultiLegWrites:
def test_repeated_transfer_keeps_two_legs(self, client):
a = _bank(client, "A")
b = _bank(client, "B")
payload = {
"from_account_id": a["id"], "to_account_id": b["id"],
"amount": 40.0, "currency": "EUR", "date": "2026-08-22",
"client_ref": "xfer-001",
}
first = client.create_transfer(payload)
second = client.create_transfer(payload)
assert len(first) == 2 and len(second) == 2
assert [t["id"] for t in first] == [t["id"] for t in second]
assert len(client.list_transactions()) == 2, "retry created a second pair of legs"
def test_repeated_settlement_keeps_two_legs(self, client):
bank = _bank(client, "Bank")
sw = client.create_account({"name": "SW", "type": "splitwise", "currency": "EUR"})
payload = {
"bank_account_id": bank["id"], "splitwise_account_id": sw["id"],
"amount": 25.0, "currency": "EUR", "date": "2026-08-22",
"direction": "splitwise_to_bank", "client_ref": "settle-001",
}
first = client.create_settlement(payload)
second = client.create_settlement(payload)
assert [t["id"] for t in first] == [t["id"] for t in second]
assert len(client.list_transactions()) == 2
def test_repeated_batch_writes_rows_once(self, client):
acct = _bank(client)
payload = {
"account_id": acct["id"], "date": "2026-08-22", "client_ref": "batch-001",
"items": [{"amount": 12.40}, {"amount": 4.20}, {"amount": 31.00}],
}
first = client.create_transactions_batch(payload)
second = client.create_transactions_batch(payload)
assert len(first) == 3
assert [t["id"] for t in first] == [t["id"] for t in second]
assert len(client.list_transactions(account_id=acct["id"])) == 3
def test_every_row_of_a_batch_carries_the_key(self, client):
"""The whole group shares one key, which is how the retry finds all of it."""
acct = _bank(client)
client.create_transactions_batch({
"account_id": acct["id"], "date": "2026-08-22", "client_ref": "batch-002",
"items": [{"amount": 1.0}, {"amount": 2.0}],
})
stored = client.list_transactions(account_id=acct["id"])
assert all(t["client_ref"] == "batch-002" for t in stored)
class TestOrphanLegGuard:
"""A1 — the raw create endpoint must not be able to write half a pair."""
@pytest.mark.parametrize("bad_type", ["transfer", "settlement"])
def test_two_leg_types_are_refused(self, client, bad_type):
acct = _bank(client)
with pytest.raises(FinClientError) as exc:
client.create_transaction({
"account_id": acct["id"], "date": "2026-08-22", "amount": -10.0,
"currency": "EUR", "type": bad_type,
})
assert exc.value.status_code == 422
assert client.list_transactions() == []
def test_single_leg_types_still_work(self, client):
"""Narrowing must not break the flows that legitimately post single rows."""
acct = _bank(client)
sw = client.create_account({"name": "SW", "type": "splitwise", "currency": "EUR"})
for account_id, t in [
(acct["id"], "expense"), (acct["id"], "income"),
(sw["id"], "splitwise_receive"), (sw["id"], "splitwise_pay"),
]:
created = client.create_transaction({
"account_id": account_id, "date": "2026-08-22", "amount": -5.0,
"currency": "EUR", "type": t,
})
assert created["type"] == t
def test_manual_linkage_is_refused(self, client):
"""A free-standing linked_account_id would fake half of a linked pair."""
a = _bank(client, "A")
b = _bank(client, "B")
with pytest.raises(FinClientError) as exc:
client.create_transaction({
"account_id": a["id"], "date": "2026-08-22", "amount": -10.0,
"currency": "EUR", "type": "expense", "linked_account_id": b["id"],
})
assert exc.value.status_code == 422
assert client.list_transactions() == []