fix(collections): address review findings
This commit is contained in:
@@ -492,3 +492,110 @@ class TestCollectionListingFilter(CollectionRoutesTestCase):
|
||||
with self.assertRaises(Exception) as caught:
|
||||
await self.api.resolve_listing_filter({"collection": "77"})
|
||||
self.assertEqual(type(caught.exception).__name__, "CollectionMissing")
|
||||
|
||||
|
||||
class TestReviewFindings(CollectionRoutesTestCase):
|
||||
"""PR #400 review: loop work, counts snapshot, id and query rules."""
|
||||
|
||||
async def test_no_realpath_runs_on_the_event_loop(self):
|
||||
import os
|
||||
import threading
|
||||
|
||||
names = [f"img{n}.png" for n in range(20)]
|
||||
self.make(*names)
|
||||
await self.call("GET", BASE) # warm the listing cache
|
||||
loop_thread = threading.current_thread()
|
||||
on_loop = []
|
||||
real = os.path.realpath
|
||||
|
||||
def watched(*args, **kwargs):
|
||||
if threading.current_thread() is loop_thread:
|
||||
on_loop.append(args[0])
|
||||
return real(*args, **kwargs)
|
||||
|
||||
items = [self.item(name) for name in names]
|
||||
cid = None
|
||||
with patch("os.path.realpath", side_effect=watched):
|
||||
made = await self.add(name="Set", items=items)
|
||||
cid = made["collection"]["id"]
|
||||
await self.call("DELETE", ITEMS, {"collection_id": cid, "items": items[:5]})
|
||||
await self.call(
|
||||
"GET",
|
||||
MEMBERSHIP,
|
||||
root_index="0",
|
||||
root_scope=items[0]["root_scope"],
|
||||
path="img9.png",
|
||||
)
|
||||
await self.call("GET", BASE)
|
||||
self.assertEqual(on_loop, [])
|
||||
|
||||
async def test_missing_is_never_negative(self):
|
||||
self.make("a.png", "b.png")
|
||||
made = await self.add(
|
||||
name="Set", items=[self.item("a.png"), self.item("b.png")]
|
||||
)
|
||||
cid = made["collection"]["id"]
|
||||
real = self.api.db.collection_count_snapshot
|
||||
|
||||
def raced(root_keys):
|
||||
members, stored = real(root_keys)
|
||||
return members, {cid: 1} # a remove landed between two reads
|
||||
|
||||
with patch.object(self.api.db, "collection_count_snapshot", side_effect=raced):
|
||||
listed = await self.listed()
|
||||
self.assertEqual((listed[0]["count"], listed[0]["missing"]), (2, 0))
|
||||
|
||||
async def test_path_ids_follow_the_listing_rule(self):
|
||||
self.make("a.png")
|
||||
made = await self.add(name="Set", items=[self.item("a.png")])
|
||||
cid = made["collection"]["id"]
|
||||
for raw in ("0", "007", f"0{cid}"):
|
||||
with self.subTest(raw=raw):
|
||||
await self.call("PATCH", f"{BASE}/{raw}", {"name": "x"}, 400)
|
||||
await self.call("DELETE", f"{BASE}/{raw}", None, 400)
|
||||
self.assertEqual(len(await self.listed()), 1)
|
||||
|
||||
async def test_membership_refuses_repeated_query_keys(self):
|
||||
self.make("a.png", "b.png")
|
||||
response = await self.client.get(
|
||||
MEMBERSHIP
|
||||
+ f"?root_index=0&root_scope={self.scope()}&path=a.png&path=b.png"
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
response = await self.client.get(
|
||||
MEMBERSHIP
|
||||
+ f"?root_index=0&root_index=0&root_scope={self.scope()}&path=a.png"
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
|
||||
async def test_deleting_a_collection_leaves_everything_else(self):
|
||||
from py.api.images import _favorite_root_key
|
||||
|
||||
try:
|
||||
from tests.test_annotation_schema import insert_group_rows, table_rows
|
||||
except ImportError:
|
||||
from test_annotation_schema import insert_group_rows, table_rows
|
||||
|
||||
self.make("a.png")
|
||||
db = self.api.db
|
||||
db.set_image_favorite(_favorite_root_key(self.output_dir), "a.png")
|
||||
prompt = self._save_prompt("Linked")
|
||||
db.link_image_to_prompt(prompt, str(self.output_dir / "a.png"))
|
||||
with db.model.get_connection() as conn:
|
||||
insert_group_rows(conn, locator=str(self.output_dir / "a.png"))
|
||||
made = await self.add(name="Set", items=[self.item("a.png")])
|
||||
tables = (
|
||||
"image_favorites",
|
||||
"generated_images",
|
||||
"prompts",
|
||||
"tags",
|
||||
"media_annotation_files",
|
||||
"media_annotation_bytes",
|
||||
"media_image_tags",
|
||||
)
|
||||
before = table_rows(db.model.get_connection(), tables)
|
||||
file_bytes = (self.output_dir / "a.png").read_bytes()
|
||||
await self.call("DELETE", f"{BASE}/{made['collection']['id']}")
|
||||
self.assertEqual(table_rows(db.model.get_connection(), tables), before)
|
||||
self.assertEqual((self.output_dir / "a.png").read_bytes(), file_bytes)
|
||||
self.assertTrue(all(before[t] for t in tables))
|
||||
|
||||
@@ -4,6 +4,7 @@ import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
@@ -175,7 +176,7 @@ class ImageCollectionOperationsTests(unittest.TestCase):
|
||||
self.require()
|
||||
made = self.add([(ROOT, "a.png")], name=" LoRa \t testing\n ")["collection"]
|
||||
self.assertEqual(made["name"], "LoRa testing")
|
||||
for bad in ("", " ", "x" * 101, "bell\x07", 5, None):
|
||||
for bad in ("", " ", "x" * 101, "bell\x07", "c1\x81", "del\x7f", 5, None):
|
||||
with self.subTest(name=bad):
|
||||
with self.assertRaises(CollectionInvalid):
|
||||
self.add([(ROOT, "a.png")], name=bad)
|
||||
@@ -199,6 +200,50 @@ class ImageCollectionOperationsTests(unittest.TestCase):
|
||||
with self.assertRaises(CollectionInvalid):
|
||||
self.add(items, collection_id=made["id"])
|
||||
|
||||
def test_a_failure_after_the_membership_move_rolls_it_back(self):
|
||||
self.require()
|
||||
made = self.add([(ROOT, "b.png")], name="Grids")["collection"]
|
||||
move = {
|
||||
"removed": [],
|
||||
"kept": [],
|
||||
"targets": {},
|
||||
"kept_path": "/real/output/a.png",
|
||||
"favorites": [(ROOT, "b.png", ROOT, "a.png")],
|
||||
"removed_locator": "/real/output/b.png",
|
||||
"kept_locator": "/real/output/a.png",
|
||||
"sha256": "0" * 64,
|
||||
"kept_size": 1,
|
||||
"kept_mtime_ns": 1,
|
||||
}
|
||||
with patch.object(
|
||||
type(self.db), "_move_copy_tags", side_effect=RuntimeError("boom")
|
||||
):
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.db.move_copy_records(move)
|
||||
self.assertEqual(self.db.file_collections(ROOT, "b.png"), [made["id"]])
|
||||
self.assertEqual(self.db.file_collections(ROOT, "a.png"), [])
|
||||
# Without the failure the same move succeeds.
|
||||
self.assertEqual(self.db.move_copy_records(move)["collections"], 1)
|
||||
self.assertEqual(self.db.file_collections(ROOT, "a.png"), [made["id"]])
|
||||
|
||||
def test_count_snapshot_reads_in_one_transaction(self):
|
||||
self.require()
|
||||
made = self.add([(ROOT, "a.png"), (OTHER, "b.png")], name="Set")["collection"]
|
||||
statements = []
|
||||
conn = self.db.model.get_connection()
|
||||
conn.set_trace_callback(statements.append)
|
||||
try:
|
||||
members, stored = self.db.collection_count_snapshot([ROOT, OTHER, ROOT])
|
||||
finally:
|
||||
conn.set_trace_callback(None)
|
||||
self.assertEqual(
|
||||
members, {ROOT: {made["id"]: {"a.png"}}, OTHER: {made["id"]: {"b.png"}}}
|
||||
)
|
||||
self.assertEqual(stored, {made["id"]: 2})
|
||||
self.assertEqual(statements[0], "BEGIN")
|
||||
self.assertEqual(statements[-1], "ROLLBACK")
|
||||
self.assertFalse(conn.in_transaction)
|
||||
|
||||
def test_reads_use_an_index(self):
|
||||
self.require()
|
||||
self.add([(ROOT, "a.png")], name="Set")
|
||||
|
||||
@@ -390,7 +390,8 @@ class ImageCollectionsSchemaTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_restore_refuses_a_non_text_name(self):
|
||||
self.assert_refused("int-name", f"UPDATE {COLLECTIONS} SET name = 5")
|
||||
# Column affinity turns a number into text; a blob stays a blob.
|
||||
self.assert_refused("blob-name", f"UPDATE {COLLECTIONS} SET name = x'61'")
|
||||
|
||||
def test_restore_refuses_an_empty_name(self):
|
||||
self.assert_refused("empty", f"UPDATE {COLLECTIONS} SET name = '', name_key=''")
|
||||
@@ -420,8 +421,43 @@ class ImageCollectionsSchemaTests(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
|
||||
def test_restore_refuses_a_wrong_name_key(self):
|
||||
self.assert_refused("key", f"UPDATE {COLLECTIONS} SET name_key = 'Good'")
|
||||
def test_restore_accepts_a_name_key_from_other_case_tables(self):
|
||||
# Python versions fold some characters differently; a key written
|
||||
# by another version must never make a backup unrestorable.
|
||||
self.require_module()
|
||||
path = self.closed_current(
|
||||
"other-key.db",
|
||||
(
|
||||
f"INSERT INTO {COLLECTIONS} (id, name, name_key, created_at) "
|
||||
"VALUES (1, 'Good', 'Good', ?)",
|
||||
(NOW,),
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(path, require_library_state=True),
|
||||
(True, "ok"),
|
||||
)
|
||||
|
||||
def test_restore_refuses_an_empty_or_non_text_name_key(self):
|
||||
self.assert_refused("empty-key", f"UPDATE {COLLECTIONS} SET name_key = ''")
|
||||
self.assert_refused(
|
||||
"blob-name-key", f"UPDATE {COLLECTIONS} SET name_key = x'61'"
|
||||
)
|
||||
self.assert_refused(
|
||||
"long-name-key",
|
||||
(f"UPDATE {COLLECTIONS} SET name_key = ?", ("k" * 1201,)),
|
||||
)
|
||||
|
||||
def test_restore_refuses_a_c1_control_character(self):
|
||||
for name in ("a\x81b", "a\x7fb"):
|
||||
with self.subTest(name=repr(name)):
|
||||
self.assert_refused(
|
||||
f"c1-{ord(name[1])}",
|
||||
(
|
||||
f"UPDATE {COLLECTIONS} SET name = ?, name_key = ?",
|
||||
(name, key(name)),
|
||||
),
|
||||
)
|
||||
|
||||
def test_restore_refuses_a_non_text_key(self):
|
||||
# Column affinity turns a number into text; a blob stays a blob.
|
||||
|
||||
Reference in New Issue
Block a user