"""Tests for PersistentRecipeCache.""" import json import os import tempfile from typing import Any, Dict, List import pytest from py.services.persistent_recipe_cache import PersistentRecipeCache, PersistedRecipeData @pytest.fixture def temp_db_path(): """Create a temporary database path.""" with tempfile.NamedTemporaryFile(suffix=".sqlite", delete=False) as f: path = f.name yield path # Cleanup if os.path.exists(path): os.unlink(path) # Also clean up WAL files for suffix in ["-wal", "-shm"]: wal_path = path + suffix if os.path.exists(wal_path): os.unlink(wal_path) @pytest.fixture def sample_recipes() -> List[Dict[str, Any]]: """Create sample recipe data.""" return [ { "id": "recipe-001", "file_path": "/path/to/image1.png", "title": "Test Recipe 1", "folder": "folder1", "base_model": "SD1.5", "fingerprint": "abc123", "created_date": 1700000000.0, "modified": 1700000100.0, "favorite": True, "repair_version": 3, "preview_nsfw_level": 1, "loras": [ {"hash": "hash1", "file_name": "lora1", "strength": 0.8}, {"hash": "hash2", "file_name": "lora2", "strength": 1.0}, ], "checkpoint": {"name": "model.safetensors", "hash": "cphash"}, "gen_params": {"prompt": "test prompt", "negative_prompt": "bad"}, "tags": ["tag1", "tag2"], }, { "id": "recipe-002", "file_path": "/path/to/image2.png", "title": "Test Recipe 2", "folder": "", "base_model": "SDXL", "fingerprint": "def456", "created_date": 1700000200.0, "modified": 1700000300.0, "favorite": False, "repair_version": 2, "preview_nsfw_level": 0, "loras": [{"hash": "hash3", "file_name": "lora3", "strength": 0.5}], "gen_params": {"prompt": "another prompt"}, "tags": [], }, ] class TestPersistentRecipeCache: """Tests for PersistentRecipeCache class.""" def test_init_creates_db(self, temp_db_path): """Test that initialization creates the database.""" cache = PersistentRecipeCache(db_path=temp_db_path) assert cache.is_enabled() assert os.path.exists(temp_db_path) def test_save_and_load_roundtrip(self, temp_db_path, sample_recipes): """Test save and load cycle preserves data.""" cache = PersistentRecipeCache(db_path=temp_db_path) # Save recipes json_paths = { "recipe-001": "/path/to/recipe-001.recipe.json", "recipe-002": "/path/to/recipe-002.recipe.json", } cache.save_cache(sample_recipes, json_paths) # Load recipes loaded = cache.load_cache() assert loaded is not None assert len(loaded.raw_data) == 2 # Verify first recipe r1 = next(r for r in loaded.raw_data if r["id"] == "recipe-001") assert r1["title"] == "Test Recipe 1" assert r1["folder"] == "folder1" assert r1["base_model"] == "SD1.5" assert r1["fingerprint"] == "abc123" assert r1["favorite"] is True assert r1["repair_version"] == 3 assert len(r1["loras"]) == 2 assert r1["loras"][0]["hash"] == "hash1" assert r1["checkpoint"]["name"] == "model.safetensors" assert r1["gen_params"]["prompt"] == "test prompt" assert r1["tags"] == ["tag1", "tag2"] # Verify second recipe r2 = next(r for r in loaded.raw_data if r["id"] == "recipe-002") assert r2["title"] == "Test Recipe 2" assert r2["folder"] == "" assert r2["favorite"] is False def test_empty_cache_returns_none(self, temp_db_path): """Test that loading empty cache returns None.""" cache = PersistentRecipeCache(db_path=temp_db_path) loaded = cache.load_cache() assert loaded is None def test_update_single_recipe(self, temp_db_path, sample_recipes): """Test updating a single recipe.""" cache = PersistentRecipeCache(db_path=temp_db_path) cache.save_cache(sample_recipes) # Update a recipe updated_recipe = dict(sample_recipes[0]) updated_recipe["title"] = "Updated Title" updated_recipe["favorite"] = False cache.update_recipe(updated_recipe, "/path/to/recipe-001.recipe.json") # Load and verify loaded = cache.load_cache() assert loaded is not None r1 = next(r for r in loaded.raw_data if r["id"] == "recipe-001") assert r1["title"] == "Updated Title" assert r1["favorite"] is False def test_remove_recipe(self, temp_db_path, sample_recipes): """Test removing a recipe.""" cache = PersistentRecipeCache(db_path=temp_db_path) cache.save_cache(sample_recipes) # Remove a recipe cache.remove_recipe("recipe-001") # Load and verify loaded = cache.load_cache() assert loaded is not None assert len(loaded.raw_data) == 1 assert loaded.raw_data[0]["id"] == "recipe-002" def test_get_indexed_recipe_ids(self, temp_db_path, sample_recipes): """Test getting all indexed recipe IDs.""" cache = PersistentRecipeCache(db_path=temp_db_path) cache.save_cache(sample_recipes) ids = cache.get_indexed_recipe_ids() assert ids == {"recipe-001", "recipe-002"} def test_get_recipe_count(self, temp_db_path, sample_recipes): """Test getting recipe count.""" cache = PersistentRecipeCache(db_path=temp_db_path) assert cache.get_recipe_count() == 0 cache.save_cache(sample_recipes) assert cache.get_recipe_count() == 2 cache.remove_recipe("recipe-001") assert cache.get_recipe_count() == 1 def test_file_stats(self, temp_db_path, sample_recipes): """Test file stats tracking.""" cache = PersistentRecipeCache(db_path=temp_db_path) json_paths = { "recipe-001": "/path/to/recipe-001.recipe.json", "recipe-002": "/path/to/recipe-002.recipe.json", } cache.save_cache(sample_recipes, json_paths) stats = cache.get_file_stats() # File stats will be (0.0, 0) since files don't exist assert len(stats) == 2 def test_disabled_cache(self, temp_db_path, sample_recipes, monkeypatch): """Test that disabled cache returns None.""" monkeypatch.setenv("LORA_MANAGER_DISABLE_PERSISTENT_CACHE", "1") cache = PersistentRecipeCache(db_path=temp_db_path) assert not cache.is_enabled() cache.save_cache(sample_recipes) assert cache.load_cache() is None def test_invalid_recipe_skipped(self, temp_db_path): """Test that recipes without ID are skipped.""" cache = PersistentRecipeCache(db_path=temp_db_path) recipes = [ {"title": "No ID recipe"}, # Missing ID {"id": "valid-001", "title": "Valid recipe"}, ] cache.save_cache(recipes) loaded = cache.load_cache() assert loaded is not None assert len(loaded.raw_data) == 1 assert loaded.raw_data[0]["id"] == "valid-001" def test_get_default_singleton(self, monkeypatch): """Test singleton behavior.""" # Use temp directory with tempfile.TemporaryDirectory() as tmpdir: monkeypatch.setenv("LORA_MANAGER_RECIPE_CACHE_DB", os.path.join(tmpdir, "test.sqlite")) PersistentRecipeCache.clear_instances() cache1 = PersistentRecipeCache.get_default("test_lib") cache2 = PersistentRecipeCache.get_default("test_lib") assert cache1 is cache2 cache3 = PersistentRecipeCache.get_default("other_lib") assert cache1 is not cache3 PersistentRecipeCache.clear_instances() def test_loras_json_handling(self, temp_db_path): """Test that complex loras data is preserved.""" cache = PersistentRecipeCache(db_path=temp_db_path) recipes = [ { "id": "complex-001", "title": "Complex Loras", "loras": [ { "hash": "abc123", "file_name": "test_lora", "strength": 0.75, "modelVersionId": 12345, "modelName": "Test Model", "isDeleted": False, }, { "hash": "def456", "file_name": "another_lora", "strength": 1.0, "clip_strength": 0.8, }, ], } ] cache.save_cache(recipes) loaded = cache.load_cache() assert loaded is not None loras = loaded.raw_data[0]["loras"] assert len(loras) == 2 assert loras[0]["modelVersionId"] == 12345 assert loras[1]["clip_strength"] == 0.8 # ============================================================================= # Tests for concurrent access (from Phase 2 improvement plan) # ============================================================================= def test_concurrent_reads_do_not_corrupt_data(self, temp_db_path, sample_recipes): """Verify concurrent reads don't corrupt database state.""" import threading import time cache = PersistentRecipeCache(db_path=temp_db_path) cache.save_cache(sample_recipes) results = [] errors = [] def read_operation(): try: for _ in range(10): loaded = cache.load_cache() if loaded is not None: results.append(len(loaded.raw_data)) time.sleep(0.01) except Exception as e: errors.append(str(e)) # Start multiple reader threads threads = [threading.Thread(target=read_operation) for _ in range(5)] for t in threads: t.start() for t in threads: t.join() # No errors should occur assert len(errors) == 0, f"Errors during concurrent reads: {errors}" # All reads should return consistent data assert all(count == 2 for count in results), "Inconsistent read results" def test_concurrent_write_and_read(self, temp_db_path, sample_recipes): """Verify thread safety under concurrent writes and reads.""" import threading import time cache = PersistentRecipeCache(db_path=temp_db_path) cache.save_cache(sample_recipes) write_errors = [] read_errors = [] write_count = [0] def write_operation(): try: for i in range(5): recipe = { "id": f"concurrent-{i}", "title": f"Concurrent Recipe {i}", } cache.update_recipe(recipe) write_count[0] += 1 time.sleep(0.02) except Exception as e: write_errors.append(str(e)) def read_operation(): try: for _ in range(10): cache.load_cache() cache.get_recipe_count() time.sleep(0.01) except Exception as e: read_errors.append(str(e)) # Mix of read and write threads threads = ( [threading.Thread(target=write_operation) for _ in range(2)] + [threading.Thread(target=read_operation) for _ in range(3)] ) for t in threads: t.start() for t in threads: t.join() # No errors should occur assert len(write_errors) == 0, f"Write errors: {write_errors}" assert len(read_errors) == 0, f"Read errors: {read_errors}" # Writes should complete successfully assert write_count[0] > 0 def test_concurrent_updates_to_same_recipe(self, temp_db_path): """Verify concurrent updates to the same recipe don't corrupt data.""" import threading cache = PersistentRecipeCache(db_path=temp_db_path) # Initialize with one recipe initial_recipe = { "id": "concurrent-update", "title": "Initial Title", "version": 1, } cache.save_cache([initial_recipe]) errors = [] successful_updates = [] def update_operation(thread_id): try: for i in range(5): recipe = { "id": "concurrent-update", "title": f"Title from thread {thread_id} update {i}", "version": i + 1, } cache.update_recipe(recipe) successful_updates.append((thread_id, i)) except Exception as e: errors.append(f"Thread {thread_id}: {e}") # Multiple threads updating the same recipe threads = [ threading.Thread(target=update_operation, args=(i,)) for i in range(3) ] for t in threads: t.start() for t in threads: t.join() # No errors should occur assert len(errors) == 0, f"Update errors: {errors}" # All updates should complete assert len(successful_updates) == 15 # Final state should be valid final_count = cache.get_recipe_count() assert final_count == 1 def test_schema_initialization_thread_safety(self, temp_db_path): """Verify schema initialization is thread-safe.""" import threading errors = [] initialized_caches = [] def create_cache(): try: cache = PersistentRecipeCache(db_path=temp_db_path) initialized_caches.append(cache) except Exception as e: errors.append(str(e)) # Multiple threads creating cache simultaneously threads = [threading.Thread(target=create_cache) for _ in range(5)] for t in threads: t.start() for t in threads: t.join() # No errors should occur assert len(errors) == 0, f"Initialization errors: {errors}" # All caches should be created assert len(initialized_caches) == 5 def test_concurrent_save_and_remove(self, temp_db_path, sample_recipes): """Verify concurrent save and remove operations don't corrupt database.""" import threading import time cache = PersistentRecipeCache(db_path=temp_db_path) errors = [] operation_counts = {"saves": 0, "removes": 0} def save_operation(): try: for i in range(5): recipes = [ {"id": f"recipe-{j}", "title": f"Recipe {j}"} for j in range(i * 2, i * 2 + 2) ] cache.save_cache(recipes) operation_counts["saves"] += 1 time.sleep(0.015) except Exception as e: errors.append(f"Save error: {e}") def remove_operation(): try: for i in range(5): cache.remove_recipe(f"recipe-{i}") operation_counts["removes"] += 1 time.sleep(0.02) except Exception as e: errors.append(f"Remove error: {e}") # Concurrent save and remove threads threads = [ threading.Thread(target=save_operation), threading.Thread(target=remove_operation), ] for t in threads: t.start() for t in threads: t.join() # No errors should occur assert len(errors) == 0, f"Operation errors: {errors}" # Operations should complete assert operation_counts["saves"] == 5 assert operation_counts["removes"] == 5 # ----------------------------------------------------------------------- # image_id_map persistence (Phase 1 improvement) # ----------------------------------------------------------------------- def test_save_and_load_image_id_map_roundtrip(self, temp_db_path, sample_recipes): """Save image_id_map via save_cache() and verify it round-trips through load_cache().""" cache = PersistentRecipeCache(db_path=temp_db_path) image_id_map = { "12345": "recipe-alpha", "67890": "recipe-beta", } cache.save_cache(sample_recipes, image_id_map=image_id_map) loaded = cache.load_cache() assert loaded is not None assert loaded.image_id_map == image_id_map def test_load_without_image_id_map_returns_empty_dict(self, temp_db_path, sample_recipes): """Loading from a cache that has no image_id_map metadata must yield {}.""" cache = PersistentRecipeCache(db_path=temp_db_path) # Save without image_id_map cache.save_cache(sample_recipes) loaded = cache.load_cache() assert loaded is not None assert loaded.image_id_map == {} def test_save_cache_without_image_id_map_does_not_corrupt_existing( self, temp_db_path, sample_recipes, ): """Overwriting cache without passing image_id_map must not leave stale data. The previous image_id_map entry in cache_metadata should be replaced with {}. """ cache = PersistentRecipeCache(db_path=temp_db_path) cache.save_cache(sample_recipes, image_id_map={"123": "old-recipe"}) # Overwrite without image_id_map cache.save_cache(sample_recipes) loaded = cache.load_cache() assert loaded is not None assert loaded.image_id_map == {} def test_image_id_map_survives_recipe_update(self, temp_db_path, sample_recipes): """Updating a single recipe must not drop the image_id_map metadata.""" cache = PersistentRecipeCache(db_path=temp_db_path) cache.save_cache(sample_recipes, image_id_map={"123": "recipe-alpha"}) updated = dict(sample_recipes[0]) updated["title"] = "Updated" cache.update_recipe(updated) loaded = cache.load_cache() assert loaded is not None assert loaded.image_id_map == {"123": "recipe-alpha"} def test_save_image_id_map_persists_without_full_save(self, temp_db_path, sample_recipes): """save_image_id_map must update cache_metadata without rewriting all recipes.""" cache = PersistentRecipeCache(db_path=temp_db_path) cache.save_cache(sample_recipes) cache.save_image_id_map({"555": "new-recipe", "666": "another-recipe"}) loaded = cache.load_cache() assert loaded is not None assert loaded.image_id_map == {"555": "new-recipe", "666": "another-recipe"} def test_save_image_id_map_overwrites_previous(self, temp_db_path, sample_recipes): """Calling save_image_id_map twice must replace, not merge.""" cache = PersistentRecipeCache(db_path=temp_db_path) cache.save_cache(sample_recipes, image_id_map={"111": "old"}) cache.save_image_id_map({"222": "new-only"}) loaded = cache.load_cache() assert loaded is not None assert loaded.image_id_map == {"222": "new-only"} class TestHasWorkflowColumn: """has_workflow column persistence (plan 3.1).""" def test_save_and_load_roundtrip(self, temp_db_path): """has_workflow must round-trip through save_cache()/load_cache().""" cache = PersistentRecipeCache(db_path=temp_db_path) recipes = [ {"id": "wf-1", "title": "Has Workflow", "has_workflow": True}, {"id": "wf-2", "title": "No Workflow", "has_workflow": False}, {"id": "wf-3", "title": "Unset Workflow"}, ] cache.save_cache(recipes) loaded = cache.load_cache() assert loaded is not None by_id = {r["id"]: r for r in loaded.raw_data} assert by_id["wf-1"]["has_workflow"] is True assert by_id["wf-2"]["has_workflow"] is False assert by_id["wf-3"]["has_workflow"] is False def test_prepare_recipe_row_matches_column_order(self, temp_db_path): """The prepared row must append has_workflow in column order.""" cache = PersistentRecipeCache(db_path=temp_db_path) row_true = cache._prepare_recipe_row({"id": "r1", "has_workflow": True}, "") row_false = cache._prepare_recipe_row({"id": "r2", "has_workflow": False}, "") assert row_true[-1] == 1 assert row_false[-1] == 0 assert len(row_true) == len(cache._RECIPE_COLUMNS) assert cache._RECIPE_COLUMNS[-1] == "has_workflow" def test_update_recipe_preserves_has_workflow(self, temp_db_path): """update_recipe() must write the has_workflow column correctly.""" cache = PersistentRecipeCache(db_path=temp_db_path) cache.save_cache([{"id": "wf-update", "title": "x", "has_workflow": True}]) cache.update_recipe({"id": "wf-update", "title": "y", "has_workflow": False}) loaded = cache.load_cache() assert loaded is not None assert loaded.raw_data[0]["has_workflow"] is False def test_migrates_legacy_database_adds_has_workflow(self, temp_db_path): """A database created before has_workflow existed must still load.""" import sqlite3 conn = sqlite3.connect(temp_db_path) conn.execute( """ CREATE TABLE recipes ( recipe_id TEXT PRIMARY KEY, file_path TEXT, json_path TEXT, title TEXT, folder TEXT, source_path TEXT, base_model TEXT, fingerprint TEXT, created_date REAL, modified REAL, file_mtime REAL, file_size INTEGER, favorite INTEGER DEFAULT 0, repair_version INTEGER DEFAULT 0, preview_nsfw_level INTEGER DEFAULT 0, loras_json TEXT, checkpoint_json TEXT, gen_params_json TEXT, tags_json TEXT ) """ ) conn.execute("INSERT INTO recipes (recipe_id, title) VALUES ('legacy-1', 'Legacy')") conn.commit() conn.close() cache = PersistentRecipeCache(db_path=temp_db_path) loaded = cache.load_cache() assert loaded is not None assert loaded.raw_data[0]["id"] == "legacy-1" assert loaded.raw_data[0]["has_workflow"] is False