diff --git a/tests/test_api_images.py b/tests/test_api_images.py index d84bca4..5214d95 100644 --- a/tests/test_api_images.py +++ b/tests/test_api_images.py @@ -11,7 +11,7 @@ import sys import tempfile import unittest from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -859,5 +859,436 @@ class TestLinkImage(ImageAPITestCase): self.assertEqual(resp.status, 404) +def _raise(*_args, **_kwargs): + raise RuntimeError("boom") + + +class ImageRouteCoverageCase(ImageAPITestCase): + """Helpers for driving success and failure branches of each route.""" + + def _break_db(self, db_method): + setattr(self.api.db, db_method, _raise) + + async def _json(self, method, path, **kwargs): + resp = await self.client.request(method, path, **kwargs) + return resp.status, await resp.json() + + +class TestPathHelpers(unittest.TestCase): + + def test_has_traversal(self): + self.assertTrue(images_module._has_traversal("")) + self.assertTrue(images_module._has_traversal("a/../b.png")) + self.assertTrue(images_module._has_traversal("\\\\srv\\share\\x.png")) + self.assertTrue(images_module._has_traversal(os.path.abspath(os.sep))) + self.assertFalse(images_module._has_traversal("sub/x.png")) + + +class TestPromptImagesAndSearch(ImageRouteCoverageCase): + + async def test_prompt_images_success_and_nan_cleanup(self): + png = make_png(self.output_dir / "p.png") + self._link_image(png, text="owner") + prompt_id = self.api.db.get_prompt_by_hash(generate_prompt_hash("owner"))["id"] + + status, data = await self._json( + "GET", f"/prompt_manager/prompts/{prompt_id}/images" + ) + + self.assertEqual(status, 200) + self.assertEqual(len(data["images"]), 1) + self.assertTrue(data["images"][0]["url"].endswith("p.png")) + + self.api.db.get_prompt_images = lambda _pid: [ + {"id": 1, "image_path": str(png), "width": float("nan")} + ] + status, data = await self._json("GET", "/prompt_manager/prompts/1/images") + self.assertEqual(status, 200) + self.assertIsNone(data["images"][0]["width"]) + + async def test_prompt_images_db_failure(self): + self._break_db("get_prompt_images") + status, data = await self._json("GET", "/prompt_manager/prompts/1/images") + self.assertEqual(status, 500) + self.assertFalse(data["success"]) + + async def test_search_images_paths(self): + self._link_image(make_png(self.output_dir / "s.png"), text="sunset beach") + missing, _ = await self._json("GET", "/prompt_manager/images/search") + ok, data = await self._json("GET", "/prompt_manager/images/search?q=sunset") + self._break_db("search_images_by_prompt") + err, _ = await self._json("GET", "/prompt_manager/images/search?q=x") + self.assertEqual((missing, ok, err), (400, 200, 500)) + self.assertEqual(data["query"], "sunset") + self.assertEqual(len(data["images"]), 1) + + async def test_list_routes_db_failures(self): + self._break_db("get_recent_images") + self._break_db("get_all_images") + recent, _ = await self._json("GET", "/prompt_manager/images/recent") + every, _ = await self._json("GET", "/prompt_manager/images/all") + self.assertEqual((recent, every), (500, 500)) + + +class TestServeErrorBranches(ImageRouteCoverageCase): + + async def test_serve_by_id_db_failure(self): + self._break_db("get_image_by_id") + status, _ = await self._json("GET", "/prompt_manager/images/1/file") + self.assertEqual(status, 500) + + async def test_serve_output_lookup_failure(self): + self.api._get_all_output_dirs = _raise + status, _ = await self._json("GET", "/prompt_manager/images/serve/x.png") + self.assertEqual(status, 500) + + async def test_out_of_range_root_index_searches_all_roots(self): + make_png(self.output_dir / "r.png") + resp = await self.client.request( + "GET", "/prompt_manager/images/serve/r.png?root=7" + ) + self.assertEqual(resp.status, 200) + + async def test_directory_named_like_media_is_404(self): + (self.output_dir / "dir.png").mkdir() + status, _ = await self._json("GET", "/prompt_manager/images/serve/dir.png") + self.assertEqual(status, 404) + + async def test_lora_dirs_extend_allowed_roots(self): + from py.config import IntegrationConfig + + with ( + tempfile.TemporaryDirectory() as lora, + tempfile.TemporaryDirectory() as cache, + ): + png = make_png(Path(lora) / "preview.png") + image_id = self._link_image(png) + with ( + patch.object(IntegrationConfig, "LORA_MANAGER_ENABLED", True), + patch("py.lora_utils.find_lora_directories", return_value=[lora]), + patch("py.lora_utils.get_lora_image_cache_dir", return_value=cache), + ): + resp = await self.client.request( + "GET", f"/prompt_manager/images/{image_id}/file" + ) + self.assertEqual(resp.status, 200) + + with ( + patch.object(IntegrationConfig, "LORA_MANAGER_ENABLED", True), + patch("py.lora_utils.find_lora_directories", side_effect=RuntimeError), + ): + resp = await self.client.request( + "GET", f"/prompt_manager/images/{image_id}/file" + ) + self.assertEqual(resp.status, 403) + + +class TestGalleryListing(ImageRouteCoverageCase): + + async def test_subfolders_with_and_without_ancestors(self): + make_png(self.output_dir / "a" / "b" / "x.png") + make_png(self.output_dir / "c" / "y.png") + plain, data = await self._json("GET", "/prompt_manager/gallery/subfolders") + _, with_anc = await self._json( + "GET", "/prompt_manager/gallery/subfolders?include_ancestors=true" + ) + self.assertEqual(plain, 200) + self.assertEqual(data["subfolders"], [os.path.join("a", "b"), "c"]) + self.assertIn("a", with_anc["subfolders"]) + + async def test_subfolders_and_output_lookup_failures(self): + self.api._get_all_output_dirs = _raise + sub, _ = await self._json("GET", "/prompt_manager/gallery/subfolders") + out, _ = await self._json("GET", "/prompt_manager/images/output") + self.assertEqual((sub, out), (500, 500)) + + async def test_vanished_file_is_dropped_from_page(self): + png = make_png(self.output_dir / "gone.png") + await self._json("GET", "/prompt_manager/images/output") + png.unlink() + status, data = await self._json("GET", "/prompt_manager/images/output") + self.assertEqual(status, 200) + self.assertEqual(data["total"], 1) + self.assertEqual(data["images"], []) + + def test_gallery_scan_skips_unreadable_entries(self): + ghost = self.output_dir / "ghost.png" + with patch.object( + images_module, "_iter_media_files", return_value=iter([ghost]) + ): + found = self.api._scan_gallery_files_sync(self.output_dir) + self.assertEqual(found, []) + + +class TestThumbnailUnits(ImageRouteCoverageCase): + + SIZE = (64, 64) + + def test_generate_one_reports_vanished_source(self): + result = self.api._generate_one( + self.output_dir / "nope.png", self._dst("nope"), self.SIZE, False + ) + self.assertEqual(result["action"], "error") + self.assertIn("no longer exists", result["error"]) + + def test_generate_one_video_success_and_failure(self): + src = self.output_dir / "clip.mp4" + src.write_bytes(b"\x00") + with patch.object(self.api, "_generate_video_thumbnail", return_value=True): + ok = self.api._generate_one(src, self._dst("clip", ".jpg"), self.SIZE, True) + with patch.object(self.api, "_generate_video_thumbnail", return_value=False): + bad = self.api._generate_one( + src, self._dst("clip", ".jpg"), self.SIZE, True + ) + self.assertEqual((ok["action"], ok["type"]), ("generated", "video")) + self.assertEqual(bad["action"], "error") + + def test_generate_one_unexpected_error_is_safe(self): + src = make_png(self.output_dir / "boom.png") + with patch.object( + images_module, "_write_image_thumbnail", side_effect=RuntimeError("bad") + ): + result = self.api._generate_one(src, self._dst("boom"), self.SIZE, False) + self.assertEqual(result["action"], "error") + self.assertEqual(result["error"], "bad") + + def test_rgba_and_jpeg_sources(self): + with Image.new("RGBA", (32, 32), (0, 255, 0, 128)) as img: + img.save(self.output_dir / "alpha.png") + with Image.new("RGB", (32, 32), (0, 0, 255)) as img: + img.save(self.output_dir / "photo.jpg", "JPEG") + result = self.api._generate_thumbnails_sync( + self.output_dir, self.output_dir / "thumbnails", self.SIZE + ) + self.assertEqual(result["count"], 2) + self.assertTrue(self._dst("alpha").is_file()) + self.assertTrue(self._dst("photo", ".jpg").is_file()) + + async def test_emit_sse_swallows_write_errors(self): + response = MagicMock() + response.write = AsyncMock(side_effect=ConnectionResetError("gone")) + await self.api._emit_sse(response, "progress", {"n": 1}) + response.write.assert_awaited_once() + + async def test_progress_stream_reports_internal_failure(self): + self.api._thumbnail_targets = _raise + resp = await self.client.request( + "GET", "/prompt_manager/images/generate-thumbnails/progress" + ) + events = parse_sse(await resp.text()) + self.assertEqual(events[-1][0], "error") + + async def test_post_generate_reports_internal_failure(self): + self.api._find_comfyui_output_dir = _raise + status, _ = await self._json( + "POST", "/prompt_manager/images/generate-thumbnails", json={} + ) + self.assertEqual(status, 500) + + def _dst(self, name, suffix=".png"): + return self.output_dir / "thumbnails" / f"{name}_thumb{suffix}" + + +class TestVideoThumbnailFallbacks(ImageRouteCoverageCase): + """_generate_video_thumbnail degrades: cv2 -> ffmpeg -> placeholder.""" + + SIZE = (48, 48) + + @property + def video(self): + # output_dir only exists once the app is built, so create lazily. + path = self.output_dir / "clip.mp4" + if not path.exists(): + path.write_bytes(b"\x00\x00") + return path + + @property + def thumb(self): + return self.output_dir / "clip_thumb.jpg" + + def _fake_cv2(self, opened=True, read_ok=True): + numpy = self._numpy() + cv2 = MagicMock() + cap = cv2.VideoCapture.return_value + cap.isOpened.return_value = opened + cap.get.return_value = 30 + frame = numpy.zeros((8, 8, 3), dtype=numpy.uint8) + cap.read.return_value = (read_ok, frame if read_ok else None) + cv2.cvtColor.side_effect = lambda f, _code: f + return cv2 + + def _numpy(self): + try: + import numpy + except ImportError: + self.skipTest("numpy not installed") + return numpy + + def test_cv2_success(self): + with patch.dict(sys.modules, {"cv2": self._fake_cv2()}): + ok = self.api._generate_video_thumbnail(self.video, self.thumb, self.SIZE) + self.assertTrue(ok) + with Image.open(self.thumb) as img: + self.assertEqual(img.format, "JPEG") + + def test_cv2_cannot_open_or_read(self): + with patch.dict(sys.modules, {"cv2": self._fake_cv2(opened=False)}): + closed = self.api._generate_video_thumbnail( + self.video, self.thumb, self.SIZE + ) + with patch.dict(sys.modules, {"cv2": self._fake_cv2(read_ok=False)}): + unread = self.api._generate_video_thumbnail( + self.video, self.thumb, self.SIZE + ) + self.assertEqual((closed, unread), (False, False)) + + def test_cv2_crash_is_contained(self): + cv2 = MagicMock() + cv2.VideoCapture.side_effect = RuntimeError("driver crash") + with patch.dict(sys.modules, {"cv2": cv2}): + ok = self.api._generate_video_thumbnail(self.video, self.thumb, self.SIZE) + self.assertFalse(ok) + + def test_ffmpeg_absent_yields_placeholder(self): + with ( + patch.dict(sys.modules, {"cv2": None}), + patch("subprocess.run", side_effect=FileNotFoundError("ffmpeg")), + ): + ok = self.api._generate_video_thumbnail(self.video, self.thumb, self.SIZE) + self.assertTrue(ok) + with Image.open(self.thumb) as img: + self.assertEqual(img.size, self.SIZE) + + def test_ffmpeg_failure_yields_placeholder(self): + failed = MagicMock(returncode=1, stderr="no stream") + with ( + patch.dict(sys.modules, {"cv2": None}), + patch("subprocess.run", return_value=failed), + ): + ok = self.api._generate_video_thumbnail(self.video, self.thumb, self.SIZE) + self.assertTrue(ok) + self.assertTrue(self.thumb.is_file()) + + def test_ffmpeg_success(self): + with ( + patch.dict(sys.modules, {"cv2": None}), + patch("subprocess.run", return_value=MagicMock(returncode=0)) as run, + ): + ok = self.api._generate_video_thumbnail(self.video, self.thumb, self.SIZE) + self.assertTrue(ok) + self.assertEqual(run.call_args.args[0][0], "ffmpeg") + + def test_placeholder_failure_returns_false(self): + with ( + patch.dict(sys.modules, {"cv2": None}), + patch("subprocess.run", side_effect=FileNotFoundError), + patch.object( + images_module.Image, "new", side_effect=RuntimeError("no PIL") + ), + ): + ok = self.api._generate_video_thumbnail(self.video, self.thumb, self.SIZE) + self.assertFalse(ok) + + +class TestClearThumbnails(ImageRouteCoverageCase): + + URL = "/prompt_manager/images/clear-thumbnails" + + async def test_missing_output_dir_is_404(self): + self.api._find_comfyui_output_dir = lambda: None + status, _ = await self._json("POST", self.URL) + self.assertEqual(status, 404) + + async def test_no_thumbnails_dir_is_noop(self): + status, data = await self._json("POST", self.URL) + self.assertEqual(status, 200) + self.assertEqual(data["cleared_files"], 0) + + async def test_clears_only_generated_thumbnails(self): + thumbs = self.output_dir / "thumbnails" + make_png(thumbs / "sub" / "a_thumb.png") + make_png(thumbs / "b_thumb.png") + (thumbs / "readme.txt").write_text("keep") + make_png(self.output_dir / "original.png") + + status, data = await self._json("POST", self.URL) + + self.assertEqual(status, 200) + self.assertEqual(data["cleared_files"], 2) + self.assertIn("cleared_size_formatted", data) + self.assertFalse((thumbs / "sub").exists()) + self.assertTrue((thumbs / "readme.txt").is_file()) + self.assertTrue((self.output_dir / "original.png").is_file()) + + async def test_unlink_failure_is_logged_not_raised(self): + make_png(self.output_dir / "thumbnails" / "a_thumb.png") + with patch.object(Path, "unlink", side_effect=PermissionError("locked")): + status, data = await self._json("POST", self.URL) + self.assertEqual(status, 200) + self.assertEqual(data["cleared_files"], 0) + + async def test_internal_failure_is_500(self): + self.api._find_comfyui_output_dir = _raise + status, _ = await self._json("POST", self.URL) + self.assertEqual(status, 500) + + +class TestImagePromptLookup(ImageRouteCoverageCase): + + URL = "/prompt_manager/images/prompt/" + + async def test_relative_path_resolves_against_output_dir(self): + self._link_image(make_png(self.output_dir / "gen.png"), text="the prompt") + status, data = await self._json("GET", f"{self.URL}gen.png") + self.assertEqual(status, 200) + self.assertTrue(data["success"]) + self.assertEqual(data["prompt"]["text"], "the prompt") + self.assertEqual(data["prompt"]["image_path"], str(self.output_dir / "gen.png")) + + async def test_unknown_image_reports_no_prompt(self): + status, data = await self._json("GET", f"{self.URL}unknown.png") + self.assertEqual(status, 200) + self.assertFalse(data["success"]) + self.assertIn("image_path", data) + + async def test_empty_path_is_400(self): + status, _ = await self._json("GET", self.URL) + self.assertEqual(status, 400) + + async def test_db_failure_is_500_without_details(self): + self._break_db("get_image_prompt_info") + status, data = await self._json("GET", f"{self.URL}x.png") + self.assertEqual(status, 500) + self.assertEqual(data["error"], "Database error occurred") + + async def test_output_dir_lookup_failure_is_500(self): + self.api._find_comfyui_output_dir = _raise + status, _ = await self._json("GET", f"{self.URL}x.png") + self.assertEqual(status, 500) + + +class TestDeleteAndLinkErrors(ImageRouteCoverageCase): + + async def test_delete_image_paths(self): + image_id = self._link_image(make_png(self.output_dir / "d.png")) + ok, _ = await self._json("DELETE", f"/prompt_manager/images/{image_id}") + gone, _ = await self._json("DELETE", f"/prompt_manager/images/{image_id}") + bad, _ = await self._json("DELETE", "/prompt_manager/images/abc") + self._break_db("delete_image") + err, _ = await self._json("DELETE", "/prompt_manager/images/1") + self.assertEqual((ok, gone, bad, err), (200, 404, 400, 500)) + + async def test_link_db_failure_is_500(self): + prompt_id = self._save_prompt("to link") + png = make_png(self.output_dir / "l.png") + self._break_db("link_image_to_prompt") + status, _ = await self._json( + "POST", + "/prompt_manager/images/link", + json={"prompt_id": prompt_id, "image_path": str(png)}, + ) + self.assertEqual(status, 500) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_api_prompts.py b/tests/test_api_prompts.py index db847e6..47fe232 100644 --- a/tests/test_api_prompts.py +++ b/tests/test_api_prompts.py @@ -186,5 +186,446 @@ class TestRecentBounds(PromptAPITestCase): self.assertEqual(len(data["results"]), 1) +def _raise(*_args, **_kwargs): + raise RuntimeError("boom") + + +class RouteCoverageCase(PromptAPITestCase): + """Helpers for driving success and failure branches of each route.""" + + def _break(self, db_method): + """Make a DB method raise so the route's 500 branch runs.""" + setattr(self.api.db, db_method, _raise) + + def _stub(self, db_method, value): + setattr(self.api.db, db_method, lambda *a, **k: value) + + async def _json(self, method, path, **kwargs): + resp = await self.client.request(method, path, **kwargs) + return resp.status, await resp.json() + + +class TestListRoutesErrorPaths(RouteCoverageCase): + + async def test_search_db_failure_is_500(self): + self._break("search_prompts") + status, data = await self._json("GET", "/prompt_manager/search") + self.assertEqual(status, 500) + self.assertFalse(data["success"]) + self.assertEqual(data["results"], []) + + async def test_search_ignores_non_integer_min_rating(self): + self._save_prompt("rated", rating=3) + status, data = await self._json("GET", "/prompt_manager/search?min_rating=x") + self.assertEqual(status, 200) + self.assertEqual(len(data["results"]), 1) + + async def test_recent_db_failure_is_500(self): + self._break("get_recent_prompts") + status, data = await self._json("GET", "/prompt_manager/recent") + self.assertEqual(status, 500) + self.assertEqual(data["pagination"]["total"], 0) + + async def test_categories_and_tags_db_failures_are_500(self): + self._break("get_all_categories") + self._break("get_all_tags") + c_status, c_data = await self._json("GET", "/prompt_manager/categories") + t_status, t_data = await self._json("GET", "/prompt_manager/tags") + self.assertEqual((c_status, t_status), (500, 500)) + self.assertEqual(c_data["categories"], []) + self.assertEqual(t_data["tags"], []) + + async def test_subfolders_success_and_failure(self): + ok_status, ok_data = await self._json( + "GET", "/prompt_manager/subfolders?include_ancestors=true" + ) + self._break("get_prompt_subfolders") + bad_status, _ = await self._json("GET", "/prompt_manager/subfolders") + self.assertEqual(ok_status, 200) + self.assertEqual(ok_data["subfolders"], []) + self.assertEqual(bad_status, 500) + + async def test_export_db_failure_is_500(self): + self._break("search_prompts") + status, data = await self._json("GET", "/prompt_manager/export") + self.assertEqual(status, 500) + self.assertFalse(data["success"]) + + +class TestTagStatsAndFilters(RouteCoverageCase): + + async def test_tags_stats_rejects_bad_limit(self): + status, _ = await self._json("GET", "/prompt_manager/tags/stats?limit=x") + self.assertEqual(status, 400) + + async def test_tags_stats_db_failure_is_500(self): + self._break("get_tags_with_counts") + status, _ = await self._json("GET", "/prompt_manager/tags/stats") + self.assertEqual(status, 500) + + async def test_tag_prompts_success(self): + self._save_prompt("P1", tags=["sky"]) + status, data = await self._json("GET", "/prompt_manager/tags/sky/prompts") + self.assertEqual(status, 200) + self.assertEqual(data["tag"], "sky") + self.assertEqual(len(data["prompts"]), 1) + self.assertIn("pagination", data) + + async def test_tag_prompts_bad_limit_and_db_failure(self): + bad_status, _ = await self._json( + "GET", "/prompt_manager/tags/sky/prompts?offset=z" + ) + self._break("get_prompts_by_tags") + err_status, _ = await self._json("GET", "/prompt_manager/tags/sky/prompts") + self.assertEqual((bad_status, err_status), (400, 500)) + + async def test_tags_filter_untagged_mode(self): + self._save_prompt("no tags") + self._save_prompt("tagged", tags=["x"]) + status, data = await self._json( + "GET", "/prompt_manager/tags/filter?untagged=true" + ) + self.assertEqual(status, 200) + self.assertEqual(data["mode"], "untagged") + self.assertEqual(len(data["prompts"]), 1) + + async def test_tags_filter_untagged_bad_limit(self): + status, _ = await self._json( + "GET", "/prompt_manager/tags/filter?untagged=true&limit=x" + ) + self.assertEqual(status, 400) + + async def test_tags_filter_requires_tags(self): + status, _ = await self._json("GET", "/prompt_manager/tags/filter") + self.assertEqual(status, 400) + + async def test_tags_filter_and_or_modes(self): + self._save_prompt("both", tags=["a", "b"]) + self._save_prompt("only a", tags=["a"]) + _, and_data = await self._json( + "GET", "/prompt_manager/tags/filter?tags=a,b&mode=and" + ) + _, or_data = await self._json( + "GET", "/prompt_manager/tags/filter?tags=a,b&mode=weird" + ) + self.assertEqual(len(and_data["prompts"]), 1) + self.assertEqual(or_data["mode"], "and") + self.assertEqual(len(or_data["prompts"]), 1) + + async def test_tags_filter_bad_limit_and_db_failure(self): + bad_status, _ = await self._json( + "GET", "/prompt_manager/tags/filter?tags=a&limit=x" + ) + self._break("get_prompts_by_tags") + err_status, _ = await self._json("GET", "/prompt_manager/tags/filter?tags=a") + self.assertEqual((bad_status, err_status), (400, 500)) + + +class TestTagMutationRoutes(RouteCoverageCase): + + async def test_rename_rejects_bad_json_and_empty_name(self): + bad_json, _ = await self._json( + "PUT", + "/prompt_manager/tags/old", + data=b"{nope", + headers={"Content-Type": "application/json"}, + ) + empty, _ = await self._json( + "PUT", "/prompt_manager/tags/old", json={"new_name": " "} + ) + self.assertEqual((bad_json, empty), (400, 400)) + + async def test_rename_reports_skipped_rows_and_db_failure(self): + self._stub("rename_tag_all_prompts", {"affected_count": 1, "skipped_count": 2}) + status, data = await self._json( + "PUT", "/prompt_manager/tags/old", json={"new_name": "new"} + ) + self.assertEqual(status, 200) + self.assertEqual(data["skipped_count"], 2) + self.assertIn("warning", data) + self._break("rename_tag_all_prompts") + err_status, _ = await self._json( + "PUT", "/prompt_manager/tags/old", json={"new_name": "new"} + ) + self.assertEqual(err_status, 500) + + async def test_delete_tag_reports_skipped_rows_and_db_failure(self): + self._stub("delete_tag_all_prompts", {"affected_count": 3, "skipped_count": 1}) + status, data = await self._json("DELETE", "/prompt_manager/tags/gone") + self.assertEqual(status, 200) + self.assertEqual(data["skipped_count"], 1) + self._break("delete_tag_all_prompts") + err_status, _ = await self._json("DELETE", "/prompt_manager/tags/gone") + self.assertEqual(err_status, 500) + + async def test_merge_validation_errors(self): + bad_json, _ = await self._json( + "POST", + "/prompt_manager/tags/merge", + data=b"{nope", + headers={"Content-Type": "application/json"}, + ) + no_source, _ = await self._json( + "POST", "/prompt_manager/tags/merge", json={"target_tag": "t"} + ) + no_target, _ = await self._json( + "POST", "/prompt_manager/tags/merge", json={"source_tags": ["s"]} + ) + self.assertEqual((bad_json, no_source, no_target), (400, 400, 400)) + + async def test_merge_reports_skipped_rows_and_db_failure(self): + self._stub( + "merge_tags", + {"affected_count": 2, "tags_merged": ["s"], "skipped_count": 1}, + ) + status, data = await self._json( + "POST", + "/prompt_manager/tags/merge", + json={"source_tags": ["s"], "target_tag": "t"}, + ) + self.assertEqual(status, 200) + self.assertEqual(data["skipped_count"], 1) + self._break("merge_tags") + err_status, _ = await self._json( + "POST", + "/prompt_manager/tags/merge", + json={"source_tags": ["s"], "target_tag": "t"}, + ) + self.assertEqual(err_status, 500) + + +class TestSaveRoute(RouteCoverageCase): + + async def test_invalid_rating_is_400(self): + status, data = await self._json( + "POST", "/prompt_manager/save", json={"text": "ok", "rating": 99} + ) + self.assertEqual(status, 400) + self.assertIn("Rating", data["error"]) + + async def test_duplicate_updates_metadata(self): + first = self._save_prompt("same text") + status, data = await self._json( + "POST", + "/prompt_manager/save", + json={"text": "same text", "category": "cat", "tags": ["t"], "rating": 4}, + ) + self.assertEqual(status, 200) + self.assertTrue(data["is_duplicate"]) + self.assertEqual(data["prompt_id"], first) + stored = self.api.db.get_prompt_by_id(first) + self.assertEqual(stored["category"], "cat") + self.assertEqual(stored["rating"], 4) + + async def test_duplicate_without_metadata_leaves_row_alone(self): + first = self._save_prompt("plain dup") + status, data = await self._json( + "POST", "/prompt_manager/save", json={"text": "plain dup"} + ) + self.assertEqual(status, 200) + self.assertEqual(data["prompt_id"], first) + + async def test_db_failure_is_500(self): + self._break("get_prompt_by_hash") + status, data = await self._json( + "POST", "/prompt_manager/save", json={"text": "boom"} + ) + self.assertEqual(status, 500) + self.assertFalse(data["success"]) + + +class TestDeleteUpdateRatingRoutes(RouteCoverageCase): + + async def test_delete_invalid_id_and_db_failure(self): + bad, _ = await self._json("DELETE", "/prompt_manager/delete/abc") + self._break("delete_prompt") + err, _ = await self._json("DELETE", "/prompt_manager/delete/1") + self.assertEqual((bad, err), (400, 500)) + + async def test_update_text_validation_and_not_found(self): + pid = self._save_prompt("orig") + empty, _ = await self._json( + "PUT", f"/prompt_manager/prompts/{pid}", json={"text": " "} + ) + too_long, _ = await self._json( + "PUT", f"/prompt_manager/prompts/{pid}", json={"text": "x" * 10_001} + ) + missing, _ = await self._json( + "PUT", "/prompt_manager/prompts/999999", json={"text": "new"} + ) + bad_id, _ = await self._json( + "PUT", "/prompt_manager/prompts/abc", json={"text": "new"} + ) + self.assertEqual((empty, too_long, missing, bad_id), (400, 400, 404, 400)) + + async def test_update_text_db_failure(self): + pid = self._save_prompt("orig") + self._break("update_prompt_text") + status, _ = await self._json( + "PUT", f"/prompt_manager/prompts/{pid}", json={"text": "new"} + ) + self.assertEqual(status, 500) + + async def test_rating_validation_not_found_and_failure(self): + pid = self._save_prompt("rate me") + invalid, _ = await self._json( + "PUT", f"/prompt_manager/prompts/{pid}/rating", json={"rating": 0} + ) + missing, _ = await self._json( + "PUT", "/prompt_manager/prompts/999999/rating", json={"rating": 3} + ) + bad_id, _ = await self._json( + "PUT", "/prompt_manager/prompts/abc/rating", json={"rating": 3} + ) + self._break("update_prompt_rating") + err, _ = await self._json( + "PUT", f"/prompt_manager/prompts/{pid}/rating", json={"rating": 3} + ) + self.assertEqual((invalid, missing, bad_id, err), (400, 404, 400, 500)) + + +class TestPromptTagRoutes(RouteCoverageCase): + + async def test_add_tag_validation_paths(self): + pid = self._save_prompt("tagme", tags=["have"]) + empty, _ = await self._json( + "POST", f"/prompt_manager/prompts/{pid}/tags", json={"tag": ""} + ) + missing, _ = await self._json( + "POST", "/prompt_manager/prompts/999999/tags", json={"tag": "x"} + ) + bad_id, _ = await self._json( + "POST", "/prompt_manager/prompts/abc/tags", json={"tag": "x"} + ) + dup, dup_data = await self._json( + "POST", f"/prompt_manager/prompts/{pid}/tags", json={"tag": "have"} + ) + self.assertEqual((empty, missing, bad_id, dup), (400, 404, 400, 200)) + self.assertTrue(dup_data["success"]) + self.assertEqual(self.api.db.get_prompt_by_id(pid)["tags"], ["have"]) + + async def test_add_tag_db_failure(self): + pid = self._save_prompt("tagme") + self._break("set_prompt_tags") + status, _ = await self._json( + "POST", f"/prompt_manager/prompts/{pid}/tags", json={"tag": "x"} + ) + self.assertEqual(status, 500) + + async def test_add_tags_bulk_to_single_prompt(self): + pid = self._save_prompt("multi", tags=["a"]) + status, data = await self._json( + "POST", + "/prompt_manager/prompts/tags", + json={"prompt_id": pid, "tags": ["a", " b ", "", "c"]}, + ) + self.assertEqual(status, 200) + self.assertEqual(data["tags_added"], 2) + again, again_data = await self._json( + "POST", + "/prompt_manager/prompts/tags", + json={"prompt_id": pid, "tags": ["a"]}, + ) + self.assertEqual(again, 200) + self.assertEqual(again_data["tags_added"], 0) + self.assertIn("No new tags", again_data["message"]) + + async def test_add_tags_validation_and_failures(self): + no_id, _ = await self._json( + "POST", "/prompt_manager/prompts/tags", json={"tags": ["a"]} + ) + no_tags, _ = await self._json( + "POST", "/prompt_manager/prompts/tags", json={"prompt_id": 1, "tags": "a"} + ) + missing, _ = await self._json( + "POST", + "/prompt_manager/prompts/tags", + json={"prompt_id": 999999, "tags": ["a"]}, + ) + self._break("get_prompt_by_id") + err, _ = await self._json( + "POST", "/prompt_manager/prompts/tags", json={"prompt_id": 1, "tags": ["a"]} + ) + self.assertEqual((no_id, no_tags, missing, err), (400, 400, 404, 500)) + + async def test_remove_tag_paths(self): + pid = self._save_prompt("untag", tags=["a", "b"]) + removed, _ = await self._json( + "DELETE", f"/prompt_manager/prompts/{pid}/tags", json={"tag": "a"} + ) + absent, _ = await self._json( + "DELETE", f"/prompt_manager/prompts/{pid}/tags", json={"tag": "zzz"} + ) + missing, _ = await self._json( + "DELETE", "/prompt_manager/prompts/999999/tags", json={"tag": "a"} + ) + bad_id, _ = await self._json( + "DELETE", "/prompt_manager/prompts/abc/tags", json={"tag": "a"} + ) + self.assertEqual((removed, absent, missing, bad_id), (200, 200, 404, 400)) + self.assertEqual(self.api.db.get_prompt_by_id(pid)["tags"], ["b"]) + + async def test_remove_tag_db_failure(self): + pid = self._save_prompt("untag", tags=["a"]) + self._break("set_prompt_tags") + status, _ = await self._json( + "DELETE", f"/prompt_manager/prompts/{pid}/tags", json={"tag": "a"} + ) + self.assertEqual(status, 500) + + +class TestBulkRoutes(RouteCoverageCase): + + async def test_bulk_delete_paths(self): + ids = [self._save_prompt(f"bulk {i}") for i in range(3)] + empty, _ = await self._json( + "POST", "/prompt_manager/bulk/delete", json={"prompt_ids": []} + ) + ok, ok_data = await self._json( + "POST", "/prompt_manager/bulk/delete", json={"prompt_ids": ids[:2]} + ) + self._break("bulk_delete_prompts") + err, _ = await self._json( + "POST", "/prompt_manager/bulk/delete", json={"prompt_ids": ids} + ) + self.assertEqual((empty, ok, err), (400, 200, 500)) + self.assertEqual(ok_data["deleted_count"], 2) + + async def test_bulk_add_tags_paths(self): + ids = [self._save_prompt(f"bulk tag {i}") for i in range(2)] + empty, _ = await self._json( + "POST", "/prompt_manager/bulk/tags", json={"prompt_ids": ids, "tags": []} + ) + ok, ok_data = await self._json( + "POST", "/prompt_manager/bulk/tags", json={"prompt_ids": ids, "tags": ["z"]} + ) + self._break("bulk_add_tags") + err, _ = await self._json( + "POST", "/prompt_manager/bulk/tags", json={"prompt_ids": ids, "tags": ["z"]} + ) + self.assertEqual((empty, ok, err), (400, 200, 500)) + self.assertEqual(ok_data["updated_count"], 2) + + async def test_bulk_set_category_paths(self): + ids = [self._save_prompt(f"bulk cat {i}") for i in range(2)] + empty, _ = await self._json( + "POST", "/prompt_manager/bulk/category", json={"category": "c"} + ) + ok, ok_data = await self._json( + "POST", + "/prompt_manager/bulk/category", + json={"prompt_ids": ids, "category": "c"}, + ) + self._break("bulk_set_category") + err, _ = await self._json( + "POST", + "/prompt_manager/bulk/category", + json={"prompt_ids": ids, "category": "c"}, + ) + self.assertEqual((empty, ok, err), (400, 200, 500)) + self.assertEqual(ok_data["updated_count"], 2) + self.assertEqual(self.api.db.get_prompt_by_id(ids[0])["category"], "c") + + if __name__ == "__main__": unittest.main()