fleet-memory/hindsight-api-slim/tests/test_webhooks.py
Nicolò Boschi 15ea23d5d6
feat: introduce hindsight-api-slim and hindsight-all-slim packages (#560)
* feat: introduce hindsight-api-slim and hindsight-all-slim packages

Closes #552

- Move all source code from hindsight-api/ to new hindsight-api-slim/
- hindsight-api-slim has heavy ML deps (torch, sentence-transformers,
  transformers, einops, flashrank, mlx, mlx-lm, safetensors) and
  pg0-embedded as optional extras: [local-ml], [embedded-db], [all]
- hindsight-api becomes a zero-code meta-package depending on
  hindsight-api-slim[all] for full backward compatibility
- Add hindsight-all-slim meta-package: hindsight-api-slim + client + embed
- hindsight-all updated to depend on hindsight-api-slim[all]
- pg0.py: lazy-import pg0 with clear ImportError pointing to [embedded-db]
- Dockerfile: replace sed hack with proper uv sync --extra flags
- Update release.yml, test.yml, lint.sh, release.sh, CLAUDE.md and
  all path references throughout the repo

* refactor: rename hindsight/ directory to hindsight-all/

* docs: document hindsight-api-slim and hindsight-all-slim package variants

Add package variants table and extras explanation to installation.md

* docs: remove emojis from installation.md, use professional tone

* docs: link Docker slim variant to pip package variants section

* docs: consolidate Docker image variants into single table

* ci: fix working-directory paths after package restructure

- Replace all hindsight-api → hindsight-api-slim in test.yml
- Replace hindsight → hindsight-all in test.yml
- Add --extra embedded-db to test-embed API install step

* ci: add local-ml and embedded-db extras to API sync steps

These extras were previously implicit in the old hindsight-api package
(which bundled everything). Now that hindsight-api-slim uses optional
extras, we must explicitly request local-ml and embedded-db in CI.

* ci: add API install step with embedded-db to test-embed smoke test

The smoke test starts hindsight-api as a daemon, which requires pg0-embedded.
Add a dedicated install step for hindsight-api-slim with embedded-db extra
so the daemon can start successfully.

* ci: remove --no-install-project when using optional extras

When --no-install-project is combined with --extra, the optional deps
are not installed because extras require the project to be active.
Remove --no-install-project from steps that need local-ml or embedded-db.

* ci: fix ordering of uv sync steps to preserve optional extras

When uv sync runs for a different workspace member, it removes optional
extras installed for other members. Fix by always running extra-requiring
API sync last, after other workspace member syncs.

Also remove --no-install-project from embedded-db sync in test-embed,
as --no-install-project prevents optional extras from being active.

* ci: add local-ml extra to test-embed API install for smoke test

The smoke test starts the full API server which needs sentence-transformers
for local embeddings (default provider). Add local-ml extra to the install.

* ci: simplify extras with --all-extras and add slim pip smoke test

- Replace explicit --extra local-ml --extra embedded-db with --all-extras
  for cleaner, more maintainable sync steps
- Add test-pip-slim job: tests hindsight-api-slim[embedded-db] without
  local ML models, using Cohere for embeddings/reranking (mirrors Docker
  slim smoke test approach)

* ci: simplify slim smoke test to health check only (mirrors Docker test)
2026-03-13 13:50:03 +01:00

794 lines
30 KiB
Python

"""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)