From 59a3bb8382b0cf52aa57cae419e48ef809bed94f Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 17:55:10 -0700 Subject: [PATCH 1/7] =?UTF-8?q?feat(community):=20migration=20026=20?= =?UTF-8?q?=E2=80=94=20community=5Fpseudonyms=20table=20in=20per-user=20ki?= =?UTF-8?q?wi.db?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/026_community_pseudonyms.sql | 21 ++++++++ tests/db/test_migration_026.py | 49 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 app/db/migrations/026_community_pseudonyms.sql create mode 100644 tests/db/test_migration_026.py diff --git a/app/db/migrations/026_community_pseudonyms.sql b/app/db/migrations/026_community_pseudonyms.sql new file mode 100644 index 0000000..ed6ac3f --- /dev/null +++ b/app/db/migrations/026_community_pseudonyms.sql @@ -0,0 +1,21 @@ +-- 026_community_pseudonyms.sql +-- Per-user pseudonym store: maps the user's chosen community display name +-- to their Directus user ID. This table lives in per-user kiwi.db only. +-- It is NEVER replicated to the community PostgreSQL — pseudonym isolation is by design. +-- +-- A user may have one active pseudonym. Old pseudonyms are retained for reference +-- (posts published under them keep their pseudonym attribution) but only one is +-- flagged as current (is_current = 1). + +CREATE TABLE IF NOT EXISTS community_pseudonyms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pseudonym TEXT NOT NULL, + directus_user_id TEXT NOT NULL, + is_current INTEGER NOT NULL DEFAULT 1 CHECK (is_current IN (0, 1)), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Only one pseudonym can be current at a time per user +CREATE UNIQUE INDEX IF NOT EXISTS idx_community_pseudonyms_current + ON community_pseudonyms (directus_user_id) + WHERE is_current = 1; diff --git a/tests/db/test_migration_026.py b/tests/db/test_migration_026.py new file mode 100644 index 0000000..596be38 --- /dev/null +++ b/tests/db/test_migration_026.py @@ -0,0 +1,49 @@ +# tests/db/test_migration_026.py +import pytest +from pathlib import Path +from app.db.store import Store + + +def test_migration_026_adds_community_pseudonyms(tmp_path): + """Migration 026 adds community_pseudonyms table to per-user kiwi.db.""" + store = Store(tmp_path / "test.db") + cur = store.conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='community_pseudonyms'" + ) + assert cur.fetchone() is not None, "community_pseudonyms table must exist after migrations" + store.close() + + +def test_migration_026_unique_index_enforces_single_current_pseudonym(tmp_path): + """Only one is_current=1 pseudonym per directus_user_id is allowed.""" + store = Store(tmp_path / "test.db") + store.conn.execute( + "INSERT INTO community_pseudonyms (pseudonym, directus_user_id, is_current) VALUES (?, ?, 1)", + ("PastaWitch", "user-abc-123"), + ) + store.conn.commit() + + import sqlite3 + with pytest.raises(sqlite3.IntegrityError): + store.conn.execute( + "INSERT INTO community_pseudonyms (pseudonym, directus_user_id, is_current) VALUES (?, ?, 1)", + ("NoodleNinja", "user-abc-123"), + ) + store.conn.commit() + + store.close() + + +def test_migration_026_allows_historical_pseudonyms(tmp_path): + """Multiple is_current=0 pseudonyms are allowed for the same user.""" + store = Store(tmp_path / "test.db") + store.conn.executemany( + "INSERT INTO community_pseudonyms (pseudonym, directus_user_id, is_current) VALUES (?, ?, 0)", + [("OldName1", "user-abc-123"), ("OldName2", "user-abc-123")], + ) + store.conn.commit() + cur = store.conn.execute( + "SELECT COUNT(*) FROM community_pseudonyms WHERE directus_user_id='user-abc-123'" + ) + assert cur.fetchone()[0] == 2 + store.close() -- 2.45.2 From 22f0bfff9cd5bbefdb6d727b1260138d725e346c Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 17:55:52 -0700 Subject: [PATCH 2/7] feat(community): add COMMUNITY_DB_URL config + community tier gates (publish/fork_adapt) --- app/core/config.py | 11 +++++++++++ app/tiers.py | 6 ++++++ 2 files changed, 17 insertions(+) diff --git a/app/core/config.py b/app/core/config.py index 091b574..f4bb75a 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -53,6 +53,17 @@ class Settings: # Feature flags ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes") + # Community feature + # COMMUNITY_DB_URL: unset = community writes disabled (local/offline mode, fail soft) + COMMUNITY_DB_URL: str | None = os.environ.get("COMMUNITY_DB_URL") or None + COMMUNITY_PSEUDONYM_SALT: str = os.environ.get( + "COMMUNITY_PSEUDONYM_SALT", "kiwi-default-salt-change-in-prod" + ) + COMMUNITY_CLOUD_FEED_URL: str = os.environ.get( + "COMMUNITY_CLOUD_FEED_URL", + "https://menagerie.circuitforge.tech/kiwi/api/v1/community/posts", + ) + # Runtime DEBUG: bool = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes") CLOUD_MODE: bool = os.environ.get("CLOUD_MODE", "false").lower() in ("1", "true", "yes") diff --git a/app/tiers.py b/app/tiers.py index c3257ce..05193b2 100644 --- a/app/tiers.py +++ b/app/tiers.py @@ -18,6 +18,7 @@ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({ "style_classifier", "meal_plan_llm", "meal_plan_llm_timing", + "community_fork_adapt", # Fork a community plan with LLM pantry adaptation }) # Feature → minimum tier required @@ -44,6 +45,11 @@ KIWI_FEATURES: dict[str, str] = { "recipe_collections": "paid", "style_classifier": "paid", # LLM auto-tag for saved recipe style tags; BYOK-unlockable + # Community (free to browse, paid to publish/fork) + "community_browse": "free", # Read-only feed access + "community_publish": "paid", # Publish plans/outcomes to community feed + "community_fork_adapt": "paid", # Fork a plan with LLM pantry adaptation; BYOK-unlockable + # Premium tier "multi_household": "premium", "background_monitoring": "premium", -- 2.45.2 From 33188123d025ddd3dddbc639d37acd8d8ea0c954 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 17:57:09 -0700 Subject: [PATCH 3/7] =?UTF-8?q?feat(community):=20element=20snapshot=20com?= =?UTF-8?q?putation=20=E2=80=94=20SFAH=20scores,=20allergens,=20dietary=20?= =?UTF-8?q?tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/community/__init__.py | 0 app/services/community/element_snapshot.py | 127 ++++++++++++++++++ tests/services/community/__init__.py | 0 .../community/test_element_snapshot.py | 77 +++++++++++ 4 files changed, 204 insertions(+) create mode 100644 app/services/community/__init__.py create mode 100644 app/services/community/element_snapshot.py create mode 100644 tests/services/community/__init__.py create mode 100644 tests/services/community/test_element_snapshot.py diff --git a/app/services/community/__init__.py b/app/services/community/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/community/element_snapshot.py b/app/services/community/element_snapshot.py new file mode 100644 index 0000000..ad78697 --- /dev/null +++ b/app/services/community/element_snapshot.py @@ -0,0 +1,127 @@ +# app/services/community/element_snapshot.py +# MIT License + +from __future__ import annotations + +from dataclasses import dataclass + +# Ingredient name substrings → allergen flag +_ALLERGEN_MAP: dict[str, str] = { + "milk": "dairy", "cream": "dairy", "cheese": "dairy", "butter": "dairy", + "yogurt": "dairy", "whey": "dairy", + "egg": "eggs", + "wheat": "gluten", "pasta": "gluten", "flour": "gluten", "bread": "gluten", + "barley": "gluten", "rye": "gluten", + "peanut": "nuts", "almond": "nuts", "cashew": "nuts", "walnut": "nuts", + "pecan": "nuts", "hazelnut": "nuts", "pistachio": "nuts", "macadamia": "nuts", + "soy": "soy", "tofu": "soy", "edamame": "soy", "miso": "soy", "tempeh": "soy", + "shrimp": "shellfish", "crab": "shellfish", "lobster": "shellfish", + "clam": "shellfish", "mussel": "shellfish", "scallop": "shellfish", + "fish": "fish", "salmon": "fish", "tuna": "fish", "cod": "fish", + "tilapia": "fish", "halibut": "fish", + "sesame": "sesame", +} + +_MEAT_KEYWORDS = frozenset([ + "chicken", "beef", "pork", "lamb", "turkey", "bacon", "ham", "sausage", + "salami", "prosciutto", "guanciale", "pancetta", "steak", "ground meat", + "mince", "veal", "duck", "venison", "bison", "lard", +]) +_SEAFOOD_KEYWORDS = frozenset([ + "fish", "shrimp", "crab", "lobster", "tuna", "salmon", "clam", "mussel", + "scallop", "anchovy", "sardine", "cod", "tilapia", +]) +_ANIMAL_PRODUCT_KEYWORDS = frozenset([ + "milk", "cream", "cheese", "butter", "egg", "honey", "yogurt", "whey", +]) + + +def _detect_allergens(ingredient_names: list[str]) -> list[str]: + found: set[str] = set() + for ingredient in (n.lower() for n in ingredient_names): + for keyword, flag in _ALLERGEN_MAP.items(): + if keyword in ingredient: + found.add(flag) + return sorted(found) + + +def _detect_dietary_tags(ingredient_names: list[str]) -> list[str]: + all_text = " ".join(n.lower() for n in ingredient_names) + has_meat = any(k in all_text for k in _MEAT_KEYWORDS) + has_seafood = any(k in all_text for k in _SEAFOOD_KEYWORDS) + has_animal_products = any(k in all_text for k in _ANIMAL_PRODUCT_KEYWORDS) + + tags: list[str] = [] + if not has_meat and not has_seafood: + tags.append("vegetarian") + if not has_meat and not has_seafood and not has_animal_products: + tags.append("vegan") + return tags + + +@dataclass(frozen=True) +class ElementSnapshot: + seasoning_score: float + richness_score: float + brightness_score: float + depth_score: float + aroma_score: float + structure_score: float + texture_profile: str + dietary_tags: tuple + allergen_flags: tuple + flavor_molecules: tuple + fat_pct: float | None + protein_pct: float | None + moisture_pct: float | None + + +def compute_snapshot(recipe_ids: list[int], store) -> ElementSnapshot: + """Compute an element snapshot from a list of recipe IDs. + + Pulls SFAH scores, ingredient lists, and USDA FDC macros from the corpus. + Averages numeric scores across all recipes. Unions allergen flags and dietary tags. + Call at publish time only — snapshot is stored denormalized in community_posts. + """ + _empty = ElementSnapshot( + seasoning_score=0.0, richness_score=0.0, brightness_score=0.0, + depth_score=0.0, aroma_score=0.0, structure_score=0.0, + texture_profile="", dietary_tags=(), allergen_flags=(), + flavor_molecules=(), fat_pct=None, protein_pct=None, moisture_pct=None, + ) + if not recipe_ids: + return _empty + + rows = store.get_recipes_by_ids(recipe_ids) + if not rows: + return _empty + + def _avg(field: str) -> float: + vals = [r.get(field) or 0.0 for r in rows] + return sum(vals) / len(vals) + + all_ingredients: list[str] = [] + for r in rows: + names = r.get("ingredient_names") or [] + if isinstance(names, list): + all_ingredients.extend(names) + + fat_vals = [r["fat"] for r in rows if r.get("fat") is not None] + prot_vals = [r["protein"] for r in rows if r.get("protein") is not None] + moist_vals = [r["moisture"] for r in rows if r.get("moisture") is not None] + + return ElementSnapshot( + seasoning_score=_avg("seasoning_score"), + richness_score=_avg("richness_score"), + brightness_score=_avg("brightness_score"), + depth_score=_avg("depth_score"), + aroma_score=_avg("aroma_score"), + structure_score=_avg("structure_score"), + texture_profile=rows[0].get("texture_profile") or "", + dietary_tags=tuple(_detect_dietary_tags(all_ingredients)), + allergen_flags=tuple(_detect_allergens(all_ingredients)), + flavor_molecules=(), # deferred — FlavorGraph ticket + fat_pct=(sum(fat_vals) / len(fat_vals)) if fat_vals else None, + protein_pct=(sum(prot_vals) / len(prot_vals)) if prot_vals else None, + moisture_pct=(sum(moist_vals) / len(moist_vals)) if moist_vals else None, + ) diff --git a/tests/services/community/__init__.py b/tests/services/community/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/services/community/test_element_snapshot.py b/tests/services/community/test_element_snapshot.py new file mode 100644 index 0000000..36da3e5 --- /dev/null +++ b/tests/services/community/test_element_snapshot.py @@ -0,0 +1,77 @@ +# tests/services/community/test_element_snapshot.py +import pytest +from unittest.mock import MagicMock +from app.services.community.element_snapshot import compute_snapshot, ElementSnapshot + + +def make_mock_store(recipe_rows: list[dict]) -> MagicMock: + store = MagicMock() + store.get_recipes_by_ids.return_value = recipe_rows + return store + + +RECIPE_ROW = { + "id": 1, + "name": "Spaghetti Carbonara", + "ingredient_names": ["pasta", "eggs", "guanciale", "pecorino"], + "keywords": ["italian", "quick", "dinner"], + "category": "dinner", + "fat": 22.0, + "protein": 18.0, + "moisture": 45.0, + "seasoning_score": 0.7, + "richness_score": 0.8, + "brightness_score": 0.2, + "depth_score": 0.6, + "aroma_score": 0.5, + "structure_score": 0.9, + "texture_profile": "creamy", +} + + +def test_compute_snapshot_basic(): + store = make_mock_store([RECIPE_ROW]) + snap = compute_snapshot(recipe_ids=[1], store=store) + assert isinstance(snap, ElementSnapshot) + assert 0.0 <= snap.seasoning_score <= 1.0 + assert snap.texture_profile == "creamy" + + +def test_compute_snapshot_averages_multiple_recipes(): + row2 = {**RECIPE_ROW, "id": 2, "seasoning_score": 0.3, "richness_score": 0.2} + store = make_mock_store([RECIPE_ROW, row2]) + snap = compute_snapshot(recipe_ids=[1, 2], store=store) + # seasoning: average of 0.7 and 0.3 = 0.5 + assert abs(snap.seasoning_score - 0.5) < 0.01 + + +def test_compute_snapshot_allergen_flags_detected(): + row = {**RECIPE_ROW, "ingredient_names": ["pasta", "eggs", "milk", "shrimp", "peanuts"]} + store = make_mock_store([row]) + snap = compute_snapshot(recipe_ids=[1], store=store) + assert "gluten" in snap.allergen_flags # pasta + assert "dairy" in snap.allergen_flags # milk + assert "shellfish" in snap.allergen_flags # shrimp + assert "nuts" in snap.allergen_flags # peanuts + + +def test_compute_snapshot_dietary_tags_vegetarian(): + row = {**RECIPE_ROW, "ingredient_names": ["pasta", "eggs", "tomato", "basil"]} + store = make_mock_store([row]) + snap = compute_snapshot(recipe_ids=[1], store=store) + assert "vegetarian" in snap.dietary_tags + + +def test_compute_snapshot_no_recipes_returns_defaults(): + store = make_mock_store([]) + snap = compute_snapshot(recipe_ids=[], store=store) + assert snap.seasoning_score == 0.0 + assert snap.dietary_tags == () + assert snap.allergen_flags == () + + +def test_element_snapshot_immutable(): + store = make_mock_store([RECIPE_ROW]) + snap = compute_snapshot(recipe_ids=[1], store=store) + with pytest.raises((AttributeError, TypeError)): + snap.seasoning_score = 0.0 # type: ignore -- 2.45.2 From 7001d483786f45d71fe15cb0c34e5a28ceb7a779 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 17:58:14 -0700 Subject: [PATCH 4/7] feat(community): RSS 2.0 feed generator + ActivityPub JSON-LD scaffold --- app/services/community/ap_compat.py | 44 +++++++++++++++++++ app/services/community/feed.py | 43 ++++++++++++++++++ tests/services/community/test_ap_compat.py | 47 ++++++++++++++++++++ tests/services/community/test_feed.py | 51 ++++++++++++++++++++++ 4 files changed, 185 insertions(+) create mode 100644 app/services/community/ap_compat.py create mode 100644 app/services/community/feed.py create mode 100644 tests/services/community/test_ap_compat.py create mode 100644 tests/services/community/test_feed.py diff --git a/app/services/community/ap_compat.py b/app/services/community/ap_compat.py new file mode 100644 index 0000000..c3ac181 --- /dev/null +++ b/app/services/community/ap_compat.py @@ -0,0 +1,44 @@ +# app/services/community/ap_compat.py +# MIT License — AP (ActivityPub) scaffold only (no actor, inbox, outbox) + +from __future__ import annotations + +from datetime import datetime, timezone + + +def post_to_ap_json_ld(post: dict, base_url: str) -> dict: + """Serialize a community post dict to an ActivityPub-compatible JSON-LD Note. + + This is a read-only scaffold. No AP actor, inbox, or outbox is implemented. + The slug URI is stable so a future full AP implementation can envelope posts + without a database migration. + """ + slug = post["slug"] + published = post.get("published") + if isinstance(published, datetime): + published_str = published.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + else: + published_str = str(published) + + dietary_tags: list[str] = post.get("dietary_tags") or [] + tags = [{"type": "Hashtag", "name": "#kiwi"}] + for tag in dietary_tags: + tags.append({"type": "Hashtag", "name": f"#{tag.replace('-', '').replace(' ', '')}"}) + + return { + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Note", + "id": f"{base_url}/api/v1/community/posts/{slug}", + "attributedTo": post.get("pseudonym", "anonymous"), + "content": _build_content(post), + "published": published_str, + "tag": tags, + } + + +def _build_content(post: dict) -> str: + title = post.get("title") or "Untitled" + desc = post.get("description") + if desc: + return f"{title} — {desc}" + return title diff --git a/app/services/community/feed.py b/app/services/community/feed.py new file mode 100644 index 0000000..36000b9 --- /dev/null +++ b/app/services/community/feed.py @@ -0,0 +1,43 @@ +# app/services/community/feed.py +# MIT License + +from __future__ import annotations + +from datetime import datetime, timezone +from email.utils import format_datetime +from xml.etree.ElementTree import Element, SubElement, tostring + + +def posts_to_rss(posts: list[dict], base_url: str) -> str: + """Generate an RSS 2.0 feed from a list of community post dicts. + + base_url: the root URL of this Kiwi instance (no trailing slash). + Returns UTF-8 XML string. + """ + rss = Element("rss", version="2.0") + channel = SubElement(rss, "channel") + + _sub(channel, "title", "Kiwi Community Feed") + _sub(channel, "link", f"{base_url}/community") + _sub(channel, "description", "Meal plans and recipe outcomes from the Kiwi community") + _sub(channel, "language", "en") + _sub(channel, "lastBuildDate", format_datetime(datetime.now(timezone.utc))) + + for post in posts: + item = SubElement(channel, "item") + _sub(item, "title", post.get("title") or "Untitled") + _sub(item, "link", f"{base_url}/api/v1/community/posts/{post['slug']}") + _sub(item, "guid", f"{base_url}/api/v1/community/posts/{post['slug']}") + if post.get("description"): + _sub(item, "description", post["description"]) + published = post.get("published") + if isinstance(published, datetime): + _sub(item, "pubDate", format_datetime(published)) + + return '\n' + tostring(rss, encoding="unicode") + + +def _sub(parent: Element, tag: str, text: str) -> Element: + el = SubElement(parent, tag) + el.text = text + return el diff --git a/tests/services/community/test_ap_compat.py b/tests/services/community/test_ap_compat.py new file mode 100644 index 0000000..524eb58 --- /dev/null +++ b/tests/services/community/test_ap_compat.py @@ -0,0 +1,47 @@ +# tests/services/community/test_ap_compat.py +import pytest +from datetime import datetime, timezone +from app.services.community.ap_compat import post_to_ap_json_ld + + +POST = { + "slug": "kiwi-plan-test-pasta-week", + "title": "Pasta Week", + "description": "Seven days of carbs", + "published": datetime(2026, 4, 12, 12, 0, 0, tzinfo=timezone.utc), + "pseudonym": "PastaWitch", + "dietary_tags": ["vegetarian"], +} + + +def test_ap_json_ld_context(): + doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi") + assert doc["@context"] == "https://www.w3.org/ns/activitystreams" + + +def test_ap_json_ld_type(): + doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi") + assert doc["type"] == "Note" + + +def test_ap_json_ld_id_is_uri(): + doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi") + assert doc["id"].startswith("https://") + assert POST["slug"] in doc["id"] + + +def test_ap_json_ld_published_is_iso8601(): + doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi") + from datetime import datetime + datetime.fromisoformat(doc["published"].replace("Z", "+00:00")) + + +def test_ap_json_ld_attributed_to_pseudonym(): + doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi") + assert doc["attributedTo"] == "PastaWitch" + + +def test_ap_json_ld_tags_include_kiwi(): + doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi") + tag_names = [t["name"] for t in doc.get("tag", [])] + assert "#kiwi" in tag_names diff --git a/tests/services/community/test_feed.py b/tests/services/community/test_feed.py new file mode 100644 index 0000000..bc23f8c --- /dev/null +++ b/tests/services/community/test_feed.py @@ -0,0 +1,51 @@ +# tests/services/community/test_feed.py +import pytest +from datetime import datetime, timezone +from app.services.community.feed import posts_to_rss + + +def make_post_dict(**kwargs): + defaults = dict( + slug="kiwi-plan-test-pasta-week", + title="Pasta Week", + description="Seven days of carbs", + published=datetime(2026, 4, 12, 12, 0, 0, tzinfo=timezone.utc), + post_type="plan", + pseudonym="PastaWitch", + ) + defaults.update(kwargs) + return defaults + + +def test_rss_is_valid_xml(): + import xml.etree.ElementTree as ET + rss = posts_to_rss([make_post_dict()], base_url="https://menagerie.circuitforge.tech/kiwi") + root = ET.fromstring(rss) + assert root.tag == "rss" + assert root.attrib.get("version") == "2.0" + + +def test_rss_contains_item(): + import xml.etree.ElementTree as ET + rss = posts_to_rss([make_post_dict()], base_url="https://menagerie.circuitforge.tech/kiwi") + root = ET.fromstring(rss) + items = root.findall(".//item") + assert len(items) == 1 + + +def test_rss_item_has_required_fields(): + import xml.etree.ElementTree as ET + rss = posts_to_rss([make_post_dict()], base_url="https://menagerie.circuitforge.tech/kiwi") + root = ET.fromstring(rss) + item = root.find(".//item") + assert item.find("title") is not None + assert item.find("link") is not None + assert item.find("pubDate") is not None + + +def test_rss_empty_posts(): + import xml.etree.ElementTree as ET + rss = posts_to_rss([], base_url="https://menagerie.circuitforge.tech/kiwi") + root = ET.fromstring(rss) + items = root.findall(".//item") + assert len(items) == 0 -- 2.45.2 From fd49d0ca5c40807cf96e9eb21becaaa76c82fd99 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 18:00:06 -0700 Subject: [PATCH 5/7] feat(community): mDNS advertisement via zeroconf (_kiwi._tcp.local, opt-in) --- app/services/community/mdns.py | 111 ++++++++++++++++++++++++++ pyproject.toml | 2 + tests/services/community/test_mdns.py | 39 +++++++++ 3 files changed, 152 insertions(+) create mode 100644 app/services/community/mdns.py create mode 100644 tests/services/community/test_mdns.py diff --git a/app/services/community/mdns.py b/app/services/community/mdns.py new file mode 100644 index 0000000..efc3ca9 --- /dev/null +++ b/app/services/community/mdns.py @@ -0,0 +1,111 @@ +# app/services/community/mdns.py +# MIT License +# mDNS advertisement for Kiwi instances on the local network. +# Advertises _kiwi._tcp.local so other Kiwi instances (and discovery apps) +# can find this one without manual configuration. +# +# Opt-in only: enabled=False by default. Users are prompted on first community +# tab access. Never advertised without explicit consent (a11y requirement). + +from __future__ import annotations + +import logging +import socket +from typing import Any + +logger = logging.getLogger(__name__) + +# Deferred import — avoid hard failure when zeroconf is not installed. +try: + from zeroconf import ServiceInfo, Zeroconf + _ZEROCONF_AVAILABLE = True +except ImportError: # pragma: no cover + _ZEROCONF_AVAILABLE = False + + +class KiwiMDNS: + """Context manager that advertises this Kiwi instance via mDNS (_kiwi._tcp.local). + + Defaults to disabled. User must explicitly opt in via Settings. + feed_url is broadcast in the TXT record so peer instances know where to fetch posts. + + Usage: + mdns = KiwiMDNS( + enabled=settings.MDNS_ENABLED, + port=8512, + feed_url="http://10.0.0.5:8512/api/v1/community/local-feed", + ) + mdns.start() # in lifespan startup + mdns.stop() # in lifespan shutdown + """ + + SERVICE_TYPE = "_kiwi._tcp.local." + + def __init__( + self, + port: int = 8512, + name: str | None = None, + feed_url: str = "", + enabled: bool = False, + ) -> None: + self._port = port + self._name = name or f"kiwi-{socket.gethostname()}" + self._feed_url = feed_url + self._enabled = enabled + self._zc: Any = None + self._info: Any = None + + def start(self) -> None: + if not self._enabled: + logger.info("mDNS advertisement disabled (user opt-in required)") + return + try: + local_ip = _get_local_ip() + props = {b"product": b"kiwi", b"version": b"1"} + if self._feed_url: + props[b"feed"] = self._feed_url.encode() + + self._info = ServiceInfo( + type_=self.SERVICE_TYPE, + name=f"{self._name}.{self.SERVICE_TYPE}", + addresses=[socket.inet_aton(local_ip)], + port=self._port, + properties=props, + server=f"{socket.gethostname()}.local.", + ) + self._zc = Zeroconf() + self._zc.register_service(self._info) + logger.info("mDNS: advertising %s on %s:%d", self._name, local_ip, self._port) + except Exception as exc: + logger.warning("mDNS advertisement failed (non-fatal): %s", exc) + self._zc = None + self._info = None + + def stop(self) -> None: + if self._zc and self._info: + try: + self._zc.unregister_service(self._info) + self._zc.close() + logger.info("mDNS: unregistered %s", self._name) + except Exception as exc: + logger.warning("mDNS unregister failed (non-fatal): %s", exc) + finally: + self._zc = None + self._info = None + + def __enter__(self) -> "KiwiMDNS": + self.start() + return self + + def __exit__(self, *_: object) -> None: + self.stop() + + +def _get_local_ip() -> str: + """Return the primary non-loopback IPv4 address of this host.""" + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.connect(("8.8.8.8", 80)) + return s.getsockname()[0] + except OSError: + return "127.0.0.1" diff --git a/pyproject.toml b/pyproject.toml index 1928abb..c454b95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,8 @@ dependencies = [ "requests>=2.31", # CircuitForge shared scaffold "circuitforge-core>=0.8.0", + # mDNS advertisement (opt-in: _kiwi._tcp.local discovery) + "zeroconf>=0.131", ] [tool.setuptools.packages.find] diff --git a/tests/services/community/test_mdns.py b/tests/services/community/test_mdns.py new file mode 100644 index 0000000..8cfd4d3 --- /dev/null +++ b/tests/services/community/test_mdns.py @@ -0,0 +1,39 @@ +# tests/services/community/test_mdns.py +import pytest +from unittest.mock import MagicMock, patch +from app.services.community.mdns import KiwiMDNS + + +def test_mdns_does_not_advertise_when_disabled(): + """When enabled=False, KiwiMDNS does not register any zeroconf service.""" + with patch("app.services.community.mdns.Zeroconf") as mock_zc: + mdns = KiwiMDNS(enabled=False, port=8512, feed_url="http://localhost:8512/api/v1/community/local-feed") + mdns.start() + mock_zc.assert_not_called() + + +def test_mdns_advertises_when_enabled(): + with patch("app.services.community.mdns.Zeroconf") as mock_zc_cls: + with patch("app.services.community.mdns.ServiceInfo") as mock_si: + mock_zc = MagicMock() + mock_zc_cls.return_value = mock_zc + mdns = KiwiMDNS(enabled=True, port=8512, feed_url="http://localhost:8512/api/v1/community/local-feed") + mdns.start() + mock_zc.register_service.assert_called_once() + + +def test_mdns_stop_unregisters_when_enabled(): + with patch("app.services.community.mdns.Zeroconf") as mock_zc_cls: + with patch("app.services.community.mdns.ServiceInfo"): + mock_zc = MagicMock() + mock_zc_cls.return_value = mock_zc + mdns = KiwiMDNS(enabled=True, port=8512, feed_url="http://localhost:8512/api/v1/community/local-feed") + mdns.start() + mdns.stop() + mock_zc.unregister_service.assert_called_once() + mock_zc.close.assert_called_once() + + +def test_mdns_stop_is_noop_when_not_started(): + mdns = KiwiMDNS(enabled=False, port=8512, feed_url="http://localhost/feed") + mdns.stop() # must not raise -- 2.45.2 From 62d8e36316fff1ba553937a5de7361ce318c8388 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 18:04:26 -0700 Subject: [PATCH 6/7] feat(community): KiwiCommunityStore, pseudonym helpers, community API endpoints + router wiring --- app/api/endpoints/community.py | 339 ++++++++++++++++++ app/api/routes.py | 4 +- app/db/store.py | 28 ++ app/main.py | 4 + app/services/community/community_store.py | 108 ++++++ tests/api/test_community_endpoints.py | 68 ++++ .../community/test_community_store.py | 64 ++++ 7 files changed, 614 insertions(+), 1 deletion(-) create mode 100644 app/api/endpoints/community.py create mode 100644 app/services/community/community_store.py create mode 100644 tests/api/test_community_endpoints.py create mode 100644 tests/services/community/test_community_store.py diff --git a/app/api/endpoints/community.py b/app/api/endpoints/community.py new file mode 100644 index 0000000..31df5bf --- /dev/null +++ b/app/api/endpoints/community.py @@ -0,0 +1,339 @@ +# app/api/endpoints/community.py +# MIT License + +from __future__ import annotations + +import asyncio +import logging +import re +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Request, Response + +from app.cloud_session import CloudUser, get_session +from app.core.config import settings +from app.db.store import Store +from app.services.community.feed import posts_to_rss + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/community", tags=["community"]) + +# Module-level KiwiCommunityStore — None when COMMUNITY_DB_URL is not set. +# Browse endpoints degrade gracefully to empty; write endpoints return 503. +_community_store = None + + +def _get_community_store(): + """Return the module-level KiwiCommunityStore, or None if community DB is unavailable.""" + return _community_store + + +def init_community_store(community_db_url: str | None) -> None: + """Called from main.py lifespan when COMMUNITY_DB_URL is set.""" + global _community_store + if not community_db_url: + logger.info( + "COMMUNITY_DB_URL not set — community write features disabled. " + "Browse still works via cloud fallback." + ) + return + try: + from circuitforge_core.community import CommunityDB + from app.services.community.community_store import KiwiCommunityStore + db = CommunityDB(dsn=community_db_url) + db.run_migrations() + _community_store = KiwiCommunityStore(db) + logger.info("Community store initialized (PostgreSQL).") + except Exception as exc: + logger.warning("Community store init failed — community writes disabled: %s", exc) + + +# ── Browse (no auth required — Free tier) ──────────────────────────────────── + +@router.get("/posts") +async def list_posts( + post_type: str | None = None, + dietary_tags: str | None = None, + allergen_exclude: str | None = None, + page: int = 1, + page_size: int = 20, +): + """Paginated community post list. Available on all tiers (read-only).""" + store = _get_community_store() + if store is None: + return {"posts": [], "total": 0, "note": "Community DB not available on this instance."} + + dietary = [t.strip() for t in dietary_tags.split(",")] if dietary_tags else None + allergen_ex = [t.strip() for t in allergen_exclude.split(",")] if allergen_exclude else None + offset = (page - 1) * min(page_size, 100) + + posts = await asyncio.to_thread( + store.list_posts, + limit=min(page_size, 100), + offset=offset, + post_type=post_type, + dietary_tags=dietary, + allergen_exclude=allergen_ex, + ) + return {"posts": [_post_to_dict(p) for p in posts], "page": page, "page_size": page_size} + + +@router.get("/posts/{slug}") +async def get_post(slug: str, request: Request): + """Single post. Returns AP JSON-LD when Accept: application/activity+json.""" + store = _get_community_store() + if store is None: + raise HTTPException(status_code=503, detail="Community DB not available on this instance.") + + post = await asyncio.to_thread(store.get_post_by_slug, slug) + if post is None: + raise HTTPException(status_code=404, detail="Post not found.") + + accept = request.headers.get("accept", "") + if "application/activity+json" in accept: + from app.services.community.ap_compat import post_to_ap_json_ld + base_url = str(request.base_url).rstrip("/") + return post_to_ap_json_ld(_post_to_dict(post), base_url=base_url) + + return _post_to_dict(post) + + +@router.get("/feed.rss") +async def get_rss_feed(request: Request): + """RSS 2.0 feed of recent community posts.""" + store = _get_community_store() + posts_data: list[dict] = [] + if store is not None: + posts = await asyncio.to_thread(store.list_posts, limit=50) + posts_data = [_post_to_dict(p) for p in posts] + + base_url = str(request.base_url).rstrip("/") + rss = posts_to_rss(posts_data, base_url=base_url) + return Response(content=rss, media_type="application/rss+xml; charset=utf-8") + + +@router.get("/local-feed") +async def local_feed(): + """LAN peer endpoint: last 50 posts from this instance. No auth required.""" + store = _get_community_store() + if store is None: + return [] + posts = await asyncio.to_thread(store.list_posts, limit=50) + return [_post_to_dict(p) for p in posts] + + +# ── Write endpoints (auth required) ────────────────────────────────────────── + +@router.post("/posts", status_code=201) +async def publish_post( + body: dict, + session: CloudUser = Depends(get_session), +): + """Publish a plan or outcome to the community feed. Requires Paid tier.""" + from app.tiers import can_use + if not can_use("community_publish", session.tier, session.has_byok): + raise HTTPException(status_code=402, detail="Community publishing requires Paid tier.") + + store = _get_community_store() + if store is None: + raise HTTPException( + status_code=503, + detail="This Kiwi instance is not connected to a community database. " + "Publishing is only available on cloud instances.", + ) + + # Resolve pseudonym (first-time setup inline via publish modal) + from app.services.community.community_store import get_or_create_pseudonym + db_path = session.db + + def _get_pseudonym(): + s = Store(db_path) + try: + return get_or_create_pseudonym( + store=s, + directus_user_id=session.user_id, + requested_name=body.get("pseudonym_name"), + ) + finally: + s.close() + + pseudonym = await asyncio.to_thread(_get_pseudonym) + + # Compute element snapshot from corpus recipes in the plan + recipe_ids = [slot["recipe_id"] for slot in body.get("slots", []) if slot.get("recipe_id")] + from app.services.community.element_snapshot import compute_snapshot + + def _snapshot(): + s = Store(db_path) + try: + return compute_snapshot(recipe_ids=recipe_ids, store=s) + finally: + s.close() + + snapshot = await asyncio.to_thread(_snapshot) + + # Build deterministic slug + slug_title = re.sub(r"[^a-z0-9]+", "-", (body.get("title") or "plan").lower()).strip("-") + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + post_type = body.get("post_type", "plan") + slug = f"kiwi-{_post_type_prefix(post_type)}-{pseudonym.lower().replace(' ', '')}-{today}-{slug_title}"[:120] + + try: + from circuitforge_core.community.models import CommunityPost + post = CommunityPost( + slug=slug, + pseudonym=pseudonym, + post_type=post_type, + published=datetime.now(timezone.utc), + title=body.get("title", "Untitled"), + description=body.get("description"), + photo_url=body.get("photo_url"), + slots=body.get("slots", []), + recipe_id=body.get("recipe_id"), + recipe_name=body.get("recipe_name"), + level=body.get("level"), + outcome_notes=body.get("outcome_notes"), + seasoning_score=snapshot.seasoning_score, + richness_score=snapshot.richness_score, + brightness_score=snapshot.brightness_score, + depth_score=snapshot.depth_score, + aroma_score=snapshot.aroma_score, + structure_score=snapshot.structure_score, + texture_profile=snapshot.texture_profile, + dietary_tags=list(snapshot.dietary_tags), + allergen_flags=list(snapshot.allergen_flags), + flavor_molecules=list(snapshot.flavor_molecules), + fat_pct=snapshot.fat_pct, + protein_pct=snapshot.protein_pct, + moisture_pct=snapshot.moisture_pct, + ) + except ImportError: + raise HTTPException(status_code=503, detail="Community module not available.") + + inserted = await asyncio.to_thread(store.insert_post, post) + return _post_to_dict(inserted) + + +@router.delete("/posts/{slug}", status_code=204) +async def delete_post(slug: str, session: CloudUser = Depends(get_session)): + """Hard-delete a post. Only succeeds if the caller is the post author (pseudonym match).""" + store = _get_community_store() + if store is None: + raise HTTPException(status_code=503, detail="Community DB not available.") + + def _get_pseudonym(): + s = Store(session.db) + try: + return s.get_current_pseudonym(session.user_id) + finally: + s.close() + + pseudonym = await asyncio.to_thread(_get_pseudonym) + if not pseudonym: + raise HTTPException(status_code=400, detail="No pseudonym set. Cannot delete posts.") + + deleted = await asyncio.to_thread(store.delete_post, slug=slug, pseudonym=pseudonym) + if not deleted: + raise HTTPException(status_code=404, detail="Post not found or you are not the author.") + + +@router.post("/posts/{slug}/fork", status_code=201) +async def fork_post(slug: str, session: CloudUser = Depends(get_session)): + """Exact-copy fork: creates a new meal plan in the caller's DB with matching slots. Free.""" + store = _get_community_store() + if store is None: + raise HTTPException(status_code=503, detail="Community DB not available.") + + post = await asyncio.to_thread(store.get_post_by_slug, slug) + if post is None: + raise HTTPException(status_code=404, detail="Post not found.") + if post.post_type != "plan": + raise HTTPException(status_code=400, detail="Only plan posts can be forked as a meal plan.") + + from datetime import date + + def _create_plan(): + s = Store(session.db) + try: + week_start = date.today().strftime("%Y-%m-%d") + meal_types = list({slot["meal_type"] for slot in post.slots}) or ["dinner"] + plan = s.create_meal_plan(week_start=week_start, meal_types=meal_types) + for slot in post.slots: + s.assign_recipe_to_slot( + plan_id=plan["id"], + day_of_week=slot["day"], + meal_type=slot["meal_type"], + recipe_id=slot["recipe_id"], + ) + return plan + finally: + s.close() + + plan = await asyncio.to_thread(_create_plan) + return {"plan_id": plan["id"], "week_start": plan["week_start"], "forked_from": slug} + + +@router.post("/posts/{slug}/fork-adapt", status_code=201) +async def fork_adapt_post(slug: str, session: CloudUser = Depends(get_session)): + """Fork with LLM pantry adaptation. Paid/BYOK. Returns suggestions for user review.""" + from app.tiers import can_use + if not can_use("community_fork_adapt", session.tier, session.has_byok): + raise HTTPException(status_code=402, detail="Fork with adaptation requires Paid tier or BYOK.") + + store = _get_community_store() + if store is None: + raise HTTPException(status_code=503, detail="Community DB not available.") + + post = await asyncio.to_thread(store.get_post_by_slug, slug) + if post is None: + raise HTTPException(status_code=404, detail="Post not found.") + + # BSL 1.1 call site — LLM adaptation + from app.services.meal_plan.llm_planner import adapt_plan_to_pantry + suggestions = await adapt_plan_to_pantry( + slots=list(post.slots), + db_path=session.db, + tier=session.tier, + has_byok=session.has_byok, + ) + return {"suggestions": suggestions, "forked_from": slug} + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _post_to_dict(post) -> dict: + """Convert a CommunityPost (frozen dataclass) to a JSON-serializable dict.""" + return { + "slug": post.slug, + "pseudonym": post.pseudonym, + "post_type": post.post_type, + "published": post.published.isoformat() if hasattr(post.published, "isoformat") else str(post.published), + "title": post.title, + "description": post.description, + "photo_url": post.photo_url, + "slots": list(post.slots), + "recipe_id": post.recipe_id, + "recipe_name": post.recipe_name, + "level": post.level, + "outcome_notes": post.outcome_notes, + "element_profiles": { + "seasoning_score": post.seasoning_score, + "richness_score": post.richness_score, + "brightness_score": post.brightness_score, + "depth_score": post.depth_score, + "aroma_score": post.aroma_score, + "structure_score": post.structure_score, + "texture_profile": post.texture_profile, + }, + "dietary_tags": list(post.dietary_tags), + "allergen_flags": list(post.allergen_flags), + "flavor_molecules": list(post.flavor_molecules), + "fat_pct": post.fat_pct, + "protein_pct": post.protein_pct, + "moisture_pct": post.moisture_pct, + } + + +def _post_type_prefix(post_type: str) -> str: + return {"plan": "plan", "recipe_success": "success", "recipe_blooper": "blooper"}.get(post_type, "post") diff --git a/app/api/routes.py b/app/api/routes.py index e0ea172..0e7c9d4 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1,5 +1,6 @@ from fastapi import APIRouter from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes, meal_plans +from app.api.endpoints.community import router as community_router api_router = APIRouter() @@ -14,4 +15,5 @@ api_router.include_router(staples.router, prefix="/staples", tags=["s api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"]) api_router.include_router(household.router, prefix="/household", tags=["household"]) api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"]) -api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=["meal-plans"]) \ No newline at end of file +api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=["meal-plans"]) +api_router.include_router(community_router) \ No newline at end of file diff --git a/app/db/store.py b/app/db/store.py index 576ac55..3d30b9b 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -1128,3 +1128,31 @@ class Store: ) self.conn.commit() return self._fetch_one("SELECT * FROM prep_tasks WHERE id = ?", (task_id,)) + + # ── Community pseudonyms ────────────────────────────────────────────────── + + def get_current_pseudonym(self, directus_user_id: str) -> str | None: + """Return the current community pseudonym for this user, or None if not set.""" + cur = self.conn.execute( + "SELECT pseudonym FROM community_pseudonyms " + "WHERE directus_user_id = ? AND is_current = 1 LIMIT 1", + (directus_user_id,), + ) + row = cur.fetchone() + return row["pseudonym"] if row else None + + def set_pseudonym(self, directus_user_id: str, pseudonym: str) -> None: + """Set the current community pseudonym for this user. + + Marks any previous pseudonym as non-current (retains history for attribution). + """ + self.conn.execute( + "UPDATE community_pseudonyms SET is_current = 0 WHERE directus_user_id = ?", + (directus_user_id,), + ) + self.conn.execute( + "INSERT INTO community_pseudonyms (pseudonym, directus_user_id, is_current) " + "VALUES (?, ?, 1)", + (pseudonym, directus_user_id), + ) + self.conn.commit() diff --git a/app/main.py b/app/main.py index c5ccec3..e1d06e6 100644 --- a/app/main.py +++ b/app/main.py @@ -20,6 +20,10 @@ async def lifespan(app: FastAPI): settings.ensure_dirs() register_kiwi_programs() + # Initialize community store (fail-soft if COMMUNITY_DB_URL not set) + from app.api.endpoints.community import init_community_store + init_community_store(settings.COMMUNITY_DB_URL) + # Start LLM background task scheduler from app.tasks.scheduler import get_scheduler get_scheduler(settings.DB_PATH) diff --git a/app/services/community/community_store.py b/app/services/community/community_store.py new file mode 100644 index 0000000..f1af73f --- /dev/null +++ b/app/services/community/community_store.py @@ -0,0 +1,108 @@ +# app/services/community/community_store.py +# MIT License + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + + +def get_or_create_pseudonym( + store, + directus_user_id: str, + requested_name: str | None, +) -> str: + """Return the user's current pseudonym, creating it if it doesn't exist. + + If the user has an existing pseudonym, return it (ignore requested_name). + If not, create using requested_name (must be provided for first-time setup). + + Raises ValueError if no existing pseudonym and requested_name is None or blank. + """ + existing = store.get_current_pseudonym(directus_user_id) + if existing: + return existing + + if not requested_name or not requested_name.strip(): + raise ValueError( + "A pseudonym is required for first publish. " + "Pass requested_name with your chosen display name." + ) + + name = requested_name.strip() + if "@" in name: + raise ValueError( + "Pseudonym must not contain '@' — use a display name, not an email address." + ) + + store.set_pseudonym(directus_user_id, name) + return name + + +try: + from circuitforge_core.community import SharedStore, CommunityPost + + class KiwiCommunityStore(SharedStore): + """Kiwi-specific community store: adds kiwi-domain query methods on top of SharedStore.""" + + def list_meal_plans( + self, + limit: int = 20, + offset: int = 0, + dietary_tags: list[str] | None = None, + allergen_exclude: list[str] | None = None, + ) -> list[CommunityPost]: + return self.list_posts( + limit=limit, + offset=offset, + post_type="plan", + dietary_tags=dietary_tags, + allergen_exclude=allergen_exclude, + source_product="kiwi", + ) + + def list_outcomes( + self, + limit: int = 20, + offset: int = 0, + post_type: str | None = None, + ) -> list[CommunityPost]: + if post_type in ("recipe_success", "recipe_blooper"): + return self.list_posts( + limit=limit, offset=offset, + post_type=post_type, source_product="kiwi", + ) + # Fetch both types and merge by published date + success = self.list_posts( + limit=limit, offset=0, post_type="recipe_success", source_product="kiwi", + ) + bloopers = self.list_posts( + limit=limit, offset=0, post_type="recipe_blooper", source_product="kiwi", + ) + merged = sorted(success + bloopers, key=lambda p: p.published, reverse=True) + return merged[:limit] + +except ImportError: + # cf-core community module not yet merged — stub for local dev without community DB + class KiwiCommunityStore: # type: ignore[no-redef] + def __init__(self, *args, **kwargs): + pass + + def list_meal_plans(self, **kwargs): + return [] + + def list_outcomes(self, **kwargs): + return [] + + def list_posts(self, **kwargs): + return [] + + def get_post_by_slug(self, slug): + return None + + def insert_post(self, post): + return post + + def delete_post(self, slug, pseudonym): + return False diff --git a/tests/api/test_community_endpoints.py b/tests/api/test_community_endpoints.py new file mode 100644 index 0000000..78c8a19 --- /dev/null +++ b/tests/api/test_community_endpoints.py @@ -0,0 +1,68 @@ +# tests/api/test_community_endpoints.py +import pytest +from unittest.mock import patch, MagicMock +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + + +def test_get_community_posts_no_db_returns_empty(): + """When COMMUNITY_DB_URL is not set, GET /community/posts returns empty list (no 500).""" + with patch("app.api.endpoints.community._community_store", None): + response = client.get("/api/v1/community/posts") + assert response.status_code == 200 + data = response.json() + assert "posts" in data + assert isinstance(data["posts"], list) + + +def test_get_community_post_not_found(): + """GET /community/posts/{slug} returns 404 when slug doesn't exist.""" + mock_store = MagicMock() + mock_store.get_post_by_slug.return_value = None + with patch("app.api.endpoints.community._community_store", mock_store): + response = client.get("/api/v1/community/posts/nonexistent-slug") + assert response.status_code == 404 + + +def test_get_community_rss(): + """GET /community/feed.rss returns XML content-type.""" + with patch("app.api.endpoints.community._community_store", None): + response = client.get("/api/v1/community/feed.rss") + assert response.status_code == 200 + assert "xml" in response.headers.get("content-type", "") + + +def test_post_community_no_store_returns_503(): + """POST /community/posts returns 503 when community DB is not configured. + + In local/dev mode the session auth is bypassed (local session). The endpoint + should then fail-soft with 503, not 500. Production cloud mode enforces auth + before the store check — tested in integration tests. + """ + with patch("app.api.endpoints.community._community_store", None): + response = client.post("/api/v1/community/posts", json={"title": "Test"}) + # 503 = no community store; 402 = tier gate fired first; both are acceptable + assert response.status_code in (402, 503) + + +def test_delete_post_no_store_returns_503(): + """DELETE /community/posts/{slug} returns 503 when community DB is not configured.""" + with patch("app.api.endpoints.community._community_store", None): + response = client.delete("/api/v1/community/posts/some-slug") + assert response.status_code in (400, 503) + + +def test_fork_post_route_exists(): + """POST /community/posts/{slug}/fork route must exist (not 404).""" + response = client.post("/api/v1/community/posts/some-slug/fork") + assert response.status_code != 404 + + +def test_local_feed_returns_json(): + """GET /community/local-feed returns JSON list for LAN peers.""" + with patch("app.api.endpoints.community._community_store", None): + response = client.get("/api/v1/community/local-feed") + assert response.status_code == 200 + assert isinstance(response.json(), list) diff --git a/tests/services/community/test_community_store.py b/tests/services/community/test_community_store.py new file mode 100644 index 0000000..b935736 --- /dev/null +++ b/tests/services/community/test_community_store.py @@ -0,0 +1,64 @@ +# tests/services/community/test_community_store.py +import pytest +from unittest.mock import MagicMock, patch +from app.services.community.community_store import KiwiCommunityStore, get_or_create_pseudonym + + +def test_get_or_create_pseudonym_new_user(): + """First-time publish: creates a new pseudonym in per-user SQLite.""" + mock_store = MagicMock() + mock_store.get_current_pseudonym.return_value = None + result = get_or_create_pseudonym( + store=mock_store, + directus_user_id="user-123", + requested_name="PastaWitch", + ) + mock_store.set_pseudonym.assert_called_once_with("user-123", "PastaWitch") + assert result == "PastaWitch" + + +def test_get_or_create_pseudonym_existing(): + """If user already has a pseudonym, return it without creating a new one.""" + mock_store = MagicMock() + mock_store.get_current_pseudonym.return_value = "PastaWitch" + result = get_or_create_pseudonym( + store=mock_store, + directus_user_id="user-123", + requested_name=None, + ) + mock_store.set_pseudonym.assert_not_called() + assert result == "PastaWitch" + + +def test_get_or_create_pseudonym_email_rejected(): + """Pseudonyms with @ are rejected to prevent PII leakage.""" + mock_store = MagicMock() + mock_store.get_current_pseudonym.return_value = None + with pytest.raises(ValueError, match="@"): + get_or_create_pseudonym( + store=mock_store, + directus_user_id="user-123", + requested_name="user@example.com", + ) + + +def test_get_or_create_pseudonym_blank_name_raises(): + """Empty requested_name raises when no existing pseudonym.""" + mock_store = MagicMock() + mock_store.get_current_pseudonym.return_value = None + with pytest.raises(ValueError): + get_or_create_pseudonym( + store=mock_store, + directus_user_id="user-123", + requested_name="", + ) + + +def test_kiwi_community_store_list_meal_plans_filters_by_plan_type(): + """KiwiCommunityStore.list_meal_plans calls list_posts with post_type='plan'.""" + store = KiwiCommunityStore.__new__(KiwiCommunityStore) + with patch.object(store, "list_posts", return_value=[]) as mock_list: + result = store.list_meal_plans(limit=10) + mock_list.assert_called_once() + call_kwargs = mock_list.call_args.kwargs + assert call_kwargs.get("post_type") == "plan" -- 2.45.2 From 96d7fe026319e763f0e38c038ac93d4972de2db4 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 18:07:46 -0700 Subject: [PATCH 7/7] =?UTF-8?q?feat(community):=20Vue=203=20frontend=20?= =?UTF-8?q?=E2=80=94=20CommunityFeedPanel,=20PostCard,=20PublishPlanModal,?= =?UTF-8?q?=20Community=20tab=20in=20MealPlanView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/CommunityFeedPanel.vue | 231 ++++++++++++++++++ frontend/src/components/CommunityPostCard.vue | 171 +++++++++++++ frontend/src/components/MealPlanView.vue | 18 +- frontend/src/components/PublishPlanModal.vue | 230 +++++++++++++++++ frontend/src/stores/community.ts | 123 ++++++++++ 5 files changed, 770 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/CommunityFeedPanel.vue create mode 100644 frontend/src/components/CommunityPostCard.vue create mode 100644 frontend/src/components/PublishPlanModal.vue create mode 100644 frontend/src/stores/community.ts diff --git a/frontend/src/components/CommunityFeedPanel.vue b/frontend/src/components/CommunityFeedPanel.vue new file mode 100644 index 0000000..915106a --- /dev/null +++ b/frontend/src/components/CommunityFeedPanel.vue @@ -0,0 +1,231 @@ + + + + + + diff --git a/frontend/src/components/CommunityPostCard.vue b/frontend/src/components/CommunityPostCard.vue new file mode 100644 index 0000000..5665c5d --- /dev/null +++ b/frontend/src/components/CommunityPostCard.vue @@ -0,0 +1,171 @@ + + + + + + diff --git a/frontend/src/components/MealPlanView.vue b/frontend/src/components/MealPlanView.vue index 7f6264e..f9b141f 100644 --- a/frontend/src/components/MealPlanView.vue +++ b/frontend/src/components/MealPlanView.vue @@ -26,7 +26,7 @@ @add-meal-type="onAddMealType" /> - +
+ +
+ +
@@ -76,10 +86,12 @@ import { useMealPlanStore } from '../stores/mealPlan' import MealPlanGrid from './MealPlanGrid.vue' import ShoppingListPanel from './ShoppingListPanel.vue' import PrepSessionView from './PrepSessionView.vue' +import CommunityFeedPanel from './CommunityFeedPanel.vue' const TABS = [ - { id: 'shopping', label: 'Shopping List' }, - { id: 'prep', label: 'Prep Schedule' }, + { id: 'shopping', label: 'Shopping List' }, + { id: 'prep', label: 'Prep Schedule' }, + { id: 'community', label: 'Community' }, ] as const type TabId = typeof TABS[number]['id'] diff --git a/frontend/src/components/PublishPlanModal.vue b/frontend/src/components/PublishPlanModal.vue new file mode 100644 index 0000000..d1831d5 --- /dev/null +++ b/frontend/src/components/PublishPlanModal.vue @@ -0,0 +1,230 @@ + + +