webhook-platform 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tests/__init__.py ADDED
File without changes
tests/test_client.py ADDED
@@ -0,0 +1,288 @@
1
+ """Tests for WebhookPlatform client."""
2
+
3
+ import pytest
4
+
5
+ from webhook_platform import (
6
+ WebhookPlatform,
7
+ WebhookPlatformError,
8
+ AuthenticationError,
9
+ RateLimitError,
10
+ ValidationError,
11
+ NotFoundError,
12
+ Event,
13
+ EventResponse,
14
+ Endpoint,
15
+ EndpointCreateParams,
16
+ EndpointUpdateParams,
17
+ Subscription,
18
+ SubscriptionCreateParams,
19
+ Delivery,
20
+ DeliveryAttempt,
21
+ DeliveryListParams,
22
+ PaginatedResponse,
23
+ RateLimitInfo,
24
+ )
25
+
26
+
27
+ class TestWebhookPlatformClient:
28
+ """Tests for WebhookPlatform client initialization."""
29
+
30
+ def test_creates_with_api_key(self):
31
+ """Should create client with API key."""
32
+ client = WebhookPlatform(api_key="test_api_key")
33
+ assert client is not None
34
+ assert client.api_key == "test_api_key"
35
+
36
+ def test_raises_without_api_key(self):
37
+ """Should raise error without API key."""
38
+ with pytest.raises(ValueError) as exc:
39
+ WebhookPlatform(api_key="")
40
+ assert "API key is required" in str(exc.value)
41
+
42
+ def test_uses_default_base_url(self):
43
+ """Should use default base URL."""
44
+ client = WebhookPlatform(api_key="test_api_key")
45
+ assert client.base_url == "http://localhost:8080"
46
+
47
+ def test_accepts_custom_base_url(self):
48
+ """Should accept custom base URL."""
49
+ client = WebhookPlatform(
50
+ api_key="test_api_key",
51
+ base_url="https://api.example.com/",
52
+ )
53
+ assert client.base_url == "https://api.example.com"
54
+
55
+ def test_strips_trailing_slash(self):
56
+ """Should strip trailing slash from base URL."""
57
+ client = WebhookPlatform(
58
+ api_key="test_api_key",
59
+ base_url="https://api.example.com/",
60
+ )
61
+ assert not client.base_url.endswith("/")
62
+
63
+ def test_accepts_custom_timeout(self):
64
+ """Should accept custom timeout."""
65
+ client = WebhookPlatform(api_key="test_api_key", timeout=60)
66
+ assert client.timeout == 60
67
+
68
+ def test_initializes_api_modules(self):
69
+ """Should initialize all API modules."""
70
+ client = WebhookPlatform(api_key="test_api_key")
71
+ assert client.events is not None
72
+ assert client.endpoints is not None
73
+ assert client.subscriptions is not None
74
+ assert client.deliveries is not None
75
+
76
+
77
+ class TestErrorClasses:
78
+ """Tests for error classes."""
79
+
80
+ def test_webhook_platform_error(self):
81
+ """WebhookPlatformError should have correct properties."""
82
+ error = WebhookPlatformError("Test error", 500, "test_code")
83
+ assert error.message == "Test error"
84
+ assert error.status == 500
85
+ assert error.code == "test_code"
86
+ assert "Test error" in str(error)
87
+
88
+ def test_authentication_error_defaults(self):
89
+ """AuthenticationError should have correct defaults."""
90
+ error = AuthenticationError()
91
+ assert error.message == "Invalid API key"
92
+ assert error.status == 401
93
+ assert error.code == "authentication_error"
94
+
95
+ def test_authentication_error_custom_message(self):
96
+ """AuthenticationError should accept custom message."""
97
+ error = AuthenticationError("Custom auth error")
98
+ assert error.message == "Custom auth error"
99
+
100
+ def test_rate_limit_error(self):
101
+ """RateLimitError should have rate limit info."""
102
+ info = RateLimitInfo(limit=100, remaining=0, reset=1700000000000)
103
+ error = RateLimitError("Rate limit exceeded", info)
104
+ assert error.status == 429
105
+ assert error.code == "rate_limit_exceeded"
106
+ assert error.rate_limit_info.limit == 100
107
+
108
+ def test_validation_error(self):
109
+ """ValidationError should have field errors."""
110
+ field_errors = {"email": "Invalid email", "url": "Invalid URL"}
111
+ error = ValidationError("Validation failed", field_errors)
112
+ assert error.status == 400
113
+ assert error.code == "validation_error"
114
+ assert error.field_errors == field_errors
115
+
116
+ def test_validation_error_empty_fields(self):
117
+ """ValidationError should default to empty field errors."""
118
+ error = ValidationError("Validation failed")
119
+ assert error.field_errors == {}
120
+
121
+ def test_not_found_error(self):
122
+ """NotFoundError should have correct defaults."""
123
+ error = NotFoundError()
124
+ assert error.message == "Resource not found"
125
+ assert error.status == 404
126
+ assert error.code == "not_found"
127
+
128
+
129
+ class TestTypeClasses:
130
+ """Tests for type/model classes."""
131
+
132
+ def test_event_creation(self):
133
+ """Event should be created correctly."""
134
+ event = Event(type="order.completed", data={"orderId": "123"})
135
+ assert event.type == "order.completed"
136
+ assert event.data == {"orderId": "123"}
137
+
138
+ def test_event_response_from_dict(self):
139
+ """EventResponse should parse from dict correctly."""
140
+ data = {
141
+ "eventId": "evt_123",
142
+ "type": "order.completed",
143
+ "createdAt": "2024-01-01T00:00:00Z",
144
+ "deliveriesCreated": 3,
145
+ }
146
+ response = EventResponse.from_dict(data)
147
+ assert response.event_id == "evt_123"
148
+ assert response.type == "order.completed"
149
+ assert response.deliveries_created == 3
150
+
151
+ def test_endpoint_from_dict(self):
152
+ """Endpoint should parse from dict correctly."""
153
+ data = {
154
+ "id": "ep_123",
155
+ "url": "https://example.com/webhook",
156
+ "secret": "whsec_abc",
157
+ "enabled": True,
158
+ "createdAt": "2024-01-01T00:00:00Z",
159
+ "description": "Test endpoint",
160
+ "rateLimitPerSecond": 10,
161
+ }
162
+ endpoint = Endpoint.from_dict(data)
163
+ assert endpoint.id == "ep_123"
164
+ assert endpoint.url == "https://example.com/webhook"
165
+ assert endpoint.secret == "whsec_abc"
166
+ assert endpoint.enabled is True
167
+ assert endpoint.description == "Test endpoint"
168
+ assert endpoint.rate_limit_per_second == 10
169
+
170
+ def test_endpoint_create_params_to_dict(self):
171
+ """EndpointCreateParams should convert to dict correctly."""
172
+ params = EndpointCreateParams(
173
+ url="https://example.com/webhook",
174
+ description="Test",
175
+ enabled=True,
176
+ rate_limit_per_second=10,
177
+ )
178
+ data = params.to_dict()
179
+ assert data["url"] == "https://example.com/webhook"
180
+ assert data["description"] == "Test"
181
+ assert data["enabled"] is True
182
+ assert data["rateLimitPerSecond"] == 10
183
+
184
+ def test_endpoint_update_params_to_dict(self):
185
+ """EndpointUpdateParams should only include set fields."""
186
+ params = EndpointUpdateParams(url="https://new-url.com")
187
+ data = params.to_dict()
188
+ assert data == {"url": "https://new-url.com"}
189
+
190
+ def test_subscription_from_dict(self):
191
+ """Subscription should parse from dict correctly."""
192
+ data = {
193
+ "id": "sub_123",
194
+ "endpointId": "ep_456",
195
+ "eventTypes": ["order.completed", "order.cancelled"],
196
+ "enabled": True,
197
+ "createdAt": "2024-01-01T00:00:00Z",
198
+ }
199
+ subscription = Subscription.from_dict(data)
200
+ assert subscription.id == "sub_123"
201
+ assert subscription.endpoint_id == "ep_456"
202
+ assert subscription.event_types == ["order.completed", "order.cancelled"]
203
+
204
+ def test_subscription_create_params_to_dict(self):
205
+ """SubscriptionCreateParams should convert to dict correctly."""
206
+ params = SubscriptionCreateParams(
207
+ endpoint_id="ep_123",
208
+ event_types=["order.completed"],
209
+ enabled=True,
210
+ )
211
+ data = params.to_dict()
212
+ assert data["endpointId"] == "ep_123"
213
+ assert data["eventTypes"] == ["order.completed"]
214
+ assert data["enabled"] is True
215
+
216
+ def test_delivery_from_dict(self):
217
+ """Delivery should parse from dict correctly."""
218
+ data = {
219
+ "id": "dlv_123",
220
+ "eventId": "evt_456",
221
+ "endpointId": "ep_789",
222
+ "status": "SUCCESS",
223
+ "attemptCount": 1,
224
+ "maxAttempts": 7,
225
+ "createdAt": "2024-01-01T00:00:00Z",
226
+ "succeededAt": "2024-01-01T00:00:01Z",
227
+ }
228
+ delivery = Delivery.from_dict(data)
229
+ assert delivery.id == "dlv_123"
230
+ assert delivery.event_id == "evt_456"
231
+ assert delivery.status.value == "SUCCESS"
232
+ assert delivery.attempt_count == 1
233
+
234
+ def test_delivery_attempt_from_dict(self):
235
+ """DeliveryAttempt should parse from dict correctly."""
236
+ data = {
237
+ "id": "att_123",
238
+ "attemptNumber": 1,
239
+ "httpStatus": 200,
240
+ "responseBody": "OK",
241
+ "latencyMs": 150,
242
+ "attemptedAt": "2024-01-01T00:00:00Z",
243
+ }
244
+ attempt = DeliveryAttempt.from_dict(data)
245
+ assert attempt.id == "att_123"
246
+ assert attempt.attempt_number == 1
247
+ assert attempt.http_status == 200
248
+ assert attempt.latency_ms == 150
249
+
250
+ def test_delivery_list_params_to_params(self):
251
+ """DeliveryListParams should convert to query params correctly."""
252
+ from webhook_platform.types import DeliveryStatus
253
+
254
+ params = DeliveryListParams(
255
+ status=DeliveryStatus.FAILED,
256
+ endpoint_id="ep_123",
257
+ page=1,
258
+ size=50,
259
+ )
260
+ query_params = params.to_params()
261
+ assert query_params["status"] == "FAILED"
262
+ assert query_params["endpointId"] == "ep_123"
263
+ assert query_params["page"] == 1
264
+ assert query_params["size"] == 50
265
+
266
+ def test_paginated_response_from_dict(self):
267
+ """PaginatedResponse should parse from dict correctly."""
268
+ data = {
269
+ "content": [
270
+ {
271
+ "id": "dlv_1",
272
+ "eventId": "evt_1",
273
+ "endpointId": "ep_1",
274
+ "status": "SUCCESS",
275
+ "attemptCount": 1,
276
+ "maxAttempts": 7,
277
+ "createdAt": "2024-01-01T00:00:00Z",
278
+ }
279
+ ],
280
+ "totalElements": 100,
281
+ "totalPages": 5,
282
+ "size": 20,
283
+ "number": 0,
284
+ }
285
+ response = PaginatedResponse.from_dict(data)
286
+ assert len(response.content) == 1
287
+ assert response.total_elements == 100
288
+ assert response.total_pages == 5
tests/test_webhooks.py ADDED
@@ -0,0 +1,264 @@
1
+ """Tests for webhook signature verification."""
2
+
3
+ import json
4
+ import time
5
+ import pytest
6
+
7
+ from webhook_platform import (
8
+ verify_signature,
9
+ construct_event,
10
+ generate_signature,
11
+ WebhookPlatformError,
12
+ )
13
+
14
+
15
+ class TestGenerateSignature:
16
+ """Tests for generate_signature function."""
17
+
18
+ def test_generates_valid_format(self):
19
+ """Should generate signature in correct format."""
20
+ payload = '{"type": "test"}'
21
+ secret = "whsec_test_secret"
22
+
23
+ signature = generate_signature(payload, secret)
24
+
25
+ assert signature.startswith("t=")
26
+ assert ",v1=" in signature
27
+ parts = signature.split(",")
28
+ assert len(parts) == 2
29
+
30
+ def test_uses_provided_timestamp(self):
31
+ """Should use provided timestamp."""
32
+ payload = '{"type": "test"}'
33
+ secret = "whsec_test_secret"
34
+ timestamp = 1700000000000
35
+
36
+ signature = generate_signature(payload, secret, timestamp)
37
+
38
+ assert f"t={timestamp}" in signature
39
+
40
+ def test_consistent_signatures(self):
41
+ """Should generate consistent signatures for same inputs."""
42
+ payload = '{"type": "test"}'
43
+ secret = "whsec_test_secret"
44
+ timestamp = 1700000000000
45
+
46
+ sig1 = generate_signature(payload, secret, timestamp)
47
+ sig2 = generate_signature(payload, secret, timestamp)
48
+
49
+ assert sig1 == sig2
50
+
51
+ def test_different_payloads_different_signatures(self):
52
+ """Different payloads should produce different signatures."""
53
+ secret = "whsec_test_secret"
54
+ timestamp = 1700000000000
55
+
56
+ sig1 = generate_signature('{"a": 1}', secret, timestamp)
57
+ sig2 = generate_signature('{"b": 2}', secret, timestamp)
58
+
59
+ assert sig1 != sig2
60
+
61
+ def test_different_secrets_different_signatures(self):
62
+ """Different secrets should produce different signatures."""
63
+ payload = '{"type": "test"}'
64
+ timestamp = 1700000000000
65
+
66
+ sig1 = generate_signature(payload, "secret1", timestamp)
67
+ sig2 = generate_signature(payload, "secret2", timestamp)
68
+
69
+ assert sig1 != sig2
70
+
71
+
72
+ class TestVerifySignature:
73
+ """Tests for verify_signature function."""
74
+
75
+ def test_verifies_valid_signature(self):
76
+ """Should verify a valid signature."""
77
+ payload = '{"type": "order.completed", "data": {"id": "123"}}'
78
+ secret = "whsec_test_secret"
79
+ timestamp = int(time.time() * 1000)
80
+ signature = generate_signature(payload, secret, timestamp)
81
+
82
+ assert verify_signature(payload, signature, secret) is True
83
+
84
+ def test_raises_on_missing_signature(self):
85
+ """Should raise on missing signature."""
86
+ with pytest.raises(WebhookPlatformError) as exc:
87
+ verify_signature("payload", "", "secret")
88
+
89
+ assert "Missing signature header" in str(exc.value)
90
+ assert exc.value.code == "invalid_signature"
91
+
92
+ def test_raises_on_invalid_format(self):
93
+ """Should raise on invalid signature format."""
94
+ with pytest.raises(WebhookPlatformError) as exc:
95
+ verify_signature("payload", "invalid_format", "secret")
96
+
97
+ assert "Invalid signature format" in str(exc.value)
98
+
99
+ def test_raises_on_missing_timestamp(self):
100
+ """Should raise when timestamp is missing."""
101
+ with pytest.raises(WebhookPlatformError) as exc:
102
+ verify_signature("payload", "v1=abc123", "secret")
103
+
104
+ assert "Invalid signature format" in str(exc.value)
105
+
106
+ def test_raises_on_missing_v1(self):
107
+ """Should raise when v1 signature is missing."""
108
+ with pytest.raises(WebhookPlatformError) as exc:
109
+ verify_signature("payload", "t=1700000000000", "secret")
110
+
111
+ assert "Invalid signature format" in str(exc.value)
112
+
113
+ def test_raises_on_expired_timestamp(self):
114
+ """Should raise on expired timestamp."""
115
+ payload = '{"type": "test"}'
116
+ secret = "whsec_test_secret"
117
+ old_timestamp = int(time.time() * 1000) - 600000 # 10 min ago
118
+ signature = generate_signature(payload, secret, old_timestamp)
119
+
120
+ with pytest.raises(WebhookPlatformError) as exc:
121
+ verify_signature(payload, signature, secret)
122
+
123
+ assert "outside tolerance window" in str(exc.value)
124
+ assert exc.value.code == "timestamp_expired"
125
+
126
+ def test_raises_on_future_timestamp(self):
127
+ """Should raise on future timestamp outside tolerance."""
128
+ payload = '{"type": "test"}'
129
+ secret = "whsec_test_secret"
130
+ future_timestamp = int(time.time() * 1000) + 600000 # 10 min in future
131
+ signature = generate_signature(payload, secret, future_timestamp)
132
+
133
+ with pytest.raises(WebhookPlatformError) as exc:
134
+ verify_signature(payload, signature, secret)
135
+
136
+ assert "outside tolerance window" in str(exc.value)
137
+
138
+ def test_accepts_timestamp_within_tolerance(self):
139
+ """Should accept timestamp within tolerance."""
140
+ payload = '{"type": "test"}'
141
+ secret = "whsec_test_secret"
142
+ recent_timestamp = int(time.time() * 1000) - 60000 # 1 min ago
143
+ signature = generate_signature(payload, secret, recent_timestamp)
144
+
145
+ assert verify_signature(payload, signature, secret) is True
146
+
147
+ def test_raises_on_invalid_signature(self):
148
+ """Should raise on invalid signature value."""
149
+ payload = '{"type": "test"}'
150
+ secret = "whsec_test_secret"
151
+ timestamp = int(time.time() * 1000)
152
+
153
+ with pytest.raises(WebhookPlatformError) as exc:
154
+ verify_signature(payload, f"t={timestamp},v1=invalid", secret)
155
+
156
+ assert "Invalid signature" in str(exc.value)
157
+
158
+ def test_raises_on_tampered_payload(self):
159
+ """Should raise when payload is tampered."""
160
+ payload = '{"type": "test"}'
161
+ secret = "whsec_test_secret"
162
+ timestamp = int(time.time() * 1000)
163
+ signature = generate_signature(payload, secret, timestamp)
164
+
165
+ tampered = '{"type": "hacked"}'
166
+
167
+ with pytest.raises(WebhookPlatformError) as exc:
168
+ verify_signature(tampered, signature, secret)
169
+
170
+ assert "Invalid signature" in str(exc.value)
171
+
172
+ def test_respects_custom_tolerance(self):
173
+ """Should respect custom tolerance setting."""
174
+ payload = '{"type": "test"}'
175
+ secret = "whsec_test_secret"
176
+ old_timestamp = int(time.time() * 1000) - 60000 # 1 min ago
177
+ signature = generate_signature(payload, secret, old_timestamp)
178
+
179
+ # Should fail with 30s tolerance
180
+ with pytest.raises(WebhookPlatformError):
181
+ verify_signature(payload, signature, secret, tolerance_ms=30000)
182
+
183
+ # Should pass with 2min tolerance
184
+ assert verify_signature(payload, signature, secret, tolerance_ms=120000) is True
185
+
186
+
187
+ class TestConstructEvent:
188
+ """Tests for construct_event function."""
189
+
190
+ def test_constructs_event_from_valid_request(self):
191
+ """Should construct event from valid request."""
192
+ payload = '{"type": "order.completed", "data": {"orderId": "123"}}'
193
+ secret = "whsec_test_secret"
194
+ timestamp = int(time.time() * 1000)
195
+ signature = generate_signature(payload, secret, timestamp)
196
+
197
+ headers = {
198
+ "x-signature": signature,
199
+ "x-timestamp": str(timestamp),
200
+ "x-event-id": "evt_123",
201
+ "x-delivery-id": "dlv_456",
202
+ }
203
+
204
+ event = construct_event(payload, headers, secret)
205
+
206
+ assert event.event_id == "evt_123"
207
+ assert event.delivery_id == "dlv_456"
208
+ assert event.timestamp == timestamp
209
+ assert event.type == "order.completed"
210
+ assert event.data == {"orderId": "123"}
211
+
212
+ def test_handles_uppercase_headers(self):
213
+ """Should handle uppercase headers."""
214
+ payload = '{"type": "test", "data": {}}'
215
+ secret = "whsec_test_secret"
216
+ timestamp = int(time.time() * 1000)
217
+ signature = generate_signature(payload, secret, timestamp)
218
+
219
+ headers = {
220
+ "X-Signature": signature,
221
+ "X-Timestamp": str(timestamp),
222
+ "X-Event-Id": "evt_123",
223
+ }
224
+
225
+ event = construct_event(payload, headers, secret)
226
+
227
+ assert event.event_id == "evt_123"
228
+
229
+ def test_raises_on_missing_signature(self):
230
+ """Should raise on missing signature header."""
231
+ headers = {"x-timestamp": "1700000000000"}
232
+
233
+ with pytest.raises(WebhookPlatformError) as exc:
234
+ construct_event('{"type": "test"}', headers, "secret")
235
+
236
+ assert "Missing X-Signature header" in str(exc.value)
237
+
238
+ def test_raises_on_invalid_json(self):
239
+ """Should raise on invalid JSON payload."""
240
+ secret = "whsec_test_secret"
241
+ timestamp = int(time.time() * 1000)
242
+ invalid_payload = "not valid json"
243
+ signature = generate_signature(invalid_payload, secret, timestamp)
244
+
245
+ headers = {"x-signature": signature}
246
+
247
+ with pytest.raises(WebhookPlatformError) as exc:
248
+ construct_event(invalid_payload, headers, secret)
249
+
250
+ assert "Invalid JSON payload" in str(exc.value)
251
+
252
+ def test_handles_flat_payload(self):
253
+ """Should handle payload without nested data field."""
254
+ payload = '{"type": "test.event", "value": 123}'
255
+ secret = "whsec_test_secret"
256
+ timestamp = int(time.time() * 1000)
257
+ signature = generate_signature(payload, secret, timestamp)
258
+
259
+ headers = {"x-signature": signature}
260
+
261
+ event = construct_event(payload, headers, secret)
262
+
263
+ assert event.type == "test.event"
264
+ assert event.data == {"type": "test.event", "value": 123}
@@ -0,0 +1,54 @@
1
+ """Official Python SDK for Webhook Platform."""
2
+
3
+ from .client import WebhookPlatform
4
+ from .errors import (
5
+ WebhookPlatformError,
6
+ AuthenticationError,
7
+ RateLimitError,
8
+ ValidationError,
9
+ NotFoundError,
10
+ )
11
+ from .webhooks import verify_signature, construct_event, generate_signature
12
+ from .types import (
13
+ Event,
14
+ EventResponse,
15
+ Endpoint,
16
+ EndpointCreateParams,
17
+ EndpointUpdateParams,
18
+ Subscription,
19
+ SubscriptionCreateParams,
20
+ Delivery,
21
+ DeliveryAttempt,
22
+ DeliveryListParams,
23
+ PaginatedResponse,
24
+ EndpointTestResult,
25
+ RateLimitInfo,
26
+ WebhookEvent,
27
+ )
28
+
29
+ __version__ = "1.0.0"
30
+ __all__ = [
31
+ "WebhookPlatform",
32
+ "WebhookPlatformError",
33
+ "AuthenticationError",
34
+ "RateLimitError",
35
+ "ValidationError",
36
+ "NotFoundError",
37
+ "verify_signature",
38
+ "construct_event",
39
+ "generate_signature",
40
+ "Event",
41
+ "EventResponse",
42
+ "Endpoint",
43
+ "EndpointCreateParams",
44
+ "EndpointUpdateParams",
45
+ "Subscription",
46
+ "SubscriptionCreateParams",
47
+ "Delivery",
48
+ "DeliveryAttempt",
49
+ "DeliveryListParams",
50
+ "PaginatedResponse",
51
+ "EndpointTestResult",
52
+ "RateLimitInfo",
53
+ "WebhookEvent",
54
+ ]