"""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