mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-14 09:43:22 -03:00
feat(delete): stage model and recipe deletes for 30s undo
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from py.services.model_lifecycle_service import ModelLifecycleService, _require_path_in_library_roots
|
||||
from py.services.pending_delete_service import PENDING_DELETE_DIR_NAME, _reset_pending_delete_service
|
||||
from py.services.settings_manager import get_settings_manager
|
||||
from py.utils.metadata_manager import MetadataManager
|
||||
from py.utils.models import LoraMetadata
|
||||
|
||||
@@ -901,3 +906,167 @@ async def test_extract_model_id_handles_string_values():
|
||||
|
||||
payload = {"civitai": {"modelId": "54321"}}
|
||||
assert service._extract_model_id_from_payload(payload) == 54321
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for delete_model undo staging
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_pending_delete_singleton() -> Iterator[None]:
|
||||
"""Reset the pending-delete singleton around every test in this module.
|
||||
|
||||
The singleton keeps an in-process list of staging roots across tests;
|
||||
resetting avoids cross-test pollution (a stale root from one tmp_path
|
||||
leaking into the next test's opportunistic purge enumeration).
|
||||
"""
|
||||
_reset_pending_delete_service()
|
||||
yield
|
||||
_reset_pending_delete_service()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_scanner_registry_getters(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Prevent purge enumeration from instantiating real scanner singletons.
|
||||
|
||||
``stage_model_delete`` triggers an opportunistic purge whose root
|
||||
enumeration queries the ServiceRegistry scanner getters; stubbing them to
|
||||
``None`` keeps tests fast and isolated (mirrors test_pending_delete_service).
|
||||
"""
|
||||
from py.services.service_registry import ServiceRegistry
|
||||
|
||||
async def _none(*_args: Any, **_kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(ServiceRegistry, "get_lora_scanner", _none)
|
||||
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _none)
|
||||
monkeypatch.setattr(ServiceRegistry, "get_embedding_scanner", _none)
|
||||
|
||||
|
||||
def _make_delete_service(scanner: Any) -> ModelLifecycleService:
|
||||
return ModelLifecycleService(
|
||||
scanner=scanner,
|
||||
metadata_manager=DummyMetadataManager({"civitai": {"modelId": 1}}),
|
||||
metadata_loader=_empty_metadata_loader,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_model_stages_file_when_undo_enabled(tmp_path: Path):
|
||||
"""Undo enabled (the default): artifacts are renamed into a
|
||||
``.lm-pending-delete/<batch_id>/`` staging dir under the model root, the
|
||||
response carries the batch_id, the cache entry is removed and the cache
|
||||
is persisted (``_persist_calls`` tracked by ``ScannerForDelete``)."""
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
model_path = root / "model.safetensors"
|
||||
model_path.write_bytes(b"content")
|
||||
metadata_path = root / "model.metadata.json"
|
||||
metadata_path.write_text(json.dumps({}))
|
||||
preview_path = root / "model.preview.png"
|
||||
preview_path.write_bytes(b"preview")
|
||||
|
||||
scanner = ScannerForDelete(
|
||||
raw_data=[
|
||||
{
|
||||
"file_path": str(model_path),
|
||||
"civitai": {"modelId": 1, "id": 10},
|
||||
"sha256": "abc123",
|
||||
}
|
||||
],
|
||||
roots=[str(root)],
|
||||
)
|
||||
service = _make_delete_service(scanner)
|
||||
|
||||
result = await service.delete_model(str(model_path))
|
||||
|
||||
assert result["success"] is True
|
||||
batch_id = result["batch_id"]
|
||||
assert isinstance(batch_id, str)
|
||||
|
||||
assert not model_path.exists()
|
||||
assert not metadata_path.exists()
|
||||
assert not preview_path.exists()
|
||||
batch_dir = root / PENDING_DELETE_DIR_NAME / batch_id
|
||||
assert batch_dir.is_dir()
|
||||
assert (batch_dir / "model.safetensors").read_bytes() == b"content"
|
||||
assert (batch_dir / "model.metadata.json").exists()
|
||||
assert (batch_dir / "model.preview.png").exists()
|
||||
|
||||
assert scanner.cache.raw_data == []
|
||||
assert scanner._hash_index.removed == [str(model_path)]
|
||||
assert scanner._persist_calls == [True]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_model_hard_deletes_when_undo_disabled(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""delete_undo_enabled=false: old os.remove behavior, batch_id is None and
|
||||
no staging directory is ever created."""
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
model_path = root / "model.safetensors"
|
||||
model_path.write_bytes(b"content")
|
||||
|
||||
settings_manager = get_settings_manager()
|
||||
monkeypatch.setattr(
|
||||
settings_manager,
|
||||
"get",
|
||||
lambda key, default=None: False
|
||||
if key == "delete_undo_enabled"
|
||||
else default,
|
||||
)
|
||||
|
||||
scanner = ScannerForDelete(
|
||||
raw_data=[{"file_path": str(model_path)}],
|
||||
roots=[str(root)],
|
||||
)
|
||||
service = _make_delete_service(scanner)
|
||||
|
||||
result = await service.delete_model(str(model_path))
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["batch_id"] is None
|
||||
assert result["deleted_files"]
|
||||
assert not model_path.exists()
|
||||
assert not (root / PENDING_DELETE_DIR_NAME).exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_model_falls_back_when_staging_fails(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Staging rename failure: delete_model_artifacts fallback removes the
|
||||
files, batch_id is None and no staged data is left behind."""
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
model_path = root / "model.safetensors"
|
||||
model_path.write_bytes(b"content")
|
||||
|
||||
real_rename = os.rename
|
||||
|
||||
def _failing_rename(src: str, dst: str) -> None:
|
||||
if PENDING_DELETE_DIR_NAME in dst:
|
||||
raise OSError("simulated staging failure")
|
||||
real_rename(src, dst)
|
||||
|
||||
monkeypatch.setattr(os, "rename", _failing_rename)
|
||||
|
||||
scanner = ScannerForDelete(
|
||||
raw_data=[{"file_path": str(model_path)}],
|
||||
roots=[str(root)],
|
||||
)
|
||||
service = _make_delete_service(scanner)
|
||||
|
||||
result = await service.delete_model(str(model_path))
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["batch_id"] is None
|
||||
assert result["deleted_files"]
|
||||
assert not model_path.exists()
|
||||
# No staged batch files remain (the batch dir is rolled back; an empty
|
||||
# staging parent, if left behind by the rollback, holds no data).
|
||||
staging_parent = root / PENDING_DELETE_DIR_NAME
|
||||
assert not staging_parent.exists() or not any(staging_parent.iterdir())
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from types import MethodType
|
||||
@@ -11,7 +16,13 @@ from py.services import model_scanner
|
||||
from py.services.model_cache import ModelCache
|
||||
from py.services.model_hash_index import ModelHashIndex
|
||||
from py.services.model_scanner import CacheBuildResult, ModelScanner
|
||||
from py.services.pending_delete_service import (
|
||||
PENDING_DELETE_DIR_NAME,
|
||||
PENDING_DELETE_TTL_SECONDS,
|
||||
_reset_pending_delete_service,
|
||||
)
|
||||
from py.services.persistent_model_cache import PersistentModelCache, DEFAULT_LICENSE_FLAGS
|
||||
from py.services.settings_manager import get_settings_manager
|
||||
from py.utils.civitai_utils import build_license_flags
|
||||
from py.utils.models import BaseModelMetadata
|
||||
|
||||
@@ -104,6 +115,27 @@ def stub_register_service(monkeypatch):
|
||||
monkeypatch.setattr(model_scanner.ServiceRegistry, "register_service", noop)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_pending_delete_singleton() -> Iterator[None]:
|
||||
"""Reset the pending-delete singleton before and after each test."""
|
||||
_reset_pending_delete_service()
|
||||
yield
|
||||
_reset_pending_delete_service()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_service_registry_getters(monkeypatch) -> None:
|
||||
"""Prevent pending-delete purge enumeration from building real scanners."""
|
||||
from py.services.service_registry import ServiceRegistry
|
||||
|
||||
async def _none(*_args, **_kwargs) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(ServiceRegistry, "get_lora_scanner", _none)
|
||||
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _none)
|
||||
monkeypatch.setattr(ServiceRegistry, "get_embedding_scanner", _none)
|
||||
|
||||
|
||||
def _create_files(root: Path) -> tuple[Path, Path, Path]:
|
||||
first = root / "one.txt"
|
||||
first.write_text("one", encoding="utf-8")
|
||||
@@ -974,3 +1006,235 @@ async def test_sync_cache_conditional_resort_triggered(tmp_path: Path, monkeypat
|
||||
)
|
||||
assert changed is True
|
||||
assert resort_calls == 1
|
||||
|
||||
|
||||
# ── bulk_delete_models staging (undo-delete feature, todo 3) ───────────────
|
||||
|
||||
|
||||
def _make_bulk_scanner(root: Path, file_paths: List[Path]) -> DummyScanner:
|
||||
"""Build a DummyScanner whose cache mirrors the given files on disk."""
|
||||
scanner = DummyScanner(root)
|
||||
raw_data = []
|
||||
for path in file_paths:
|
||||
name = os.path.splitext(os.path.basename(path))[0]
|
||||
raw_data.append(
|
||||
{
|
||||
"file_path": str(path),
|
||||
"folder": "",
|
||||
"sha256": f"hash-{name}",
|
||||
"tags": ["alpha"] if "one" in name else ["beta"],
|
||||
"model_name": name,
|
||||
"file_name": name,
|
||||
"size": 1,
|
||||
"modified": 1.0,
|
||||
}
|
||||
)
|
||||
scanner._cache = ModelCache(
|
||||
raw_data=raw_data, folders=[], name_display_mode="model_name"
|
||||
)
|
||||
scanner._tags_count = {"alpha": 1, "beta": 1}
|
||||
for entry in raw_data:
|
||||
scanner._hash_index.add_entry(entry["sha256"], entry["file_path"])
|
||||
return scanner
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_stages_two_files_into_single_batch(tmp_path: Path):
|
||||
"""Two-file bulk delete -> one merged batch id with both files staged."""
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
first = root / "one.txt"
|
||||
first.write_text("one", encoding="utf-8")
|
||||
second = root / "two.txt"
|
||||
second.write_text("two", encoding="utf-8")
|
||||
scanner = _make_bulk_scanner(root, [first, second])
|
||||
|
||||
result = await scanner.bulk_delete_models([str(first), str(second)])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["status"] == "success"
|
||||
assert result["total_deleted"] == 2
|
||||
assert result["cache_updated"] is True
|
||||
|
||||
# ONE batch id, no batch_ids array, and both files staged in its dir.
|
||||
assert "batch_id" in result
|
||||
assert "batch_ids" not in result
|
||||
batch_id = result["batch_id"]
|
||||
assert batch_id is not None
|
||||
staging = root / PENDING_DELETE_DIR_NAME
|
||||
batch_dir = staging / batch_id
|
||||
assert batch_dir.is_dir()
|
||||
assert (batch_dir / "one.txt").read_bytes() == b"one"
|
||||
assert (batch_dir / "two.txt").read_bytes() == b"two"
|
||||
|
||||
# Loser batch dirs are removed by the merge - exactly one batch remains.
|
||||
batch_dirs = [d.name for d in staging.iterdir() if d.is_dir()]
|
||||
assert batch_dirs == [batch_id]
|
||||
|
||||
# The manifest carries the winner's cache snapshot for later undo.
|
||||
manifest = json.loads((batch_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert manifest["model_snapshot"]["file_path"] == str(first)
|
||||
|
||||
# Originals gone; cache entries removed.
|
||||
assert not first.exists()
|
||||
assert not second.exists()
|
||||
cached_paths = {item["file_path"] for item in scanner._cache.raw_data}
|
||||
assert str(first) not in cached_paths
|
||||
assert str(second) not in cached_paths
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_merged_manifest_reanchors_expiry(tmp_path: Path):
|
||||
"""Merged manifest expires_at is re-anchored to now+TTL at merge time."""
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
first = root / "one.txt"
|
||||
first.write_text("one", encoding="utf-8")
|
||||
second = root / "two.txt"
|
||||
second.write_text("two", encoding="utf-8")
|
||||
scanner = _make_bulk_scanner(root, [first, second])
|
||||
|
||||
before = int(time.time())
|
||||
result = await scanner.bulk_delete_models([str(first), str(second)])
|
||||
after = int(time.time())
|
||||
|
||||
batch_dir = root / PENDING_DELETE_DIR_NAME / result["batch_id"]
|
||||
manifest = json.loads((batch_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
# expires_at >= staging completion time + TTL (re-anchor assertion).
|
||||
assert manifest["expires_at"] >= after + PENDING_DELETE_TTL_SECONDS - 2
|
||||
assert manifest["expires_at"] >= before + PENDING_DELETE_TTL_SECONDS
|
||||
# Both files are entries of the merged manifest.
|
||||
assert len(manifest["entries"]) == 2
|
||||
assert (batch_dir / "one.txt").exists()
|
||||
assert (batch_dir / "two.txt").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_merge_failure_falls_back_to_batch_ids(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
"""Merge move failure -> batch_ids array of the intact constituent batches."""
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
first = root / "one.txt"
|
||||
first.write_text("one", encoding="utf-8")
|
||||
second = root / "two.txt"
|
||||
second.write_text("two", encoding="utf-8")
|
||||
scanner = _make_bulk_scanner(root, [first, second])
|
||||
|
||||
real_rename = os.rename
|
||||
fail_next = {"enabled": True}
|
||||
|
||||
def flaky_merge_rename(src: str, dst: str) -> None:
|
||||
# Fail only when moving between batch dirs (merge), never during
|
||||
# staging (src is then the original path, outside .lm-pending-delete).
|
||||
if (
|
||||
fail_next["enabled"]
|
||||
and PENDING_DELETE_DIR_NAME in src
|
||||
and PENDING_DELETE_DIR_NAME in dst
|
||||
):
|
||||
fail_next["enabled"] = False
|
||||
raise OSError("simulated merge failure")
|
||||
return real_rename(src, dst)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.services.pending_delete_service.os.rename", flaky_merge_rename
|
||||
)
|
||||
|
||||
result = await scanner.bulk_delete_models([str(first), str(second)])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["total_deleted"] == 2
|
||||
# No single batch id - the constituent ids are returned instead.
|
||||
assert "batch_id" not in result
|
||||
assert "batch_ids" in result
|
||||
assert len(result["batch_ids"]) == 2
|
||||
|
||||
# Both constituent batches are intact: dirs + manifests + staged files.
|
||||
staging = root / PENDING_DELETE_DIR_NAME
|
||||
batch_dirs = sorted(d.name for d in staging.iterdir() if d.is_dir())
|
||||
assert sorted(result["batch_ids"]) == batch_dirs
|
||||
for bid in result["batch_ids"]:
|
||||
batch_dir = staging / bid
|
||||
assert (batch_dir / "manifest.json").exists()
|
||||
staged_files = [
|
||||
f.name
|
||||
for bid in result["batch_ids"]
|
||||
for f in (staging / bid).iterdir()
|
||||
if f.is_file() and f.name != "manifest.json"
|
||||
]
|
||||
assert sorted(staged_files) == ["one.txt", "two.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_undo_disabled_hard_deletes(tmp_path: Path):
|
||||
"""delete_undo_enabled=false -> old hard delete, no batch, no staging dirs."""
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
first = root / "one.txt"
|
||||
first.write_text("one", encoding="utf-8")
|
||||
second = root / "two.txt"
|
||||
second.write_text("two", encoding="utf-8")
|
||||
scanner = _make_bulk_scanner(root, [first, second])
|
||||
|
||||
get_settings_manager().settings["delete_undo_enabled"] = False
|
||||
|
||||
result = await scanner.bulk_delete_models([str(first), str(second)])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["status"] == "success"
|
||||
assert result["total_deleted"] == 2
|
||||
assert result.get("batch_id") is None
|
||||
assert "batch_ids" not in result
|
||||
|
||||
# Old hard-delete behavior: files removed, zero staging dirs created.
|
||||
assert not first.exists()
|
||||
assert not second.exists()
|
||||
assert not (root / PENDING_DELETE_DIR_NAME).exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_cancelled_after_one_staged_batch_present(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
"""Cancelled mid-way -> status='cancelled' AND the staged subset undoable."""
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
first = root / "one.txt"
|
||||
first.write_text("one", encoding="utf-8")
|
||||
second = root / "two.txt"
|
||||
second.write_text("two", encoding="utf-8")
|
||||
scanner = _make_bulk_scanner(root, [first, second])
|
||||
|
||||
real_rename = os.rename
|
||||
rename_count = {"n": 0}
|
||||
|
||||
def cancelling_rename(src: str, dst: str) -> None:
|
||||
rename_count["n"] += 1
|
||||
result = real_rename(src, dst)
|
||||
# After the first file is staged, request cancellation so the loop
|
||||
# stops before the second file is processed.
|
||||
if rename_count["n"] == 1:
|
||||
scanner.cancel_task()
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.services.pending_delete_service.os.rename", cancelling_rename
|
||||
)
|
||||
|
||||
result = await scanner.bulk_delete_models([str(first), str(second)])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["status"] == "cancelled"
|
||||
assert result["total_deleted"] == 1
|
||||
assert "batch_id" in result
|
||||
assert result["batch_id"] is not None
|
||||
assert "batch_ids" not in result
|
||||
|
||||
# The staged subset is merged into one undoable batch.
|
||||
batch_dir = root / PENDING_DELETE_DIR_NAME / result["batch_id"]
|
||||
assert batch_dir.is_dir()
|
||||
assert (batch_dir / "one.txt").read_bytes() == b"one"
|
||||
assert not first.exists()
|
||||
# The second file was never touched.
|
||||
assert second.exists()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,338 @@
|
||||
"""Tests for recipe delete staging in :mod:`py.services.recipes.persistence_service`.
|
||||
|
||||
Covers the delete-undo wiring (plan todo 4): ``delete_recipe`` and
|
||||
``bulk_delete`` stage recipe JSON + preview image into the global pending-delete
|
||||
staging dir when undo is enabled, fall back to the existing hard delete when it
|
||||
is disabled, and expose the batch field(s) in the result payload. Merge failure
|
||||
falls back to a ``batch_ids`` array (same no-merge contract as the model bulk
|
||||
path).
|
||||
|
||||
Deterministic time control: no real sleeps - the re-anchored ``expires_at`` is
|
||||
compared against a loose before/after window instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from py.services.pending_delete_service import (
|
||||
PENDING_DELETE_DIR_NAME,
|
||||
PENDING_DELETE_TTL_SECONDS,
|
||||
_reset_pending_delete_service,
|
||||
)
|
||||
from py.services.recipes.persistence_service import (
|
||||
PersistenceResult,
|
||||
RecipePersistenceService,
|
||||
)
|
||||
from py.services.settings_manager import get_settings_manager
|
||||
from py.utils import settings_paths
|
||||
|
||||
|
||||
class DummyExifUtils:
|
||||
"""Exif double matching the persistence service constructor contract."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.appended = None
|
||||
self.optimized_calls = 0
|
||||
|
||||
def optimize_image(self, image_data, target_width, format, quality, preserve_metadata):
|
||||
self.optimized_calls += 1
|
||||
return image_data, ".webp"
|
||||
|
||||
def append_recipe_metadata(self, image_path, recipe_data):
|
||||
self.appended = (image_path, recipe_data)
|
||||
|
||||
def extract_image_metadata(self, path):
|
||||
return {}
|
||||
|
||||
|
||||
class RecipeScannerStub:
|
||||
"""Scanner double exposing the persistence methods used by delete flows."""
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.recipes_dir = str(root)
|
||||
self.removed: List[str] = []
|
||||
self.bulk_removed: List[str] = []
|
||||
self._json_paths: Dict[str, str] = {}
|
||||
|
||||
def register_recipe(self, recipe_id: str, json_path: Path) -> None:
|
||||
self._json_paths[str(recipe_id)] = str(json_path)
|
||||
|
||||
async def get_recipe_json_path(self, recipe_id: str) -> Optional[str]:
|
||||
return self._json_paths.get(str(recipe_id))
|
||||
|
||||
async def remove_recipe(self, recipe_id: str) -> bool:
|
||||
self.removed.append(str(recipe_id))
|
||||
return True
|
||||
|
||||
async def bulk_remove(self, recipe_ids) -> int:
|
||||
self.bulk_removed.extend(str(recipe_id) for recipe_id in recipe_ids)
|
||||
return len(list(recipe_ids))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_service_singleton() -> Iterator[None]:
|
||||
"""Reset the pending-delete singleton before and after each test."""
|
||||
_reset_pending_delete_service()
|
||||
yield
|
||||
_reset_pending_delete_service()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_scanner_registry(monkeypatch) -> None:
|
||||
"""Prevent purge enumeration from instantiating real scanner singletons."""
|
||||
from py.services.service_registry import ServiceRegistry
|
||||
|
||||
async def _none(*_args: Any, **_kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(ServiceRegistry, "get_lora_scanner", _none)
|
||||
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _none)
|
||||
monkeypatch.setattr(ServiceRegistry, "get_embedding_scanner", _none)
|
||||
|
||||
|
||||
def _make_service() -> RecipePersistenceService:
|
||||
return RecipePersistenceService(
|
||||
exif_utils=DummyExifUtils(),
|
||||
card_preview_width=512,
|
||||
logger=logging.getLogger("test"),
|
||||
)
|
||||
|
||||
|
||||
def _write_recipe(root: Path, recipe_id: str) -> tuple[Path, Path, Dict[str, Any]]:
|
||||
"""Write a recipe JSON + preview image; return (json_path, image_path, data)."""
|
||||
recipes_dir = root / "recipes"
|
||||
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||
image_path = recipes_dir / f"{recipe_id}.webp"
|
||||
image_path.write_bytes(f"{recipe_id}-image".encode())
|
||||
json_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||
recipe_data: Dict[str, Any] = {
|
||||
"id": recipe_id,
|
||||
"title": f"Recipe {recipe_id}",
|
||||
"file_path": str(image_path),
|
||||
"loras": [],
|
||||
}
|
||||
json_path.write_text(json.dumps(recipe_data), encoding="utf-8")
|
||||
return json_path, image_path, recipe_data
|
||||
|
||||
|
||||
def _write_json_only_recipe(root: Path, recipe_id: str) -> tuple[Path, Dict[str, Any]]:
|
||||
"""Write a recipe JSON whose preview image does NOT exist."""
|
||||
recipes_dir = root / "recipes"
|
||||
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||
json_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||
recipe_data: Dict[str, Any] = {
|
||||
"id": recipe_id,
|
||||
"title": f"Recipe {recipe_id}",
|
||||
"file_path": str(recipes_dir / f"{recipe_id}.missing.webp"),
|
||||
"loras": [],
|
||||
}
|
||||
json_path.write_text(json.dumps(recipe_data), encoding="utf-8")
|
||||
return json_path, recipe_data
|
||||
|
||||
|
||||
def _staging_parent() -> Path:
|
||||
# Resolve through the module namespace so the conftest settings-dir
|
||||
# isolation patch takes effect at call time.
|
||||
return Path(settings_paths.get_settings_dir()) / PENDING_DELETE_DIR_NAME
|
||||
|
||||
|
||||
def _batch_dirs() -> List[Path]:
|
||||
parent = _staging_parent()
|
||||
if not parent.is_dir():
|
||||
return []
|
||||
return [p for p in parent.iterdir() if p.is_dir()]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (1) delete_recipe with undo enabled -> staged JSON + image, originals gone,
|
||||
# payload batch_id set, manifest recipe_snapshot present
|
||||
# ---------------------------------------------------------------------------
|
||||
async def test_delete_recipe_stages_json_and_image(tmp_path: Path) -> None:
|
||||
scanner = RecipeScannerStub(tmp_path)
|
||||
json_path, image_path, recipe_data = _write_recipe(tmp_path, "r1")
|
||||
scanner.register_recipe("r1", json_path)
|
||||
json_bytes = json_path.read_bytes()
|
||||
image_bytes = image_path.read_bytes()
|
||||
|
||||
result = await _make_service().delete_recipe(
|
||||
recipe_scanner=scanner, recipe_id="r1"
|
||||
)
|
||||
|
||||
assert isinstance(result, PersistenceResult)
|
||||
batch_id = result.payload["batch_id"]
|
||||
assert batch_id is not None
|
||||
|
||||
# JSON + image exist in the global staging dir; originals removed.
|
||||
batch_dir = _staging_parent() / batch_id
|
||||
assert batch_dir.is_dir()
|
||||
assert not json_path.exists()
|
||||
assert not image_path.exists()
|
||||
|
||||
# QA: staged copies match the original bytes.
|
||||
assert (batch_dir / json_path.name).read_bytes() == json_bytes
|
||||
assert (batch_dir / image_path.name).read_bytes() == image_bytes
|
||||
|
||||
manifest = json.loads((batch_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert manifest["batch_id"] == batch_id
|
||||
assert manifest["kind"] == "recipe"
|
||||
assert manifest["model_type"] is None
|
||||
assert manifest["state"] == "staged"
|
||||
assert manifest["recipe_snapshot"] == recipe_data
|
||||
assert manifest["model_snapshot"] is None
|
||||
assert len(manifest["entries"]) == 2
|
||||
originals = {entry["original"] for entry in manifest["entries"]}
|
||||
assert originals == {str(json_path), str(image_path)}
|
||||
|
||||
# Scanner cache removal still runs.
|
||||
assert scanner.removed == ["r1"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (2) recipe with missing preview image -> only JSON staged, no crash
|
||||
# ---------------------------------------------------------------------------
|
||||
async def test_delete_recipe_skips_missing_preview_image(tmp_path: Path) -> None:
|
||||
scanner = RecipeScannerStub(tmp_path)
|
||||
json_path, recipe_data = _write_json_only_recipe(tmp_path, "r2")
|
||||
scanner.register_recipe("r2", json_path)
|
||||
|
||||
result = await _make_service().delete_recipe(
|
||||
recipe_scanner=scanner, recipe_id="r2"
|
||||
)
|
||||
|
||||
batch_id = result.payload["batch_id"]
|
||||
assert batch_id is not None
|
||||
batch_dir = _staging_parent() / batch_id
|
||||
assert batch_dir.is_dir()
|
||||
assert (batch_dir / "r2.recipe.json").read_text(encoding="utf-8") == json.dumps(
|
||||
recipe_data
|
||||
)
|
||||
|
||||
manifest = json.loads((batch_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert len(manifest["entries"]) == 1
|
||||
assert manifest["recipe_snapshot"] == recipe_data
|
||||
|
||||
assert not json_path.exists()
|
||||
assert scanner.removed == ["r2"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (3) undo disabled -> no staging, payload batch_id None, existing behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
async def test_delete_recipe_undo_disabled_no_staging(tmp_path: Path) -> None:
|
||||
get_settings_manager().settings["delete_undo_enabled"] = False
|
||||
|
||||
scanner = RecipeScannerStub(tmp_path)
|
||||
json_path, image_path, _recipe_data = _write_recipe(tmp_path, "r3")
|
||||
scanner.register_recipe("r3", json_path)
|
||||
|
||||
result = await _make_service().delete_recipe(
|
||||
recipe_scanner=scanner, recipe_id="r3"
|
||||
)
|
||||
|
||||
assert result.payload["batch_id"] is None
|
||||
# No staging leftovers when undo is disabled.
|
||||
assert not _staging_parent().exists()
|
||||
# Existing hard delete behavior unchanged.
|
||||
assert not json_path.exists()
|
||||
assert not image_path.exists()
|
||||
assert scanner.removed == ["r3"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (4) bulk_delete with 2 ids -> single batch_id, one batch dir with both
|
||||
# recipes, re-anchored expires_at in the merged manifest
|
||||
# ---------------------------------------------------------------------------
|
||||
async def test_bulk_delete_merges_into_single_batch(tmp_path: Path) -> None:
|
||||
scanner = RecipeScannerStub(tmp_path)
|
||||
json_a, img_a, data_a = _write_recipe(tmp_path, "ra")
|
||||
json_b, img_b, data_b = _write_recipe(tmp_path, "rb")
|
||||
scanner.register_recipe("ra", json_a)
|
||||
scanner.register_recipe("rb", json_b)
|
||||
json_a_bytes = json_a.read_bytes()
|
||||
image_a_bytes = img_a.read_bytes()
|
||||
json_b_bytes = json_b.read_bytes()
|
||||
image_b_bytes = img_b.read_bytes()
|
||||
before = int(time.time())
|
||||
|
||||
result = await _make_service().bulk_delete(
|
||||
recipe_scanner=scanner, recipe_ids=["ra", "rb"]
|
||||
)
|
||||
|
||||
batch_id = result.payload["batch_id"]
|
||||
assert batch_id is not None
|
||||
assert "batch_ids" not in result.payload
|
||||
assert len(_batch_dirs()) == 1, "loser batch dir must be removed after merge"
|
||||
|
||||
batch_dir = _staging_parent() / batch_id
|
||||
manifest = json.loads((batch_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert manifest["batch_id"] == batch_id
|
||||
assert len(manifest["entries"]) == 4
|
||||
|
||||
# Re-anchored expires_at: now + TTL at merge time (not the earlier of the
|
||||
# two staged expiries). Loose window avoids any timing flakiness.
|
||||
assert (
|
||||
before + PENDING_DELETE_TTL_SECONDS - 2
|
||||
<= manifest["expires_at"]
|
||||
<= int(time.time()) + PENDING_DELETE_TTL_SECONDS + 2
|
||||
)
|
||||
|
||||
# Both recipes' files live under ONE batch dir, byte-identical to originals.
|
||||
assert (batch_dir / "ra.recipe.json").read_bytes() == json_a_bytes
|
||||
assert (batch_dir / "ra.webp").read_bytes() == image_a_bytes
|
||||
assert (batch_dir / "rb.recipe.json").read_bytes() == json_b_bytes
|
||||
assert (batch_dir / "rb.webp").read_bytes() == image_b_bytes
|
||||
|
||||
# Originals removed; both snapshots present.
|
||||
# Originals removed; merged manifest holds the winner's recipe snapshot.
|
||||
assert not json_a.exists()
|
||||
assert not json_b.exists()
|
||||
assert manifest["recipe_snapshot"] in (data_a, data_b)
|
||||
assert all(entry["restored"] is False for entry in manifest["entries"])
|
||||
|
||||
assert scanner.bulk_removed == ["ra", "rb"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (5) merge failure fallback -> batch_ids array of length 2, batches intact
|
||||
# ---------------------------------------------------------------------------
|
||||
async def test_bulk_delete_merge_failure_falls_back_to_batch_ids(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
scanner = RecipeScannerStub(tmp_path)
|
||||
json_a, _img_a, _data_a = _write_recipe(tmp_path, "ra")
|
||||
json_b, _img_b, _data_b = _write_recipe(tmp_path, "rb")
|
||||
scanner.register_recipe("ra", json_a)
|
||||
scanner.register_recipe("rb", json_b)
|
||||
|
||||
def failing_rename(src: str, dst: str) -> None:
|
||||
raise OSError("simulated merge move failure")
|
||||
|
||||
monkeypatch.setattr("py.services.pending_delete_service.os.rename", failing_rename)
|
||||
|
||||
result = await _make_service().bulk_delete(
|
||||
recipe_scanner=scanner, recipe_ids=["ra", "rb"]
|
||||
)
|
||||
|
||||
batch_ids = result.payload["batch_ids"]
|
||||
assert "batch_id" not in result.payload
|
||||
assert len(batch_ids) == 2
|
||||
assert len(_batch_dirs()) == 2, "both constituent batches stay intact"
|
||||
|
||||
# Each constituent batch is complete and individually undoable.
|
||||
for batch_id in batch_ids:
|
||||
batch_dir = _staging_parent() / batch_id
|
||||
assert batch_dir.is_dir()
|
||||
assert (batch_dir / "manifest.json").exists()
|
||||
assert any(entry["original"] == str(json_a) or entry["original"] == str(json_b) for entry in json.loads((batch_dir / "manifest.json").read_text(encoding="utf-8"))["entries"])
|
||||
|
||||
assert not json_a.exists()
|
||||
assert not json_b.exists()
|
||||
assert scanner.bulk_removed == ["ra", "rb"]
|
||||
@@ -1178,3 +1178,8 @@ def test_skip_previously_downloaded_model_versions_coerces_string_input(manager)
|
||||
|
||||
assert manager.get_skip_previously_downloaded_model_versions() is True
|
||||
assert manager.settings["skip_previously_downloaded_model_versions"] is True
|
||||
|
||||
|
||||
def test_delete_undo_enabled_defaults_true(manager):
|
||||
assert settings_manager_module.DEFAULT_SETTINGS.get("delete_undo_enabled") is True
|
||||
assert manager.get("delete_undo_enabled") is True
|
||||
|
||||
Reference in New Issue
Block a user