* 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)
794 lines
30 KiB
Python
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)
|