Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1c6759c
feat: add integration test infrastructure
podmar Jun 11, 2026
77a4a50
test: locations CRUD, isolation, and delete-with-move
podmar Jun 12, 2026
43a2f1b
test: idems CRUD, isolation, barcode upsert and cascade delete
podmar Jun 15, 2026
9cee665
test: batches CRUD, merge-on-collision, adjust, expiry, and isolation
podmar Jun 15, 2026
ac4ee67
test: categories CRUD and isolation
podmar Jun 15, 2026
a7b0b7f
fix: check batch collision before setattr to avoid autoflush on PATCH
podmar Jun 15, 2026
15735d9
feat: add run-tests script, fix-test skill, and skill template guidance
podmar Jun 15, 2026
26fc123
fix: improve speed by truncating rather than dropping all tables afte…
podmar Jun 15, 2026
917d9fd
fix: guard against TEST_DATABASE_URL pointing at production
podmar Jun 16, 2026
1adec6c
test: make batch merge collision test explicit about location and expiry
podmar Jun 16, 2026
8a759e9
test: add expiry-date collision test for PATCH batch
podmar Jun 16, 2026
68bc28f
chore: clarify why NullPool is required in test setup
podmar Jun 16, 2026
7ed3f19
test: verify barcode uniqueness is scoped to household
podmar Jun 16, 2026
f548c1e
test: add isolation test for PATCH items
podmar Jun 16, 2026
0b350da
test: add isolation tests for PATCH, DELETE, and GET batches
podmar Jun 16, 2026
7708823
chore: include NullPool, tee, TRUNCATE into til
podmar Jun 16, 2026
f15c1fb
chore: restrict the uniqe barcode contraint to household in tech spec
podmar Jun 16, 2026
4c3e1fb
chore: include info on running tests and testing tooling in docs
podmar Jun 16, 2026
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
29 changes: 29 additions & 0 deletions .claude/commands/fix-test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
Read `docs/test-report.txt`. If the file doesn't exist, tell the user to run `./scripts/run-tests.sh` first and stop. If all tests passed, say so and stop.

Count the failures and tell the user how many there are before starting. Work through them one at a time in the order they appear in the report.

For each failure:

Show the test name and the full error message and traceback — do not truncate it.

Read the failing test function from its test file. Also read the router or source function it exercises — find it by tracing the HTTP path the test calls (e.g. `POST /items` → `app/routers/items.py`).

Diagnose which of these four failure modes applies:
- **Source bug**: the source code does not implement what the test expects. Fix the source.
- **Test bug**: the test asserts something wrong (wrong status code, wrong field name, wrong logic). Fix the test.
- **Fixture bug**: a shared fixture in `conftest.py` produces unexpected state. Fix the fixture.
- **Structural error**: import failure, missing dependency, schema mismatch. Name the root cause explicitly before proposing anything.

If the diagnosis is ambiguous between two modes, say which two and ask the user before touching anything.

For source bugs and test bugs: propose the exact change and state why you're confident in the diagnosis. Wait for the user to confirm before writing.

For fixture bugs: do the same, but name every test that uses the fixture so the user knows the blast radius.

For structural errors: do not propose a fix until the root cause is certain. Describe what you'd need to verify and ask whether to proceed.

After each fix, ask the user to re-run `./scripts/run-tests.sh` and confirm before continuing.

Do not commit. The user handles all commits.

When all failures are done, list every file changed and what was changed in each. List any failure you skipped and why.
2 changes: 2 additions & 0 deletions .claude/prompts/skill-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ Paste into the conversation before describing the skill you want:
---

Write this as direct instructions to Claude, not a document. No headers. Imperative voice throughout ("do X", "skip if Y"). For each thing that could go wrong, name the failure mode explicitly rather than stating a virtue. Include: where to get inputs, confirm-before-write if it writes to a file, "if nothing qualifies say so and stop" if relevant, and pacing (iterative: wait after each item / single-output: gather → draft → confirm → write).

Do not add a self-review step for quality or correctness — Claude is anchored to what it just produced and will tend to confirm rather than critique. For those, end the skill by telling the user they can send a follow-up message (e.g. "check this for X" or "/code-review"). A self-check step is fine for completeness against a concrete checklist (e.g. "confirm every failure was either fixed or explicitly skipped").
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -232,4 +232,5 @@ Thumbs.db
docs/context.md
docs/pr-description.md
docs/pr-comments.md
docs/lint-report.md
docs/lint-report.md
docs/test-report.txt
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,10 @@ Import `SQLAlchemyUserDatabase` directly from `fastapi_users_db_sqlalchemy`, not

- `./scripts/create-pr.sh` — opens a PR from the current branch
- `./scripts/fetch-pr-comments.sh` — fetches open PR review comments into `docs/pr-comments.md`
- `./scripts/run-tests.sh` — runs the test suite and saves output to `docs/test-report.txt`
- `/pr-description` — generates a PR description and saves to `docs/pr-description.md`
- `/review-comments` — works through `docs/pr-comments.md` one comment at a time
- `/fix-test` — works through `docs/test-report.txt` one failure at a time
- `/til` — updates `docs/til.md` with learnings from the current session
- `/update-spec` — updates `docs/spec.md` with project-specific API and data model decisions

Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ The loop looks like this:

1. `./scripts/run-lint.sh` — runs Ruff and Pyright, writes results to `docs/lint-report.md`
2. `/fix-lint` (Claude Code skill) — works through lint issues one file at a time, auto-fixing clear errors and confirming design decisions
3. `/pr-description` (Claude Code skill) — drafts the PR description
4. `./scripts/create-pr.sh` — opens a PR from the current branch
5. CI runs an automated Claude review (`.github/workflows/claude-review.yml`)
6. `./scripts/fetch-pr-comments.sh` — pulls review comments into `docs/pr-comments.md`
7. `/review-comments` (Claude Code skill) — works through each comment interactively, one at a time
3. `./scripts/run-tests.sh` — runs the test suite, writes results to `docs/test-report.txt`
4. `/fix-test` (Claude Code skill) — works through test failures one at a time, diagnosing root cause before applying any fix
5. `/pr-description` (Claude Code skill) — drafts the PR description
6. `./scripts/create-pr.sh` — opens a PR from the current branch
7. CI runs an automated Claude review (`.github/workflows/claude-review.yml`)
8. `./scripts/fetch-pr-comments.sh` — pulls review comments into `docs/pr-comments.md`
9. `/review-comments` (Claude Code skill) — works through each comment interactively, one at a time

## Stack

Expand Down
1 change: 1 addition & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# Use the asyncpg driver (required for async FastAPI)
# Use ssl=require (not sslmode=require) for asyncpg compatibility with Neon
DATABASE_URL=postgresql+asyncpg://user:password@host/dbname?ssl=require
TEST_DATABASE_URL=postgresql+asyncpg://user:password@host/test_dbname?ssl=require

# ── Auth (FastAPI Users) ─────────────────────────────────────────────
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
Expand Down
9 changes: 8 additions & 1 deletion backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ uv run fastapi dev app/main.py
| Variable | Description |
|-----------------------------|------------------------------------|
| `DATABASE_URL` | Neon PostgreSQL connection string |
| `TEST_DATABASE_URL` | Separate Neon database for tests — must differ from `DATABASE_URL` (tests truncate all tables) |
| `SECRET_KEY` | JWT signing secret |
| `ALGORITHM` | JWT algorithm (default: HS256) |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | Token lifetime (default: 30) |
Expand All @@ -50,5 +51,11 @@ uv run pyright
## Tests

```bash
# Run tests (from project root — writes output to docs/test-report.txt)
./scripts/run-tests.sh

# Or run directly from backend/
uv run pytest
```
```

Use `/fix-test` in Claude Code to work through any failures.
2 changes: 1 addition & 1 deletion backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@


class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
# no default value for database_url, it must be provided via environment variable or .env file
database_url: SecretStr = Field(
description="Database URL in the format: postgresql+asyncpg://user:password@host:port/database",
Expand Down
19 changes: 11 additions & 8 deletions backend/app/routers/batches.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,19 +168,19 @@ async def update_batch(
if location is None or location.household_id != user.household_id:
raise HTTPException(status_code=404, detail="Location not found")

for field, value in updates.items():
setattr(batch, field, value)
# Resolve what the new (location, expiry) would be after the update, then check
# for a collision BEFORE modifying the batch. Modifying first would mark the ORM
# object dirty; SQLAlchemy's autoflush would then emit the UPDATE before our SELECT,
# hitting the unique constraint instead of letting us handle the merge gracefully.
new_location_id = updates.get("location_id", batch.location_id)
new_expiry_date = updates.get("expiry_date", batch.expiry_date)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nice fix! This is exactly right — checking the collision before modifying the batch prevents SQLAlchemy's autoflush from firing the UPDATE prematurely.

Interview tip: This is a great example to walk through in an interview when discussing ORM behavior. The comment explains the "why" clearly, which is exactly what CLAUDE.md asks for. Well done.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Excellent fix! This correctly solves the autoflush issue. By calculating the hypothetical future values before modifying the batch object, you avoid marking it dirty, which would trigger an autoflush during the collision-check query and hit the unique constraint.

The comment explaining the "why" is perfect for a portfolio project — a reviewer will immediately understand the subtlety of ORM behavior here. Well done.


# Check whether the updated (item, location, expiry) would collide with an existing batch.
# If so, merge quantities into the surviving batch and delete this one — same behaviour
# as the location-delete move flow. Returns the surviving batch so the client sees the
# correct quantity at the destination without needing a separate fetch.
existing = (
await session.exec(
select(Batch).where(
Batch.item_id == batch.item_id,
Batch.location_id == batch.location_id,
Batch.expiry_date == batch.expiry_date,
Batch.location_id == new_location_id,
Batch.expiry_date == new_expiry_date,
Batch.household_id == user.household_id,
Batch.id != batch_id,
)
Expand All @@ -195,6 +195,9 @@ async def update_batch(
await session.refresh(existing)
return BatchRead.model_validate(existing)

for field, value in updates.items():
setattr(batch, field, value)

session.add(batch)
await session.commit()
await session.refresh(batch)
Expand Down
2 changes: 2 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,6 @@ testpaths = ["tests"]
[dependency-groups]
dev = [
"pyright>=1.1.410",
"pytest>=9.0.3",
"pytest-asyncio>=1.4.0",
]
Empty file added backend/tests/__init__.py
Empty file.
101 changes: 101 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import asyncio
from collections.abc import AsyncGenerator

import pytest
from httpx import ASGITransport, AsyncClient
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy import text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession

import app.database as _db


class _Env(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: SecretStr
test_database_url: SecretStr


_env = _Env() # type: ignore[call-arg]

if _env.database_url.get_secret_value() == _env.test_database_url.get_secret_value():
raise RuntimeError(
"TEST_DATABASE_URL must differ from DATABASE_URL — tests TRUNCATE all tables."
)

# Replace the app's engine and session factory with test-DB equivalents before
# app.main is imported. All subsequent imports see the patched module globals,
# so every request in tests hits the test DB — not production.
# NullPool creates a fresh connection per operation and never retains one between
# uses — required for async SQLAlchemy tests where each test runs in its own
# event loop. A retained connection would be bound to the loop that created it
# and fail with "Future attached to a different loop" in subsequent tests,
# because pytest-asyncio creates a new event loop per test function by default.
_test_engine = create_async_engine(
_env.test_database_url.get_secret_value(),

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 choice using NullPool. The comment on line 27-29 explains it well, but you could make it even clearer:

# NullPool creates a fresh connection per operation and never retains one between
# uses. This prevents "Future attached to a different loop" errors when running
# async tests where each test gets its own event loop (required with pytest-asyncio).

This level of detail helps someone reading this as a learning resource understand exactly when they'd hit this error (it's not obvious until you hit it!).

poolclass=NullPool,
)
_db.engine = _test_engine
_db._session_factory = async_sessionmaker(
_test_engine, class_=AsyncSession, expire_on_commit=False
)

from app.main import app # noqa: E402 — must follow the patch above


@pytest.fixture(scope="session", autouse=True)
def create_schema() -> None:
# Run DDL once per session rather than per test. asyncio.run() opens a fresh
# event loop here so the session-scoped fixture doesn't conflict with the
# function-scoped loops used by individual tests (required with NullPool).
async def _setup() -> None:
async with _test_engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.drop_all)
await conn.run_sync(SQLModel.metadata.create_all)

asyncio.run(_setup())


@pytest.fixture(autouse=True)
async def truncate_tables() -> None:
# TRUNCATE is DML, not DDL — much faster than drop/create per test.
# CASCADE handles FK ordering; RESTART IDENTITY resets sequences between tests.
table_names = ", ".join(f'"{t.name}"' for t in SQLModel.metadata.sorted_tables)
async with _test_engine.begin() as conn:
await conn.execute(text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE"))


@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient]:
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
yield ac


@pytest.fixture
async def auth(client: AsyncClient) -> dict[str, str]:
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"},
)
return {"Authorization": f"Bearer {resp.json()['access_token']}"}


@pytest.fixture
async def other_auth(client: AsyncClient) -> dict[str, str]:
"""Second user in a separate household — for isolation tests."""
await client.post(
"/auth/register", json={"email": "bob@test.com", "password": "testpass123"}
)
resp = await client.post(
"/auth/jwt/login", data={"username": "bob@test.com", "password": "testpass123"}
)
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
Loading