266 lines
8.2 KiB
Python
266 lines
8.2 KiB
Python
"""Tests for transaction CRUD endpoints."""
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
from tests.conftest import create_user_and_login
|
|
|
|
|
|
async def _get_category_id(client: AsyncClient, token: str, type_: str = "expense") -> str:
|
|
resp = await client.get(
|
|
f"/api/v1/categories?type={type_}",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
return resp.json()[0]["id"]
|
|
|
|
|
|
async def _create_tx(
|
|
client: AsyncClient,
|
|
token: str,
|
|
category_id: str,
|
|
*,
|
|
amount_cents: int = 5000,
|
|
type_: str = "expense",
|
|
date: str = "2026-03-15",
|
|
description: str | None = None,
|
|
) -> dict:
|
|
payload = {
|
|
"amount_cents": amount_cents,
|
|
"type": type_,
|
|
"category_id": category_id,
|
|
"transaction_date": date,
|
|
}
|
|
if description:
|
|
payload["description"] = description
|
|
resp = await client.post(
|
|
"/api/v1/transactions",
|
|
json=payload,
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
return resp.json()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_transaction(client: AsyncClient):
|
|
_, token = await create_user_and_login(client)
|
|
cat_id = await _get_category_id(client, token)
|
|
|
|
resp = await client.post(
|
|
"/api/v1/transactions",
|
|
json={
|
|
"amount_cents": 4500,
|
|
"type": "expense",
|
|
"category_id": cat_id,
|
|
"description": "Courses",
|
|
"transaction_date": "2026-03-15",
|
|
},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert resp.status_code == 201
|
|
data = resp.json()
|
|
assert data["amount_cents"] == 4500
|
|
assert data["type"] == "expense"
|
|
assert data["description"] == "Courses"
|
|
assert data["category"]["id"] == cat_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_transaction_invalid_amount(client: AsyncClient):
|
|
_, token = await create_user_and_login(client)
|
|
cat_id = await _get_category_id(client, token)
|
|
|
|
resp = await client.post(
|
|
"/api/v1/transactions",
|
|
json={"amount_cents": 0, "type": "expense", "category_id": cat_id, "transaction_date": "2026-03-15"},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_transactions(client: AsyncClient):
|
|
_, token = await create_user_and_login(client)
|
|
cat_id = await _get_category_id(client, token)
|
|
|
|
await _create_tx(client, token, cat_id, amount_cents=1000)
|
|
await _create_tx(client, token, cat_id, amount_cents=2000)
|
|
|
|
resp = await client.get(
|
|
"/api/v1/transactions",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total"] == 2
|
|
assert len(data["items"]) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_transactions_filter_by_month(client: AsyncClient):
|
|
_, token = await create_user_and_login(client)
|
|
cat_id = await _get_category_id(client, token)
|
|
|
|
await _create_tx(client, token, cat_id, date="2026-02-10")
|
|
await _create_tx(client, token, cat_id, date="2026-03-15")
|
|
await _create_tx(client, token, cat_id, date="2026-03-20")
|
|
|
|
resp = await client.get(
|
|
"/api/v1/transactions?month=2026-03",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total"] == 2
|
|
for item in data["items"]:
|
|
assert item["transaction_date"].startswith("2026-03")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_transactions_filter_by_category(client: AsyncClient):
|
|
_, token = await create_user_and_login(client)
|
|
cats = (await client.get("/api/v1/categories?type=expense", headers={"Authorization": f"Bearer {token}"})).json()
|
|
cat_a = cats[0]["id"]
|
|
cat_b = cats[1]["id"]
|
|
|
|
await _create_tx(client, token, cat_a)
|
|
await _create_tx(client, token, cat_b)
|
|
|
|
resp = await client.get(
|
|
f"/api/v1/transactions?category_id={cat_a}",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
data = resp.json()
|
|
assert data["total"] == 1
|
|
assert data["items"][0]["category"]["id"] == cat_a
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_transactions_filter_by_type(client: AsyncClient):
|
|
_, token = await create_user_and_login(client)
|
|
exp_cat = await _get_category_id(client, token, "expense")
|
|
inc_cat = await _get_category_id(client, token, "income")
|
|
|
|
await _create_tx(client, token, exp_cat, type_="expense")
|
|
await _create_tx(client, token, inc_cat, type_="income")
|
|
|
|
resp = await client.get(
|
|
"/api/v1/transactions?type=income",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
data = resp.json()
|
|
assert data["total"] == 1
|
|
assert data["items"][0]["type"] == "income"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_transaction(client: AsyncClient):
|
|
_, token = await create_user_and_login(client)
|
|
cat_id = await _get_category_id(client, token)
|
|
tx = await _create_tx(client, token, cat_id)
|
|
|
|
resp = await client.get(
|
|
f"/api/v1/transactions/{tx['id']}",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["id"] == tx["id"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_transaction(client: AsyncClient):
|
|
_, token = await create_user_and_login(client)
|
|
cat_id = await _get_category_id(client, token)
|
|
tx = await _create_tx(client, token, cat_id, amount_cents=1000)
|
|
|
|
resp = await client.put(
|
|
f"/api/v1/transactions/{tx['id']}",
|
|
json={"amount_cents": 9999, "description": "Updated"},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["amount_cents"] == 9999
|
|
assert resp.json()["description"] == "Updated"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_soft_delete_transaction(client: AsyncClient):
|
|
_, token = await create_user_and_login(client)
|
|
cat_id = await _get_category_id(client, token)
|
|
tx = await _create_tx(client, token, cat_id)
|
|
|
|
del_resp = await client.delete(
|
|
f"/api/v1/transactions/{tx['id']}",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert del_resp.status_code == 204
|
|
|
|
# GET should 404
|
|
get_resp = await client.get(
|
|
f"/api/v1/transactions/{tx['id']}",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert get_resp.status_code == 404
|
|
|
|
# Should not appear in list
|
|
list_resp = await client.get(
|
|
"/api/v1/transactions",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
ids = [t["id"] for t in list_resp.json()["items"]]
|
|
assert tx["id"] not in ids
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transaction_isolation_between_users(client: AsyncClient):
|
|
"""User A cannot see or modify User B's transactions."""
|
|
_, token_a = await create_user_and_login(client, email="a@example.com")
|
|
_, token_b = await create_user_and_login(client, email="b@example.com")
|
|
|
|
cat_id_a = await _get_category_id(client, token_a)
|
|
tx = await _create_tx(client, token_a, cat_id_a)
|
|
|
|
# User B gets 404 on User A's transaction
|
|
resp = await client.get(
|
|
f"/api/v1/transactions/{tx['id']}",
|
|
headers={"Authorization": f"Bearer {token_b}"},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
# User B's list is empty
|
|
list_resp = await client.get(
|
|
"/api/v1/transactions",
|
|
headers={"Authorization": f"Bearer {token_b}"},
|
|
)
|
|
assert list_resp.json()["total"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pagination(client: AsyncClient):
|
|
_, token = await create_user_and_login(client)
|
|
cat_id = await _get_category_id(client, token)
|
|
|
|
for i in range(5):
|
|
await _create_tx(client, token, cat_id, amount_cents=100 * (i + 1))
|
|
|
|
resp = await client.get(
|
|
"/api/v1/transactions?page=1&per_page=2",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
data = resp.json()
|
|
assert data["total"] == 5
|
|
assert len(data["items"]) == 2
|
|
assert data["page"] == 1
|
|
assert data["per_page"] == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_nonexistent_transaction_returns_404(client: AsyncClient):
|
|
_, token = await create_user_and_login(client)
|
|
fake_id = "00000000-0000-0000-0000-000000000000"
|
|
resp = await client.get(
|
|
f"/api/v1/transactions/{fake_id}",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert resp.status_code == 404
|