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.
This commit is contained in:
pyr0ball 2026-07-13 13:43:09 -07:00
parent 8f5526c2c0
commit a90c3fc172
4 changed files with 182 additions and 2 deletions

View file

@ -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):

66
backend/app/main.py Normal file
View file

@ -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()

35
backend/app/schemas.py Normal file
View file

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

View file

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