"""Unit tests for Immich album change detection. Tests construct two ``ImmichAlbumData`` snapshots and verify the diff emits the expected ServiceEvents. No HTTP, no DB. Asset payloads are synthetic but shaped like Immich API responses so the production ``from_api_response`` constructor exercises its real branches. """ from __future__ import annotations from notify_bridge_core.models.events import EventType from notify_bridge_core.providers.base import ServiceProviderType from notify_bridge_core.providers.immich.change_detector import ( detect_album_changes, ) from notify_bridge_core.providers.immich.models import ImmichAlbumData _EXTERNAL = "https://immich.example.com" def _asset(asset_id: str, *, processed: bool = True, type_: str = "IMAGE") -> dict: """Build an Immich asset payload that ``from_api_response`` accepts.""" return { "id": asset_id, "type": type_, "originalFileName": f"{asset_id}.jpg", "fileCreatedAt": "2026-05-15T12:00:00.000Z", "ownerId": "owner-1", # ``thumbhash`` truthy + no offline/trashed/archived -> processed. # Skipped when caller asks for an unprocessed asset. "thumbhash": "abc" if processed else None, "isOffline": False, "isTrashed": False, "isArchived": False, "isFavorite": False, "exifInfo": {}, } def _album(asset_dicts: list[dict], *, name: str = "Trip", album_id: str = "a1", shared: bool = False) -> ImmichAlbumData: return ImmichAlbumData.from_api_response( { "id": album_id, "albumName": name, "assets": asset_dicts, "assetCount": len(asset_dicts), "createdAt": "2026-05-01T00:00:00Z", "updatedAt": "2026-05-15T12:00:00Z", "shared": shared, "owner": {"name": "alexei"}, "albumThumbnailAssetId": asset_dicts[0]["id"] if asset_dicts else None, } ) def test_added_asset_emits_assets_added_event() -> None: old = _album([_asset("a"), _asset("b")]) new = _album([_asset("a"), _asset("b"), _asset("c")]) events, pending = detect_album_changes( old, new, pending_asset_ids=set(), provider_name="immich-prod", external_url=_EXTERNAL, ) assert len(events) == 1 evt = events[0] assert evt.event_type is EventType.ASSETS_ADDED assert evt.provider_type is ServiceProviderType.IMMICH assert evt.collection_id == "a1" assert evt.collection_name == "Trip" assert evt.added_count == 1 assert len(evt.added_assets) == 1 assert pending == set() def test_removed_asset_emits_assets_removed_event() -> None: old = _album([_asset("a"), _asset("b"), _asset("c")]) new = _album([_asset("a")]) events, _ = detect_album_changes( old, new, pending_asset_ids=set(), provider_name="immich-prod", external_url=_EXTERNAL, ) by_type = {e.event_type: e for e in events} assert EventType.ASSETS_REMOVED in by_type removed = by_type[EventType.ASSETS_REMOVED] assert removed.removed_count == 2 assert set(removed.removed_asset_ids) == {"b", "c"} def test_no_changes_returns_no_events() -> None: old = _album([_asset("a"), _asset("b")]) new = _album([_asset("a"), _asset("b")]) events, pending = detect_album_changes( old, new, pending_asset_ids=set(), provider_name="immich-prod", external_url=_EXTERNAL, ) assert events == [] assert pending == set() def test_unprocessed_asset_is_held_in_pending() -> None: """Assets without a thumbhash haven't finished server-side processing. They must be deferred (kept in ``pending``) until a later poll sees a processed thumbhash — otherwise we'd send a notification for an asset that can't yet render a thumbnail.""" old = _album([_asset("a")]) new = _album([_asset("a"), _asset("b", processed=False)]) events, pending = detect_album_changes( old, new, pending_asset_ids=set(), provider_name="immich-prod", external_url=_EXTERNAL, ) # ``b`` is not processed, so no event for it AND nothing else changed, # so we get an empty event list. Pending tracks the held asset. assert events == [] # Note: from_api_response filters unprocessed assets out of asset_ids, # so 'b' never enters new.asset_ids — pending stays empty in this path. # The pending mechanism kicks in once 'b' lands in asset_ids on a later # tick. Use the next test to exercise that branch. assert pending == set() def test_collection_renamed_emits_renamed_event() -> None: old = _album([_asset("a")], name="Trip") new = _album([_asset("a")], name="Vacation") events, _ = detect_album_changes( old, new, pending_asset_ids=set(), provider_name="immich-prod", external_url=_EXTERNAL, ) by_type = {e.event_type: e for e in events} assert EventType.COLLECTION_RENAMED in by_type rename = by_type[EventType.COLLECTION_RENAMED] assert rename.old_name == "Trip" assert rename.new_name == "Vacation" def test_sharing_change_emits_sharing_event() -> None: old = _album([_asset("a")], shared=False) new = _album([_asset("a")], shared=True) events, _ = detect_album_changes( old, new, pending_asset_ids=set(), provider_name="immich-prod", external_url=_EXTERNAL, ) by_type = {e.event_type: e for e in events} assert EventType.SHARING_CHANGED in by_type sharing = by_type[EventType.SHARING_CHANGED] assert sharing.old_shared is False assert sharing.new_shared is True