feat(delete): stage model and recipe deletes for 30s undo

This commit is contained in:
Will Miao
2026-08-11 14:08:15 +08:00
parent 6a259a14fa
commit 2d6cf545b9
12 changed files with 3444 additions and 21 deletions
@@ -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())
+264
View File
@@ -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
+338
View File
@@ -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"]
+5
View File
@@ -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