"""Webhook regression tests — JSONB containment on Webhook.events. Regression (2026-09-14, 158 failed outbox events in production): Webhook.events is a JSONB array column, NOT a relationship. Both webhook_service.list_webhooks (event filter) and webhook_dispatcher._dispatch_event called Webhook.events.any(event), which only exists on relationship attributes. Every event publish crashed with "Neither 'AnnotatedColumn' nor 'Comparator' object has an attribute 'any'" and the outbox marked the events as failed after retries. Fix: PostgreSQL JSONB containment events @> '[""]' via cast(Webhook.events, JSONB).contains([event]). These tests prove both fixed code paths against a real PostgreSQL test DB (the JSONB cast only compiles correctly on PostgreSQL, not SQLite). """ from __future__ import annotations import uuid from unittest.mock import AsyncMock, patch import pytest import pytest_asyncio from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.core.webhook_dispatcher import _dispatch_event from app.models.webhook import Webhook from app.services.webhook_service import list_webhooks @pytest_asyncio.fixture def test_session_factory(session_factory: async_sessionmaker[AsyncSession]) -> async_sessionmaker[AsyncSession]: """The test session factory — patches the dispatcher's global factory. ``_dispatch_event`` opens its own session via ``get_session_factory()`` (the app engine). In tests the global engine points at the app DB unless ``reset_engine_for_testing`` is active, so we patch the symbol the dispatcher resolves at call time to use the test engine instead. """ return session_factory def _make_webhook( tenant_id: uuid.UUID, events: list[str], *, url: str = "https://example.com/hook", is_active: bool = True, ) -> Webhook: return Webhook( tenant_id=tenant_id, url=url, events=events, is_active=is_active, retry_count=3, timeout_seconds=30, ) @pytest_asyncio.fixture async def tenant_id(db_session: AsyncSession) -> uuid.UUID: """A fresh tenant id; Webhook rows are tenant-scoped.""" return uuid.uuid4() @pytest.mark.asyncio class TestListWebhooksEventFilter: """webhook_service.list_webhooks — event filter uses JSONB containment.""" async def test_event_filter_returns_matching_webhook( self, db_session: AsyncSession, tenant_id: uuid.UUID ) -> None: """Regression: .any() raised AttributeError before the JSONB fix.""" matching = _make_webhook(tenant_id, ["file.deleted", "contact.created"]) other = _make_webhook(tenant_id, ["contact.created"]) db_session.add_all([matching, other]) await db_session.flush() # Before the fix this raised: # Neither 'AnnotatedColumn' object nor 'Comparator' object # has an attribute 'any' result = await list_webhooks(db_session, tenant_id, event="file.deleted") assert len(result) == 1, "event filter must select only matching webhooks" assert result[0].id == matching.id assert result[0].url == matching.url async def test_event_filter_empty_when_no_match( self, db_session: AsyncSession, tenant_id: uuid.UUID ) -> None: db_session.add(_make_webhook(tenant_id, ["contact.created"])) await db_session.flush() result = await list_webhooks(db_session, tenant_id, event="file.deleted") assert result == [] async def test_no_event_filter_returns_all( self, db_session: AsyncSession, tenant_id: uuid.UUID ) -> None: db_session.add_all([ _make_webhook(tenant_id, ["file.deleted"]), _make_webhook(tenant_id, ["contact.created"]), ]) await db_session.flush() result = await list_webhooks(db_session, tenant_id) assert len(result) == 2 @pytest.mark.asyncio class TestDispatcherEventMatching: """webhook_dispatcher._dispatch_event — wildcard handler query.""" async def test_dispatch_matches_webhook_by_event( self, db_session: AsyncSession, tenant_id: uuid.UUID, test_session_factory ) -> None: """Regression: the dispatcher query crashed with .any() and every outbox event failed (158 production events 2026-08-27).""" hook = _make_webhook(tenant_id, ["file.deleted", "contact.created"]) db_session.add(hook) await db_session.commit() # dispatcher opens its own session sent = AsyncMock(return_value={"success": True, "status_code": 200}) with patch("app.core.webhook_dispatcher.send_webhook", sent), \ patch("app.core.webhook_dispatcher.get_session_factory", return_value=test_session_factory): # Before the fix this raised AttributeError and the outbox # marked the event failed after retries. await _dispatch_event({ "event_name": "file.deleted", "tenant_id": str(tenant_id), "data": {"file_id": "abc"}, }) sent.assert_awaited_once() dispatched_hook = sent.await_args.args[0] assert dispatched_hook.id == hook.id assert sent.await_args.args[1] == "file.deleted" assert sent.await_args.args[2] == {"file_id": "abc"} async def test_dispatch_skips_webhook_with_other_events( self, db_session: AsyncSession, tenant_id: uuid.UUID, test_session_factory ) -> None: db_session.add(_make_webhook(tenant_id, ["contact.created"])) await db_session.commit() sent = AsyncMock(return_value={"success": True, "status_code": 200}) with patch("app.core.webhook_dispatcher.send_webhook", sent), \ patch("app.core.webhook_dispatcher.get_session_factory", return_value=test_session_factory): await _dispatch_event({ "event_name": "file.deleted", "tenant_id": str(tenant_id), "data": {}, }) sent.assert_not_awaited() async def test_dispatch_skips_inactive_webhook( self, db_session: AsyncSession, tenant_id: uuid.UUID, test_session_factory ) -> None: db_session.add(_make_webhook(tenant_id, ["file.deleted"], is_active=False)) await db_session.commit() sent = AsyncMock(return_value={"success": True, "status_code": 200}) with patch("app.core.webhook_dispatcher.send_webhook", sent), \ patch("app.core.webhook_dispatcher.get_session_factory", return_value=test_session_factory): await _dispatch_event({ "event_name": "file.deleted", "tenant_id": str(tenant_id), "data": {}, }) sent.assert_not_awaited()