From 8f5526c2c00cab067d680431957a9bf340305889 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 13 Jul 2026 13:38:36 -0700 Subject: [PATCH] feat(backend): add idempotent CRUD layer for items --- backend/app/crud.py | 77 ++++++++++++++++++++++++++++++++++++++ backend/tests/test_crud.py | 63 +++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 backend/app/crud.py create mode 100644 backend/tests/test_crud.py diff --git a/backend/app/crud.py b/backend/app/crud.py new file mode 100644 index 0000000..65884bf --- /dev/null +++ b/backend/app/crud.py @@ -0,0 +1,77 @@ +from datetime import datetime +from sqlalchemy import select +from sqlalchemy.orm import Session +from app.models import Item +from app.stages import is_valid_stage + +DONE_STAGES = {"done", "logged_in_bookmark"} + + +def create_item( + session: Session, + *, + modality: str, + raw_content: str, + captured_at: datetime, + discord_message_id: str | None, + sender_id: str | None = None, +) -> tuple[Item, bool]: + if discord_message_id is not None: + existing = session.scalar( + select(Item).where(Item.discord_message_id == discord_message_id) + ) + if existing is not None: + return existing, False + + item = Item( + modality=modality, + raw_content=raw_content, + captured_at=captured_at, + discord_message_id=discord_message_id, + sender_id=sender_id, + stage="new", + ) + session.add(item) + session.commit() + session.refresh(item) + return item, True + + +def list_items( + session: Session, + *, + stage: str | None = None, + modality: str | None = None, + include_done: bool = False, +) -> list[Item]: + stmt = select(Item) + if stage is not None: + stmt = stmt.where(Item.stage == stage) + if modality is not None: + stmt = stmt.where(Item.modality == modality) + if not include_done: + stmt = stmt.where(Item.stage.not_in(DONE_STAGES)) + stmt = stmt.order_by(Item.captured_at.desc()) + return list(session.scalars(stmt)) + + +def get_item(session: Session, item_id: int) -> Item | None: + return session.get(Item, item_id) + + +def update_item(session: Session, item_id: int, **fields) -> Item | None: + item = session.get(Item, item_id) + if item is None: + return None + + new_type = fields.get("type", item.type) + new_stage = fields.get("stage", item.stage) + if "stage" in fields and not is_valid_stage(new_type, new_stage): + raise ValueError(f"'{new_stage}' is not a valid stage for type '{new_type}'") + + for key, value in fields.items(): + setattr(item, key, value) + + session.commit() + session.refresh(item) + return item diff --git a/backend/tests/test_crud.py b/backend/tests/test_crud.py new file mode 100644 index 0000000..97b73b9 --- /dev/null +++ b/backend/tests/test_crud.py @@ -0,0 +1,63 @@ +from datetime import datetime, timezone +import pytest +from app import crud + + +def _now(): + return datetime.now(timezone.utc) + + +def test_create_item_sets_defaults(db_session): + item, created = crud.create_item( + db_session, + modality="bh_email", + raw_content="Hi, I have books to donate", + captured_at=_now(), + discord_message_id="msg-1", + ) + assert created is True + assert item.id is not None + assert item.stage == "new" + assert item.type is None + + +def test_create_item_is_idempotent_by_discord_message_id(db_session): + first, first_created = crud.create_item( + db_session, modality="bh_email", raw_content="a", captured_at=_now(), + discord_message_id="dup-1", + ) + second, second_created = crud.create_item( + db_session, modality="bh_email", raw_content="b (should be ignored)", + captured_at=_now(), discord_message_id="dup-1", + ) + assert first_created is True + assert second_created is False + assert second.id == first.id + assert second.raw_content == "a" + + +def test_list_items_excludes_done_by_default(db_session): + crud.create_item(db_session, modality="voice", raw_content="x", captured_at=_now(), + discord_message_id="m1") + done_item, _ = crud.create_item(db_session, modality="voice", raw_content="y", + captured_at=_now(), discord_message_id="m2") + crud.update_item(db_session, done_item.id, type="other", stage="new") + crud.update_item(db_session, done_item.id, stage="done") + + results = crud.list_items(db_session) + assert done_item.id not in [i.id for i in results] + + results_with_done = crud.list_items(db_session, include_done=True) + assert done_item.id in [i.id for i in results_with_done] + + +def test_update_item_rejects_invalid_stage_for_type(db_session): + item, _ = crud.create_item(db_session, modality="voice", raw_content="x", + captured_at=_now(), discord_message_id="m3") + crud.update_item(db_session, item.id, type="other") + with pytest.raises(ValueError): + crud.update_item(db_session, item.id, stage="pickup_scheduled") + + +def test_update_item_returns_none_for_missing_id(db_session): + assert crud.update_item(db_session, 9999, notes="x") is None