feat: production readiness — security, perf, bug fixes, bridge self-monitoring

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>
This commit is contained in:
2026-05-16 02:16:49 +03:00
parent 22127e2a59
commit 10d30fc956
97 changed files with 5423 additions and 821 deletions
@@ -0,0 +1,268 @@
"""End-to-end backup roundtrip: seed -> export -> wipe -> import -> verify.
Drives the backup service module directly (no HTTP layer) against a fresh
SQLite DB built in the conftest temp data dir. Verifies entity counts and
key fields survive a full round-trip.
Kept under 5s by avoiding the lifespan startup — we build a private engine
in an isolated DB file so we don't share state with other tests in the
session.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel import SQLModel, select
from sqlmodel.ext.asyncio.session import AsyncSession
@pytest.fixture
async def isolated_engine(tmp_path: Path):
"""A throwaway SQLite engine + freshly created schema for one test.
Avoids the global engine in ``database.engine`` — tests in the same
session share that singleton, and recreating tables on it would corrupt
parallel tests' state.
"""
# Importing the module registers all SQLModel tables on the metadata.
from notify_bridge_server.database import models # noqa: F401
db_path = tmp_path / "roundtrip.db"
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
yield engine
await engine.dispose()
async def _seed(session: AsyncSession, user_id: int) -> dict[str, int]:
"""Insert enough rows to exercise the major code paths in import/export."""
from notify_bridge_server.database.models import (
EventLog,
NotificationTarget,
NotificationTracker,
ServiceProvider,
TargetReceiver,
TelegramBot,
TrackingConfig,
User,
)
user = User(
id=user_id,
username="roundtrip-user",
hashed_password="hash",
role="user",
)
session.add(user)
await session.flush()
bot = TelegramBot(
user_id=user_id, name="Test bot", token="123456:fake-token-value",
bot_username="testbot", bot_id=1,
)
session.add(bot)
await session.flush()
provider = ServiceProvider(
user_id=user_id, type="immich", name="Immich prod",
config={"base_url": "https://immich.example.com", "api_key": "secret"},
)
session.add(provider)
await session.flush()
target = NotificationTarget(
user_id=user_id, type="telegram", name="My channel",
config={"bot_token_id": bot.id, "disable_url_preview": True},
)
session.add(target)
await session.flush()
receiver = TargetReceiver(
target_id=target.id, name="Channel A",
config={"chat_id": "-100123"}, receiver_key="-100123", locale="en",
)
session.add(receiver)
tc = TrackingConfig(
user_id=user_id, provider_type="immich",
name="Default Immich tracking", track_assets_added=True,
)
session.add(tc)
await session.flush()
tracker = NotificationTracker(
user_id=user_id, provider_id=provider.id,
name="Family album tracker", scan_interval=120,
collection_ids=["album-uuid-1"],
)
session.add(tracker)
await session.flush()
# Capture IDs before commit — accessing attributes after commit
# triggers a refresh that needs an async-IO context the test caller
# may not be inside. Better to snapshot now and use plain ints later.
ids = {
"provider_id": provider.id,
"target_id": target.id,
"bot_id": bot.id,
"tracker_id": tracker.id,
"tracking_config_id": tc.id,
"tracker_name": tracker.name,
"provider_name": provider.name,
}
# EventLog rows are NOT in the backup schema — they're operational data,
# not configuration. Insert a few anyway so we can verify they survive
# the export step (since export only reads, never writes/wipes them).
for i in range(3):
session.add(EventLog(
user_id=user_id, tracker_id=ids["tracker_id"], tracker_name=ids["tracker_name"],
provider_id=ids["provider_id"], provider_name=ids["provider_name"],
event_type="assets_added", collection_id="album-uuid-1",
collection_name="Family", assets_count=i,
))
await session.commit()
return ids
async def _wipe_user_owned_rows(engine, user_id: int) -> None:
"""Delete every backup-able row for the user via raw SQL.
Using ORM-level deletes triggers SQLAlchemy's cascade machinery, which
lazy-loads relationships in a sync context that the async driver cannot
serve (MissingGreenlet). Raw DELETE statements skip cascades and let
SQLite's FKs enforce ordering naturally.
Order matters: child rows first, then parents.
"""
from sqlalchemy import text
statements = (
"DELETE FROM event_log",
"DELETE FROM notification_tracker_target",
"DELETE FROM notification_tracker",
"DELETE FROM target_receiver",
"DELETE FROM notification_target",
"DELETE FROM tracking_config",
"DELETE FROM service_provider",
"DELETE FROM template_slot",
"DELETE FROM template_config",
"DELETE FROM telegram_bot",
"DELETE FROM appsetting",
)
async with engine.begin() as conn:
for stmt in statements:
try:
await conn.execute(text(stmt))
except Exception: # noqa: BLE001 — table may not exist in test schema
pass
@pytest.mark.asyncio
async def test_export_wipe_import_roundtrip(isolated_engine, tmp_data_dir) -> None: # noqa: ARG001
"""A full round-trip preserves entity counts and the key fields the
UI relies on — names, configs (with secrets included), provider
references via id_map.
"""
from notify_bridge_server.database.models import (
NotificationTarget, NotificationTracker, ServiceProvider,
TargetReceiver, TelegramBot, TrackingConfig,
)
from notify_bridge_server.services.backup_schema import (
ConflictMode, SecretsMode,
)
from notify_bridge_server.services.backup_service import (
export_backup, import_backup,
)
user_id = 1
# ---- Seed ----
async with AsyncSession(isolated_engine) as session:
ids = await _seed(session, user_id)
# ---- Export with secrets included so import sees real values ----
async with AsyncSession(isolated_engine) as session:
backup = await export_backup(
session, user_id, secrets_mode=SecretsMode.INCLUDE,
)
assert len(backup.data.providers) == 1
assert len(backup.data.telegram_bots) == 1
assert len(backup.data.targets) == 1
assert len(backup.data.targets[0].receivers) == 1
assert len(backup.data.tracking_configs) == 1
assert len(backup.data.notification_trackers) == 1
assert backup.data.providers[0].config["api_key"] == "secret"
# ---- Wipe ----
await _wipe_user_owned_rows(isolated_engine, user_id)
async with AsyncSession(isolated_engine) as session:
result = await session.exec(
select(ServiceProvider).where(ServiceProvider.user_id == user_id)
)
assert result.all() == []
# ---- Import ----
async with AsyncSession(isolated_engine) as session:
result = await import_backup(
session, user_id, backup, conflict_mode=ConflictMode.SKIP,
)
assert result.errors == [], f"Import errors: {result.errors}"
assert result.created > 0
# ---- Verify the entities are back ----
async with AsyncSession(isolated_engine) as session:
providers = (await session.exec(
select(ServiceProvider).where(ServiceProvider.user_id == user_id)
)).all()
assert len(providers) == 1
prov = providers[0]
assert prov.name == "Immich prod"
assert prov.config["base_url"] == "https://immich.example.com"
# Secrets imported intact when SecretsMode.INCLUDE was used at export.
assert prov.config["api_key"] == "secret"
bots = (await session.exec(
select(TelegramBot).where(TelegramBot.user_id == user_id)
)).all()
assert len(bots) == 1
assert bots[0].name == "Test bot"
targets = (await session.exec(
select(NotificationTarget).where(NotificationTarget.user_id == user_id)
)).all()
assert len(targets) == 1
receivers = (await session.exec(
select(TargetReceiver).where(TargetReceiver.target_id == targets[0].id)
)).all()
assert len(receivers) == 1
assert receivers[0].config["chat_id"] == "-100123"
tcs = (await session.exec(
select(TrackingConfig).where(TrackingConfig.user_id == user_id)
)).all()
assert len(tcs) == 1
assert tcs[0].name == "Default Immich tracking"
trackers = (await session.exec(
select(NotificationTracker).where(NotificationTracker.user_id == user_id)
)).all()
assert len(trackers) == 1
# provider_id was remapped via id_map — original provider id may have
# changed across the wipe, so just check it links to a real row.
assert trackers[0].provider_id == prov.id
assert trackers[0].scan_interval == 120
assert trackers[0].collection_ids == ["album-uuid-1"]
+265
View File
@@ -0,0 +1,265 @@
"""Tests for the bridge self-monitoring provider.
Covers:
1. ``build_event`` parses a well-formed payload and rejects malformed ones.
2. The threshold-crossing helpers in ``services.bridge_self`` only emit on
the actual crossing, not on every increment afterwards (anti-spam).
3. ``ensure_bridge_self_provider_for_user`` creates exactly one provider
per user and is idempotent on re-run.
4. The capability registry exposes the new event/slot definitions.
"""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from sqlmodel import SQLModel, select
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine
# ---------------------------------------------------------------------------
# Event parser
# ---------------------------------------------------------------------------
def test_build_event_well_formed_payload() -> None:
from notify_bridge_core.providers.bridge_self.event_parser import build_event
from notify_bridge_core.models.events import EventType
from notify_bridge_core.providers.base import ServiceProviderType
payload = {
"failure_type": "poll_failures",
"subject_id": 7,
"subject_name": "My Tracker",
"count": 3,
"threshold": 3,
"last_error": "Timeout",
"details": {"tracker_id": 7},
}
when = datetime(2026, 5, 16, 10, 0, tzinfo=timezone.utc)
event = build_event(payload, timestamp=when)
assert event is not None
assert event.event_type == EventType.BRIDGE_SELF_POLL_FAILURES
assert event.provider_type == ServiceProviderType.BRIDGE_SELF
assert event.collection_id == "7"
assert event.collection_name == "My Tracker"
assert event.timestamp == when
assert event.extra["count"] == 3
assert event.extra["threshold"] == 3
assert event.extra["last_error"] == "Timeout"
assert event.extra["failure_type"] == "poll_failures"
assert event.extra["details"] == {"tracker_id": 7}
def test_build_event_unknown_failure_type_returns_none() -> None:
from notify_bridge_core.providers.bridge_self.event_parser import build_event
assert build_event({"failure_type": "rocket_launch"}) is None
def test_build_event_non_dict_payload_returns_none() -> None:
from notify_bridge_core.providers.bridge_self.event_parser import build_event
assert build_event("not a dict") is None # type: ignore[arg-type]
assert build_event(None) is None # type: ignore[arg-type]
def test_build_event_clamps_long_error_messages() -> None:
from notify_bridge_core.providers.bridge_self.event_parser import (
build_event, _MAX_ERROR_LEN,
)
huge = "X" * (_MAX_ERROR_LEN * 5)
event = build_event({
"failure_type": "target_failures",
"subject_id": 1,
"subject_name": "t",
"count": 5,
"threshold": 5,
"last_error": huge,
})
assert event is not None
assert len(event.extra["last_error"]) <= _MAX_ERROR_LEN
# ---------------------------------------------------------------------------
# Threshold-crossing counters
# ---------------------------------------------------------------------------
def test_record_poll_failure_increments_then_success_resets() -> None:
from notify_bridge_server.services import bridge_self as bs
# Use a tracker_id we know is unique to this test to avoid pollution
# across tests sharing the module-level dicts.
tid = 9_001
bs.reset_poll_counter(tid)
assert bs.record_poll_failure(tid, "boom") == 1
assert bs.record_poll_failure(tid, "boom") == 2
assert bs.record_poll_failure(tid, "boom") == 3
assert bs.get_poll_failure_count(tid) == 3
assert bs.get_poll_last_error(tid) == "boom"
bs.record_poll_success(tid)
assert bs.get_poll_failure_count(tid) == 0
assert bs.get_poll_last_error(tid) == ""
def test_record_target_failure_increments_then_success_resets() -> None:
from notify_bridge_server.services import bridge_self as bs
tid = 9_101
bs.reset_target_counter(tid)
assert bs.record_target_failure(tid, "503") == 1
assert bs.record_target_failure(tid, "503") == 2
assert bs.get_target_failure_count(tid) == 2
bs.record_target_success(tid)
assert bs.get_target_failure_count(tid) == 0
def test_backlog_state_only_emits_on_crossing() -> None:
"""Only the False -> True transition should report a crossing.
A sustained backlog must not re-fire on every scan, and a recovered
backlog re-arms the latch so the next crossing is reported again.
"""
from notify_bridge_server.services import bridge_self as bs
user_id = 9_201
# Reset latch by going through a False reading first.
bs._backlog_above_threshold.pop(user_id, None)
# Initial above-threshold reading IS a crossing (None -> True latch).
assert bs.record_backlog_state(user_id, True) is True
# Sustained above — no second alert.
assert bs.record_backlog_state(user_id, True) is False
assert bs.record_backlog_state(user_id, True) is False
# Drop below — no alert (we don't notify on recovery).
assert bs.record_backlog_state(user_id, False) is False
# Cross again — alert.
assert bs.record_backlog_state(user_id, True) is True
# ---------------------------------------------------------------------------
# ensure_bridge_self_provider_for_user — DB roundtrip
# ---------------------------------------------------------------------------
@pytest.fixture
async def session() -> AsyncSession:
"""Fresh in-memory DB with the SQLModel schema applied."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
async with AsyncSession(engine) as session:
yield session
await engine.dispose()
@pytest.mark.asyncio
async def test_ensure_bridge_self_provider_creates_once(session: AsyncSession) -> None:
from notify_bridge_server.database.models import ServiceProvider, User
from notify_bridge_server.database.seeds import (
ensure_bridge_self_provider_for_user,
)
# Create a real user.
user = User(username="alice", hashed_password="x", role="user")
session.add(user)
await session.commit()
await session.refresh(user)
user_id = user.id
p1 = await ensure_bridge_self_provider_for_user(session, user_id)
assert p1 is not None
p1_id = p1.id
assert p1.type == "bridge_self"
assert p1.user_id == user_id
assert p1.config["poll_failure_threshold"] == 3
assert p1.config["deferred_backlog_threshold"] == 100
assert p1.config["target_failure_threshold"] == 5
await session.commit()
# Idempotent: second call returns the same row, no duplicates.
p2 = await ensure_bridge_self_provider_for_user(session, user_id)
assert p2 is not None
assert p2.id == p1_id
await session.commit()
rows = (
await session.exec(
select(ServiceProvider).where(
ServiceProvider.user_id == user_id,
ServiceProvider.type == "bridge_self",
)
)
).all()
assert len(rows) == 1
@pytest.mark.asyncio
async def test_ensure_bridge_self_provider_skips_system_user(session: AsyncSession) -> None:
"""user_id <= 0 is the __system__ placeholder — never gets a provider."""
from notify_bridge_server.database.seeds import (
ensure_bridge_self_provider_for_user,
)
result = await ensure_bridge_self_provider_for_user(session, 0)
assert result is None
# ---------------------------------------------------------------------------
# Capability registry
# ---------------------------------------------------------------------------
def test_capability_registry_lists_bridge_self() -> None:
from notify_bridge_core.providers.capabilities import (
get_capabilities, get_all_capabilities,
)
caps = get_capabilities("bridge_self")
assert caps is not None
assert caps.provider_type == "bridge_self"
assert caps.webhook_based is False
event_names = {e["name"] for e in caps.events}
assert event_names == {
"bridge_self_poll_failures",
"bridge_self_deferred_backlog",
"bridge_self_target_failures",
}
slot_names = {s["name"] for s in caps.notification_slots}
assert slot_names == {
"message_bridge_self_poll_failures",
"message_bridge_self_deferred_backlog",
"message_bridge_self_target_failures",
}
# And it shows up in the global registry.
assert "bridge_self" in get_all_capabilities()
def test_default_template_loader_returns_bridge_self_slots() -> None:
"""All three bridge_self slots have shipped Jinja2 default templates."""
from notify_bridge_core.templates.defaults.loader import load_default_templates
en = load_default_templates("en", "bridge_self")
ru = load_default_templates("ru", "bridge_self")
expected = {
"message_bridge_self_poll_failures",
"message_bridge_self_deferred_backlog",
"message_bridge_self_target_failures",
}
assert set(en.keys()) == expected
assert set(ru.keys()) == expected
# Sanity: each template references at least one of the bridge_self vars.
for tpl in list(en.values()) + list(ru.values()):
assert "{{" in tpl
+249
View File
@@ -0,0 +1,249 @@
"""Unit tests for the Gitea webhook parser.
Pure-function tests against ``parse_webhook`` using realistic Gitea
payloads (trimmed to the fields the parser actually consumes). No DB or
HTTP fixtures needed.
"""
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.gitea.event_parser import parse_webhook
def _repo() -> dict:
return {
"id": 42,
"name": "demo",
"full_name": "alexei/demo",
"html_url": "https://git.example.com/alexei/demo",
"description": "Demo repo",
"private": False,
"owner": {
"id": 1,
"login": "alexei",
"full_name": "Alexei",
"email": "alexei@example.com",
"avatar_url": "https://git.example.com/avatars/1",
},
}
def _sender() -> dict:
return {
"id": 1,
"login": "alexei",
"full_name": "Alexei",
"avatar_url": "https://git.example.com/avatars/1",
}
def test_push_event() -> None:
payload = {
"ref": "refs/heads/master",
"before": "0000000000000000000000000000000000000000",
"after": "abcdef0123456789abcdef0123456789abcdef01",
"compare_url": "https://git.example.com/alexei/demo/compare/000...abc",
"commits": [
{
"id": "abcdef0123456789abcdef0123456789abcdef01",
"message": "feat: initial commit\n\nMore detail.",
"url": "https://git.example.com/alexei/demo/commit/abcdef0",
"author": {
"name": "Alexei",
"email": "alexei@example.com",
"username": "alexei",
},
"timestamp": "2026-05-16T10:00:00Z",
},
{
"id": "1234567890123456789012345678901234567890",
"message": "chore: tweak",
"url": "https://git.example.com/alexei/demo/commit/1234567",
"author": {"name": "Alexei", "email": "alexei@example.com"},
"timestamp": "2026-05-16T10:05:00Z",
},
],
"repository": _repo(),
"sender": _sender(),
}
evt = parse_webhook("push", payload, provider_name="gitea-prod")
assert evt is not None
assert evt.event_type is EventType.PUSH
assert evt.provider_type is ServiceProviderType.GITEA
assert evt.collection_id == "alexei/demo"
assert evt.collection_name == "alexei/demo"
assert evt.extra["ref"] == "refs/heads/master"
assert evt.extra["branch"] == "master"
assert evt.extra["commit_count"] == 2
assert evt.extra["commits"][0]["short_id"] == "abcdef0"
# The first commit's multi-line body must be preserved (.strip handles
# trailing newlines but should keep the inner '\n').
assert "feat: initial commit" in evt.extra["commits"][0]["message"]
def test_issue_opened() -> None:
payload = {
"action": "opened",
"issue": {
"id": 100,
"number": 7,
"title": "Bug: thing broken",
"html_url": "https://git.example.com/alexei/demo/issues/7",
"state": "open",
"body": "Steps to reproduce...",
"labels": [{"name": "bug"}, {"name": "p1"}],
},
"repository": _repo(),
"sender": _sender(),
}
evt = parse_webhook("issues", payload, provider_name="gitea-prod")
assert evt is not None
assert evt.event_type is EventType.ISSUE_OPENED
assert evt.collection_id == "alexei/demo"
assert evt.extra["issue_number"] == 7
assert evt.extra["issue_title"] == "Bug: thing broken"
assert evt.extra["issue_labels"] == ["bug", "p1"]
def test_issue_closed() -> None:
payload = {
"action": "closed",
"issue": {
"id": 100,
"number": 7,
"title": "Bug: thing broken",
"html_url": "https://git.example.com/alexei/demo/issues/7",
"state": "closed",
"body": "",
"labels": [],
},
"repository": _repo(),
"sender": _sender(),
}
evt = parse_webhook("issues", payload, provider_name="gitea-prod")
assert evt is not None
assert evt.event_type is EventType.ISSUE_CLOSED
assert evt.extra["issue_state"] == "closed"
def test_pr_opened() -> None:
payload = {
"action": "opened",
"pull_request": {
"id": 200,
"number": 12,
"title": "Add metrics endpoint",
"html_url": "https://git.example.com/alexei/demo/pulls/12",
"state": "open",
"body": "PR body",
"merged": False,
"base": {"ref": "master", "label": "alexei:master"},
"head": {"ref": "feat/metrics", "label": "alexei:feat/metrics"},
"labels": [{"name": "enhancement"}],
},
"repository": _repo(),
"sender": _sender(),
}
evt = parse_webhook("pull_request", payload, provider_name="gitea-prod")
assert evt is not None
assert evt.event_type is EventType.PR_OPENED
assert evt.extra["pr_number"] == 12
assert evt.extra["pr_merged"] is False
assert evt.extra["pr_base"] == "alexei:master"
assert evt.extra["pr_head"] == "alexei:feat/metrics"
def test_pr_merged_resolves_from_closed_with_merged_flag() -> None:
"""A 'closed' action with merged=True is the merge signal — Gitea does
not send a distinct event header for it, so the parser must promote
PR_CLOSED -> PR_MERGED on its own."""
payload = {
"action": "closed",
"pull_request": {
"id": 200,
"number": 12,
"title": "Add metrics endpoint",
"html_url": "https://git.example.com/alexei/demo/pulls/12",
"state": "closed",
"body": "",
"merged": True,
"base": {"ref": "master"},
"head": {"ref": "feat/metrics"},
"labels": [],
},
"repository": _repo(),
"sender": _sender(),
}
evt = parse_webhook("pull_request", payload, provider_name="gitea-prod")
assert evt is not None
assert evt.event_type is EventType.PR_MERGED
assert evt.extra["pr_merged"] is True
def test_pr_closed_without_merge() -> None:
payload = {
"action": "closed",
"pull_request": {
"id": 200,
"number": 12,
"title": "Abandoned PR",
"html_url": "https://git.example.com/alexei/demo/pulls/12",
"state": "closed",
"body": "",
"merged": False,
"base": {"ref": "master"},
"head": {"ref": "feat/x"},
"labels": [],
},
"repository": _repo(),
"sender": _sender(),
}
evt = parse_webhook("pull_request", payload, provider_name="gitea-prod")
assert evt is not None
assert evt.event_type is EventType.PR_CLOSED
def test_release_published() -> None:
payload = {
"action": "published",
"release": {
"id": 9,
"tag_name": "v1.2.3",
"name": "Release v1.2.3",
"html_url": "https://git.example.com/alexei/demo/releases/tag/v1.2.3",
"body": "Bug fixes and improvements",
"draft": False,
"prerelease": False,
},
"repository": _repo(),
"sender": _sender(),
}
evt = parse_webhook("release", payload, provider_name="gitea-prod")
assert evt is not None
assert evt.event_type is EventType.RELEASE_PUBLISHED
assert evt.extra["release_tag"] == "v1.2.3"
assert evt.extra["release_prerelease"] is False
def test_release_non_published_is_ignored() -> None:
"""Only ``published`` releases should produce events — drafts and edits
are noise and would spam any tracker subscribed to release notifications."""
payload = {
"action": "edited",
"release": {
"id": 9, "tag_name": "v1.2.3", "name": "x",
"html_url": "", "body": "",
"draft": True, "prerelease": False,
},
"repository": _repo(),
"sender": _sender(),
}
assert parse_webhook("release", payload, provider_name="g") is None
def test_unknown_event_header_returns_none() -> None:
payload = {"repository": _repo(), "sender": _sender()}
assert parse_webhook("unknown_event", payload, provider_name="g") is None
+7 -1
View File
@@ -27,7 +27,13 @@ def test_ready_endpoint(tmp_data_dir) -> None: # noqa: ARG001
resp = client.get("/api/ready")
# By the time TestClient yields, lifespan startup has completed.
assert resp.status_code == 200
assert resp.json()["status"] == "ready"
body = resp.json()
assert body["ready"] is True
assert body["checks"]["db"] == "ok"
assert body["checks"]["scheduler"] == "ok"
# No HA providers configured by default in the test fixture.
assert body["checks"]["ha"] == "na"
assert body["errors"] == []
def test_health_is_anonymous(tmp_data_dir) -> None: # noqa: ARG001
@@ -0,0 +1,159 @@
"""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
+147
View File
@@ -0,0 +1,147 @@
"""Unit tests for the Planka webhook parser.
Pure-function tests against ``parse_webhook`` using realistic Planka
webhook payload shapes. The parser is forgiving about missing ``included``
data (older Planka builds), so we mix payloads with and without it to
catch regressions in the fallback paths.
"""
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.planka.event_parser import parse_webhook
_BASE_URL = "https://planka.example.com"
def _user() -> dict:
return {"id": "u1", "username": "alexei", "name": "Alexei"}
def test_card_created() -> None:
payload = {
"user": _user(),
"item": {
"id": "c1",
"name": "Implement metrics",
"description": "Wire prometheus client.",
"boardId": "b1",
"listId": "l1",
"position": 1,
},
"included": {
"board": {"id": "b1", "name": "Roadmap"},
"lists": [{"id": "l1", "name": "Todo"}],
},
}
evt = parse_webhook("cardCreate", payload, provider_name="planka", base_url=_BASE_URL)
assert evt is not None
assert evt.event_type is EventType.CARD_CREATED
assert evt.provider_type is ServiceProviderType.PLANKA
assert evt.collection_id == "b1"
assert evt.collection_name == "Roadmap"
assert evt.extra["card_name"] == "Implement metrics"
assert evt.extra["card_url"] == f"{_BASE_URL}/cards/c1"
assert evt.extra["list_name"] == "Todo"
assert evt.extra["sender"] == "alexei"
def test_card_moved_when_list_changes() -> None:
"""beforeUpdate.listId != item.listId is the signal Planka uses for a
card move; the parser must promote the generic cardUpdate event into
CARD_MOVED so trackers can subscribe to moves specifically."""
payload = {
"user": _user(),
"beforeUpdate": {"listId": "l1"},
"item": {
"id": "c1",
"name": "Implement metrics",
"description": "",
"boardId": "b1",
"listId": "l2",
},
"included": {
"board": {"id": "b1", "name": "Roadmap"},
"lists": [
{"id": "l1", "name": "Todo"},
{"id": "l2", "name": "In progress"},
],
},
}
evt = parse_webhook("cardUpdate", payload, provider_name="planka", base_url=_BASE_URL)
assert evt is not None
assert evt.event_type is EventType.CARD_MOVED
assert evt.extra["old_list_id"] == "l1"
assert evt.extra["new_list_id"] == "l2"
assert evt.extra["old_list_name"] == "Todo"
assert evt.extra["new_list_name"] == "In progress"
def test_card_update_without_list_change_is_card_updated() -> None:
payload = {
"user": _user(),
"beforeUpdate": {"name": "Old name"},
"item": {
"id": "c1", "name": "New name", "description": "", "boardId": "b1", "listId": "l1",
},
}
evt = parse_webhook("cardUpdate", payload, provider_name="planka", base_url=_BASE_URL)
assert evt is not None
assert evt.event_type is EventType.CARD_UPDATED
def test_comment_created() -> None:
payload = {
"user": _user(),
"item": {
"id": "cm1",
"text": "LGTM, ship it.",
"cardId": "c1",
"userId": "u1",
},
"included": {
"card": {"id": "c1", "name": "Implement metrics", "boardId": "b1"},
"board": {"id": "b1", "name": "Roadmap"},
},
}
evt = parse_webhook(
"commentCreate", payload, provider_name="planka", base_url=_BASE_URL,
)
assert evt is not None
assert evt.event_type is EventType.CARD_COMMENTED
assert evt.collection_id == "b1"
assert evt.extra["comment_text"] == "LGTM, ship it."
assert evt.extra["card_id"] == "c1"
assert evt.extra["card_url"] == f"{_BASE_URL}/cards/c1"
def test_task_completion_emits_only_on_transition() -> None:
"""Task updates should only produce TASK_COMPLETED when the task flips
from incomplete to complete — toggling the description or other fields
on a task that was already complete must NOT spam notifications."""
completing = {
"user": _user(),
"beforeUpdate": {"isCompleted": False},
"item": {"id": "t1", "name": "Step 1", "isCompleted": True, "cardId": "c1"},
"included": {
"card": {"id": "c1", "name": "Implement metrics", "boardId": "b1"},
"board": {"id": "b1", "name": "Roadmap"},
},
}
evt = parse_webhook("taskUpdate", completing, provider_name="planka", base_url=_BASE_URL)
assert evt is not None
assert evt.event_type is EventType.TASK_COMPLETED
# Editing a task that was already completed -> no event.
re_edit = {
"user": _user(),
"beforeUpdate": {"isCompleted": True},
"item": {"id": "t1", "name": "Step 1 v2", "isCompleted": True, "cardId": "c1"},
}
assert parse_webhook("taskUpdate", re_edit, provider_name="planka", base_url=_BASE_URL) is None
def test_unknown_event_returns_none() -> None:
assert parse_webhook("nonexistent", {"item": {}}, provider_name="planka", base_url="") is None