Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ venv = ".venv"
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
filterwarnings = [
# fastapi-users-db-sqlalchemy uses SQLAlchemy's session.execute() internally;
# this is their code, not ours, and the warning is noise in every test run.
"ignore::DeprecationWarning:fastapi_users_db_sqlalchemy",
]

[dependency-groups]
dev = [
Expand Down
59 changes: 59 additions & 0 deletions backend/tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from httpx import AsyncClient


async def test_register_creates_user(client: AsyncClient):
resp = await client.post(
"/auth/register", json={"email": "alice@test.com", "password": "testpass123"}
)
assert resp.status_code == 201
body = resp.json()
assert body["email"] == "alice@test.com"
assert "password" not in body


async def test_register_duplicate_email_returns_400(client: AsyncClient):
await client.post(
"/auth/register", json={"email": "alice@test.com", "password": "testpass123"}
)
resp = await client.post(
"/auth/register", json={"email": "alice@test.com", "password": "testpass123"}
)
assert resp.status_code == 400


async def test_register_invalid_email_returns_422(client: AsyncClient):
resp = await client.post(
"/auth/register", json={"email": "not-an-email", "password": "testpass123"}
)
assert resp.status_code == 422


async def test_login_returns_token(client: AsyncClient):
await client.post(
"/auth/register", json={"email": "alice@test.com", "password": "testpass123"}
)
resp = await client.post(
"/auth/jwt/login",
data={"username": "alice@test.com", "password": "testpass123"},
)
assert resp.status_code == 200
assert "access_token" in resp.json()


async def test_login_wrong_password_returns_400(client: AsyncClient):
await client.post(
"/auth/register", json={"email": "alice@test.com", "password": "testpass123"}
)
resp = await client.post(
"/auth/jwt/login",
data={"username": "alice@test.com", "password": "wrongpassword"},
)
assert resp.status_code == 400


async def test_login_unknown_email_returns_400(client: AsyncClient):
resp = await client.post(
"/auth/jwt/login",
data={"username": "nobody@test.com", "password": "testpass123"},
)
assert resp.status_code == 400
150 changes: 119 additions & 31 deletions backend/tests/test_batches.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,24 @@

@pytest.fixture
async def item_id(client, auth):
resp = await client.post("/items", json={"name": "Milk", "unit": "litre"}, headers=auth)
resp = await client.post(
"/items", json={"name": "Milk", "unit": "litre"}, headers=auth
)
return resp.json()["id"]


@pytest.fixture
async def batch_id(client, auth, item_id):
resp = await client.post(f"/items/{item_id}/batches", json={"quantity": 5}, headers=auth)
resp = await client.post(
f"/items/{item_id}/batches", json={"quantity": 5}, headers=auth
)
return resp.json()["id"]


async def test_create_batch(client, auth, item_id):
resp = await client.post(f"/items/{item_id}/batches", json={"quantity": 3}, headers=auth)
resp = await client.post(
f"/items/{item_id}/batches", json={"quantity": 3}, headers=auth
)
assert resp.status_code == 201
assert resp.json()["quantity"] == 3

Expand All @@ -24,7 +30,8 @@ async def test_create_batch_merges_on_collision(client: AsyncClient, auth, item_
from datetime import date, timedelta

pantry_id = next(
loc["id"] for loc in (await client.get("/locations", headers=auth)).json()
loc["id"]
for loc in (await client.get("/locations", headers=auth)).json()
if loc["name"] == "Pantry"
)
expiry = str(date.today() + timedelta(days=365))
Expand Down Expand Up @@ -53,7 +60,9 @@ async def test_list_batches(client, auth, item_id, batch_id):


async def test_update_batch_quantity(client, auth, batch_id):
resp = await client.patch(f"/batches/{batch_id}", json={"quantity": 10}, headers=auth)
resp = await client.patch(
f"/batches/{batch_id}", json={"quantity": 10}, headers=auth
)
assert resp.status_code == 200
assert resp.json()["quantity"] == 10

Expand All @@ -65,36 +74,57 @@ async def test_update_batch_merges_on_collision(client: AsyncClient, auth, item_
pantry_id = next(loc["id"] for loc in pantry_id if loc["name"] == "Pantry")

# Batch A in Pantry, batch B in Fridge — same item, same default expiry.
batch_a = (await client.post(f"/items/{item_id}/batches", json={"quantity": 3, "location_id": pantry_id}, headers=auth)).json()
batch_b = (await client.post(f"/items/{item_id}/batches", json={"quantity": 2, "location_id": fridge_id}, headers=auth)).json()
batch_a = (
await client.post(
f"/items/{item_id}/batches",
json={"quantity": 3, "location_id": pantry_id},
headers=auth,
)
).json()
batch_b = (
await client.post(
f"/items/{item_id}/batches",
json={"quantity": 2, "location_id": fridge_id},
headers=auth,
)
).json()

# Moving batch B to Pantry collides with batch A — should merge.
resp = await client.patch(f"/batches/{batch_b['id']}", json={"location_id": pantry_id}, headers=auth)
resp = await client.patch(
f"/batches/{batch_b['id']}", json={"location_id": pantry_id}, headers=auth
)
assert resp.status_code == 200
assert resp.json()["id"] == batch_a["id"]
assert resp.json()["quantity"] == 5


async def test_update_batch_expiry_merges_on_collision(client: AsyncClient, auth, item_id):
async def test_update_batch_expiry_merges_on_collision(
client: AsyncClient, auth, item_id
):
from datetime import date, timedelta

pantry_id = next(
loc["id"] for loc in (await client.get("/locations", headers=auth)).json()
loc["id"]
for loc in (await client.get("/locations", headers=auth)).json()
if loc["name"] == "Pantry"
)
expiry_a = str(date.today() + timedelta(days=30))
expiry_b = str(date.today() + timedelta(days=60))

batch_a = (await client.post(
f"/items/{item_id}/batches",
json={"quantity": 3, "location_id": pantry_id, "expiry_date": expiry_a},
headers=auth,
)).json()
batch_b = (await client.post(
f"/items/{item_id}/batches",
json={"quantity": 2, "location_id": pantry_id, "expiry_date": expiry_b},
headers=auth,
)).json()
batch_a = (
await client.post(
f"/items/{item_id}/batches",
json={"quantity": 3, "location_id": pantry_id, "expiry_date": expiry_a},
headers=auth,
)
).json()
batch_b = (
await client.post(
f"/items/{item_id}/batches",
json={"quantity": 2, "location_id": pantry_id, "expiry_date": expiry_b},
headers=auth,
)
).json()

# Changing batch B's expiry to match batch A → should merge.
resp = await client.patch(
Expand All @@ -113,26 +143,36 @@ async def test_delete_batch(client, auth, item_id, batch_id):


async def test_adjust_increases_quantity(client, auth, batch_id):
resp = await client.post(f"/batches/{batch_id}/adjust", json={"delta": 3}, headers=auth)
resp = await client.post(
f"/batches/{batch_id}/adjust", json={"delta": 3}, headers=auth
)
assert resp.status_code == 200
assert resp.json()["quantity"] == 8


async def test_adjust_decreases_quantity(client, auth, batch_id):
resp = await client.post(f"/batches/{batch_id}/adjust", json={"delta": -2}, headers=auth)
resp = await client.post(
f"/batches/{batch_id}/adjust", json={"delta": -2}, headers=auth
)
assert resp.status_code == 200
assert resp.json()["quantity"] == 3


async def test_adjust_to_zero_deletes_batch(client: AsyncClient, auth, item_id, batch_id):
resp = await client.post(f"/batches/{batch_id}/adjust", json={"delta": -5}, headers=auth)
async def test_adjust_to_zero_deletes_batch(
client: AsyncClient, auth, item_id, batch_id
):
resp = await client.post(
f"/batches/{batch_id}/adjust", json={"delta": -5}, headers=auth
)
assert resp.status_code == 204
batches = (await client.get(f"/items/{item_id}/batches", headers=auth)).json()
assert batches == []


async def test_adjust_below_zero_returns_422(client, auth, batch_id):
resp = await client.post(f"/batches/{batch_id}/adjust", json={"delta": -99}, headers=auth)
resp = await client.post(
f"/batches/{batch_id}/adjust", json={"delta": -99}, headers=auth
)
assert resp.status_code == 422


Expand All @@ -142,8 +182,16 @@ async def test_expiring(client: AsyncClient, auth, item_id):
expiring_soon = str(date.today() + timedelta(days=7))
not_expiring = str(date.today() + timedelta(days=60))

await client.post(f"/items/{item_id}/batches", json={"quantity": 1, "expiry_date": expiring_soon}, headers=auth)
await client.post(f"/items/{item_id}/batches", json={"quantity": 1, "expiry_date": not_expiring}, headers=auth)
await client.post(
f"/items/{item_id}/batches",
json={"quantity": 1, "expiry_date": expiring_soon},
headers=auth,
)
await client.post(
f"/items/{item_id}/batches",
json={"quantity": 1, "expiry_date": not_expiring},
headers=auth,
)

resp = await client.get("/expiring?days=30", headers=auth)
assert resp.status_code == 200
Expand All @@ -152,23 +200,58 @@ async def test_expiring(client: AsyncClient, auth, item_id):
assert not_expiring not in expiry_dates


async def test_expiring_boundary(client: AsyncClient, auth, item_id):
from datetime import date, timedelta

cutoff = str(date.today() + timedelta(days=7))
just_outside = str(date.today() + timedelta(days=8))

await client.post(
f"/items/{item_id}/batches",
json={"quantity": 1, "expiry_date": cutoff},
headers=auth,
)
await client.post(
f"/items/{item_id}/batches",
json={"quantity": 1, "expiry_date": just_outside},
headers=auth,
)

resp = await client.get("/expiring?days=7", headers=auth)
expiry_dates = [b["expiry_date"] for b in resp.json()]
assert cutoff in expiry_dates
assert just_outside not in expiry_dates

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good boundary test! This confirms that the <= operator on line 271 of batches.py is correct — batches expiring on the cutoff day should be included. A common off-by-one bug in expiry logic.

This is the kind of test that catches subtle regressions when someone "simplifies" the query later.



async def test_expiring_isolation(client: AsyncClient, auth, other_auth):
from datetime import date, timedelta

expiring_soon = str(date.today() + timedelta(days=7))

# Create a batch for user A expiring soon.
item_id = (await client.post("/items", json={"name": "Milk", "unit": "litre"}, headers=auth)).json()["id"]
await client.post(f"/items/{item_id}/batches", json={"quantity": 1, "expiry_date": expiring_soon}, headers=auth)
item_id = (
await client.post(
"/items", json={"name": "Milk", "unit": "litre"}, headers=auth
)
).json()["id"]
await client.post(
f"/items/{item_id}/batches",
json={"quantity": 1, "expiry_date": expiring_soon},
headers=auth,
)

# User B's expiring list should be empty — different household.
resp = await client.get("/expiring?days=30", headers=other_auth)
assert resp.status_code == 200
assert resp.json() == []


async def test_isolation_cannot_adjust_other_household_batch(client: AsyncClient, auth, other_auth, batch_id):
resp = await client.post(f"/batches/{batch_id}/adjust", json={"delta": -1}, headers=other_auth)
async def test_isolation_cannot_adjust_other_household_batch(
client: AsyncClient, auth, other_auth, batch_id
):
resp = await client.post(
f"/batches/{batch_id}/adjust", json={"delta": -1}, headers=other_auth
)
assert resp.status_code == 404


Expand All @@ -193,3 +276,8 @@ async def test_isolation_cannot_list_other_household_batches(
):
resp = await client.get(f"/items/{item_id}/batches", headers=other_auth)
assert resp.status_code == 404


async def test_unauthenticated_returns_401(client: AsyncClient):
resp = await client.get("/expiring")
assert resp.status_code == 401
35 changes: 28 additions & 7 deletions backend/tests/test_categories.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,29 +28,50 @@ async def test_create_duplicate_name_returns_409(client, auth):


async def test_update_category(client, auth, category_id):
resp = await client.patch(f"/categories/{category_id}", json={"name": "Toiletries"}, headers=auth)
resp = await client.patch(
f"/categories/{category_id}", json={"name": "Toiletries"}, headers=auth
)
assert resp.status_code == 200
assert resp.json()["name"] == "Toiletries"


async def test_delete_category(client, auth, category_id):
resp = await client.delete(f"/categories/{category_id}", headers=auth)
assert resp.status_code == 204
names = [cat["name"] for cat in (await client.get("/categories", headers=auth)).json()]
names = [
cat["name"] for cat in (await client.get("/categories", headers=auth)).json()
]
assert "Cleaning" not in names


async def test_cannot_delete_category_with_items(client: AsyncClient, auth, category_id):
await client.post("/items", json={"name": "Soap", "unit": "bar", "category_id": category_id}, headers=auth)
async def test_cannot_delete_category_with_items(
client: AsyncClient, auth, category_id
):
await client.post(
"/items",
json={"name": "Soap", "unit": "bar", "category_id": category_id},
headers=auth,
)
resp = await client.delete(f"/categories/{category_id}", headers=auth)
assert resp.status_code == 409


async def test_isolation_cannot_update_other_household_category(client: AsyncClient, auth, other_auth, category_id):
resp = await client.patch(f"/categories/{category_id}", json={"name": "Stolen"}, headers=other_auth)
async def test_isolation_cannot_update_other_household_category(
client: AsyncClient, auth, other_auth, category_id
):
resp = await client.patch(
f"/categories/{category_id}", json={"name": "Stolen"}, headers=other_auth
)
assert resp.status_code == 404


async def test_isolation_cannot_delete_other_household_category(client: AsyncClient, auth, other_auth, category_id):
async def test_isolation_cannot_delete_other_household_category(
client: AsyncClient, auth, other_auth, category_id
):
resp = await client.delete(f"/categories/{category_id}", headers=other_auth)
assert resp.status_code == 404


async def test_unauthenticated_returns_401(client: AsyncClient):
resp = await client.get("/categories")
assert resp.status_code == 401
Loading