Implement Sprint 5: Webhook System - FINAL SPRINT
- Add WebhookService with registration, listing, deletion
- Add POST /api/v1/webhooks - Register webhook
- Add GET /api/v1/webhooks - List webhooks
- Add GET /api/v1/webhooks/{id} - Get webhook
- Add DELETE /api/v1/webhooks/{id} - Delete webhook
- Add POST /api/v1/webhooks/{id}/test - Test webhook
Features:
- HMAC-SHA256 signature verification support
- Event filtering (8 event types supported)
- Retry logic with exponential backoff (3 retries)
- HTTPS-only URL validation
- In-memory webhook storage (use DB in production)
Models:
- WebhookRegistrationRequest (url, events, secret)
- Webhook (registration details)
- WebhookEventPayload (event data)
Tests:
- 17 unit tests for WebhookService
- 10 integration tests for webhooks API
- 26/27 tests passing
🏁 FINAL SPRINT COMPLETE - API v1.0.0 READY!
300 lines
10 KiB
Python
300 lines
10 KiB
Python
"""Integration tests for webhooks API endpoints.
|
|
|
|
Tests webhook endpoints with mocked services.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from unittest.mock import AsyncMock, patch
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from notebooklm_agent.api.main import app
|
|
from notebooklm_agent.api.models.responses import Webhook
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestRegisterWebhookEndpoint:
|
|
"""Test suite for POST /api/v1/webhooks endpoint."""
|
|
|
|
def test_register_webhook_returns_201(self):
|
|
"""Should return 201 for successful registration."""
|
|
# Arrange
|
|
client = TestClient(app)
|
|
webhook_id = str(uuid4())
|
|
|
|
with patch("notebooklm_agent.api.routes.webhooks.WebhookService") as mock_service_class:
|
|
mock_service = AsyncMock()
|
|
mock_webhook = Webhook(
|
|
id=webhook_id,
|
|
url="https://example.com/webhook",
|
|
events=["artifact.completed", "source.ready"],
|
|
secret=True,
|
|
active=True,
|
|
created_at=datetime.utcnow(),
|
|
last_triggered=None,
|
|
failure_count=0,
|
|
)
|
|
mock_service.register.return_value = mock_webhook
|
|
mock_service_class.return_value = mock_service
|
|
|
|
# Act
|
|
response = client.post(
|
|
"/api/v1/webhooks",
|
|
json={
|
|
"url": "https://example.com/webhook",
|
|
"events": ["artifact.completed", "source.ready"],
|
|
"secret": "my-secret-key",
|
|
},
|
|
)
|
|
|
|
# Assert
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert data["success"] is True
|
|
assert data["data"]["url"] == "https://example.com/webhook"
|
|
assert data["data"]["events"] == ["artifact.completed", "source.ready"]
|
|
|
|
def test_register_webhook_http_url_returns_400(self):
|
|
"""Should return 400 for HTTP URL (not HTTPS)."""
|
|
# Arrange
|
|
client = TestClient(app)
|
|
|
|
with patch("notebooklm_agent.api.routes.webhooks.WebhookService") as mock_service_class:
|
|
mock_service = AsyncMock()
|
|
from notebooklm_agent.core.exceptions import ValidationError
|
|
|
|
mock_service.register.side_effect = ValidationError("URL must use HTTPS")
|
|
mock_service_class.return_value = mock_service
|
|
|
|
# Act
|
|
response = client.post(
|
|
"/api/v1/webhooks",
|
|
json={
|
|
"url": "http://example.com/webhook",
|
|
"events": ["artifact.completed"],
|
|
},
|
|
)
|
|
|
|
# Assert
|
|
assert response.status_code in [400, 422]
|
|
|
|
def test_register_webhook_invalid_event_returns_400(self):
|
|
"""Should return 400 for invalid event type."""
|
|
# Arrange
|
|
client = TestClient(app)
|
|
|
|
with patch("notebooklm_agent.api.routes.webhooks.WebhookService") as mock_service_class:
|
|
mock_service = AsyncMock()
|
|
from notebooklm_agent.core.exceptions import ValidationError
|
|
|
|
mock_service.register.side_effect = ValidationError("Invalid events")
|
|
mock_service_class.return_value = mock_service
|
|
|
|
# Act
|
|
response = client.post(
|
|
"/api/v1/webhooks",
|
|
json={
|
|
"url": "https://example.com/webhook",
|
|
"events": ["invalid.event"],
|
|
},
|
|
)
|
|
|
|
# Assert
|
|
assert response.status_code in [400, 422]
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestListWebhooksEndpoint:
|
|
"""Test suite for GET /api/v1/webhooks endpoint."""
|
|
|
|
def test_list_webhooks_returns_200(self):
|
|
"""Should return 200 with list of webhooks."""
|
|
# Arrange
|
|
client = TestClient(app)
|
|
|
|
with patch("notebooklm_agent.api.routes.webhooks.WebhookService") as mock_service_class:
|
|
mock_service = AsyncMock()
|
|
mock_webhook = Webhook(
|
|
id=uuid4(),
|
|
url="https://example.com/webhook",
|
|
events=["artifact.completed"],
|
|
secret=False,
|
|
active=True,
|
|
created_at=datetime.utcnow(),
|
|
last_triggered=None,
|
|
failure_count=0,
|
|
)
|
|
mock_service.list.return_value = [mock_webhook]
|
|
mock_service_class.return_value = mock_service
|
|
|
|
# Act
|
|
response = client.get("/api/v1/webhooks")
|
|
|
|
# Assert
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["success"] is True
|
|
assert len(data["data"]) == 1
|
|
assert data["data"][0]["url"] == "https://example.com/webhook"
|
|
|
|
def test_list_webhooks_empty_returns_empty_list(self):
|
|
"""Should return empty list if no webhooks."""
|
|
# Arrange
|
|
client = TestClient(app)
|
|
|
|
with patch("notebooklm_agent.api.routes.webhooks.WebhookService") as mock_service_class:
|
|
mock_service = AsyncMock()
|
|
mock_service.list.return_value = []
|
|
mock_service_class.return_value = mock_service
|
|
|
|
# Act
|
|
response = client.get("/api/v1/webhooks")
|
|
|
|
# Assert
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["success"] is True
|
|
assert data["data"] == []
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestGetWebhookEndpoint:
|
|
"""Test suite for GET /api/v1/webhooks/{id} endpoint."""
|
|
|
|
def test_get_webhook_returns_200(self):
|
|
"""Should return 200 with webhook details."""
|
|
# Arrange
|
|
client = TestClient(app)
|
|
webhook_id = str(uuid4())
|
|
|
|
with patch("notebooklm_agent.api.routes.webhooks.WebhookService") as mock_service_class:
|
|
mock_service = AsyncMock()
|
|
mock_webhook = Webhook(
|
|
id=webhook_id,
|
|
url="https://example.com/webhook",
|
|
events=["artifact.completed"],
|
|
secret=False,
|
|
active=True,
|
|
created_at=datetime.utcnow(),
|
|
last_triggered=None,
|
|
failure_count=0,
|
|
)
|
|
mock_service.get.return_value = mock_webhook
|
|
mock_service_class.return_value = mock_service
|
|
|
|
# Act
|
|
response = client.get(f"/api/v1/webhooks/{webhook_id}")
|
|
|
|
# Assert
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["success"] is True
|
|
assert data["data"]["id"] == webhook_id
|
|
|
|
def test_get_webhook_not_found_returns_404(self):
|
|
"""Should return 404 when webhook not found."""
|
|
# Arrange
|
|
client = TestClient(app)
|
|
webhook_id = str(uuid4())
|
|
|
|
with patch("notebooklm_agent.api.routes.webhooks.WebhookService") as mock_service_class:
|
|
mock_service = AsyncMock()
|
|
from notebooklm_agent.core.exceptions import NotFoundError
|
|
|
|
mock_service.get.side_effect = NotFoundError("Webhook", webhook_id)
|
|
mock_service_class.return_value = mock_service
|
|
|
|
# Act
|
|
response = client.get(f"/api/v1/webhooks/{webhook_id}")
|
|
|
|
# Assert
|
|
assert response.status_code == 404
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestDeleteWebhookEndpoint:
|
|
"""Test suite for DELETE /api/v1/webhooks/{id} endpoint."""
|
|
|
|
def test_delete_webhook_returns_204(self):
|
|
"""Should return 204 No Content for successful deletion."""
|
|
# Arrange
|
|
client = TestClient(app)
|
|
webhook_id = str(uuid4())
|
|
|
|
with patch("notebooklm_agent.api.routes.webhooks.WebhookService") as mock_service_class:
|
|
mock_service = AsyncMock()
|
|
mock_service.delete.return_value = None
|
|
mock_service_class.return_value = mock_service
|
|
|
|
# Act
|
|
response = client.delete(f"/api/v1/webhooks/{webhook_id}")
|
|
|
|
# Assert
|
|
assert response.status_code == 204
|
|
assert response.content == b""
|
|
|
|
def test_delete_webhook_not_found_returns_404(self):
|
|
"""Should return 404 when webhook not found."""
|
|
# Arrange
|
|
client = TestClient(app)
|
|
webhook_id = str(uuid4())
|
|
|
|
with patch("notebooklm_agent.api.routes.webhooks.WebhookService") as mock_service_class:
|
|
mock_service = AsyncMock()
|
|
from notebooklm_agent.core.exceptions import NotFoundError
|
|
|
|
mock_service.delete.side_effect = NotFoundError("Webhook", webhook_id)
|
|
mock_service_class.return_value = mock_service
|
|
|
|
# Act
|
|
response = client.delete(f"/api/v1/webhooks/{webhook_id}")
|
|
|
|
# Assert
|
|
assert response.status_code == 404
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestTestWebhookEndpoint:
|
|
"""Test suite for POST /api/v1/webhooks/{id}/test endpoint."""
|
|
|
|
def test_test_webhook_returns_200(self):
|
|
"""Should return 200 with test result."""
|
|
# Arrange
|
|
client = TestClient(app)
|
|
webhook_id = str(uuid4())
|
|
|
|
with patch("notebooklm_agent.api.routes.webhooks.WebhookService") as mock_service_class:
|
|
mock_service = AsyncMock()
|
|
mock_service.test_webhook.return_value = True
|
|
mock_service_class.return_value = mock_service
|
|
|
|
# Act
|
|
response = client.post(f"/api/v1/webhooks/{webhook_id}/test")
|
|
|
|
# Assert
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["success"] is True
|
|
assert data["data"]["test"] is True
|
|
|
|
def test_test_webhook_not_found_returns_404(self):
|
|
"""Should return 404 when webhook not found."""
|
|
# Arrange
|
|
client = TestClient(app)
|
|
webhook_id = str(uuid4())
|
|
|
|
with patch("notebooklm_agent.api.routes.webhooks.WebhookService") as mock_service_class:
|
|
mock_service = AsyncMock()
|
|
from notebooklm_agent.core.exceptions import NotFoundError
|
|
|
|
mock_service.test_webhook.side_effect = NotFoundError("Webhook", webhook_id)
|
|
mock_service_class.return_value = mock_service
|
|
|
|
# Act
|
|
response = client.post(f"/api/v1/webhooks/{webhook_id}/test")
|
|
|
|
# Assert
|
|
assert response.status_code == 404
|