"""Integration tests for Recipe Toggle System — Backend Validation. Tests: 1. Recipe Extraction (URL fetch + structured JSON) 2. Temp State (SQLite CRUD, 5-min TTL, JSON serialization) 3. Callback Handler (toggle, commit, cancel) 4. Telegram Keyboard Builder (InlineKeyboardMarkup format) """ import json import os import sqlite3 import sys import tempfile import time from datetime import datetime, timedelta from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest # Ensure icarus is importable sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) from icarus.core.db.grocery_list import ( DB_PATH, SCHEMA, add_items, clear_list, delete_temp_state, get_temp_state, init_db, list_items, save_temp_state, update_temp_state, update_status, ) from icarus.core.handlers.recipe_toggle import ( build_caption, build_toggle_keyboard, ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) def temp_db(tmp_path, monkeypatch): """Use a temporary database for all tests.""" db_file = tmp_path / "test_icarus.db" monkeypatch.setattr("icarus.core.db.grocery_list.DB_PATH", db_file) # Re-init with temp path import icarus.core.db.grocery_list as db_mod old_conn = db_mod._get_connection def _new_conn(): conn = sqlite3.connect(str(db_file), check_same_thread=False) conn.row_factory = sqlite3.Row return conn monkeypatch.setattr(db_mod, "_get_connection", _new_conn) init_db() yield db_file SAMPLE_RECIPE_URL = "https://www.allrecipes.com/recipe/158968/spinach-and-strawberry-salad/" SAMPLE_INGREDIENTS = [ "6 cups fresh spinach", "1 pint strawberries, sliced", "1/2 cup sliced almonds", "1/4 cup red onion, sliced", "1/2 cup poppy seed dressing", ] SAMPLE_INGREDIENTS_STATE = [ {"index": 0, "text": "6 cups fresh spinach", "selected": True, "recipe_id": "spinach-strawberry-salad"}, {"index": 1, "text": "1 pint strawberries, sliced", "selected": True, "recipe_id": "spinach-strawberry-salad"}, {"index": 2, "text": "1/2 cup sliced almonds", "selected": True, "recipe_id": "spinach-strawberry-salad"}, {"index": 3, "text": "1/4 cup red onion, sliced", "selected": True, "recipe_id": "spinach-strawberry-salad"}, {"index": 4, "text": "1/2 cup poppy seed dressing", "selected": True, "recipe_id": "spinach-strawberry-salad"}, ] # =========================================================================== # TEST 1: Recipe Extraction # =========================================================================== class TestRecipeExtraction: """Test recipe extraction module (unit-level, no network).""" def test_extract_ld_json_from_html(self): """Verify ld+json parsing from HTML content.""" from icarus.core.extractors.recipe import _extract_ld_json html = """ """ result = _extract_ld_json(html) assert result is not None, "Should parse ld+json from HTML" assert result["@type"] == "Recipe" assert result["name"] == "Spinach Strawberry Salad" assert len(result["recipeIngredient"]) == 3 assert "spinach" in result["recipeIngredient"][0].lower() def test_extract_ld_json_graph_format(self): """Verify ld+json extraction from @graph structure (common on recipe sites).""" from icarus.core.extractors.recipe import _extract_ld_json html = """ """ result = _extract_ld_json(html) assert result is not None assert result["@type"] == "Recipe" assert result["name"] == "Test Recipe" def test_parse_servings(self): """Test servings parsing from various formats.""" from icarus.core.extractors.recipe import _parse_servings assert _parse_servings("6") == "6" assert _parse_servings("Serves 4-6") == "4-6" assert _parse_servings("8 servings") == "8" assert _parse_servings(None) is None assert _parse_servings("") is None def test_parse_duration(self): """Test ISO 8601 duration parsing.""" from icarus.core.extractors.recipe import _parse_duration assert _parse_duration("PT15M") == "15 min" assert _parse_duration("PT1H30M") == "1 hr 30 min" assert _parse_duration("PT2H") == "2 hr" assert _parse_duration(None) is None def test_clean_ingredients(self): """Test ingredient cleaning.""" from icarus.core.extractors.recipe import _clean_ingredients raw = [" 6 cups fresh spinach ", "1 pint\nstrawberries", "", None] result = _clean_ingredients(raw) assert len(result) == 2 # Empty and None filtered assert "6 cups fresh spinach" in result[0] assert "strawberries" in result[1] def test_parse_instructions(self): """Test instruction parsing from various formats.""" from icarus.core.extractors.recipe import _parse_instructions # HowToStep format steps = [ {"@type": "HowToStep", "text": "Combine ingredients."}, {"@type": "HowToStep", "text": "Bake for 30 min."}, ] result = _parse_instructions(steps) assert len(result) == 2 assert result[0] == "Combine ingredients." # Plain string result = _parse_instructions("Step one\nStep two") assert len(result) >= 2 def test_extract_tags(self): """Test tag extraction from recipe metadata.""" from icarus.core.extractors.recipe import _extract_tags recipe = { "name": "Chicken Stir Fry", "recipeCategory": "Dinner", "recipeCuisine": "Asian", "keywords": "chicken, stir fry, easy, 30-minute", } tags = _extract_tags(recipe) assert "Dinner" in tags or "dinner" in " ".join(tags).lower() assert len(tags) <= 8 def test_slugify(self): """Test recipe ID generation.""" from icarus.core.extractors.recipe import _slugify assert _slugify("Spinach & Strawberry Salad") == "spinach-strawberry-salad" assert _slugify("Mom's Best Chili!!!") == "mom-s-best-chili" def test_fix_json_newlines(self): """Test JSON newline fixing for recipe site ld+json.""" from icarus.core.extractors.recipe import _fix_json_newlines # Newlines inside strings should be escaped broken = '"text": "Step 1:\nMix ingredients"' fixed = _fix_json_newlines(broken) assert "\\n" in fixed assert "\n" not in fixed.replace("\\n", "") # No raw newlines remain in strings def test_build_text_from_html(self): """Test HTML → clean text conversion.""" from icarus.core.extractors.recipe import _build_text_from_html html = """

Ingredients

6 cups fresh spinach

1 pint strawberries

""" text = _build_text_from_html(html) assert "spinach" in text.lower() assert "footer" not in text.lower() assert len(text) > 0 def test_format_recipe(self): """Test recipe formatting for Telegram.""" from icarus.core.extractors.recipe import format_recipe recipe = { "title": "Test Salad", "servings": "6", "prep_time": "15 min", "ingredients": ["spinach", "strawberries", "almonds"], "instructions": ["Combine ingredients.", "Serve immediately."], "tags": ["salad", "easy"], "source_url": "https://example.com/recipe", } result = format_recipe(recipe) assert "Test Salad" in result assert "spinach" in result assert "Combine ingredients" in result # =========================================================================== # TEST 2: Temp State (SQLite CRUD + TTL) # =========================================================================== class TestTempState: """Test recipe_temp_state table: CRUD, TTL, JSON serialization.""" def test_init_db_creates_tables(self, temp_db): """Verify schema creates both required tables.""" conn = sqlite3.connect(str(temp_db)) conn.row_factory = sqlite3.Row # Check grocery_list table tables = conn.execute( "SELECT name FROM sqlite_master WHERE type='table'" ).fetchall() table_names = {t["name"] for t in tables} assert "grocery_list" in table_names assert "recipe_temp_state" in table_names # Check indexes indexes = conn.execute( "SELECT name FROM sqlite_master WHERE type='index'" ).fetchall() index_names = {i["name"] for i in indexes} assert "idx_grocery_status" in index_names assert "idx_temp_expires" in index_names conn.close() def test_save_and_get_temp_state(self): """Test saving and retrieving temp state.""" save_temp_state( recipe_id="spinach-strawberry-salad", user_id=8386527252, chat_id=8386527252, message_id=42, title="Spinach Strawberry Salad", source_url=SAMPLE_RECIPE_URL, ingredients=SAMPLE_INGREDIENTS_STATE, ) state = get_temp_state("spinach-strawberry-salad") assert state is not None assert state["recipe_id"] == "spinach-strawberry-salad" assert state["title"] == "Spinach Strawberry Salad" assert state["user_id"] == 8386527252 assert state["chat_id"] == 8386527252 assert len(state["ingredients"]) == 5 assert state["ingredients"][0]["text"] == "6 cups fresh spinach" assert state["ingredients"][0]["selected"] is True def test_temp_state_json_serialization(self): """Verify ingredients_json serializes/deserializes correctly.""" ingredients = [ {"index": 0, "text": "1/2 cup olive oil", "selected": True, "recipe_id": "test"}, {"index": 1, "text": "2 cloves garlic, minced", "selected": False, "recipe_id": "test"}, ] save_temp_state( recipe_id="json-test", user_id=123, chat_id=456, message_id=789, title="JSON Test", source_url="https://example.com", ingredients=ingredients, ) state = get_temp_state("json-test") assert state is not None assert len(state["ingredients"]) == 2 # Verify special characters preserved assert state["ingredients"][0]["text"] == "1/2 cup olive oil" assert state["ingredients"][1]["text"] == "2 cloves garlic, minced" assert state["ingredients"][1]["selected"] is False def test_temp_state_ttl_expiry(self, temp_db): """Verify that expired temp state entries are cleaned up.""" save_temp_state( recipe_id="ttl-test", user_id=111, chat_id=222, message_id=333, title="TTL Test", source_url="https://example.com", ingredients=[{"index": 0, "text": "test item", "selected": True, "recipe_id": "ttl-test"}], ) # Verify it's there state = get_temp_state("ttl-test") assert state is not None # Manually expire it by setting expires_at to the past conn = sqlite3.connect(str(temp_db)) conn.execute( "UPDATE recipe_temp_state SET expires_at = datetime('now', '-1 minute') WHERE recipe_id = ?", ("ttl-test",), ) conn.commit() conn.close() # Now get_temp_state should clean it up and return None state = get_temp_state("ttl-test") assert state is None, "Expired temp state should be cleaned up" def test_temp_state_5_min_ttl(self): """Verify TTL is 5 minutes from creation.""" # Save state, then verify via get_temp_state that it's not expired yet. # Then check the expires_at field directly via module connection. before = datetime.now() save_temp_state( recipe_id="5min-test", user_id=111, chat_id=222, message_id=333, title="5 Min Test", source_url="https://example.com", ingredients=[{"index": 0, "text": "test", "selected": True, "recipe_id": "5min-test"}], ) after = datetime.now() # State should still be valid (not expired) state = get_temp_state("5min-test") assert state is not None, "State should exist immediately after creation" # Verify TTL duration by reading expires_at directly via module connection import icarus.core.db.grocery_list as db_mod conn = db_mod._get_connection() try: row = conn.execute( "SELECT expires_at FROM recipe_temp_state WHERE recipe_id = ?", ("5min-test",), ).fetchone() from datetime import timezone expires_at = datetime.fromisoformat(dict(row)["expires_at"]) expected_min = before + timedelta(minutes=5) expected_max = after + timedelta(minutes=5) assert expected_min <= expires_at <= expected_max, \ f"Expected TTL ~5min, got expires_at={expires_at}" finally: conn.close() def test_update_temp_state(self): """Test updating ingredients in temp state (toggle operation).""" save_temp_state( recipe_id="toggle-test", user_id=111, chat_id=222, message_id=333, title="Toggle Test", source_url="https://example.com", ingredients=SAMPLE_INGREDIENTS_STATE, ) # Toggle ingredient #2 (strawberries) toggled = SAMPLE_INGREDIENTS_STATE.copy() toggled[1] = {**toggled[1], "selected": False} update_temp_state("toggle-test", toggled) state = get_temp_state("toggle-test") assert state is not None assert state["ingredients"][1]["selected"] is False assert state["ingredients"][0]["selected"] is True # Others unchanged def test_delete_temp_state(self): """Test deleting temp state.""" save_temp_state( recipe_id="delete-test", user_id=111, chat_id=222, message_id=333, title="Delete Test", source_url="https://example.com", ingredients=[{"index": 0, "text": "test", "selected": True, "recipe_id": "delete-test"}], ) assert get_temp_state("delete-test") is not None delete_temp_state("delete-test") assert get_temp_state("delete-test") is None def test_get_nonexistent_temp_state(self): """Test getting a non-existent recipe state returns None.""" state = get_temp_state("does-not-exist") assert state is None # =========================================================================== # TEST 3: Callback Handler Logic (toggle, commit, cancel) # =========================================================================== class TestCallbackHandlers: """Test callback handler logic (toggle, commit, cancel).""" def test_toggle_flips_selected(self): """Simulate toggle callback: ingredient #0 toggles from selected→deselected.""" save_temp_state( recipe_id="toggle-cb-test", user_id=111, chat_id=222, message_id=333, title="Toggle CB Test", source_url=SAMPLE_RECIPE_URL, ingredients=SAMPLE_INGREDIENTS_STATE, ) # Simulate toggle of ingredient #0 state = get_temp_state("toggle-cb-test") assert state is not None ingredients = state["ingredients"] # Toggle ingredient 0 ingredients[0]["selected"] = not ingredients[0]["selected"] update_temp_state("toggle-cb-test", ingredients) # Verify state = get_temp_state("toggle-cb-test") assert state["ingredients"][0]["selected"] is False # Was True assert state["ingredients"][1]["selected"] is True # Unchanged def test_toggle_callback_data_format(self): """Verify callback data parsing for toggle:{recipe_id}:{index}.""" callback_data = "toggle:spinach-strawberry-salad:0" parts = callback_data.split(":") assert parts[0] == "toggle" assert parts[1] == "spinach-strawberry-salad" assert int(parts[2]) == 0 callback_data = "toggle:spinach-strawberry-salad:4" parts = callback_data.split(":") assert int(parts[2]) == 4 def test_commit_callback_data_format(self): """Verify callback data parsing for commit:{recipe_id}.""" callback_data = "commit:spinach-strawberry-salad" parts = callback_data.split(":") assert parts[0] == "commit" assert parts[1] == "spinach-strawberry-salad" def test_cancel_callback_data_format(self): """Verify callback data parsing for cancel:{recipe_id}.""" callback_data = "cancel:spinach-strawberry-salad" parts = callback_data.split(":") assert parts[0] == "cancel" assert parts[1] == "spinach-strawberry-salad" def test_commit_adds_selected_to_grocery_list(self): """Simulate commit: selected ingredients get added to grocery_list table.""" # Create temp state with some deselected ingredients = SAMPLE_INGREDIENTS_STATE.copy() ingredients[2] = {**ingredients[2], "selected": False} # Deselect almonds ingredients[4] = {**ingredients[4], "selected": False} # Deselect dressing save_temp_state( recipe_id="commit-test", user_id=111, chat_id=222, message_id=333, title="Commit Test", source_url=SAMPLE_RECIPE_URL, ingredients=ingredients, ) state = get_temp_state("commit-test") selected = [i for i in state["ingredients"] if i["selected"]] # Should be 3 selected (indices 0, 1, 3) assert len(selected) == 3 assert selected[0]["text"] == "6 cups fresh spinach" assert selected[1]["text"] == "1 pint strawberries, sliced" assert selected[2]["text"] == "1/4 cup red onion, sliced" # Add to grocery list (simulating commit handler logic) count = add_items( items=[{"text": i["text"]} for i in selected], recipe_source="Commit Test", recipe_url=SAMPLE_RECIPE_URL, requested_by="matt", ) assert count == 3 # Verify grocery_list has the items items = list_items() assert len(items) == 3 recipe_items = [i for i in items if i["recipe_source"] == "Commit Test"] assert len(recipe_items) == 3 # Verify deselected items are NOT in the list item_names = [i["item"] for i in items] assert "6 cups fresh spinach" in item_names assert "1/2 cup sliced almonds" not in item_names assert "1/2 cup poppy seed dressing" not in item_names # Clean up delete_temp_state("commit-test") def test_cancel_deletes_temp_state(self): """Simulate cancel: temp state is deleted, no grocery items added.""" save_temp_state( recipe_id="cancel-test", user_id=111, chat_id=222, message_id=333, title="Cancel Test", source_url=SAMPLE_RECIPE_URL, ingredients=SAMPLE_INGREDIENTS_STATE, ) # Cancel = delete temp state delete_temp_state("cancel-test") assert get_temp_state("cancel-test") is None # Verify no items in grocery list from this recipe items = list_items(requested_by="matt") cancel_items = [i for i in items if i["recipe_source"] == "Cancel Test"] assert len(cancel_items) == 0 def test_multiple_toggles_then_commit(self): """Simulate a realistic flow: toggle multiple ingredients, then commit.""" recipe_id = "multi-toggle-test" # Initial state: all selected save_temp_state( recipe_id=recipe_id, user_id=111, chat_id=222, message_id=333, title="Multi Toggle Test", source_url=SAMPLE_RECIPE_URL, ingredients=SAMPLE_INGREDIENTS_STATE, ) # Toggle ingredient 0 OFF state = get_temp_state(recipe_id) ingredients = state["ingredients"] ingredients[0]["selected"] = False update_temp_state(recipe_id, ingredients) # Toggle ingredient 2 OFF state = get_temp_state(recipe_id) ingredients = state["ingredients"] ingredients[2]["selected"] = False update_temp_state(recipe_id, ingredients) # Toggle ingredient 0 back ON (change mind) state = get_temp_state(recipe_id) ingredients = state["ingredients"] ingredients[0]["selected"] = True update_temp_state(recipe_id, ingredients) # Verify final state state = get_temp_state(recipe_id) assert state["ingredients"][0]["selected"] is True # Toggled back on assert state["ingredients"][1]["selected"] is True # Never changed assert state["ingredients"][2]["selected"] is False # Toggled off assert state["ingredients"][3]["selected"] is True # Never changed assert state["ingredients"][4]["selected"] is True # Never changed # Commit selected selected = [i for i in state["ingredients"] if i["selected"]] assert len(selected) == 4 count = add_items( items=[{"text": i["text"]} for i in selected], recipe_source="Multi Toggle Test", recipe_url=SAMPLE_RECIPE_URL, requested_by="matt", ) assert count == 4 # Cleanup delete_temp_state(recipe_id) # =========================================================================== # TEST 4: Telegram Keyboard Builder # =========================================================================== class TestToggleKeyboard: """Test build_toggle_keyboard and caption builder.""" def test_keyboard_structure_all_selected(self): """Test keyboard with all ingredients selected.""" keyboard = build_toggle_keyboard(SAMPLE_INGREDIENTS_STATE, 5) assert "inline_keyboard" in keyboard kb = keyboard["inline_keyboard"] # Should have ingredient buttons (5 items in rows of 4 = 2 rows) # Row 1: buttons 0-3, Row 2: button 4 + commit/cancel ingredient_rows = [row for row in kb if any("toggle" in btn.get("callback_data", "") for btn in row)] assert len(ingredient_rows) == 2 # 4+1 # First row: 4 ingredient buttons assert len(ingredient_rows[0]) == 4 # Second row: 1 ingredient button assert len(ingredient_rows[1]) == 1 # Last row: commit + cancel action_row = kb[-1] assert len(action_row) == 2 assert action_row[0]["callback_data"].startswith("commit:") assert action_row[1]["callback_data"].startswith("cancel:") def test_keyboard_callback_data_format(self): """Verify callback data matches spec: toggle:{recipe_id}:{index}.""" keyboard = build_toggle_keyboard(SAMPLE_INGREDIENTS_STATE, 5) kb = keyboard["inline_keyboard"] # Check first ingredient button first_btn = kb[0][0] assert first_btn["callback_data"] == "toggle:spinach-strawberry-salad:0" assert "✅" in first_btn["text"] or "❌" in first_btn["text"] # Check commit button action_row = kb[-1] assert action_row[0]["callback_data"] == "commit:spinach-strawberry-salad" assert "Commit" in action_row[0]["text"] def test_keyboard_button_labels(self): """Test button labels show ✅/❌ based on selection state.""" # All selected keyboard = build_toggle_keyboard(SAMPLE_INGREDIENTS_STATE, 5) kb = keyboard["inline_keyboard"] first_btn = kb[0][0] assert "✅" in first_btn["text"] # Selected # Deselect first ingredient deselected = SAMPLE_INGREDIENTS_STATE.copy() deselected[0] = {**deselected[0], "selected": False} keyboard = build_toggle_keyboard(deselected, 4) kb = keyboard["inline_keyboard"] first_btn = kb[0][0] assert "❌" in first_btn["text"] # Deselected def test_keyboard_commit_button_shows_count(self): """Test commit button shows selected count.""" keyboard = build_toggle_keyboard(SAMPLE_INGREDIENTS_STATE, 5) action_row = keyboard["inline_keyboard"][-1] assert "5" in action_row[0]["text"] # "✅ Commit (5)" # With fewer selected keyboard = build_toggle_keyboard(SAMPLE_INGREDIENTS_STATE, 3) action_row = keyboard["inline_keyboard"][-1] assert "3" in action_row[0]["text"] def test_keyboard_zero_selected(self): """Test keyboard when no items are selected.""" deselected = [ {**ing, "selected": False} for ing in SAMPLE_INGREDIENTS_STATE ] keyboard = build_toggle_keyboard(deselected, 0) action_row = keyboard["inline_keyboard"][-1] assert "Select at least 1" in action_row[0]["text"] def test_keyboard_4_per_row_layout(self): """Test that ingredient buttons are laid out 4 per row.""" # 5 ingredients → rows of [4, 1] keyboard = build_toggle_keyboard(SAMPLE_INGREDIENTS_STATE, 5) kb = keyboard["inline_keyboard"] # First row should have 4 buttons assert len(kb[0]) == 4 # With 8 ingredients → rows of [4, 4] ingredients_8 = [ {"index": i, "text": f"Item {i}", "selected": True, "recipe_id": "test"} for i in range(8) ] keyboard = build_toggle_keyboard(ingredients_8, 8) kb = keyboard["inline_keyboard"] assert len(kb[0]) == 4 assert len(kb[1]) == 4 # With 9 ingredients → rows of [4, 4, 1] ingredients_9 = [ {"index": i, "text": f"Item {i}", "selected": True, "recipe_id": "test"} for i in range(9) ] keyboard = build_toggle_keyboard(ingredients_9, 9) kb = keyboard["inline_keyboard"] assert len(kb[0]) == 4 assert len(kb[1]) == 4 assert len(kb[2]) == 1 def test_caption_all_selected(self): """Test caption with all ingredients selected.""" caption = build_caption( "Spinach Strawberry Salad", "www.allrecipes.com", SAMPLE_INGREDIENTS_STATE, ) # Title and domain should be present (may be MarkdownV2-escaped) assert "Spinach Strawberry Salad" in caption # Domain dots are escaped in MarkdownV2 (www\.allrecipes\.com) assert "allrecipes.com" in caption.replace("\\", "") or "allrecipes.com" in caption assert "✅" in caption # All items should have ✅ for i, ing in enumerate(SAMPLE_INGREDIENTS_STATE): assert f"#{i+1}" in caption def test_caption_with_deselected(self): """Test caption with some ingredients deselected.""" deselected = SAMPLE_INGREDIENTS_STATE.copy() deselected[2] = {**deselected[2], "selected": False} caption = build_caption( "Spinach Strawberry Salad", "www.allrecipes.com", deselected, ) # Should have ✅ for selected and ❌ for deselected assert "❌" in caption # Deselected item should have strikethrough in MarkdownV2 assert "~" in caption # Strikethrough markers # =========================================================================== # TEST 5: Grocery List CRUD # =========================================================================== class TestGroceryList: """Test grocery_list table CRUD operations.""" def test_add_and_list_items(self): """Test adding and listing grocery items.""" items = [ {"text": "6 cups fresh spinach"}, {"text": "1 pint strawberries, sliced"}, {"text": "1/2 cup sliced almonds"}, ] count = add_items(items, "Test Recipe", "https://example.com", "matt") assert count == 3 listed = list_items() assert len(listed) == 3 assert listed[0]["item"] == "6 cups fresh spinach" assert listed[0]["recipe_source"] == "Test Recipe" assert listed[0]["requested_by"] == "matt" assert listed[0]["status"] == "pending" def test_add_items_with_quantity(self): """Test adding items with quantity field.""" items = [ {"text": "2 lbs chicken breast", "quantity": "2 lbs"}, {"text": "1 cup rice", "quantity": "1 cup"}, ] count = add_items(items, "Chicken Rice", "https://example.com", "matt") assert count == 2 listed = list_items() rice_item = next(i for i in listed if "rice" in i["item"].lower()) assert rice_item["quantity"] == "1 cup" def test_filter_by_status(self): """Test listing items filtered by status.""" items = [{"text": "milk"}, {"text": "eggs"}, {"text": "bread"}] add_items(items, "Test", "https://example.com", "matt") # All should be pending pending = list_items(status="pending") assert len(pending) == 3 # Update one to in_cart first_id = pending[0]["id"] result = update_status(first_id, "in_cart") assert result is True in_cart = list_items(status="in_cart") assert len(in_cart) == 1 assert in_cart[0]["item"] == "milk" def test_filter_by_user(self): """Test listing items filtered by user.""" add_items([{"text": "milk"}], "Test", "https://example.com", "matt") add_items([{"text": "bread"}], "Test", "https://example.com", "aundrea") matt_items = list_items(requested_by="matt") assert len(matt_items) == 1 assert matt_items[0]["item"] == "milk" def test_update_status_invalid(self): """Test that invalid status is rejected.""" result = update_status(1, "invalid_status") assert result is False def test_clear_list(self): """Test clearing the grocery list.""" add_items([{"text": "milk"}], "Test", "https://example.com", "matt") add_items([{"text": "eggs"}], "Test", "https://example.com", "aundrea") # Clear all removed = clear_list() assert removed >= 2 # Verify empty assert len(list_items()) == 0 def test_clear_list_by_user(self): """Test clearing items for a specific user.""" add_items([{"text": "milk"}], "Test", "https://example.com", "matt") add_items([{"text": "eggs"}], "Test", "https://example.com", "aundrea") removed = clear_list(requested_by="matt") assert removed == 1 # Only aundrea's items remain remaining = list_items() assert len(remaining) == 1 assert remaining[0]["requested_by"] == "aundrea" def test_normalized_item_dedup(self): """Test that normalized items work for deduplication.""" # The _normalize_item function strips quantities for deduplication # This tests the normalization, not auto-dedup (which isn't implemented) from icarus.core.db.grocery_list import _normalize_item assert _normalize_item("2 cups flour") == "flour" assert _normalize_item("1 lb chicken thighs") == "chicken thighs" assert _normalize_item("fresh spinach") == "fresh spinach" # =========================================================================== # RUN AS SCRIPT # =========================================================================== if __name__ == "__main__": pytest.main([__file__, "-v", "--tb=short"])