""" Tests for item visibility in L1 web UI. Bug found 2026-01-12: L1 run succeeded but web UI not showing: - Runs - Recipes - Created media Root causes identified: 1. Recipes: owner field filtering but owner never set in loaded recipes 2. Media: item_types table entries not created on upload/import 3. Run outputs: outputs not registered in item_types table """ import pytest from pathlib import Path from unittest.mock import MagicMock, AsyncMock, patch import tempfile class TestRecipeVisibility: """Tests for recipe listing visibility.""" def test_recipe_filter_allows_none_owner(self) -> None: """ Regression test: The recipe filter should allow recipes where owner is None. Bug: recipe_service.list_recipes() filtered by owner == actor_id, but owner field is None in recipes loaded from S-expression files. This caused ALL recipes to be filtered out. Fix: The filter is now: actor_id is None or owner is None or owner == actor_id """ # Simulate the filter logic from recipe_service.list_recipes # OLD (broken): if actor_id is None or owner == actor_id # NEW (fixed): if actor_id is None or owner is None or owner == actor_id test_cases = [ # (actor_id, owner, expected_visible, description) (None, None, True, "No filter, no owner -> visible"), (None, "@someone@example.com", True, "No filter, has owner -> visible"), ("@testuser@example.com", None, True, "Has filter, no owner -> visible (shared)"), ("@testuser@example.com", "@testuser@example.com", True, "Filter matches owner -> visible"), ("@testuser@example.com", "@other@example.com", False, "Filter doesn't match -> hidden"), ] for actor_id, owner, expected_visible, description in test_cases: # This is the FIXED filter logic from recipe_service.py line 86 is_visible = actor_id is None or owner is None or owner == actor_id assert is_visible == expected_visible, f"Failed: {description}" def test_recipe_filter_old_logic_was_broken(self) -> None: """Document that the old filter logic excluded all recipes with owner=None.""" # OLD filter: actor_id is None or owner == actor_id # This broke when owner=None and actor_id was provided actor_id = "@testuser@example.com" owner = None # This is what compiled sexp produces # OLD logic (broken): old_logic_visible = actor_id is None or owner == actor_id assert old_logic_visible is False, "Old logic incorrectly hid owner=None recipes" # NEW logic (fixed): new_logic_visible = actor_id is None or owner is None or owner == actor_id assert new_logic_visible is True, "New logic should show owner=None recipes" class TestMediaVisibility: """Tests for media visibility after upload.""" @pytest.mark.asyncio async def test_upload_content_creates_item_type_record(self) -> None: """ Test: Uploaded media must be registered in item_types table via save_item_metadata. The save_item_metadata function creates entries in item_types table, enabling the media to appear in list_media queries. """ import importlib.util spec = importlib.util.spec_from_file_location( "cache_service", "/home/giles/art/art-celery/app/services/cache_service.py" ) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) CacheService = module.CacheService # Create mocks mock_db = AsyncMock() mock_db.create_cache_item = AsyncMock() mock_db.save_item_metadata = AsyncMock() mock_cache = MagicMock() cached_result = MagicMock() cached_result.cid = "QmUploadedContent123" mock_cache.put.return_value = (cached_result, "QmIPFSCid123") service = CacheService(database=mock_db, cache_manager=mock_cache) # Upload content cid, ipfs_cid, error = await service.upload_content( content=b"test video content", filename="test.mp4", actor_id="@testuser@example.com", ) assert error is None, f"Upload failed: {error}" assert cid is not None # Verify save_item_metadata was called (which creates item_types entry) mock_db.save_item_metadata.assert_called_once() # Verify it was called with correct actor_id and a media type (not mime type) call_kwargs = mock_db.save_item_metadata.call_args[1] assert call_kwargs.get('actor_id') == "@testuser@example.com", \ "save_item_metadata must be called with the uploading user's actor_id" # item_type should be media category like "video", "image", "audio", "unknown" # NOT mime type like "video/mp4" item_type = call_kwargs.get('item_type') assert item_type in ("video", "image", "audio", "unknown"), \ f"item_type should be media category, got '{item_type}'" @pytest.mark.asyncio async def test_import_from_ipfs_creates_item_type_record(self) -> None: """ Test: Imported media must be registered in item_types table via save_item_metadata. The save_item_metadata function creates entries in item_types table with detected media type, enabling the media to appear in list_media queries. """ import importlib.util spec = importlib.util.spec_from_file_location( "cache_service", "/home/giles/art/art-celery/app/services/cache_service.py" ) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) CacheService = module.CacheService mock_db = AsyncMock() mock_db.create_cache_item = AsyncMock() mock_db.save_item_metadata = AsyncMock() mock_cache = MagicMock() cached_result = MagicMock() cached_result.cid = "QmImportedContent123" mock_cache.put.return_value = (cached_result, "QmIPFSCid456") service = CacheService(database=mock_db, cache_manager=mock_cache) service.cache_dir = Path(tempfile.gettempdir()) # We need to mock the ipfs_client module at the right location import importlib ipfs_module = MagicMock() ipfs_module.get_file = MagicMock(return_value=True) # Patch at module level with patch.dict('sys.modules', {'ipfs_client': ipfs_module}): # Import from IPFS cid, error = await service.import_from_ipfs( ipfs_cid="QmSourceIPFSCid", actor_id="@testuser@example.com", ) # Verify save_item_metadata was called (which creates item_types entry) mock_db.save_item_metadata.assert_called_once() # Verify it was called with detected media type (not hardcoded "media") call_kwargs = mock_db.save_item_metadata.call_args[1] item_type = call_kwargs.get('item_type') assert item_type in ("video", "image", "audio", "unknown"), \ f"item_type should be detected media category, got '{item_type}'" class TestRunOutputVisibility: """Tests for run output visibility.""" @pytest.mark.asyncio async def test_completed_run_output_visible_in_media_list(self) -> None: """ Run outputs should be accessible in media listings. When a run completes, its output should be registered in item_types so it appears in the user's media gallery. """ # This test documents the expected behavior # Run outputs are stored in run_cache but should also be in item_types # for the media gallery to show them # The fix should either: # 1. Add item_types entry when run completes, OR # 2. Modify list_media to also check run_cache outputs pass # Placeholder for implementation test class TestDatabaseItemTypes: """Tests for item_types database operations.""" @pytest.mark.asyncio async def test_add_item_type_function_exists(self) -> None: """Verify add_item_type function exists and has correct signature.""" import database assert hasattr(database, 'add_item_type'), \ "database.add_item_type function should exist" # Check it's an async function import inspect assert inspect.iscoroutinefunction(database.add_item_type), \ "add_item_type should be an async function" @pytest.mark.asyncio async def test_get_user_items_returns_items_from_item_types(self) -> None: """ Verify get_user_items queries item_types table. If item_types has no entries for a user, they see no media. """ # This is a documentation test showing the data flow: # 1. User uploads content -> should create item_types entry # 2. list_media -> calls get_user_items -> queries item_types # 3. If step 1 didn't create item_types entry, step 2 returns empty pass class TestTemplateRendering: """Tests for template variable passing.""" def test_cache_not_found_template_receives_content_hash(self) -> None: """ Regression test: cache/not_found.html template requires content_hash. Bug: The template uses {{ content_hash[:24] }} but the route doesn't pass content_hash to the render context. Error: jinja2.exceptions.UndefinedError: 'content_hash' is undefined """ # This test documents the bug - the template expects content_hash # but the route at /app/app/routers/cache.py line 57 doesn't provide it pass # Will verify fix by checking route code class TestOwnerFieldInRecipes: """Tests for owner field handling in recipes.""" def test_sexp_recipe_has_none_owner_by_default(self) -> None: """ S-expression recipes have owner=None by default. The compiled recipe includes owner field but it's None, so the list_recipes filter must allow owner=None to show shared/public recipes. """ sample_sexp = """ (recipe "test" (-> (source :input true :name "video") (fx identity))) """ from artdag.sexp import compile_string compiled = compile_string(sample_sexp) recipe_dict = compiled.to_dict() # The compiled recipe has owner field but it's None assert recipe_dict.get("owner") is None, \ "Compiled S-expression should have owner=None" # This means the filter must allow owner=None for recipes to be visible # The fix: if actor_id is None or owner is None or owner == actor_id