/
/
/
1"""Tests for image-cache invalidation on library item updates and removals."""
2
3from __future__ import annotations
4
5from unittest.mock import AsyncMock, MagicMock
6
7from music_assistant_models.enums import ImageType
8from music_assistant_models.media_items import (
9 MediaItemImage,
10 MediaItemMetadata,
11 ProviderMapping,
12 Track,
13)
14from music_assistant_models.unique_list import UniqueList
15
16from music_assistant.controllers.music.media.base import SUPPRESS_MEDIA_ITEM_UPDATES
17from music_assistant.controllers.music.media.tracks import TracksController
18
19
20def _image(path: str) -> MediaItemImage:
21 """Build a MediaItemImage for the given path."""
22 return MediaItemImage(
23 type=ImageType.THUMB, path=path, provider="test--1", remotely_accessible=False
24 )
25
26
27def _track(images: list[MediaItemImage]) -> Track:
28 """Build a minimal library Track carrying the given images."""
29 return Track(
30 item_id="1",
31 provider="library",
32 name="Test Track",
33 provider_mappings={
34 ProviderMapping(item_id="1", provider_domain="test", provider_instance="test--1")
35 },
36 metadata=MediaItemMetadata(images=UniqueList(images) or None),
37 )
38
39
40def _controller_returning(item: Track) -> tuple[TracksController, AsyncMock]:
41 """Build a TracksController with mocked db plus its invalidation mock."""
42 mass = MagicMock()
43 mass.music.database = AsyncMock()
44 # deferred_commit is used as (sync-called) async context manager
45 mass.music.database.deferred_commit = MagicMock()
46 invalidate_mock = AsyncMock()
47 mass.metadata.invalidate_image_cache = invalidate_mock
48 mass.get_provider.return_value = None
49 controller = TracksController(mass)
50 controller.get_library_item = AsyncMock(return_value=item) # type: ignore[method-assign]
51 controller._update_library_item = AsyncMock() # type: ignore[method-assign]
52 return controller, invalidate_mock
53
54
55async def test_updated_item_artwork_is_invalidated() -> None:
56 """Updating an item drops its cached artwork so replaced art is served fresh."""
57 updated_item = _track([_image("Artist/cover.jpg")])
58 controller, invalidate_mock = _controller_returning(updated_item)
59 await controller.update_item_in_library("1", updated_item)
60 invalidate_mock.assert_awaited_once_with("test--1", "Artist/cover.jpg")
61
62
63async def test_update_without_images_invalidates_nothing() -> None:
64 """An item without any images busts nothing."""
65 updated_item = _track([])
66 controller, invalidate_mock = _controller_returning(updated_item)
67 await controller.update_item_in_library("1", updated_item)
68 invalidate_mock.assert_not_awaited()
69
70
71async def test_suppressed_update_skips_invalidation() -> None:
72 """During a provider sync no invalidation runs for updated items."""
73 updated_item = _track([_image("Artist/new-cover.jpg")])
74 controller, invalidate_mock = _controller_returning(updated_item)
75 token = SUPPRESS_MEDIA_ITEM_UPDATES.set(True)
76 try:
77 await controller.update_item_in_library("1", updated_item)
78 finally:
79 SUPPRESS_MEDIA_ITEM_UPDATES.reset(token)
80 invalidate_mock.assert_not_awaited()
81
82
83async def test_removed_item_artwork_is_invalidated() -> None:
84 """Removing an item drops its cached artwork."""
85 library_item = _track([_image("Artist/cover.jpg")])
86 controller, invalidate_mock = _controller_returning(library_item)
87 await controller.remove_item_from_library("1", recursive=False)
88 invalidate_mock.assert_awaited_once_with("test--1", "Artist/cover.jpg")
89