10d30fc956
Comprehensive multi-area pass driven by a parallel 8-agent production
review. Frontend, backend, database, security, performance, operational,
plus a new self-monitoring feature.
## Critical fixes
- Planka webhook: reads bounded raw body (was NameError on every call)
- HA quiet hours: ha_state_changed/automation_triggered/service_called/
event_fired added to deferrable set (were silently dropped)
- DNS-rebinding SSRF: PinnedResolver wired into shared aiohttp session
- Telegram inbound webhook: secret now mandatory (401 without)
- Generic webhook: auth_mode="none" requires explicit
acknowledge_unauthenticated=true; per-IP rate limit 60/min
- svelte-check: 5 null-narrowing errors in EventDetailModal fixed
- Provider hardcoding: Immich-only block extracted to descriptor
featureDiscoveryHint
- command_sync: snapshot+expunge bot before exiting AsyncSession
## Bug fixes
- notifier asyncio.gather(return_exceptions=True) — one bad chat no longer
cancels peer sends
- NotificationDispatcher hoisted out of per-tracker loop
- Provider credential resolution unified across all 5 dispatch sites
- HA asyncio.shield now drains inner task on cancellation
- Provider construction switched from if/elif ladder to factory registry
- NUT first poll seeds silently (no spurious ups_on_battery)
- Quiet-hours gate: event-type-disabled now wins over deferral
- APScheduler drain job ID resolution upgraded to seconds
- HA on_status_change wired through to EventLog
- Webhook payload rollback failures now logged (not swallowed)
- Batched receivers/chats/bots in load_link_data (was per-target N+1)
- flag_modified on JSON column reassignments in deferred_dispatch
## Database
- UNIQUE indexes on service_provider.webhook_token,
telegram_bot.webhook_path_id, partial UNIQUE on telegram_bot.bot_id,
telegram_chat(bot_id, chat_id), notification_tracker_target unique link,
partial UNIQUE on bridge_self provider per user
- Composite ix_event_log_user_event_type_created index
- save_chat_from_webhook switched to ON CONFLICT DO UPDATE
- ondelete=CASCADE on user-id FKs (model annotation; app-side cascade
delete added for existing data)
- delete_notification_tracker converted from N+1 to bulk DELETE/UPDATE
- Module-level asyncio.Lock replaced with lazy _get_lock() pattern
- VACUUM INTO snapshot now PRAGMA integrity_check verified
## Performance
- Jinja2 template compilation LRU cached (lru_cache maxsize=512)
- Per-locale render cache in NotificationDispatcher (skips re-rendering
identical content for receivers sharing a locale)
- Tracker list cached per provider_id with 5s TTL + explicit invalidation
on tracker CRUD (relieves HA chat-bus rate query pressure)
- Nav-counts collapsed from 16 round-trips to single UNION ALL
- HA event_log: skip persisting empty assets_added/removed events
## Security hardening
- Mass-assignment guard on Action create/update; cron sub-minute reject
- Backup JSON depth/node-count cap (depth ≤ 10, nodes ≤ 100k)
- _sanitize_config extended to all JSON-typed fields on backup import
- Telegram _safe_get walks redirects manually with SSRF revalidation
- Bcrypt 72-byte password length cap with clear 422
- Webhook payload body redaction; sensitive substring set extended with
oauth/client_secret/webhook_secret/csrf in both header filter and
template extras filter
## Frontend
- 76 catch (err: any) sites converted to errMsg(err) helper
- globalProviderFilter: pure getter; reconciliation moved to one-time
$effect in +layout
- Provider-filter binding: removed paired $effects + _syncingFilter flag,
now one-way derived
- entity-cache: separate _refreshing flag for background re-fetches
- api.ts 401 handling: AuthRedirectError class + dedup _redirecting flag,
goto() instead of window.location.href
- a11y: aria-expanded on mobile More, role=switch + aria-checked on
Telegram bot toggles
## Tests & operations
- CI pytest gate added to .gitea/workflows/build.yml + release.yml
(wheel-built install to dodge editable-install slowness)
- /api/ready upgraded to deep healthcheck (db SELECT 1, scheduler.running,
HA supervisor presence) returning {ready, checks, errors, version}
- /api/metrics endpoint with prometheus_client (deferred_pending,
event_log_total, dispatch_duration, poll_failures, send_failures)
- New OPERATIONS.md covering deploy, healthchecks, metrics, backup/restore
procedures, log handling, common scenarios, upgrade flow
- New tests: test_bridge_self (11), test_gitea_parser (9),
test_planka_parser (6), test_immich_change_detector (6),
test_backup_roundtrip (1)
## New feature: bridge self-monitoring
- New bridge_self provider type — internal sink for bridge health events
- Three event types: bridge_self_poll_failures (consecutive tracker poll
failures), bridge_self_deferred_backlog (pending count crosses
threshold), bridge_self_target_failures (consecutive 5xx/network
failures per target)
- Per-user thresholds (defaults: 3 / 100 / 5) configurable via the
provider config form
- Auto-seeded on user create + /setup + boot backfill for existing users
- Anti-spam: counters reset after emission; backlog uses transition latch
- Self-loop guard: bridge_self failures don't count toward target-failure
thresholds (logged only) — wire to your own Telegram/Email/Matrix to
get notified when polls/dispatches/sends fail
- 6 default templates (3 events × 2 locales), tracking config columns
with backfill migration, frontend descriptor (excluded from "create
provider" wizard since auto-managed)
Operator-visible behavior changes (call out in release notes):
- NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET now REQUIRED for webhook mode
- Existing webhook providers with auth_mode="none" need explicit opt-in
- Generic webhook endpoint rate-limited 60/min per source IP
- HA disconnect/reconnect writes ha_status_* EventLog rows
- Every user gets a bridge_self provider — wire it to a target to
receive failure alerts
Pre-existing test failures (test_ssrf, test_release_provider) on
Python 3.13 are unrelated; CI runs on 3.12.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
160 lines
5.5 KiB
Python
160 lines
5.5 KiB
Python
"""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
|