From a90c3fc17266203326f130de69f6d7260f0c7329 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 13 Jul 2026 13:43:09 -0700 Subject: [PATCH] feat(backend): add FastAPI routes for items Wraps the Task 2 CRUD layer in a FastAPI app (schemas.py + main.py) with POST/GET/GET-by-id/PATCH routes for items, matching the exact paths and status codes the Discord bot's HTTP client will depend on. Also fixes a latent bug in db.get_engine: sqlite:///:memory: without a StaticPool gives each new session a fresh, empty database, which broke as soon as more than one session shared an engine (the API's per-request session pattern). Tasks 1-2 never hit this because their tests used a single session per engine. --- backend/app/db.py | 13 +++++- backend/app/main.py | 66 +++++++++++++++++++++++++++++++ backend/app/schemas.py | 35 +++++++++++++++++ backend/tests/test_items_api.py | 70 +++++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 backend/app/main.py create mode 100644 backend/app/schemas.py create mode 100644 backend/tests/test_items_api.py diff --git a/backend/app/db.py b/backend/app/db.py index 8fc4972..563e388 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -1,5 +1,6 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, DeclarativeBase +from sqlalchemy.pool import StaticPool class Base(DeclarativeBase): @@ -7,8 +8,16 @@ class Base(DeclarativeBase): def get_engine(db_url: str): - connect_args = {"check_same_thread": False} if db_url.startswith("sqlite") else {} - return create_engine(db_url, connect_args=connect_args) + if db_url.startswith("sqlite"): + connect_args = {"check_same_thread": False} + # In-memory SQLite is per-connection; without a shared StaticPool, + # each new session would see a fresh, empty database. + if ":memory:" in db_url: + return create_engine( + db_url, connect_args=connect_args, poolclass=StaticPool + ) + return create_engine(db_url, connect_args=connect_args) + return create_engine(db_url) def make_session_factory(engine): diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..74214ce --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,66 @@ +import os +from fastapi import FastAPI, HTTPException, Response +from app import crud +from app.db import get_engine, make_session_factory, init_db +from app.schemas import ItemCreate, ItemUpdate, ItemOut + + +def create_app(session_factory=None) -> FastAPI: + if session_factory is None: + db_url = os.environ.get("CHORUS_DB_URL", "sqlite:///./chorus.db") + engine = get_engine(db_url) + init_db(engine) + session_factory = make_session_factory(engine) + + app = FastAPI(title="Chorus API") + + @app.post("/items", response_model=ItemOut) + def post_item(payload: ItemCreate, response: Response): + session = session_factory() + try: + item, created = crud.create_item(session, **payload.model_dump()) + response.status_code = 201 if created else 200 + return item + finally: + session.close() + + @app.get("/items", response_model=list[ItemOut]) + def get_items(stage: str | None = None, modality: str | None = None, + include_done: bool = False): + session = session_factory() + try: + return crud.list_items(session, stage=stage, modality=modality, + include_done=include_done) + finally: + session.close() + + @app.get("/items/{item_id}", response_model=ItemOut) + def get_item(item_id: int): + session = session_factory() + try: + item = crud.get_item(session, item_id) + if item is None: + raise HTTPException(status_code=404, detail="Item not found") + return item + finally: + session.close() + + @app.patch("/items/{item_id}", response_model=ItemOut) + def patch_item(item_id: int, payload: ItemUpdate): + session = session_factory() + try: + fields = {k: v for k, v in payload.model_dump().items() if v is not None} + try: + item = crud.update_item(session, item_id, **fields) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + if item is None: + raise HTTPException(status_code=404, detail="Item not found") + return item + finally: + session.close() + + return app + + +app = create_app() diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..43a64bb --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,35 @@ +from datetime import datetime, date +from pydantic import BaseModel, ConfigDict + + +class ItemCreate(BaseModel): + modality: str + raw_content: str + captured_at: datetime + discord_message_id: str | None = None + sender_id: str | None = None + + +class ItemUpdate(BaseModel): + sender_id: str | None = None + type: str | None = None + stage: str | None = None + notes: str | None = None + follow_up_date: date | None = None + + +class ItemOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + modality: str + raw_content: str + captured_at: datetime + sender_id: str | None + type: str | None + stage: str + notes: str | None + follow_up_date: date | None + discord_message_id: str | None + created_at: datetime + updated_at: datetime diff --git a/backend/tests/test_items_api.py b/backend/tests/test_items_api.py new file mode 100644 index 0000000..685b93f --- /dev/null +++ b/backend/tests/test_items_api.py @@ -0,0 +1,70 @@ +import pytest +from fastapi.testclient import TestClient +from app.db import get_engine, make_session_factory, init_db +from app.main import create_app + + +@pytest.fixture() +def client(): + engine = get_engine("sqlite:///:memory:") + init_db(engine) + SessionLocal = make_session_factory(engine) + app = create_app(SessionLocal) + return TestClient(app) + + +def test_create_item_returns_201(client): + resp = client.post("/items", json={ + "modality": "bh_email", + "raw_content": "Books to donate", + "captured_at": "2026-07-13T07:00:00Z", + "discord_message_id": "abc-1", + }) + assert resp.status_code == 201 + body = resp.json() + assert body["stage"] == "new" + assert body["modality"] == "bh_email" + + +def test_create_item_idempotent_returns_200(client): + payload = { + "modality": "bh_email", "raw_content": "x", + "captured_at": "2026-07-13T07:00:00Z", "discord_message_id": "dup-9", + } + first = client.post("/items", json=payload) + second = client.post("/items", json=payload) + assert first.status_code == 201 + assert second.status_code == 200 + assert first.json()["id"] == second.json()["id"] + + +def test_list_items_excludes_done_by_default(client): + resp = client.post("/items", json={ + "modality": "voice", "raw_content": "call note", + "captured_at": "2026-07-13T07:00:00Z", "discord_message_id": "v-1", + }) + item_id = resp.json()["id"] + client.patch(f"/items/{item_id}", json={"type": "other"}) + client.patch(f"/items/{item_id}", json={"stage": "done"}) + + listed = client.get("/items").json() + assert item_id not in [i["id"] for i in listed] + + listed_all = client.get("/items?include_done=true").json() + assert item_id in [i["id"] for i in listed_all] + + +def test_patch_invalid_stage_returns_422(client): + resp = client.post("/items", json={ + "modality": "voice", "raw_content": "x", + "captured_at": "2026-07-13T07:00:00Z", "discord_message_id": "v-2", + }) + item_id = resp.json()["id"] + client.patch(f"/items/{item_id}", json={"type": "other"}) + bad = client.patch(f"/items/{item_id}", json={"stage": "pickup_scheduled"}) + assert bad.status_code == 422 + + +def test_get_missing_item_returns_404(client): + resp = client.get("/items/999999") + assert resp.status_code == 404