Files
wehub-resource-sync cddb07a176
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:06 +08:00

265 lines
11 KiB
Python

"""Tests for ImageService (images_default.py).
Covers subfolder forwarding for all strategies and the delete_images_on_board
silent-failure contract (Points 2 & 3 from PR review).
"""
from unittest.mock import MagicMock
import pytest
from PIL import Image
from invokeai.app.services.image_records.image_records_common import (
ImageCategory,
ImageRecord,
ResourceOrigin,
)
from invokeai.app.services.images.images_default import ImageService
from invokeai.app.util.misc import get_iso_timestamp
@pytest.fixture
def image_service() -> ImageService:
svc = ImageService()
invoker = MagicMock()
# Wire up service references
invoker.services.names.create_image_name.return_value = "abc12345-test.png"
invoker.services.image_records.get.return_value = _make_record(image_subfolder="some/sub")
invoker.services.board_image_records.get_board_for_image.return_value = None
invoker.services.urls.get_image_url.return_value = "http://localhost/img.png"
invoker.services.configuration.image_subfolder_strategy = "flat"
svc.start(invoker)
return svc
def _make_record(
image_name: str = "abc12345-test.png",
image_subfolder: str = "",
is_intermediate: bool = False,
) -> ImageRecord:
now = get_iso_timestamp()
return ImageRecord(
image_name=image_name,
image_origin=ResourceOrigin.INTERNAL,
image_category=ImageCategory.GENERAL,
width=64,
height=64,
created_at=now,
updated_at=now,
is_intermediate=is_intermediate,
starred=False,
has_workflow=False,
image_subfolder=image_subfolder,
)
# ── Point 2: subfolder forwarding tests ──
class TestCreateSubfolderForwarding:
"""Verify that create() computes and forwards the correct subfolder for each strategy."""
@pytest.mark.parametrize(
"strategy_name,expected_subfolder",
[
("flat", ""),
("type", "general"),
("hash", "ab"), # first 2 chars of "abc12345-test.png"
],
ids=["flat", "type", "hash"],
)
def test_create_forwards_subfolder(self, image_service: ImageService, strategy_name: str, expected_subfolder: str):
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.configuration.image_subfolder_strategy = strategy_name
# Make get_dto work by returning a record with the expected subfolder
invoker.services.image_records.get.return_value = _make_record(image_subfolder=expected_subfolder)
image = Image.new("RGB", (64, 64))
image_service.create(
image=image,
image_origin=ResourceOrigin.INTERNAL,
image_category=ImageCategory.GENERAL,
)
# Assert image_records.save was called with the right subfolder
save_call = invoker.services.image_records.save
save_call.assert_called_once()
assert save_call.call_args.kwargs["image_subfolder"] == expected_subfolder
# Assert image_files.save was called with the same subfolder
file_save = invoker.services.image_files.save
file_save.assert_called_once()
assert file_save.call_args.kwargs["image_subfolder"] == expected_subfolder
def test_create_date_strategy_produces_date_subfolder(self, image_service: ImageService):
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.configuration.image_subfolder_strategy = "date"
invoker.services.image_records.get.return_value = _make_record(image_subfolder="2026/04/05")
image = Image.new("RGB", (64, 64))
image_service.create(
image=image,
image_origin=ResourceOrigin.INTERNAL,
image_category=ImageCategory.GENERAL,
)
subfolder = invoker.services.image_records.save.call_args.kwargs["image_subfolder"]
# Date strategy should produce YYYY/MM/DD format
parts = subfolder.split("/")
assert len(parts) == 3
assert all(p.isdigit() for p in parts)
def test_create_type_strategy_intermediate(self, image_service: ImageService):
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.configuration.image_subfolder_strategy = "type"
invoker.services.image_records.get.return_value = _make_record(image_subfolder="intermediate")
image = Image.new("RGB", (64, 64))
image_service.create(
image=image,
image_origin=ResourceOrigin.INTERNAL,
image_category=ImageCategory.GENERAL,
is_intermediate=True,
)
subfolder = invoker.services.image_records.save.call_args.kwargs["image_subfolder"]
assert subfolder == "intermediate"
class TestReadOperationsForwardSubfolder:
"""Verify that read operations look up the record and forward image_subfolder."""
def test_get_pil_image(self, image_service: ImageService):
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.image_records.get.return_value = _make_record(image_subfolder="2026/01/01")
image_service.get_pil_image("test.png")
invoker.services.image_files.get.assert_called_once_with("test.png", image_subfolder="2026/01/01")
def test_get_workflow(self, image_service: ImageService):
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.image_records.get.return_value = _make_record(image_subfolder="general")
image_service.get_workflow("test.png")
invoker.services.image_files.get_workflow.assert_called_once_with("test.png", image_subfolder="general")
def test_get_graph(self, image_service: ImageService):
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.image_records.get.return_value = _make_record(image_subfolder="general")
image_service.get_graph("test.png")
invoker.services.image_files.get_graph.assert_called_once_with("test.png", image_subfolder="general")
def test_get_path(self, image_service: ImageService):
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.image_records.get.return_value = _make_record(image_subfolder="ab")
image_service.get_path("test.png")
invoker.services.image_files.get_path.assert_called_once_with("test.png", False, image_subfolder="ab")
def test_get_path_thumbnail(self, image_service: ImageService):
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.image_records.get.return_value = _make_record(image_subfolder="ab")
image_service.get_path("test.png", thumbnail=True)
invoker.services.image_files.get_path.assert_called_once_with("test.png", True, image_subfolder="ab")
class TestDeleteForwardsSubfolder:
"""Verify that delete operations forward image_subfolder."""
def test_delete_forwards_subfolder(self, image_service: ImageService):
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.image_records.get.return_value = _make_record(image_subfolder="2026/04/05")
image_service.delete("test.png")
invoker.services.image_files.delete.assert_called_once_with("test.png", image_subfolder="2026/04/05")
invoker.services.image_records.delete.assert_called_once_with("test.png")
def test_delete_intermediates_forwards_subfolder(self, image_service: ImageService):
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.image_records.delete_intermediates.return_value = [
("img1.png", "intermediate"),
("img2.png", "intermediate"),
]
count = image_service.delete_intermediates()
assert count == 2
calls = invoker.services.image_files.delete.call_args_list
assert calls[0].args == ("img1.png",)
assert calls[0].kwargs == {"image_subfolder": "intermediate"}
assert calls[1].args == ("img2.png",)
assert calls[1].kwargs == {"image_subfolder": "intermediate"}
# ── Point 3: delete_images_on_board silent-failure contract ──
class TestDeleteImagesOnBoardContract:
"""Tests for the silent-failure behavior of delete_images_on_board."""
def test_db_rows_deleted_even_when_file_delete_fails(self, image_service: ImageService):
"""Current behavior: DB rows are deleted even if file cleanup fails for some images.
This test documents the contract so any change is intentional."""
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.board_image_records.get_all_board_image_names_for_board.return_value = [
"good.png",
"bad.png",
]
# First image record lookup succeeds, second fails
good_record = _make_record(image_name="good.png", image_subfolder="general")
bad_record = _make_record(image_name="bad.png", image_subfolder="bad/path")
invoker.services.image_records.get.side_effect = [good_record, bad_record]
# File delete succeeds for first, fails for second
invoker.services.image_files.delete.side_effect = [None, Exception("disk error")]
image_service.delete_images_on_board("board-1")
# DB rows are still deleted for all images
invoker.services.image_records.delete_many.assert_called_once_with(["good.png", "bad.png"])
def test_file_cleanup_failure_does_not_raise(self, image_service: ImageService):
"""File cleanup errors are swallowed, not propagated."""
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.board_image_records.get_all_board_image_names_for_board.return_value = ["img.png"]
record = _make_record(image_name="img.png", image_subfolder="sub")
invoker.services.image_records.get.return_value = record
invoker.services.image_files.delete.side_effect = Exception("permission denied")
# Should not raise
image_service.delete_images_on_board("board-1")
# DB delete still happens
invoker.services.image_records.delete_many.assert_called_once()
def test_record_lookup_failure_does_not_block_others(self, image_service: ImageService):
"""If getting the record for one image fails, other images are still processed."""
invoker = image_service._ImageService__invoker # type: ignore
invoker.services.board_image_records.get_all_board_image_names_for_board.return_value = [
"missing.png",
"ok.png",
]
ok_record = _make_record(image_name="ok.png", image_subfolder="")
invoker.services.image_records.get.side_effect = [Exception("not found"), ok_record]
image_service.delete_images_on_board("board-1")
# File delete was attempted for the second image only
invoker.services.image_files.delete.assert_called_once_with("ok.png", image_subfolder="")
# DB rows are still deleted for all
invoker.services.image_records.delete_many.assert_called_once_with(["missing.png", "ok.png"])