diff --git a/TESTING.md b/TESTING.md index 22ea1dd..411e14e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -490,8 +490,10 @@ set, not the page: - Absent on a plain listing's cursor pages, which read no links. The frontend keeps the first page's value. - Null when a plain first page's link read failed. The listing still returns 200, - and consumers then hide "· K positive prompts". A failure under a filter or - the rating sort stays a safe 500. + and consumers then hide "· K positive prompts". A `q` search alone also + survives the failure: it matches filenames only, as before linked search, and + every page's count is null (logged with the traceback). A failure under + `category`, `tags` or the rating sort stays a safe 500. Library parents (list, detail and command receipts) gain `run_count` (an int; the D23 migration estimate for rows from before 3.2.4) and `last_used_at`, a UTC ISO diff --git a/py/api/images.py b/py/api/images.py index 5adb8d5..da9d7d6 100644 --- a/py/api/images.py +++ b/py/api/images.py @@ -1141,7 +1141,8 @@ class ImageRoutesMixin: count is _COUNT_OMITTED, so the response leaves ``positive_count`` out and the frontend keeps the first page's value. On that plain first page a failed read leaves the listing usable and the count unknown - (None). + (None); so does a failed read under ``q`` alone, whose search then + matches filenames only (as before the linked search existed). """ order, category, tags = filters["order"], filters["category"], filters["tags"] needed = ( @@ -1161,10 +1162,19 @@ class ImageRoutesMixin: wanted=self._workspace_wanted(listings), ) except Exception: - if needed: + # Category, tags and the rating sort cannot be served without + # the links. A plain listing and a filename search can: the + # search then matches filenames only, and the count is None. + if category is not None or tags or order == "rating": raise self.logger.warning( - "Workspace positive count could not be read", exc_info=True + "Workspace prompt links could not be read; %s", + ( + "searching filenames only" + if filters["query"] + else "positive count unknown" + ), + exc_info=True, ) if order == "rating": diff --git a/tests/test_api_workspace_images.py b/tests/test_api_workspace_images.py index 4f73b3c..a4774e5 100644 --- a/tests/test_api_workspace_images.py +++ b/tests/test_api_workspace_images.py @@ -988,8 +988,14 @@ class TestWorkspaceImages(ImageAPITestCase): self.assertEqual(data["total"], 10) self.assertIsNone(data["positive_count"]) self.assertNotIn("database gone", json.dumps(data)) - # A filter or the rating sort needs the read, so its failure is an error. - for params in ({"q": "zebra"}, {"category": "Night"}, {"sort": "rating"}): + # A linked filter or the rating sort needs the read, so its failure is + # an error (filename-only search survives it, see below). + for params in ( + {"category": "Night"}, + {"tags": "a"}, + {"q": "zebra", "category": "Night"}, + {"sort": "rating"}, + ): with ( self.subTest(failing=params), patch.object( @@ -1000,6 +1006,35 @@ class TestWorkspaceImages(ImageAPITestCase): self.assertEqual(response.status, 500, data) self.assertNotIn("images", data) + async def test_filename_search_survives_a_database_failure(self): + for name, mtime in (("zebra-1.png", 300), ("zebra-2.png", 200)): + self.media(name, mtime) + self.media("other.png", 100) + with ( + patch.object( + self.api.db, + "get_image_link_matches", + side_effect=RuntimeError("database gone"), + ), + self.assertLogs(self.api.logger, "WARNING") as logged, + ): + response, first = await self.get(q="ZEBRA", limit=1) + self.assertEqual(response.status, 200, first) + response, second = await self.get( + q="ZEBRA", limit=1, cursor=first["next_cursor"] + ) + self.assertEqual(response.status, 200, second) + # Filename matches only; the count of linked positives is unknown. + self.assertEqual(self._names(first), ["zebra-1.png"]) + self.assertEqual(self._names(second), ["zebra-2.png"]) + for page in (first, second): + self.assertEqual(page["total"], 2) + self.assertIsNone(page["positive_count"]) + self.assertNotIn("database gone", json.dumps(page)) + self.assertTrue( + all(record.exc_info for record in logged.records), logged.output + ) + async def test_search_matches_filename_prompt_text_and_tags(self): from database.models import normalize_image_path