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:
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user