diff --git a/backend/app/schemas/goal.py b/backend/app/schemas/goal.py index 1efd717..1610631 100644 --- a/backend/app/schemas/goal.py +++ b/backend/app/schemas/goal.py @@ -1,7 +1,7 @@ from datetime import date as date_ from decimal import Decimal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator class GoalCreate(BaseModel): @@ -19,10 +19,20 @@ class GoalUpdate(BaseModel): class GoalContributionCreate(BaseModel): # Negative allowed — a withdrawal from the goal is still a contribution # to its running total, just in the other direction. Zero is pointless. - amount: Decimal = Field(ne=0) + amount: Decimal date: date_ note: str | None = Field(default=None, max_length=200) + @field_validator("amount") + @classmethod + def _reject_zero(cls, value: Decimal) -> Decimal: + # Has to be a validator: pydantic has no "not equal" constraint, so + # the Field(ne=0) this replaces was parsed as an unknown keyword, + # stored as schema metadata, and never actually checked anything. + if value == 0: + raise ValueError("amount must not be zero") + return value + class GoalRead(BaseModel): id: int diff --git a/backend/tests/test_goals.py b/backend/tests/test_goals.py new file mode 100644 index 0000000..98638b8 --- /dev/null +++ b/backend/tests/test_goals.py @@ -0,0 +1,34 @@ +"""Savings goals: contribution rules. + +The contribution log accepts negative amounts on purpose (taking money back +out of a goal is still an entry in its running total), which is exactly why +zero has to be rejected explicitly rather than by a `gt=0` constraint. +""" +from httpx import AsyncClient + +from tests.helpers import money + + +async def _goal(client: AsyncClient) -> dict: + return (await client.post("/goals", json={"name": "Emergency fund", "target_amount": "1000"})).json() + + +async def test_zero_contribution_is_rejected(client: AsyncClient): + goal = await _goal(client) + + resp = await client.post(f"/goals/{goal['id']}/contributions", json={"amount": "0", "date": "2026-01-15"}) + + assert resp.status_code == 422 + assert money((await client.get("/goals")).json()[0]["current_amount"]) == money(0) + + +async def test_negative_contribution_is_allowed(client: AsyncClient): + """Withdrawing from a goal is a legitimate entry — the zero check must not + turn into a positive-only constraint.""" + goal = await _goal(client) + await client.post(f"/goals/{goal['id']}/contributions", json={"amount": "250", "date": "2026-01-15"}) + + resp = await client.post(f"/goals/{goal['id']}/contributions", json={"amount": "-100", "date": "2026-02-01"}) + + assert resp.status_code == 201 + assert money(resp.json()["current_amount"]) == money("150")