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!
261 lines
7.7 KiB
Python
261 lines
7.7 KiB
Python
"""Unit tests for WebhookService.
|
|
|
|
TDD Cycle: RED → GREEN → REFACTOR
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from notebooklm_agent.core.exceptions import (
|
|
NotFoundError,
|
|
ValidationError,
|
|
)
|
|
from notebooklm_agent.services.webhook_service import WebhookService
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestWebhookServiceRegister:
|
|
"""Test suite for webhook registration."""
|
|
|
|
async def test_register_webhook_returns_webhook(self):
|
|
"""Should register webhook and return it."""
|
|
# Arrange
|
|
service = WebhookService()
|
|
url = "https://example.com/webhook"
|
|
events = ["artifact.completed", "source.ready"]
|
|
secret = "my-secret-key"
|
|
|
|
# Act
|
|
result = await service.register(url, events, secret)
|
|
|
|
# Assert
|
|
assert result.url == url
|
|
assert result.events == events
|
|
assert result.secret is True
|
|
assert result.active is True
|
|
|
|
async def test_register_without_secret(self):
|
|
"""Should register webhook without secret."""
|
|
# Arrange
|
|
service = WebhookService()
|
|
url = "https://example.com/webhook"
|
|
events = ["artifact.completed"]
|
|
|
|
# Act
|
|
result = await service.register(url, events)
|
|
|
|
# Assert
|
|
assert result.secret is False
|
|
|
|
async def test_register_http_url_raises_validation_error(self):
|
|
"""Should raise ValidationError for HTTP URL."""
|
|
# Arrange
|
|
service = WebhookService()
|
|
url = "http://example.com/webhook"
|
|
events = ["artifact.completed"]
|
|
|
|
# Act & Assert
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
await service.register(url, events)
|
|
|
|
assert "HTTPS" in str(exc_info.value)
|
|
|
|
async def test_register_invalid_event_raises_validation_error(self):
|
|
"""Should raise ValidationError for invalid event."""
|
|
# Arrange
|
|
service = WebhookService()
|
|
url = "https://example.com/webhook"
|
|
events = ["invalid.event"]
|
|
|
|
# Act & Assert
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
await service.register(url, events)
|
|
|
|
assert "Invalid events" in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestWebhookServiceList:
|
|
"""Test suite for listing webhooks."""
|
|
|
|
async def test_list_returns_empty_list_initially(self):
|
|
"""Should return empty list when no webhooks."""
|
|
# Arrange
|
|
service = WebhookService()
|
|
|
|
# Act
|
|
result = await service.list()
|
|
|
|
# Assert
|
|
assert result == []
|
|
|
|
async def test_list_returns_registered_webhooks(self):
|
|
"""Should return list of registered webhooks."""
|
|
# Arrange
|
|
service = WebhookService()
|
|
await service.register("https://example.com/webhook1", ["artifact.completed"])
|
|
await service.register("https://example.com/webhook2", ["source.ready"])
|
|
|
|
# Act
|
|
result = await service.list()
|
|
|
|
# Assert
|
|
assert len(result) == 2
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestWebhookServiceGet:
|
|
"""Test suite for getting webhook."""
|
|
|
|
async def test_get_returns_webhook(self):
|
|
"""Should return webhook by ID."""
|
|
# Arrange
|
|
service = WebhookService()
|
|
webhook = await service.register("https://example.com/webhook", ["artifact.completed"])
|
|
|
|
# Act
|
|
result = await service.get(str(webhook.id))
|
|
|
|
# Assert
|
|
assert result.id == webhook.id
|
|
|
|
async def test_get_not_found_raises_not_found(self):
|
|
"""Should raise NotFoundError if webhook not found."""
|
|
# Arrange
|
|
service = WebhookService()
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFoundError) as exc_info:
|
|
await service.get("non-existent-id")
|
|
|
|
assert "non-existent-id" in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestWebhookServiceDelete:
|
|
"""Test suite for deleting webhooks."""
|
|
|
|
async def test_delete_removes_webhook(self):
|
|
"""Should delete webhook."""
|
|
# Arrange
|
|
service = WebhookService()
|
|
webhook = await service.register("https://example.com/webhook", ["artifact.completed"])
|
|
|
|
# Act
|
|
await service.delete(str(webhook.id))
|
|
|
|
# Assert
|
|
with pytest.raises(NotFoundError):
|
|
await service.get(str(webhook.id))
|
|
|
|
async def test_delete_not_found_raises_not_found(self):
|
|
"""Should raise NotFoundError if webhook not found."""
|
|
# Arrange
|
|
service = WebhookService()
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFoundError):
|
|
await service.delete("non-existent-id")
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestWebhookServiceSignature:
|
|
"""Test suite for HMAC signature generation."""
|
|
|
|
def test_generate_signature(self):
|
|
"""Should generate HMAC-SHA256 signature."""
|
|
# Arrange
|
|
service = WebhookService()
|
|
payload = '{"event": "test"}'
|
|
secret = "my-secret"
|
|
|
|
# Act
|
|
signature = service._generate_signature(payload, secret)
|
|
|
|
# Assert
|
|
assert len(signature) == 64 # SHA256 hex is 64 chars
|
|
assert all(c in "0123456789abcdef" for c in signature)
|
|
|
|
def test_generate_signature_deterministic(self):
|
|
"""Should generate same signature for same input."""
|
|
# Arrange
|
|
service = WebhookService()
|
|
payload = '{"event": "test"}'
|
|
secret = "my-secret"
|
|
|
|
# Act
|
|
sig1 = service._generate_signature(payload, secret)
|
|
sig2 = service._generate_signature(payload, secret)
|
|
|
|
# Assert
|
|
assert sig1 == sig2
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestWebhookServiceDispatch:
|
|
"""Test suite for event dispatching."""
|
|
|
|
async def test_dispatch_event_sends_to_matching_webhooks(self):
|
|
"""Should dispatch event to webhooks subscribed to that event."""
|
|
# Arrange
|
|
service = WebhookService()
|
|
webhook = await service.register("https://example.com/webhook", ["artifact.completed"])
|
|
|
|
with patch.object(service, "_send_webhook") as mock_send:
|
|
mock_send.return_value = True
|
|
|
|
# Act
|
|
await service.dispatch_event("artifact.completed", {"artifact_id": "123"})
|
|
|
|
# Assert
|
|
mock_send.assert_called_once()
|
|
|
|
async def test_dispatch_event_skips_non_matching_webhooks(self):
|
|
"""Should not dispatch to webhooks not subscribed to event."""
|
|
# Arrange
|
|
service = WebhookService()
|
|
await service.register("https://example.com/webhook", ["source.ready"])
|
|
|
|
with patch.object(service, "_send_webhook") as mock_send:
|
|
# Act
|
|
await service.dispatch_event("artifact.completed", {"artifact_id": "123"})
|
|
|
|
# Assert
|
|
mock_send.assert_not_called()
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestWebhookServiceTest:
|
|
"""Test suite for testing webhooks."""
|
|
|
|
async def test_webhook_sends_test_event(self):
|
|
"""Should send test event to webhook."""
|
|
# Arrange
|
|
service = WebhookService()
|
|
webhook = await service.register("https://example.com/webhook", ["artifact.completed"])
|
|
|
|
with patch.object(service, "_send_webhook") as mock_send:
|
|
mock_send.return_value = True
|
|
|
|
# Act
|
|
result = await service.test_webhook(str(webhook.id))
|
|
|
|
# Assert
|
|
assert result is True
|
|
mock_send.assert_called_once()
|
|
# Check it's a test event
|
|
call_args = mock_send.call_args
|
|
assert call_args[0][1] == "webhook.test"
|
|
|
|
async def test_webhook_not_found_raises_not_found(self):
|
|
"""Should raise NotFoundError if webhook not found."""
|
|
# Arrange
|
|
service = WebhookService()
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFoundError):
|
|
await service.test_webhook("non-existent-id")
|