"""Tests for the webhook system. Covers: - Unit tests for HMAC signing and retry constants (no DB required) - Integration tests for fire_event() using a real DB (inserts into async_operations) - Integration tests for _handle_webhook_delivery() on the memory engine - HTTP API integration tests for CRUD and delivery listing endpoints """ import json import uuid from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest import pytest_asyncio from hindsight_api.api import create_app from hindsight_api.engine.memory_engine import MemoryEngine from hindsight_api.webhooks.manager import MAX_ATTEMPTS, RETRY_DELAYS, WebhookManager from hindsight_api.webhooks.models import ( ConsolidationEventData, RetainEventData, WebhookConfig, WebhookEvent, WebhookEventType, ) from hindsight_api.worker.exceptions import RetryTaskAt # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_event(bank_id: str = "bank-1") -> WebhookEvent: return WebhookEvent( event=WebhookEventType.CONSOLIDATION_COMPLETED, bank_id=bank_id, operation_id=uuid.uuid4().hex, status="completed", timestamp=datetime.now(timezone.utc), data=ConsolidationEventData(observations_created=1), ) def _make_delivery_task( bank_id: str = "bank-1", url: str = "https://example.com/hook", retry_count: int = 0, webhook_id: str | None = None, ) -> dict: return { "type": "webhook_delivery", "bank_id": bank_id, "url": url, "secret": None, "event_type": "consolidation.completed", "payload": '{"event":"consolidation.completed"}', "webhook_id": webhook_id, "_retry_count": retry_count, } # --------------------------------------------------------------------------- # Unit tests (no DB) # --------------------------------------------------------------------------- class TestHmacSigning: """Unit tests for WebhookManager._sign_payload().""" def _make_manager(self) -> WebhookManager: """Create a WebhookManager with a dummy pool (not used for signing).""" pool = MagicMock() return WebhookManager(pool=pool, global_webhooks=[]) def test_hmac_signing_format(self): """_sign_payload should return a string starting with 'sha256='.""" manager = self._make_manager() sig = manager._sign_payload("my-secret", b"hello world") assert sig.startswith("sha256="), f"Expected 'sha256=' prefix, got: {sig!r}" hex_part = sig[len("sha256="):] # SHA-256 hex digest is always 64 characters assert len(hex_part) == 64 # Hex characters only assert all(c in "0123456789abcdef" for c in hex_part) def test_hmac_signing_is_deterministic(self): """Same secret + payload always produces the same signature.""" manager = self._make_manager() payload = b'{"event":"consolidation.completed"}' sig1 = manager._sign_payload("secret-key", payload) sig2 = manager._sign_payload("secret-key", payload) assert sig1 == sig2 def test_hmac_signing_differs_with_different_secret(self): """Different secrets must produce different signatures.""" manager = self._make_manager() payload = b"payload" sig1 = manager._sign_payload("secret-a", payload) sig2 = manager._sign_payload("secret-b", payload) assert sig1 != sig2 def test_hmac_signing_differs_with_different_payload(self): """Different payloads must produce different signatures.""" manager = self._make_manager() sig1 = manager._sign_payload("secret", b"payload-one") sig2 = manager._sign_payload("secret", b"payload-two") assert sig1 != sig2 class TestRetryConstants: """Unit tests to verify retry schedule constants.""" def test_retry_delays_values(self): """RETRY_DELAYS must match the documented schedule.""" assert RETRY_DELAYS == [5, 300, 1800, 7200, 18000] def test_max_attempts(self): """MAX_ATTEMPTS should be len(RETRY_DELAYS) + 1.""" assert MAX_ATTEMPTS == 6 assert MAX_ATTEMPTS == len(RETRY_DELAYS) + 1 # --------------------------------------------------------------------------- # DB integration tests # --------------------------------------------------------------------------- @pytest_asyncio.fixture async def webhook_manager(memory: MemoryEngine) -> WebhookManager: """Return a WebhookManager backed by the test pool with no global webhooks.""" return WebhookManager(pool=memory._pool, global_webhooks=[]) async def _ensure_bank(pool, bank_id: str) -> None: """Upsert a minimal bank row so FK constraints on async_operations/webhooks pass.""" await pool.execute( "INSERT INTO banks (bank_id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING", bank_id, bank_id, ) class TestFireEvent: """Integration tests for WebhookManager.fire_event().""" @pytest.mark.asyncio async def test_fire_event_creates_delivery( self, memory: MemoryEngine, webhook_manager: WebhookManager ): """fire_event() inserts a pending webhook_delivery task in async_operations.""" bank_id = f"wh-test-{uuid.uuid4().hex[:8]}" webhook_id = uuid.uuid4() async with memory._pool.acquire() as conn: await _ensure_bank(memory._pool, bank_id) await conn.execute( """ INSERT INTO webhooks (id, bank_id, url, secret, event_types, enabled, created_at, updated_at) VALUES ($1, $2, $3, NULL, $4, true, NOW(), NOW()) """, webhook_id, bank_id, "https://example.com/hook", ["consolidation.completed"], ) try: event = _make_event(bank_id) await webhook_manager.fire_event(event) async with memory._pool.acquire() as conn: rows = await conn.fetch( """ SELECT status, task_payload FROM async_operations WHERE operation_type = 'webhook_delivery' AND bank_id = $1 AND task_payload->>'webhook_id' = $2 """, bank_id, str(webhook_id), ) assert len(rows) == 1 assert rows[0]["status"] == "pending" payload = rows[0]["task_payload"] if isinstance(payload, str): payload = json.loads(payload) assert payload["event_type"] == "consolidation.completed" finally: async with memory._pool.acquire() as conn: await conn.execute( "DELETE FROM async_operations WHERE operation_type = 'webhook_delivery' AND bank_id = $1", bank_id, ) await conn.execute("DELETE FROM webhooks WHERE id = $1", webhook_id) @pytest.mark.asyncio async def test_fire_event_global_webhook( self, memory: MemoryEngine ): """fire_event() also queues delivery tasks for global webhooks (not stored in DB).""" bank_id = f"wh-global-{uuid.uuid4().hex[:8]}" await _ensure_bank(memory._pool, bank_id) global_webhook = WebhookConfig( id="", # No DB row bank_id=None, url="https://global.example.com/hook", secret=None, event_types=["consolidation.completed"], enabled=True, ) manager = WebhookManager(pool=memory._pool, global_webhooks=[global_webhook]) event = _make_event(bank_id) await manager.fire_event(event) async with memory._pool.acquire() as conn: rows = await conn.fetch( """ SELECT status, task_payload FROM async_operations WHERE operation_type = 'webhook_delivery' AND bank_id = $1 AND task_payload->>'url' = 'https://global.example.com/hook' ORDER BY created_at DESC LIMIT 1 """ , bank_id, ) assert len(rows) == 1 assert rows[0]["status"] == "pending" payload = rows[0]["task_payload"] if isinstance(payload, str): payload = json.loads(payload) assert payload["webhook_id"] is None # global webhook has no DB row # Cleanup async with memory._pool.acquire() as conn: await conn.execute( "DELETE FROM async_operations WHERE operation_type = 'webhook_delivery' AND bank_id = $1", bank_id, ) @pytest.mark.asyncio async def test_fire_event_no_match_if_event_type_mismatch( self, memory: MemoryEngine, webhook_manager: WebhookManager ): """Webhooks registered for a different event type receive no delivery task.""" bank_id = f"wh-mismatch-{uuid.uuid4().hex[:8]}" webhook_id = uuid.uuid4() await _ensure_bank(memory._pool, bank_id) async with memory._pool.acquire() as conn: await conn.execute( """ INSERT INTO webhooks (id, bank_id, url, secret, event_types, enabled, created_at, updated_at) VALUES ($1, $2, $3, NULL, $4, true, NOW(), NOW()) """, webhook_id, bank_id, "https://example.com/other-hook", ["other.event"], ) try: event = _make_event(bank_id) await webhook_manager.fire_event(event) async with memory._pool.acquire() as conn: count = await conn.fetchval( """ SELECT COUNT(*) FROM async_operations WHERE operation_type = 'webhook_delivery' AND bank_id = $1 """, bank_id, ) assert count == 0 finally: async with memory._pool.acquire() as conn: await conn.execute("DELETE FROM webhooks WHERE id = $1", webhook_id) class TestHandleWebhookDelivery: """Integration tests for MemoryEngine._handle_webhook_delivery().""" @pytest.mark.asyncio async def test_deliver_success(self, memory: MemoryEngine): """A successful HTTP POST completes without raising.""" task_dict = _make_delivery_task(retry_count=0) mock_response = MagicMock() mock_response.raise_for_status = MagicMock() with patch.object(memory._http_client, "post", new=AsyncMock(return_value=mock_response)): # Should not raise await memory._handle_webhook_delivery(task_dict) @pytest.mark.asyncio async def test_deliver_failure_raises_retry_task_at(self, memory: MemoryEngine): """A failed HTTP POST raises RetryTaskAt when retries remain.""" task_dict = _make_delivery_task(retry_count=0) with patch.object( memory._http_client, "post", new=AsyncMock(side_effect=Exception("connection refused")) ): with pytest.raises(RetryTaskAt): await memory._handle_webhook_delivery(task_dict) @pytest.mark.asyncio async def test_deliver_exhausted_retries_raises(self, memory: MemoryEngine): """When retry_count reaches MAX_ATTEMPTS-1, a failure raises the original exception.""" task_dict = _make_delivery_task(retry_count=MAX_ATTEMPTS - 1) with patch.object( memory._http_client, "post", new=AsyncMock(side_effect=Exception("server error")) ): with pytest.raises(Exception, match="server error"): await memory._handle_webhook_delivery(task_dict) @pytest.mark.asyncio async def test_deliver_retry_at_uses_delay_schedule(self, memory: MemoryEngine): """RetryTaskAt.retry_at is approximately now + RETRY_DELAYS[retry_count].""" from datetime import timedelta task_dict = _make_delivery_task(retry_count=1) with patch.object( memory._http_client, "post", new=AsyncMock(side_effect=Exception("fail")) ): before = datetime.now(timezone.utc) with pytest.raises(RetryTaskAt) as exc_info: await memory._handle_webhook_delivery(task_dict) after = datetime.now(timezone.utc) retry_at = exc_info.value.retry_at expected_delay = RETRY_DELAYS[1] # retry_count=1 assert retry_at >= before + timedelta(seconds=expected_delay - 2) assert retry_at <= after + timedelta(seconds=expected_delay + 2) @pytest.mark.asyncio async def test_execute_task_marks_operation_completed(self, memory: MemoryEngine): """After a successful delivery, execute_task marks the async_operations row as completed.""" operation_id = str(uuid.uuid4()) bank_id = f"wh-exec-{uuid.uuid4().hex[:8]}" await _ensure_bank(memory._pool, bank_id) # Insert a real async_operations row so _mark_operation_completed has something to update async with memory._pool.acquire() as conn: await conn.execute( """ INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, result_metadata, created_at, updated_at) VALUES ($1, $2, 'webhook_delivery', 'processing', '{}'::jsonb, '{}'::jsonb, NOW(), NOW()) """, uuid.UUID(operation_id), bank_id, ) task_dict = { **_make_delivery_task(bank_id=bank_id, retry_count=0), "operation_id": operation_id, } mock_response = MagicMock() mock_response.raise_for_status = MagicMock() with patch.object(memory._http_client, "post", new=AsyncMock(return_value=mock_response)): await memory.execute_task(task_dict) async with memory._pool.acquire() as conn: row = await conn.fetchrow( "SELECT status FROM async_operations WHERE operation_id = $1", uuid.UUID(operation_id), ) assert row is not None assert row["status"] == "completed", f"Expected 'completed', got '{row['status']}'" # Cleanup async with memory._pool.acquire() as conn: await conn.execute( "DELETE FROM async_operations WHERE operation_id = $1", uuid.UUID(operation_id), ) # --------------------------------------------------------------------------- # HTTP API integration tests # --------------------------------------------------------------------------- @pytest_asyncio.fixture async def api_client(memory: MemoryEngine): """Async HTTP test client wired to the FastAPI app.""" app = create_app(memory, initialize_memory=False) transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: yield client class TestWebhookHttpApi: """HTTP API integration tests for webhook CRUD endpoints.""" @pytest.mark.asyncio async def test_http_create_webhook(self, api_client: httpx.AsyncClient): """POST /webhooks returns 201 and an id.""" bank_id = f"http-wh-{uuid.uuid4().hex[:8]}" response = await api_client.post( f"/v1/default/banks/{bank_id}/webhooks", json={ "url": "https://example.com/create", "event_types": ["consolidation.completed"], }, ) assert response.status_code == 201, response.text data = response.json() assert "id" in data assert data["url"] == "https://example.com/create" assert data["bank_id"] == bank_id assert data["secret"] is None # secrets are never echoed back # Cleanup await api_client.delete( f"/v1/default/banks/{bank_id}/webhooks/{data['id']}" ) @pytest.mark.asyncio async def test_http_list_webhooks(self, api_client: httpx.AsyncClient): """GET /webhooks returns the webhooks registered for a bank.""" bank_id = f"http-wh-{uuid.uuid4().hex[:8]}" create_resp = await api_client.post( f"/v1/default/banks/{bank_id}/webhooks", json={"url": "https://example.com/list", "event_types": ["consolidation.completed"]}, ) assert create_resp.status_code == 201 webhook_id = create_resp.json()["id"] list_resp = await api_client.get(f"/v1/default/banks/{bank_id}/webhooks") assert list_resp.status_code == 200 items = list_resp.json()["items"] assert any(item["id"] == webhook_id for item in items) # Cleanup await api_client.delete(f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}") @pytest.mark.asyncio async def test_http_delete_webhook(self, api_client: httpx.AsyncClient): """DELETE /webhooks/{id} removes the webhook; subsequent list returns empty for that bank.""" bank_id = f"http-wh-{uuid.uuid4().hex[:8]}" create_resp = await api_client.post( f"/v1/default/banks/{bank_id}/webhooks", json={"url": "https://example.com/delete", "event_types": ["consolidation.completed"]}, ) assert create_resp.status_code == 201 webhook_id = create_resp.json()["id"] delete_resp = await api_client.delete( f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}" ) assert delete_resp.status_code == 200 assert delete_resp.json()["success"] is True list_resp = await api_client.get(f"/v1/default/banks/{bank_id}/webhooks") assert list_resp.status_code == 200 ids = [item["id"] for item in list_resp.json()["items"]] assert webhook_id not in ids @pytest.mark.asyncio async def test_http_delete_webhook_not_found(self, api_client: httpx.AsyncClient): """DELETE with a non-existent webhook id returns 404.""" bank_id = f"http-wh-{uuid.uuid4().hex[:8]}" missing_id = str(uuid.uuid4()) response = await api_client.delete( f"/v1/default/banks/{bank_id}/webhooks/{missing_id}" ) assert response.status_code == 404 @pytest.mark.asyncio async def test_http_list_deliveries( self, memory: MemoryEngine, api_client: httpx.AsyncClient ): """GET /webhooks/{id}/deliveries returns delivery records for a webhook.""" bank_id = f"http-wh-{uuid.uuid4().hex[:8]}" # Create webhook via HTTP API create_resp = await api_client.post( f"/v1/default/banks/{bank_id}/webhooks", json={ "url": "https://example.com/deliveries", "event_types": ["consolidation.completed"], }, ) assert create_resp.status_code == 201 webhook_id = create_resp.json()["id"] # Insert a delivery row directly into async_operations delivery_id = uuid.uuid4() now = datetime.now(timezone.utc) task_payload = json.dumps( { "type": "webhook_delivery", "bank_id": bank_id, "url": "https://example.com/deliveries", "secret": None, "event_type": "consolidation.completed", "payload": '{"event":"consolidation.completed"}', "webhook_id": webhook_id, } ) async with memory._pool.acquire() as conn: await conn.execute( """ INSERT INTO async_operations (operation_id, bank_id, operation_type, status, retry_count, task_payload, result_metadata, created_at, updated_at) VALUES ($1, $2, 'webhook_delivery', 'completed', 0, $3::jsonb, '{}'::jsonb, $4, $4) """, delivery_id, bank_id, task_payload, now, ) try: deliveries_resp = await api_client.get( f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}/deliveries" ) assert deliveries_resp.status_code == 200 items = deliveries_resp.json()["items"] ids = [item["id"] for item in items] assert str(delivery_id) in ids # Verify shape of a delivery item delivery = next(item for item in items if item["id"] == str(delivery_id)) assert delivery["status"] == "completed" assert delivery["event_type"] == "consolidation.completed" assert delivery["attempts"] == 1 finally: async with memory._pool.acquire() as conn: await conn.execute( "DELETE FROM async_operations WHERE operation_id = $1", delivery_id ) await api_client.delete( f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}" ) @pytest.mark.asyncio async def test_http_list_deliveries_webhook_not_found(self, api_client: httpx.AsyncClient): """GET /webhooks/{id}/deliveries for a non-existent webhook returns 404.""" bank_id = f"http-wh-{uuid.uuid4().hex[:8]}" missing_id = str(uuid.uuid4()) response = await api_client.get( f"/v1/default/banks/{bank_id}/webhooks/{missing_id}/deliveries" ) assert response.status_code == 404 @pytest.mark.asyncio async def test_http_update_webhook_url(self, api_client: httpx.AsyncClient): """PATCH /webhooks/{id} updates only the provided fields.""" bank_id = f"http-wh-{uuid.uuid4().hex[:8]}" create_resp = await api_client.post( f"/v1/default/banks/{bank_id}/webhooks", json={"url": "https://example.com/original", "event_types": ["consolidation.completed"]}, ) assert create_resp.status_code == 201 webhook_id = create_resp.json()["id"] patch_resp = await api_client.patch( f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}", json={"url": "https://example.com/updated"}, ) assert patch_resp.status_code == 200 data = patch_resp.json() assert data["url"] == "https://example.com/updated" # event_types should be unchanged assert "consolidation.completed" in data["event_types"] # Cleanup await api_client.delete(f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}") @pytest.mark.asyncio async def test_http_update_webhook_event_types(self, api_client: httpx.AsyncClient): """PATCH /webhooks/{id} can update event_types.""" bank_id = f"http-wh-{uuid.uuid4().hex[:8]}" create_resp = await api_client.post( f"/v1/default/banks/{bank_id}/webhooks", json={"url": "https://example.com/hook", "event_types": ["consolidation.completed"]}, ) assert create_resp.status_code == 201 webhook_id = create_resp.json()["id"] patch_resp = await api_client.patch( f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}", json={"event_types": ["retain.completed"]}, ) assert patch_resp.status_code == 200 data = patch_resp.json() assert data["event_types"] == ["retain.completed"] # Cleanup await api_client.delete(f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}") @pytest.mark.asyncio async def test_http_update_webhook_enabled(self, api_client: httpx.AsyncClient): """PATCH /webhooks/{id} can toggle enabled.""" bank_id = f"http-wh-{uuid.uuid4().hex[:8]}" create_resp = await api_client.post( f"/v1/default/banks/{bank_id}/webhooks", json={"url": "https://example.com/hook", "event_types": ["consolidation.completed"]}, ) assert create_resp.status_code == 201 webhook_id = create_resp.json()["id"] assert create_resp.json()["enabled"] is True patch_resp = await api_client.patch( f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}", json={"enabled": False}, ) assert patch_resp.status_code == 200 assert patch_resp.json()["enabled"] is False # Cleanup await api_client.delete(f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}") @pytest.mark.asyncio async def test_http_update_webhook_http_config(self, api_client: httpx.AsyncClient): """PATCH /webhooks/{id} can update http_config.""" bank_id = f"http-wh-{uuid.uuid4().hex[:8]}" create_resp = await api_client.post( f"/v1/default/banks/{bank_id}/webhooks", json={"url": "https://example.com/hook", "event_types": ["consolidation.completed"]}, ) assert create_resp.status_code == 201 webhook_id = create_resp.json()["id"] patch_resp = await api_client.patch( f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}", json={ "http_config": { "method": "POST", "timeout_seconds": 10, "headers": {"X-Custom": "value"}, "params": {}, } }, ) assert patch_resp.status_code == 200 data = patch_resp.json() assert data["http_config"]["timeout_seconds"] == 10 assert data["http_config"]["headers"] == {"X-Custom": "value"} # Cleanup await api_client.delete(f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}") @pytest.mark.asyncio async def test_http_update_webhook_not_found(self, api_client: httpx.AsyncClient): """PATCH /webhooks/{id} returns 404 for a non-existent webhook.""" bank_id = f"http-wh-{uuid.uuid4().hex[:8]}" missing_id = str(uuid.uuid4()) response = await api_client.patch( f"/v1/default/banks/{bank_id}/webhooks/{missing_id}", json={"url": "https://example.com/new"}, ) assert response.status_code == 404 @pytest.mark.asyncio async def test_http_update_webhook_no_fields(self, api_client: httpx.AsyncClient): """PATCH /webhooks/{id} with empty body returns 422.""" bank_id = f"http-wh-{uuid.uuid4().hex[:8]}" create_resp = await api_client.post( f"/v1/default/banks/{bank_id}/webhooks", json={"url": "https://example.com/hook", "event_types": ["consolidation.completed"]}, ) assert create_resp.status_code == 201 webhook_id = create_resp.json()["id"] patch_resp = await api_client.patch( f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}", json={}, ) assert patch_resp.status_code == 422 # Cleanup await api_client.delete(f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}") # --------------------------------------------------------------------------- # retain.completed webhook tests # --------------------------------------------------------------------------- class TestRetainCompletedWebhook: """Tests for the retain.completed webhook event.""" def test_retain_event_data_model(self): """RetainEventData can be constructed with optional fields.""" data = RetainEventData(document_id="doc-123", tags=["tag1", "tag2"]) assert data.document_id == "doc-123" assert data.tags == ["tag1", "tag2"] empty = RetainEventData() assert empty.document_id is None assert empty.tags is None def test_retain_event_type_value(self): """WebhookEventType.RETAIN_COMPLETED has the correct string value.""" assert WebhookEventType.RETAIN_COMPLETED == "retain.completed" @pytest.mark.asyncio async def test_fire_retain_webhook_queues_per_document( self, memory: MemoryEngine, webhook_manager: WebhookManager ): """_fire_retain_webhook queues one delivery task per content item.""" bank_id = f"wh-retain-{uuid.uuid4().hex[:8]}" webhook_id = uuid.uuid4() await _ensure_bank(memory._pool, bank_id) async with memory._pool.acquire() as conn: await conn.execute( """ INSERT INTO webhooks (id, bank_id, url, secret, event_types, enabled, created_at, updated_at) VALUES ($1, $2, $3, NULL, $4, true, NOW(), NOW()) """, webhook_id, bank_id, "https://example.com/retain-hook", ["retain.completed"], ) try: contents = [ {"content": "Alice works at Google", "document_id": "doc-1"}, {"content": "Bob loves Python", "document_id": "doc-2"}, ] # Temporarily replace webhook manager on memory engine original_manager = memory._webhook_manager memory._webhook_manager = webhook_manager try: callback = memory._build_retain_outbox_callback( bank_id=bank_id, contents=contents, operation_id="test-op-123", ) assert callback is not None async with memory._pool.acquire() as conn: await callback(conn) finally: memory._webhook_manager = original_manager async with memory._pool.acquire() as conn: rows = await conn.fetch( """ SELECT task_payload FROM async_operations WHERE operation_type = 'webhook_delivery' AND bank_id = $1 AND task_payload->>'event_type' = 'retain.completed' ORDER BY created_at """, bank_id, ) assert len(rows) == 2 payloads = [] for row in rows: p = row["task_payload"] if isinstance(p, str): p = json.loads(p) payloads.append(p) doc_ids_in_payloads = [json.loads(p["payload"]).get("data", {}).get("document_id") for p in payloads] assert "doc-1" in doc_ids_in_payloads assert "doc-2" in doc_ids_in_payloads finally: async with memory._pool.acquire() as conn: await conn.execute( "DELETE FROM async_operations WHERE operation_type = 'webhook_delivery' AND bank_id = $1", bank_id, ) await conn.execute("DELETE FROM webhooks WHERE id = $1", webhook_id)