Add unit tests for _prune_orphaned_assets

Tests cover:

- Orphaned seed assets pruned when file removed

- Seed assets with valid files survive

- Hashed assets not pruned even without file

- Multi-root pruning

- SQL LIKE escape handling for %, _, spaces

Amp-Thread-ID: https://ampcode.com/threads/T-019c0c7a-5c8a-7548-b6c3-823e9829ce74
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Alexander Brown 2026-01-29 18:05:40 -08:00
parent eb2b38458c
commit 0b1b234d90

View File

@ -6,261 +6,136 @@ import requests
from conftest import get_asset_filename, trigger_sync_seed_assets
@pytest.fixture
def create_seed_file(comfy_tmp_base_dir: Path):
"""Create a file on disk that will become a seed asset after sync."""
created: list[Path] = []
def _create(root: str, scope: str, name: str | None = None, data: bytes = b"TEST") -> Path:
name = name or f"seed_{uuid.uuid4().hex[:8]}.bin"
path = comfy_tmp_base_dir / root / "unit-tests" / scope / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
created.append(path)
return path
yield _create
for p in created:
p.unlink(missing_ok=True)
@pytest.fixture
def find_asset(http: requests.Session, api_base: str):
"""Query API for assets matching scope and optional name."""
def _find(scope: str, name: str | None = None) -> list[dict]:
params = {"include_tags": f"unit-tests,{scope}"}
if name:
params["name_contains"] = name
r = http.get(f"{api_base}/api/assets", params=params, timeout=120)
assert r.status_code == 200
assets = r.json().get("assets", [])
if name:
return [a for a in assets if a.get("name") == name]
return assets
return _find
@pytest.mark.parametrize("root", ["input", "output"])
def test_prune_deletes_orphaned_seed_asset_when_file_removed(
def test_orphaned_seed_asset_is_pruned(
root: str,
create_seed_file,
find_asset,
http: requests.Session,
api_base: str,
comfy_tmp_base_dir: Path,
):
"""Seed asset (hash=NULL) with no file on disk should be pruned after sync."""
scope = f"prune-orphan-{uuid.uuid4().hex[:6]}"
case_dir = comfy_tmp_base_dir / root / "unit-tests" / scope
case_dir.mkdir(parents=True, exist_ok=True)
name = f"seed_{uuid.uuid4().hex[:8]}.bin"
fp = case_dir / name
fp.write_bytes(b"PRUNE_TEST" * 100)
"""Seed asset with deleted file is removed; with file present, it survives."""
scope = f"prune-{uuid.uuid4().hex[:6]}"
fp = create_seed_file(root, scope)
name = fp.name
trigger_sync_seed_assets(http, api_base)
r1 = http.get(
api_base + "/api/assets",
params={"include_tags": f"unit-tests,{scope}", "name_contains": name},
timeout=120,
)
body1 = r1.json()
assert r1.status_code == 200
matches = [a for a in body1.get("assets", []) if a.get("name") == name]
assert matches, "Seed asset should exist after sync"
assert matches[0].get("asset_hash") is None, "Should be a seed (no hash)"
assert find_asset(scope, name), "Seed asset should exist"
fp.unlink()
trigger_sync_seed_assets(http, api_base)
r2 = http.get(
api_base + "/api/assets",
params={"include_tags": f"unit-tests,{scope}", "name_contains": name},
timeout=120,
)
body2 = r2.json()
assert r2.status_code == 200
matches2 = [a for a in body2.get("assets", []) if a.get("name") == name]
assert not matches2, "Orphaned seed asset should be pruned"
assert not find_asset(scope, name), "Orphaned seed should be pruned"
def test_prune_keeps_seed_asset_with_valid_file(
def test_seed_asset_with_file_survives_prune(
create_seed_file,
find_asset,
http: requests.Session,
api_base: str,
comfy_tmp_base_dir: Path,
):
"""Seed asset with file still on disk should NOT be pruned."""
scope = f"prune-keep-{uuid.uuid4().hex[:6]}"
case_dir = comfy_tmp_base_dir / "input" / "unit-tests" / scope
case_dir.mkdir(parents=True, exist_ok=True)
name = f"keep_{uuid.uuid4().hex[:8]}.bin"
fp = case_dir / name
fp.write_bytes(b"KEEP_ME" * 100)
trigger_sync_seed_assets(http, api_base)
r1 = http.get(
api_base + "/api/assets",
params={"include_tags": f"unit-tests,{scope}", "name_contains": name},
timeout=120,
)
body1 = r1.json()
assert r1.status_code == 200
matches = [a for a in body1.get("assets", []) if a.get("name") == name]
assert matches, "Seed asset should exist"
asset_id = matches[0]["id"]
"""Seed asset with file still on disk is NOT pruned."""
scope = f"keep-{uuid.uuid4().hex[:6]}"
fp = create_seed_file("input", scope)
trigger_sync_seed_assets(http, api_base)
trigger_sync_seed_assets(http, api_base)
r2 = http.get(f"{api_base}/api/assets/{asset_id}", timeout=120)
assert r2.status_code == 200, "Seed asset with valid file should survive prune"
assert find_asset(scope, fp.name), "Seed with valid file should survive"
def test_prune_keeps_hashed_asset_even_without_file(
def test_hashed_asset_not_pruned_when_file_missing(
http: requests.Session,
api_base: str,
comfy_tmp_base_dir: Path,
asset_factory,
make_asset_bytes,
):
"""Hashed asset (hash!=NULL) should NOT be deleted by prune, even if file is missing."""
scope = f"prune-hashed-{uuid.uuid4().hex[:6]}"
name = "hashed_asset.bin"
data = make_asset_bytes(name, 2048)
"""Hashed assets are never deleted by prune, even without file."""
scope = f"hashed-{uuid.uuid4().hex[:6]}"
data = make_asset_bytes("test", 2048)
a = asset_factory("test.bin", ["input", "unit-tests", scope], {}, data)
a = asset_factory(name, ["input", "unit-tests", scope], {}, data)
aid = a["id"]
ahash = a["asset_hash"]
assert ahash is not None, "Should be a hashed asset"
p = comfy_tmp_base_dir / "input" / "unit-tests" / scope / get_asset_filename(ahash, ".bin")
assert p.exists()
p.unlink()
path = comfy_tmp_base_dir / "input" / "unit-tests" / scope / get_asset_filename(a["asset_hash"], ".bin")
path.unlink()
trigger_sync_seed_assets(http, api_base)
r = http.get(f"{api_base}/api/assets/{aid}", timeout=120)
assert r.status_code == 200, "Hashed asset should NOT be pruned even without file"
d = r.json()
assert d.get("asset_hash") == ahash
r = http.get(f"{api_base}/api/assets/{a['id']}", timeout=120)
assert r.status_code == 200, "Hashed asset should NOT be pruned"
def test_prune_deletes_seed_asset_info_when_orphaned(
def test_prune_across_multiple_roots(
create_seed_file,
find_asset,
http: requests.Session,
api_base: str,
):
"""Prune correctly handles assets across input and output roots."""
scope = f"multi-{uuid.uuid4().hex[:6]}"
input_fp = create_seed_file("input", scope, "input.bin")
output_fp = create_seed_file("output", scope, "output.bin")
trigger_sync_seed_assets(http, api_base)
assert len(find_asset(scope)) == 2
input_fp.unlink()
trigger_sync_seed_assets(http, api_base)
remaining = find_asset(scope)
assert len(remaining) == 1
assert remaining[0]["name"] == "output.bin"
@pytest.mark.parametrize("dirname", ["100%_done", "my_folder_name", "has spaces"])
def test_special_chars_in_path_escaped_correctly(
dirname: str,
create_seed_file,
find_asset,
http: requests.Session,
api_base: str,
comfy_tmp_base_dir: Path,
):
"""When seed asset is pruned, its AssetInfo should also be deleted."""
scope = f"prune-info-{uuid.uuid4().hex[:6]}"
case_dir = comfy_tmp_base_dir / "output" / "unit-tests" / scope
case_dir.mkdir(parents=True, exist_ok=True)
name = f"info_test_{uuid.uuid4().hex[:8]}.txt"
fp = case_dir / name
fp.write_bytes(b"INFO_TEST_DATA")
trigger_sync_seed_assets(http, api_base)
r1 = http.get(
api_base + "/api/assets",
params={"include_tags": f"unit-tests,{scope}"},
timeout=120,
)
body1 = r1.json()
assert r1.status_code == 200
matches = [a for a in body1.get("assets", []) if a.get("name") == name]
assert len(matches) == 1
asset_info_id = matches[0]["id"]
fp.unlink()
trigger_sync_seed_assets(http, api_base)
r2 = http.get(f"{api_base}/api/assets/{asset_info_id}", timeout=120)
assert r2.status_code == 404, "AssetInfo should be deleted when seed asset is pruned"
def test_prune_handles_multiple_roots(
http: requests.Session,
api_base: str,
comfy_tmp_base_dir: Path,
):
"""Prune should work correctly when syncing multiple roots."""
scope = f"prune-multi-{uuid.uuid4().hex[:6]}"
input_dir = comfy_tmp_base_dir / "input" / "unit-tests" / scope
output_dir = comfy_tmp_base_dir / "output" / "unit-tests" / scope
input_dir.mkdir(parents=True, exist_ok=True)
output_dir.mkdir(parents=True, exist_ok=True)
input_file = input_dir / f"input_{uuid.uuid4().hex[:8]}.bin"
output_file = output_dir / f"output_{uuid.uuid4().hex[:8]}.bin"
input_file.write_bytes(b"INPUT_DATA")
output_file.write_bytes(b"OUTPUT_DATA")
trigger_sync_seed_assets(http, api_base)
r1 = http.get(
api_base + "/api/assets",
params={"include_tags": f"unit-tests,{scope}"},
timeout=120,
)
body1 = r1.json()
assert len(body1.get("assets", [])) == 2
input_file.unlink()
trigger_sync_seed_assets(http, api_base)
r2 = http.get(
api_base + "/api/assets",
params={"include_tags": f"unit-tests,{scope}"},
timeout=120,
)
body2 = r2.json()
assets = body2.get("assets", [])
assert len(assets) == 1, "Only the output asset should remain"
assert assets[0]["name"] == output_file.name
def test_prune_handles_special_characters_in_path(
http: requests.Session,
api_base: str,
comfy_tmp_base_dir: Path,
):
"""Paths with special SQL LIKE characters (%, _) should be handled correctly."""
scope = f"prune-special-{uuid.uuid4().hex[:6]}"
special_dir = comfy_tmp_base_dir / "input" / "unit-tests" / scope / "test_100%_done"
special_dir.mkdir(parents=True, exist_ok=True)
name = f"special_{uuid.uuid4().hex[:8]}.bin"
fp = special_dir / name
fp.write_bytes(b"SPECIAL_CHAR_TEST")
trigger_sync_seed_assets(http, api_base)
r1 = http.get(
api_base + "/api/assets",
params={"include_tags": f"unit-tests,{scope}", "name_contains": name},
timeout=120,
)
body1 = r1.json()
assert r1.status_code == 200
matches = [a for a in body1.get("assets", []) if a.get("name") == name]
assert matches, "Asset with special chars in path should be created"
trigger_sync_seed_assets(http, api_base)
r2 = http.get(
api_base + "/api/assets",
params={"include_tags": f"unit-tests,{scope}", "name_contains": name},
timeout=120,
)
body2 = r2.json()
matches2 = [a for a in body2.get("assets", []) if a.get("name") == name]
assert matches2, "Asset with special chars should NOT be falsely pruned"
def test_prune_with_underscore_in_path(
http: requests.Session,
api_base: str,
comfy_tmp_base_dir: Path,
):
"""Underscore in path should be escaped properly in LIKE query."""
scope = f"prune-underscore-{uuid.uuid4().hex[:6]}"
underscore_dir = comfy_tmp_base_dir / "input" / "unit-tests" / scope / "my_folder_name"
underscore_dir.mkdir(parents=True, exist_ok=True)
name = f"underscore_{uuid.uuid4().hex[:8]}.bin"
fp = underscore_dir / name
fp.write_bytes(b"UNDERSCORE_TEST")
trigger_sync_seed_assets(http, api_base)
r1 = http.get(
api_base + "/api/assets",
params={"include_tags": f"unit-tests,{scope}"},
timeout=120,
)
body1 = r1.json()
matches = [a for a in body1.get("assets", []) if a.get("name") == name]
assert matches, "Asset should exist"
"""SQL LIKE wildcards (%, _) and spaces in paths don't cause false matches."""
scope = f"special-{uuid.uuid4().hex[:6]}/{dirname}"
fp = create_seed_file("input", scope)
trigger_sync_seed_assets(http, api_base)
trigger_sync_seed_assets(http, api_base)
r2 = http.get(
api_base + "/api/assets",
params={"include_tags": f"unit-tests,{scope}"},
timeout=120,
)
body2 = r2.json()
matches2 = [a for a in body2.get("assets", []) if a.get("name") == name]
assert matches2, "Asset with underscore in path should survive multiple prunes"
assert find_asset(scope.split("/")[0], fp.name), "Asset with special chars should survive"