Files

174 lines
6.6 KiB
Python
Raw Permalink Normal View History

"""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 @> '["<event>"]' 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()