"""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 = """
<html><head>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Recipe",
"name": "Spinach Strawberry Salad",
"recipeYield": "6",
"prepTime": "PT15M",
"cookTime": null,
"totalTime": "PT15M",
"recipeIngredient": [
"6 cups fresh spinach",
"1 pint strawberries, sliced",
"1/2 cup sliced almonds"
],
"recipeInstructions": [
{"@type": "HowToStep", "text": "Combine spinach and strawberries."},
{"@type": "HowToStep", "text": "Top with almonds and dressing."}
],
"recipeCategory": "Salad",
"keywords": "spinach, strawberry, salad, easy"
}
</script>
</head><body></body></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 = """
<html><head>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{"@type": "Organization", "name": "Allrecipes"},
{
"@type": "Recipe",
"name": "Test Recipe",
"recipeIngredient": ["eggs", "flour"],
"recipeInstructions": [{"@type": "HowToStep", "text": "Mix and bake."}]
}
]
}
</script>
</head></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 = """
<html><body>
<nav>Navigation junk</nav>
<div class="recipe-content">
<h2>Ingredients</h2>
<p>6 cups fresh spinach</p>
<p>1 pint strawberries</p>
</div>
<footer>Footer junk</footer>
</body></html>
"""
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"])