diff --git a/circuitforge_core/community/store.py b/circuitforge_core/community/store.py index 140563e..1625073 100644 --- a/circuitforge_core/community/store.py +++ b/circuitforge_core/community/store.py @@ -1,7 +1,208 @@ # circuitforge_core/community/store.py # MIT License -# Stub — implemented in full by Task 5 + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from .models import CommunityPost + +if TYPE_CHECKING: + from .db import CommunityDB + +logger = logging.getLogger(__name__) + + +def _row_to_post(row: dict) -> CommunityPost: + """Convert a psycopg2 row dict to a CommunityPost. + + JSONB columns (slots, dietary_tags, allergen_flags, flavor_molecules) come + back from psycopg2 as Python lists already -- no json.loads() needed. + """ + return CommunityPost( + slug=row["slug"], + pseudonym=row["pseudonym"], + post_type=row["post_type"], + published=row["published"], + title=row["title"], + description=row.get("description"), + photo_url=row.get("photo_url"), + slots=row.get("slots") or [], + recipe_id=row.get("recipe_id"), + recipe_name=row.get("recipe_name"), + level=row.get("level"), + outcome_notes=row.get("outcome_notes"), + seasoning_score=row["seasoning_score"] or 0.0, + richness_score=row["richness_score"] or 0.0, + brightness_score=row["brightness_score"] or 0.0, + depth_score=row["depth_score"] or 0.0, + aroma_score=row["aroma_score"] or 0.0, + structure_score=row["structure_score"] or 0.0, + texture_profile=row.get("texture_profile") or "", + dietary_tags=row.get("dietary_tags") or [], + allergen_flags=row.get("allergen_flags") or [], + flavor_molecules=row.get("flavor_molecules") or [], + fat_pct=row.get("fat_pct"), + protein_pct=row.get("protein_pct"), + moisture_pct=row.get("moisture_pct"), + ) + + +def _cursor_to_dict(cur, row) -> dict: + """Convert a psycopg2 row tuple to a dict using cursor.description.""" + if isinstance(row, dict): + return row + return {desc[0]: val for desc, val in zip(cur.description, row)} class SharedStore: - pass + """Base class for product community stores. + + Subclass this in each product: + class KiwiCommunityStore(SharedStore): + def list_posts_for_week(self, week_start: str) -> list[CommunityPost]: ... + + All methods return new objects (immutable pattern). Never mutate rows in-place. + """ + + def __init__(self, db: "CommunityDB") -> None: + self._db = db + + # ------------------------------------------------------------------ + # Reads + # ------------------------------------------------------------------ + + def get_post_by_slug(self, slug: str) -> CommunityPost | None: + conn = self._db.getconn() + try: + with conn.cursor() as cur: + cur.execute( + "SELECT * FROM community_posts WHERE slug = %s LIMIT 1", + (slug,), + ) + row = cur.fetchone() + if row is None: + return None + return _row_to_post(_cursor_to_dict(cur, row)) + finally: + self._db.putconn(conn) + + def list_posts( + self, + limit: int = 20, + offset: int = 0, + post_type: str | None = None, + dietary_tags: list[str] | None = None, + allergen_exclude: list[str] | None = None, + source_product: str | None = None, + ) -> list[CommunityPost]: + """Paginated post list with optional filters. + + dietary_tags: JSONB containment -- posts must include ALL listed tags. + allergen_exclude: JSONB overlap exclusion -- posts must NOT include any listed flag. + """ + conn = self._db.getconn() + try: + conditions = [] + params: list = [] + + if post_type: + conditions.append("post_type = %s") + params.append(post_type) + if dietary_tags: + import json + conditions.append("dietary_tags @> %s::jsonb") + params.append(json.dumps(dietary_tags)) + if allergen_exclude: + import json + conditions.append("NOT (allergen_flags && %s::jsonb)") + params.append(json.dumps(allergen_exclude)) + if source_product: + conditions.append("source_product = %s") + params.append(source_product) + + where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + params.extend([limit, offset]) + + with conn.cursor() as cur: + cur.execute( + f"SELECT * FROM community_posts {where} " + "ORDER BY published DESC LIMIT %s OFFSET %s", + params, + ) + rows = cur.fetchall() + return [_row_to_post(_cursor_to_dict(cur, r)) for r in rows] + finally: + self._db.putconn(conn) + + # ------------------------------------------------------------------ + # Writes + # ------------------------------------------------------------------ + + def insert_post(self, post: CommunityPost) -> CommunityPost: + """Insert a new community post. Returns the inserted post (unchanged -- slug is the key).""" + import json + + conn = self._db.getconn() + try: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO community_posts ( + slug, pseudonym, post_type, published, title, description, photo_url, + slots, recipe_id, recipe_name, level, outcome_notes, + seasoning_score, richness_score, brightness_score, + depth_score, aroma_score, structure_score, texture_profile, + dietary_tags, allergen_flags, flavor_molecules, + fat_pct, protein_pct, moisture_pct, source_product + ) VALUES ( + %s, %s, %s, %s, %s, %s, %s, + %s::jsonb, %s, %s, %s, %s, + %s, %s, %s, %s, %s, %s, %s, + %s::jsonb, %s::jsonb, %s::jsonb, + %s, %s, %s, %s + ) + """, + ( + post.slug, post.pseudonym, post.post_type, + post.published, post.title, post.description, post.photo_url, + json.dumps(list(post.slots)), + post.recipe_id, post.recipe_name, post.level, post.outcome_notes, + post.seasoning_score, post.richness_score, post.brightness_score, + post.depth_score, post.aroma_score, post.structure_score, + post.texture_profile, + json.dumps(list(post.dietary_tags)), + json.dumps(list(post.allergen_flags)), + json.dumps(list(post.flavor_molecules)), + post.fat_pct, post.protein_pct, post.moisture_pct, + "kiwi", + ), + ) + conn.commit() + return post + except Exception: + conn.rollback() + raise + finally: + self._db.putconn(conn) + + def delete_post(self, slug: str, pseudonym: str) -> bool: + """Hard-delete a post. Only succeeds if pseudonym matches the author. + + Returns True if a row was deleted, False if no matching row found. + """ + conn = self._db.getconn() + try: + with conn.cursor() as cur: + cur.execute( + "DELETE FROM community_posts WHERE slug = %s AND pseudonym = %s", + (slug, pseudonym), + ) + conn.commit() + return cur.rowcount > 0 + except Exception: + conn.rollback() + raise + finally: + self._db.putconn(conn) diff --git a/tests/community/test_store.py b/tests/community/test_store.py new file mode 100644 index 0000000..0aa2085 --- /dev/null +++ b/tests/community/test_store.py @@ -0,0 +1,115 @@ +# tests/community/test_store.py +import pytest +from unittest.mock import MagicMock, patch +from datetime import datetime, timezone +from circuitforge_core.community.store import SharedStore +from circuitforge_core.community.models import CommunityPost + + +def make_post_row() -> dict: + return { + "id": 1, + "slug": "kiwi-plan-test-pasta-week", + "pseudonym": "PastaWitch", + "post_type": "plan", + "published": datetime(2026, 4, 12, 12, 0, 0, tzinfo=timezone.utc), + "title": "Pasta Week", + "description": None, + "photo_url": None, + "slots": [{"day": 0, "meal_type": "dinner", "recipe_id": 1, "recipe_name": "Spaghetti"}], + "recipe_id": None, + "recipe_name": None, + "level": None, + "outcome_notes": None, + "seasoning_score": 0.7, + "richness_score": 0.6, + "brightness_score": 0.3, + "depth_score": 0.5, + "aroma_score": 0.4, + "structure_score": 0.8, + "texture_profile": "chewy", + "dietary_tags": ["vegetarian"], + "allergen_flags": ["gluten"], + "flavor_molecules": [1234], + "fat_pct": 12.5, + "protein_pct": 10.0, + "moisture_pct": 55.0, + "source_product": "kiwi", + } + + +@pytest.fixture +def mock_db(): + db = MagicMock() + conn = MagicMock() + cur = MagicMock() + db.getconn.return_value = conn + conn.cursor.return_value.__enter__.return_value = cur + return db, conn, cur + + +def test_shared_store_get_post_by_slug(mock_db): + db, conn, cur = mock_db + cur.fetchone.return_value = make_post_row() + cur.description = [(col,) for col in make_post_row().keys()] + + store = SharedStore(db) + post = store.get_post_by_slug("kiwi-plan-test-pasta-week") + + assert post is not None + assert isinstance(post, CommunityPost) + assert post.slug == "kiwi-plan-test-pasta-week" + assert post.pseudonym == "PastaWitch" + + +def test_shared_store_get_post_by_slug_not_found(mock_db): + db, conn, cur = mock_db + cur.fetchone.return_value = None + + store = SharedStore(db) + post = store.get_post_by_slug("does-not-exist") + assert post is None + + +def test_shared_store_list_posts_returns_list(mock_db): + db, conn, cur = mock_db + row = make_post_row() + cur.fetchall.return_value = [row] + cur.description = [(col,) for col in row.keys()] + + store = SharedStore(db) + posts = store.list_posts(limit=10, offset=0) + + assert isinstance(posts, list) + assert len(posts) == 1 + assert posts[0].slug == "kiwi-plan-test-pasta-week" + + +def test_shared_store_delete_post(mock_db): + db, conn, cur = mock_db + cur.rowcount = 1 + + store = SharedStore(db) + deleted = store.delete_post(slug="kiwi-plan-test-pasta-week", pseudonym="PastaWitch") + assert deleted is True + + +def test_shared_store_delete_post_wrong_owner(mock_db): + db, conn, cur = mock_db + cur.rowcount = 0 + + store = SharedStore(db) + deleted = store.delete_post(slug="kiwi-plan-test-pasta-week", pseudonym="WrongUser") + assert deleted is False + + +def test_shared_store_returns_connection_on_error(mock_db): + db, conn, cur = mock_db + cur.fetchone.side_effect = Exception("DB error") + + store = SharedStore(db) + with pytest.raises(Exception, match="DB error"): + store.get_post_by_slug("any-slug") + + # Connection must be returned to pool even on error + db.putconn.assert_called_once_with(conn)