* feat(images): link summary on workspace image items
* fix(images): carry title source and cut first-line titles like the Library
Summaries gain title_source (stored or text). The inspector always shows a
stored title and confirms only a first-line title against the file, so a
stored title equal to the first line is no longer withheld. A link-title read
failure no longer marks a readable link unavailable. First-line titles over
80 characters end with an ellipsis, as the Library's title() does. The card
gate for unconfirmed first-line titles is pinned as an expected failure for B2.
* feat(images): unambiguous seed on workspace items
Workspace items gain `seed`, read with the shared generation-parameter
parser from the prompt/workflow records the header open already loaded
(each at most 2 MiB; no extra file reads, videos give None). A
placeholder or non-integer seed gives None. JSON integers beyond 2**53
are recovered exactly, so the card prints the digits the inspector shows.
The same parse confirms first-line card titles: a `text` title shows only
when the file's parsed positive gives the same title. One helper applies
that rule on the card and in the inspector, which now reads the positive
from its own inspection records with the same parser.
* fix(images): a deeply nested record no longer fails the workspace listing
The Python parser recurses over nested arrays where JavaScript String()
does not, so a crafted 2 KB PNG turned every workspace page holding it
(and its inspect route) into a 500. Card facts now treat RecursionError
like any record the parser cannot use: no seed and no confirmed title.
Also: Library previews skip the card parse they never show; the parse
runs after the file handle closes; the exact-seed read uses the one JSON
reader (parse_js_json(..., exact_integers=True)) and the public
number_string; record size is decided from the text length unless only
the UTF-8 byte count can tell.
* feat(images): search and filter workspace images by linked prompts
The workspace listing now takes category (exact), tags (comma list, all
required) and sort=rating, and q also matches the linked positive's text,
Library title, pair title and tag names. One bounded, parameterized SELECT
(get_image_link_matches / get_image_paths_matching) returns normalized
paths of unambiguously linked images; the listing is intersected with it.
Unlinked and ambiguous files are excluded by a category or tag filter and
sort last by rating, with a newest-first tie-break. The cursor scope covers
the new filters and sort; unknown values are still rejected.
The derived listing values and sort orders are cached per gallery scan so a
50,000-file listing with 20,000 links pages well under 150 ms at p95.
* test(images): count the filter budget everywhere, time it in one job
The 150 ms wall-clock budget failed under the CI coverage tracer, so it
is split. test_filter_work_is_bounded runs on every job and under
coverage: one filter SELECT per request, the ambiguity probe is an index
search (EXPLAIN QUERY PLAN), the per-scan values are built on the cold
request only, and a cursor page calls the sort key at most
ceil(log2 n)+3 times. test_filter_budget runs only with
PROMPTMANAGER_PERF_BUDGET=1 and no tracer, asserts the best of three
rounds' p95, and has its own non-required "perf budget" job. TESTING.md
records the convention.
* fix(images): rating sort works however many link rows the database holds
The filter SELECT counted every generated_images row against a 500,000
row cap and materialized all of them first, so a large history made
sort=rating answer 400 with nothing to narrow. The rows are now streamed
and only paths in the listing's normalized set are kept, so memory is
bounded by the listing; the cap and LinkFilterTooBroad are gone.
* fix(images): rebuild workspace listing values only where the scan changed
A TTL rescan that finds the same files reuses the previous values and
sort orders; one that finds a few changes rebuilds only those files.
Gallery invalidation and a gallery-roots change drop the derived values,
so old roots no longer hold memory. category is trimmed like tags.
* fix(images): a monitor invalidation rebuilds only the new files' values
The image monitor invalidates the gallery whenever it sees a new file,
and that dropped every derived workspace value, so the next request paid
a full cold build. Invalidation now keeps the per-file values and drops
only the sort orders; the rescan reuses every unchanged file and builds
only new or changed ones. A gallery-roots change still drops everything.
* feat(library): expose run count, last use and variant totals
* fix(library): an out-of-range last use reads as unknown, not a failed list
* perf(images): read positive counts only on a plain listing's first page
* feat(images): persist image favorites
Favorites are stored in image_favorites keyed by the gallery root label
and the relative path, set and cleared through PUT/DELETE
/prompt_manager/images/favorite with a {root_index, path} body validated
against the rooted listing. Workspace image cards carry favorite: bool.
The table is created by migration, travels with backups, is optional in
old backups and required after a restore's migration.
* fix(images): key favorites by the real root and case-normalized path
Folder names are not unique across gallery roots, so two roots shown as
"output" shared each other's stars. Favorites are now keyed by the
root's real, case-normalized path (server-side only) and a
case-normalized relative path, so a Windows case-only rename keeps its
star while distinct Linux files stay distinct. Toggles look the file up
in a per-scan dictionary instead of walking the listing.
* fix(images): favorites lookup follows monitor invalidation and the stored key bound
A monitor invalidation now also drops the favorites lookup, which held the
previous scan list until the next toggle. PUT refuses a favorite whose root
or relative key would exceed the schema's 4096-character bound, so the live
database keeps passing startup and restore validation.
* docs(testing): workspace data fields for the concept pages
* fix(images): card facts read the inspector's projected graphs
The inspector hands its browser parser only graphs the inspection
projection parsed (strict JSON, depth 64, node ceiling); a deeper record
is "limited" there, so the legacy fill never runs and no seed shows. The
card parsed the raw records instead and showed a seed the inspector did
not, e.g. for a 900-deep class_type beside a normal sampler.
project_graph() is now the one projection step, used by the inspection
response and by the card. The card runs the shared parser on the
projected graphs, the same values the browser receives: big integers
arrive as their exact digits, so the separate exact-integer re-parse and
the parse_js_json flag it needed are gone.
* fix(images): send the card seed as a decimal string
JSON.parse rounds a number past 2**53, so a listing seed such as
9223372036854788153 reached the page as 9223372036854788000 while the
inspector showed the file's digits. Every non-null card seed is now its
decimal string.
The Node parity harness now JSON.parses the real listing response and
compares String(seed) with the seed the inspector shows, including a
seed past 2**53. TESTING.md describes the string contract and drops the
removed exact re-read.
* fix(images): bound digit-string seeds and isolate each card's parse
A seed recorded as a 5000-digit string reached int(), which raises
ValueError past Python's integer string limit, and the listing answered
500. A digit-string seed now has at most 20 digits (2**64 fits), and the
conversion falls back to no seed on ValueError.
Each card's parse is also guarded in the page builder: an unexpected
error in one file's records logs a warning and gives that card no seed
and no first-line title, and the rest of the page still lists.
* fix(images): filename search survives a failed prompt-link read
On main a workspace q matched only file paths, so search worked with a
broken database. Since search also matches linked prompts, any q turned
a failed link read into a 500. A q without category, tags or the rating
sort now falls back to filename matches with a null positive_count and
logs the failure with its traceback; those linked filters still fail.
* fix(images): card reads PNG records with the inspector's reader
The card read PNG text through Pillow's header info, where a later
duplicate prompt chunk silently wins and text after IDAT is missed. The
inspector's bounded reader refuses conflicting duplicates and reads text
after IDAT, so a file with two different prompt chunks showed a card
seed the inspector refused.
PNG cards now read their records with read_inspection_file, the reader
the inspector uses. When it refuses a file the card keeps its
dimensions from Pillow and shows no seed. Other formats still use
Pillow's header info, as the inspector's header read does.
* perf(images): read each card's header facts once per gallery scan
Every page parsed each card's prompt and workflow records again, about
3.4 ms per 50 KB record set and 25 ms per 400 KB workflow, inside
ComfyUI's server process, on every scroll-back and refresh.
Dimensions, the seed and the parsed positive's display title are now
cached per root by (path, mtime), beside the scan's per-file values and
invalidated the same way: a rescan keeps unchanged files and prunes the
rest, a roots change drops them all, and only files of the current scan
are stored. An unreadable header is not stored. Links and favorites
are still read on every page.
* ci: run the perf budget on main pushes and on demand only
The wall-clock budget has under 2x margin on shared ubuntu runners, and
a red exact-head check blocks a merge that must never be rerun to green.
The job now runs on each push to main and on workflow_dispatch, not on
pull requests. TESTING.md says a red run on main means fix the code.
* fix(library): cut display titles at 80 UTF-16 units, never inside a pair
The server counted code points while the Library's JavaScript counts UTF-16
units, so a first line of emoji kept 80 emoji on cards but showed 40 in the
Library. Both now cut at 80 UTF-16 units and drop a character that would
straddle the limit instead of leaving half a surrogate pair.
384 lines
17 KiB
Python
384 lines
17 KiB
Python
"""Persisted image favorites (spec D7): store, schema and rooted API boundary."""
|
|
|
|
import json
|
|
import ntpath
|
|
import os
|
|
import sqlite3
|
|
import tempfile
|
|
import unittest
|
|
from contextlib import closing
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
try:
|
|
from test_api_images import ImageAPITestCase, make_png
|
|
from open_handles import assert_closed
|
|
except ImportError:
|
|
from tests.test_api_images import ImageAPITestCase, make_png
|
|
from tests.open_handles import assert_closed
|
|
|
|
from database.image_favorites import (
|
|
MAX_KEY_LENGTH,
|
|
TABLE,
|
|
image_favorites_schema_error,
|
|
migrate_image_favorites,
|
|
)
|
|
from database.models import PromptModel
|
|
from database.operations import PromptDatabase
|
|
|
|
FAVORITE = "/prompt_manager/images/favorite"
|
|
|
|
|
|
class TestImageFavoriteApi(ImageAPITestCase):
|
|
async def tearDownAsync(self):
|
|
self.api.db.close_all()
|
|
assert_closed(self, self._temp_db.name, self._output_tmp.name)
|
|
await super().tearDownAsync()
|
|
|
|
async def send(self, method, body):
|
|
response = await self.client.request(method, FAVORITE, json=body)
|
|
return response, await response.json()
|
|
|
|
async def workspace(self, **params):
|
|
response = await self.client.get(
|
|
"/prompt_manager/images/output", params={"mode": "workspace", **params}
|
|
)
|
|
self.assertEqual(response.status, 200)
|
|
data = await response.json()
|
|
return {image["path"]: image for image in data["images"]}
|
|
|
|
async def test_favorite_round_trip_and_idempotent(self):
|
|
make_png(self.output_dir / "sub" / "a.png")
|
|
make_png(self.output_dir / "b.png")
|
|
items = await self.workspace()
|
|
self.assertIs(items["sub/a.png"]["favorite"], False)
|
|
self.assertIs(items["b.png"]["favorite"], False)
|
|
|
|
body = {"root_index": 0, "path": "sub/a.png"}
|
|
for _ in range(2): # a repeated PUT is a no-op, not an error
|
|
response, data = await self.send("PUT", body)
|
|
self.assertEqual(response.status, 200)
|
|
self.assertEqual(
|
|
data,
|
|
{
|
|
"success": True,
|
|
"favorite": True,
|
|
"root_index": 0,
|
|
"path": "sub/a.png",
|
|
},
|
|
)
|
|
items = await self.workspace()
|
|
self.assertIs(items["sub/a.png"]["favorite"], True)
|
|
self.assertIs(items["b.png"]["favorite"], False)
|
|
count = self.api.db.model.get_connection().execute(
|
|
f"SELECT COUNT(*) FROM {TABLE}"
|
|
)
|
|
self.assertEqual(count.fetchone()[0], 1)
|
|
|
|
for _ in range(2):
|
|
response, data = await self.send("DELETE", body)
|
|
self.assertEqual(response.status, 200)
|
|
self.assertTrue(data["success"])
|
|
self.assertIs(data["favorite"], False)
|
|
items = await self.workspace()
|
|
self.assertIs(items["sub/a.png"]["favorite"], False)
|
|
|
|
def rows(self):
|
|
return [
|
|
tuple(row)
|
|
for row in self.api.db.model.get_connection().execute(
|
|
f"SELECT root_key, relative_path FROM {TABLE} ORDER BY 1, 2"
|
|
)
|
|
]
|
|
|
|
async def test_favorite_is_keyed_by_root_not_index(self):
|
|
second = self.comfy_dir / "second"
|
|
make_png(second / "a.png")
|
|
make_png(self.output_dir / "a.png")
|
|
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
|
response, data = await self.send("PUT", {"root_index": 1, "path": "a.png"})
|
|
self.assertEqual(response.status, 200)
|
|
self.assertNotIn(str(self.comfy_dir), json.dumps(data))
|
|
root_key = Path(os.path.normcase(os.path.realpath(second))).as_posix()
|
|
self.assertEqual(self.rows(), [(root_key, "a.png")])
|
|
# The same root at another index keeps its favorite; the other root
|
|
# with the same relative path does not inherit it.
|
|
self.api._get_all_output_dirs = lambda: [second, self.output_dir]
|
|
self.api.invalidate_gallery_cache()
|
|
self.assertIs((await self.workspace(root=0))["a.png"]["favorite"], True)
|
|
self.assertIs((await self.workspace(root=1))["a.png"]["favorite"], False)
|
|
|
|
async def test_roots_sharing_a_folder_name_keep_separate_favorites(self):
|
|
# Two roots under different parents, both shown as "output".
|
|
with tempfile.TemporaryDirectory() as other_parent:
|
|
other = Path(os.path.realpath(other_parent)) / "output"
|
|
make_png(other / "a.png")
|
|
make_png(self.output_dir / "a.png")
|
|
self.api._get_all_output_dirs = lambda: [self.output_dir, other]
|
|
response = await self.client.get(
|
|
"/prompt_manager/images/output", params={"mode": "workspace"}
|
|
)
|
|
labels = {
|
|
item["root_index"]: item["root_dir"]
|
|
for item in (await response.json())["images"]
|
|
}
|
|
self.assertEqual(labels, {0: "output", 1: "output"})
|
|
|
|
await self.send("PUT", {"root_index": 0, "path": "a.png"})
|
|
self.assertIs((await self.workspace(root=0))["a.png"]["favorite"], True)
|
|
self.assertIs((await self.workspace(root=1))["a.png"]["favorite"], False)
|
|
|
|
await self.send("PUT", {"root_index": 1, "path": "a.png"})
|
|
response, data = await self.send(
|
|
"DELETE", {"root_index": 0, "path": "a.png"}
|
|
)
|
|
self.assertEqual(response.status, 200)
|
|
self.assertNotIn(other_parent, json.dumps(data))
|
|
self.assertIs((await self.workspace(root=0))["a.png"]["favorite"], False)
|
|
self.assertIs((await self.workspace(root=1))["a.png"]["favorite"], True)
|
|
listing = await self.client.get(
|
|
"/prompt_manager/images/output", params={"mode": "workspace"}
|
|
)
|
|
self.assertNotIn(other_parent, await listing.text())
|
|
|
|
async def test_windows_case_only_rename_keeps_the_star(self):
|
|
make_png(self.output_dir / "Sub" / "A.png")
|
|
with patch("py.api.images._path_flavor", ntpath):
|
|
response, data = await self.send(
|
|
"PUT", {"root_index": 0, "path": "Sub/A.png"}
|
|
)
|
|
self.assertEqual(response.status, 200)
|
|
self.assertEqual(data["path"], "Sub/A.png") # the listing's spelling
|
|
(self.output_dir / "Sub" / "A.png").rename(
|
|
self.output_dir / "Sub" / "a.png"
|
|
)
|
|
(self.output_dir / "Sub").rename(self.output_dir / "sub")
|
|
self.api.invalidate_gallery_cache()
|
|
self.assertIs((await self.workspace())["sub/a.png"]["favorite"], True)
|
|
response, data = await self.send(
|
|
"DELETE", {"root_index": 0, "path": "SUB/A.PNG"}
|
|
)
|
|
self.assertEqual(response.status, 200)
|
|
self.assertEqual(data["path"], "sub/a.png")
|
|
self.assertIs((await self.workspace())["sub/a.png"]["favorite"], False)
|
|
self.assertEqual(self.rows(), [])
|
|
|
|
async def test_posix_case_distinct_files_keep_independent_stars(self):
|
|
make_png(self.output_dir / "A.png")
|
|
make_png(self.output_dir / "a.png")
|
|
if len(list(self.output_dir.iterdir())) != 2:
|
|
self.skipTest("case-insensitive filesystem")
|
|
await self.send("PUT", {"root_index": 0, "path": "A.png"})
|
|
items = await self.workspace()
|
|
self.assertIs(items["A.png"]["favorite"], True)
|
|
self.assertIs(items["a.png"]["favorite"], False)
|
|
|
|
async def test_favorite_rejects_traversal_and_unknown_root(self):
|
|
make_png(self.output_dir / "a.png")
|
|
make_png(self.comfy_dir / "outside.png")
|
|
(self.output_dir / "notes.txt").write_text("not media")
|
|
make_png(self.output_dir / "thumbnails" / "a_thumb.png")
|
|
cases = [
|
|
({"root_index": 0, "path": "../outside.png"}, 403),
|
|
({"root_index": 0, "path": "sub/../../outside.png"}, 403),
|
|
({"root_index": 0, "path": str(self.comfy_dir / "outside.png")}, 403),
|
|
({"root_index": 0, "path": "/a.png"}, 403),
|
|
({"root_index": 0, "path": "C:/a.png"}, 403),
|
|
({"root_index": 0, "path": "a.png\0.png"}, 403),
|
|
({"root_index": 0, "path": "notes.txt"}, 403),
|
|
({"root_index": 1, "path": "a.png"}, 400),
|
|
({"root_index": -1, "path": "a.png"}, 400),
|
|
({"root_index": "0", "path": "a.png"}, 400),
|
|
({"root_index": True, "path": "a.png"}, 400),
|
|
({"root_index": 0}, 400),
|
|
({"root_index": 0, "path": ""}, 400),
|
|
({"root_index": 0, "path": 7}, 400),
|
|
({"root_index": 0, "path": "x" * 5000 + ".png"}, 400),
|
|
({"root_index": 0, "path": "missing.png"}, 404),
|
|
({"root_index": 0, "path": "thumbnails/a_thumb.png"}, 404),
|
|
]
|
|
for method in ("PUT", "DELETE"):
|
|
for body, status in cases:
|
|
if method == "DELETE" and status == 404:
|
|
continue # unlisting a stale favorite is allowed (below)
|
|
with self.subTest(method=method, body=str(body)[:60]):
|
|
response, data = await self.send(method, body)
|
|
self.assertEqual(response.status, status)
|
|
self.assertIs(data["success"], False)
|
|
self.assertNotIn(str(self.comfy_dir), data["error"])
|
|
response = await self.client.put(FAVORITE, data=b"[1]")
|
|
self.assertEqual(response.status, 400)
|
|
response = await self.client.put(FAVORITE, data=b"{not json")
|
|
self.assertEqual(response.status, 400)
|
|
count = self.api.db.model.get_connection().execute(
|
|
f"SELECT COUNT(*) FROM {TABLE}"
|
|
)
|
|
self.assertEqual(count.fetchone()[0], 0)
|
|
|
|
async def test_delete_clears_a_favorite_whose_file_is_gone(self):
|
|
target = make_png(self.output_dir / "gone.png")
|
|
await self.send("PUT", {"root_index": 0, "path": "gone.png"})
|
|
target.unlink()
|
|
self.api.invalidate_gallery_cache()
|
|
response, data = await self.send(
|
|
"DELETE", {"root_index": 0, "path": "gone.png"}
|
|
)
|
|
self.assertEqual(response.status, 200)
|
|
self.assertIs(data["favorite"], False)
|
|
count = self.api.db.model.get_connection().execute(
|
|
f"SELECT COUNT(*) FROM {TABLE}"
|
|
)
|
|
self.assertEqual(count.fetchone()[0], 0)
|
|
|
|
async def test_legacy_listing_is_unchanged_by_favorites(self):
|
|
make_png(self.output_dir / "a.png")
|
|
await self.send("PUT", {"root_index": 0, "path": "a.png"})
|
|
response = await self.client.get("/prompt_manager/images/output")
|
|
data = await response.json()
|
|
self.assertEqual(len(data["images"]), 1)
|
|
self.assertNotIn("favorite", data["images"][0])
|
|
|
|
async def test_toggle_lookup_is_built_once_per_scan(self):
|
|
make_png(self.output_dir / "a.png")
|
|
files = await self.api._get_gallery_files(self.output_dir)
|
|
first = self.api._favorite_listing(self.output_dir, files)
|
|
self.assertEqual(first, {"a.png": "a.png"})
|
|
self.assertIs(self.api._favorite_listing(self.output_dir, files), first)
|
|
make_png(self.output_dir / "b.png")
|
|
self.api.invalidate_gallery_cache()
|
|
rescanned = await self.api._get_gallery_files(self.output_dir)
|
|
self.assertEqual(
|
|
self.api._favorite_listing(self.output_dir, rescanned),
|
|
{"a.png": "a.png", "b.png": "b.png"},
|
|
)
|
|
self.api.reset_gallery_roots_cache()
|
|
self.assertEqual(self.api._favorite_listing_cache, {})
|
|
|
|
async def test_monitor_invalidation_drops_the_favorites_lookup(self):
|
|
make_png(self.output_dir / "a.png")
|
|
await self.send("PUT", {"root_index": 0, "path": "a.png"})
|
|
self.assertNotEqual(self.api._favorite_listing_cache, {})
|
|
# The image monitor's invalidation must not keep the old scan alive.
|
|
self.api.invalidate_gallery_cache()
|
|
self.assertEqual(self.api._favorite_listing_cache, {})
|
|
self.assertIs((await self.workspace())["a.png"]["favorite"], True)
|
|
|
|
async def test_put_rejects_a_key_beyond_the_stored_bound(self):
|
|
make_png(self.output_dir / "a.png")
|
|
too_long = "/" + "r" * MAX_KEY_LENGTH
|
|
with patch("py.api.images._favorite_root_key", return_value=too_long):
|
|
response, data = await self.send("PUT", {"root_index": 0, "path": "a.png"})
|
|
self.assertEqual(response.status, 403)
|
|
self.assertFalse(data["success"])
|
|
self.assertNotIn(too_long, json.dumps(data))
|
|
self.assertEqual(self.rows(), [])
|
|
# The live database still validates (a stored over-long key would
|
|
# fail startup and restore).
|
|
conn = self.api.db.model.get_connection()
|
|
self.assertIsNone(image_favorites_schema_error(conn))
|
|
|
|
async def test_favorite_store_failure_keeps_the_listing(self):
|
|
make_png(self.output_dir / "a.png")
|
|
|
|
def broken(*_args, **_kwargs):
|
|
raise sqlite3.OperationalError("database is locked")
|
|
|
|
self.api.db.get_image_favorites = broken
|
|
items = await self.workspace()
|
|
self.assertIs(items["a.png"]["favorite"], False)
|
|
self.api.db.set_image_favorite = broken
|
|
response, data = await self.send("PUT", {"root_index": 0, "path": "a.png"})
|
|
self.assertEqual(response.status, 500)
|
|
self.assertEqual(data, {"success": False, "error": "Favorite update failed"})
|
|
|
|
|
|
class TestImageFavoriteSchema(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmpdir = tempfile.mkdtemp()
|
|
self.path = os.path.join(self.tmpdir, "favorites.db")
|
|
|
|
def tearDown(self):
|
|
PromptModel.close_all_instances()
|
|
assert_closed(self, self.tmpdir)
|
|
for name in os.listdir(self.tmpdir):
|
|
os.unlink(os.path.join(self.tmpdir, name))
|
|
os.rmdir(self.tmpdir)
|
|
|
|
def test_store_round_trip_and_bounded_lookup(self):
|
|
db = PromptDatabase(self.path)
|
|
try:
|
|
self.assertTrue(db.set_image_favorite("output", "a.png"))
|
|
self.assertTrue(db.set_image_favorite("output", "a.png"))
|
|
db.set_image_favorite("other", "a.png")
|
|
self.assertEqual(
|
|
db.get_image_favorites("output", ["a.png", "b.png"]), {"a.png"}
|
|
)
|
|
self.assertEqual(db.get_image_favorites("output", []), set())
|
|
many = [f"{n}.png" for n in range(2000)] + ["a.png"]
|
|
self.assertEqual(db.get_image_favorites("output", many), {"a.png"})
|
|
self.assertTrue(db.clear_image_favorite("output", "a.png"))
|
|
self.assertFalse(db.clear_image_favorite("output", "a.png"))
|
|
self.assertEqual(db.get_image_favorites("output", ["a.png"]), set())
|
|
created = db.model.get_connection().execute(
|
|
f"SELECT created_at FROM {TABLE}"
|
|
)
|
|
self.assertRegex(created.fetchone()[0], r"^\d{4}-\d\d-\d\dT")
|
|
finally:
|
|
db.close_all()
|
|
|
|
def test_schema_rejects_unsupported_definitions_and_values(self):
|
|
variants = {
|
|
"missing": None,
|
|
"wrong table": f"CREATE TABLE {TABLE} (x TEXT)",
|
|
"view": f"CREATE VIEW {TABLE} AS SELECT 1",
|
|
"nullable key": (
|
|
f"CREATE TABLE {TABLE} (root_key TEXT, relative_path TEXT, "
|
|
"created_at TEXT, PRIMARY KEY(root_key, relative_path))"
|
|
),
|
|
}
|
|
for label, ddl in variants.items():
|
|
with self.subTest(label):
|
|
with closing(sqlite3.connect(":memory:")) as conn:
|
|
if ddl:
|
|
conn.execute(ddl)
|
|
error = image_favorites_schema_error(conn)
|
|
if ddl is None:
|
|
self.assertIsNone(error)
|
|
self.assertIsNotNone(
|
|
image_favorites_schema_error(conn, allow_missing=False)
|
|
)
|
|
else:
|
|
self.assertIsNotNone(error)
|
|
with self.assertRaises(sqlite3.DatabaseError):
|
|
migrate_image_favorites(conn)
|
|
with closing(sqlite3.connect(":memory:")) as conn:
|
|
migrate_image_favorites(conn)
|
|
migrate_image_favorites(conn) # idempotent
|
|
self.assertIsNone(image_favorites_schema_error(conn, allow_missing=False))
|
|
conn.execute(f"INSERT INTO {TABLE} VALUES ('r', 'a.png', 'now')")
|
|
self.assertIsNone(image_favorites_schema_error(conn))
|
|
conn.execute(f"INSERT INTO {TABLE} VALUES ('r', x'00ff', 'now')")
|
|
self.assertIsNotNone(image_favorites_schema_error(conn))
|
|
conn.execute(f"DELETE FROM {TABLE} WHERE typeof(relative_path)='blob'")
|
|
conn.execute(f"INSERT INTO {TABLE} VALUES ('r', ?, 'now')", ("x" * 5000,))
|
|
self.assertIsNotNone(image_favorites_schema_error(conn))
|
|
conn.execute(f"DELETE FROM {TABLE}")
|
|
conn.execute(f"CREATE INDEX extra ON {TABLE}(created_at)")
|
|
self.assertIsNotNone(image_favorites_schema_error(conn))
|
|
|
|
def test_verify_rejects_a_backup_with_an_invalid_favorites_table(self):
|
|
db = PromptDatabase(self.path)
|
|
db.close_all()
|
|
PromptModel.close_all_instances()
|
|
with closing(sqlite3.connect(self.path)) as conn:
|
|
conn.execute(f"DROP TABLE {TABLE}")
|
|
conn.execute(f"CREATE TABLE {TABLE} (anything TEXT)")
|
|
conn.commit()
|
|
ok, reason = PromptModel.verify_database_file(self.path)
|
|
self.assertFalse(ok)
|
|
self.assertIn("favorites", reason.lower())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|