Download tests/payment_integration/test_webhook_processing.py from techprotrade/annator-command-center: direct link, hf CLI and curl.
- Browser
- Download file 16.5 kB
-
https://huggingface.co/spaces/techprotrade/annator-command-center/resolve/main/tests/payment_integration/test_webhook_processing.py
- Command line
-
hf download hf://spaces/techprotrade/annator-command-center/tests/payment_integration/test_webhook_processing.py
-
curl -L -o test_webhook_processing.py https://huggingface.co/spaces/techprotrade/annator-command-center/resolve/main/tests/payment_integration/test_webhook_processing.py
16.5 kB
| """ | |
| Comprehensive webhook processing tests covering signature verification, | |
| deduplication, out-of-order delivery, retries, timeouts, and event types. | |
| Tests use stripe-mock server, Factory Boy factories, and webhook simulator | |
| for deterministic testing without real API calls. | |
| """ | |
| import asyncio | |
| import json | |
| import os | |
| from datetime import datetime, timedelta | |
| from typing import Dict, Any | |
| from unittest.mock import AsyncMock, MagicMock, Mock | |
| import pytest | |
| from freezegun import freeze_time | |
| from integrations.stripe_routes import ( | |
| PROCESSED_WEBHOOK_EVENTS, | |
| cleanup_processed_events, | |
| is_duplicate_event, | |
| mark_event_processed, | |
| verify_webhook_signature, | |
| ) | |
| from tests.fixtures.payment_fixtures import ( | |
| StripeWebhookEventFactory, | |
| StripePaymentIntentFactory, | |
| StripeSubscriptionFactory, | |
| StripeInvoiceFactory, | |
| ) | |
| from tests.mocks.webhook_simulator import ( | |
| WebhookSimulator, | |
| WebhookEventBuilder, | |
| generate_stripe_signature, | |
| simulate_out_of_order_delivery, | |
| simulate_retry_scenario, | |
| ) | |
| class TestWebhookSignatureVerification: | |
| """Tests for webhook signature verification (timing-safe comparison).""" | |
| def test_generate_signature_format(self): | |
| """Signature generator should produce Stripe-format headers.""" | |
| payload = '{"id": "evt_test_123"}' | |
| secret = "whsec_test_secret" | |
| signature = generate_stripe_signature(payload, secret) | |
| # Should have timestamp and v1 hash | |
| assert "t=" in signature | |
| assert "v1=" in signature | |
| # Should be deterministic for same payload | |
| signature2 = generate_stripe_signature(payload, secret) | |
| assert signature == signature2 | |
| def test_signature_different_payloads(self): | |
| """Different payloads should produce different signatures.""" | |
| secret = "whsec_test_secret" | |
| sig1 = generate_stripe_signature('{"id": "evt_1"}', secret) | |
| sig2 = generate_stripe_signature('{"id": "evt_2"}', secret) | |
| assert sig1 != sig2 | |
| def test_signature_different_secrets(self): | |
| """Different secrets should produce different signatures.""" | |
| payload = '{"id": "evt_test"}' | |
| sig1 = generate_stripe_signature(payload, "whsec_secret1") | |
| sig2 = generate_stripe_signature(payload, "whsec_secret2") | |
| assert sig1 != sig2 | |
| def test_verify_webhook_signature_no_secret(self): | |
| """ | |
| Without webhook secret configured, verification should skip | |
| and still parse payload (for testing). | |
| """ | |
| payload = b'{"id": "evt_test", "type": "payment_intent.succeeded"}' | |
| signature = "t=123,v1=abc" | |
| # No secret configured - should parse anyway | |
| event = verify_webhook_signature(payload, signature, "") | |
| assert event["id"] == "evt_test" | |
| def test_webhook_simulator_sends_events(self): | |
| """WebhookSimulator should send events to endpoints.""" | |
| simulator = WebhookSimulator("http://localhost:12111/webhook") | |
| event_builder = WebhookEventBuilder() | |
| event = event_builder.create_payment_succeeded("pi_test", 1000) | |
| # Test that send_event doesn't crash | |
| # (Will fail to connect, but that's OK for this test) | |
| try: | |
| response = simulator.send_event(event) | |
| except Exception: | |
| pass # Expected - no server running | |
| simulator.close() | |
| class TestWebhookDeduplication: | |
| """Tests for webhook event deduplication (prevent double-processing).""" | |
| def clear_cache(self): | |
| """Clear deduplication cache before each test.""" | |
| PROCESSED_WEBHOOK_EVENTS.clear() | |
| yield | |
| PROCESSED_WEBHOOK_EVENTS.clear() | |
| def test_mark_and_check_duplicate(self): | |
| """mark_event_processed should make is_duplicate_event return True.""" | |
| event_id = "evt_test_duplicate" | |
| # Initially not a duplicate | |
| assert not is_duplicate_event(event_id) | |
| # Mark as processed | |
| mark_event_processed(event_id) | |
| # Now it's a duplicate | |
| assert is_duplicate_event(event_id) | |
| def test_different_events_not_duplicates(self): | |
| """Different event IDs should not be considered duplicates.""" | |
| event1 = "evt_test_1" | |
| event2 = "evt_test_2" | |
| mark_event_processed(event1) | |
| assert is_duplicate_event(event1) | |
| assert not is_duplicate_event(event2) | |
| def test_cleanup_old_events(self): | |
| """cleanup_processed_events should remove events older than 24h.""" | |
| # Add event with old timestamp (>24 hours ago) | |
| old_timestamp = int((datetime.now() - timedelta(hours=25)).timestamp()) | |
| PROCESSED_WEBHOOK_EVENTS["evt_old"] = old_timestamp | |
| # Add recent event | |
| recent_timestamp = int((datetime.now() - timedelta(hours=1)).timestamp()) | |
| PROCESSED_WEBHOOK_EVENTS["evt_recent"] = recent_timestamp | |
| # Run cleanup | |
| cleanup_processed_events() | |
| # Old event should be removed | |
| assert "evt_old" not in PROCESSED_WEBHOOK_EVENTS | |
| # Recent event should remain | |
| assert "evt_recent" in PROCESSED_WEBHOOK_EVENTS | |
| def test_cleanup_preserves_recent_events(self): | |
| """Cleanup should not remove events from last 24 hours.""" | |
| # Add events at various ages | |
| now = int(datetime.now().timestamp()) | |
| PROCESSED_WEBHOOK_EVENTS["evt_1h_ago"] = now - 3600 # 1 hour ago | |
| PROCESSED_WEBHOOK_EVENTS["evt_12h_ago"] = now - 43200 # 12 hours ago | |
| PROCESSED_WEBHOOK_EVENTS["evt_23h_ago"] = now - 82800 # 23 hours ago | |
| cleanup_processed_events() | |
| # All should still be there | |
| assert "evt_1h_ago" in PROCESSED_WEBHOOK_EVENTS | |
| assert "evt_12h_ago" in PROCESSED_WEBHOOK_EVENTS | |
| assert "evt_23h_ago" in PROCESSED_WEBHOOK_EVENTS | |
| class TestWebhookOutOfOrderDelivery: | |
| """Tests for out-of-order webhook delivery handling.""" | |
| def clear_cache(self): | |
| """Clear deduplication cache before each test.""" | |
| PROCESSED_WEBHOOK_EVENTS.clear() | |
| yield | |
| PROCESSED_WEBHOOK_EVENTS.clear() | |
| def test_simulate_out_of_order_delivery(self): | |
| """simulate_out_of_order_delivery should shuffle events.""" | |
| events = [ | |
| {"id": "evt_1", "type": "event.1"}, | |
| {"id": "evt_2", "type": "event.2"}, | |
| {"id": "evt_3", "type": "event.3"}, | |
| ] | |
| shuffled = simulate_out_of_order_delivery(events) | |
| # Should have same number of events | |
| assert len(shuffled) == len(events) | |
| # Should have all same IDs | |
| assert {e["id"] for e in shuffled} == {e["id"] for e in events} | |
| def test_out_of_order_events_still_processed(self): | |
| """ | |
| Events processed out of order should all be handled correctly. | |
| Deduplication prevents double-processing. | |
| """ | |
| # Create events in chronological order using factories | |
| event_payment = StripeWebhookEventFactory( | |
| type="payment_intent.succeeded" | |
| ) | |
| event_invoice = StripeWebhookEventFactory( | |
| type="invoice.payment_succeeded" | |
| ) | |
| # Process out of order: invoice first | |
| payment_id_1 = event_payment["id"] | |
| invoice_id = event_invoice["id"] | |
| # Mark invoice as processed first | |
| mark_event_processed(invoice_id) | |
| # Then payment | |
| mark_event_processed(payment_id_1) | |
| # Both should be in cache | |
| assert is_duplicate_event(invoice_id) | |
| assert is_duplicate_event(payment_id_1) | |
| def test_random_shuffle_doesnt_lose_events(self): | |
| """Random shuffling should preserve all events.""" | |
| import random | |
| events = [ | |
| StripeWebhookEventFactory(type="payment_intent.succeeded") | |
| for _ in range(20) | |
| ] | |
| event_ids = {e["id"] for e in events} | |
| # Shuffle multiple times | |
| for _ in range(10): | |
| shuffled = simulate_out_of_order_delivery(events) | |
| shuffled_ids = {e["id"] for e in shuffled} | |
| assert shuffled_ids == event_ids | |
| class TestWebhookRetries: | |
| """Tests for webhook retry behavior with exponential backoff.""" | |
| def test_exponential_backoff_delays(self): | |
| """ | |
| Retry delays should follow exponential backoff: 1s, 2s, 4s. | |
| Uses freezegun to verify timing without actual delays. | |
| """ | |
| scenario = simulate_retry_scenario(2) | |
| # Should fail on attempts 1 and 2 | |
| assert scenario["fail_attempts"] == [1, 2] | |
| # Should succeed on attempt 3 | |
| assert scenario["succeed_on"] == 3 | |
| # Delays should be exponential | |
| assert scenario["retry_delays"] == [1.0, 2.0, 4.0] | |
| def test_retry_scenario_configuration(self): | |
| """simulate_retry_scenario should return valid configuration.""" | |
| scenario_1 = simulate_retry_scenario(1) | |
| scenario_3 = simulate_retry_scenario(3) | |
| assert scenario_1["succeed_on"] == 2 | |
| assert scenario_3["succeed_on"] == 4 | |
| def test_webhook_simulator_retry_logic(self): | |
| """ | |
| WebhookSimulator.send_with_retry should implement exponential backoff. | |
| """ | |
| # Create simulator | |
| simulator = WebhookSimulator("http://localhost:9999/webhook") | |
| event = {"id": "evt_test", "type": "test"} | |
| # Mock responses: fail twice, then succeed | |
| call_count = [0] | |
| def mock_post(*args, **kwargs): | |
| call_count[0] += 1 | |
| if call_count[0] <= 2: | |
| return MagicMock(status_code=503) | |
| return MagicMock(status_code=200, json=lambda: {"status": "ok"}) | |
| with pytest.MonkeyPatch.context() as m: | |
| m.setattr(simulator.client, "post", mock_post) | |
| # This will fail to connect, but tests the logic | |
| try: | |
| simulator.send_with_retry(event, max_retries=3) | |
| except Exception: | |
| pass | |
| # Should have attempted 3 times | |
| assert call_count[0] == 3 | |
| class TestWebhookTimeouts: | |
| """Tests for webhook timeout scenarios.""" | |
| def test_burst_delivery_tagging(self): | |
| """ | |
| simulate_burst_delivery should tag events with timing information. | |
| """ | |
| from tests.mocks.webhook_simulator import simulate_burst_delivery | |
| events = [ | |
| {"id": f"evt_{i}"} | |
| for i in range(100) | |
| ] | |
| tagged = simulate_burst_delivery(events, events_per_second=100) | |
| # Should have all events | |
| assert len(tagged) == 100 | |
| # Should have timing tags | |
| assert "_simulated_delay_ms" in tagged[0] | |
| # First event should have minimal delay | |
| assert tagged[0]["_simulated_delay_ms"] == 0 | |
| # Last event should have delay (~990ms for 100 events at 100/sec) | |
| assert tagged[-1]["_simulated_delay_ms"] >= 990 | |
| def test_burst_delivery_different_rates(self): | |
| """ | |
| Different event rates should produce different delays. | |
| """ | |
| from tests.mocks.webhook_simulator import simulate_burst_delivery | |
| events = [{"id": "evt_1"}, {"id": "evt_2"}] | |
| # 10 events per second = 100ms between events | |
| tagged_slow = simulate_burst_delivery(events, events_per_second=10) | |
| # 100 events per second = 10ms between events | |
| tagged_fast = simulate_burst_delivery(events, events_per_second=100) | |
| # Fast rate should have smaller delays | |
| assert tagged_fast[1]["_simulated_delay_ms"] < tagged_slow[1]["_simulated_delay_ms"] | |
| class TestWebhookEventTypes: | |
| """Tests for different Stripe webhook event types.""" | |
| def clear_cache(self): | |
| """Clear deduplication cache before each test.""" | |
| PROCESSED_WEBHOOK_EVENTS.clear() | |
| yield | |
| PROCESSED_WEBHOOK_EVENTS.clear() | |
| def test_webhook_event_builder_creates_valid_events(self): | |
| """WebhookEventBuilder should create properly formatted events.""" | |
| builder = WebhookEventBuilder() | |
| # Test payment succeeded | |
| event1 = builder.create_payment_succeeded("pi_test", 1000) | |
| assert event1["type"] == "payment_intent.succeeded" | |
| assert event1["data"]["object"]["id"] == "pi_test" | |
| assert event1["data"]["object"]["amount"] == 1000 | |
| # Test payment failed | |
| event2 = builder.create_payment_failed("pi_test", "card_declined") | |
| assert event2["type"] == "payment_intent.payment_failed" | |
| assert event2["data"]["object"]["last_payment_error"]["code"] == "card_declined" | |
| # Test subscription created | |
| event3 = builder.create_subscription_created("sub_test") | |
| assert event3["type"] == "customer.subscription.created" | |
| assert event3["data"]["object"]["id"] == "sub_test" | |
| # Test invoice paid | |
| event4 = builder.create_invoice_paid("in_test", 2000) | |
| assert event4["type"] == "invoice.payment_succeeded" | |
| assert event4["data"]["object"]["amount_paid"] == 2000 | |
| def test_factory_creates_webhook_events(self): | |
| """StripeWebhookEventFactory should create valid events.""" | |
| event = StripeWebhookEventFactory(type="payment_intent.succeeded") | |
| assert "id" in event | |
| assert event["type"] == "payment_intent.succeeded" | |
| assert "data" in event | |
| assert "object" in event["data"] | |
| def test_all_event_types_have_ids(self): | |
| """All webhook events should have unique IDs.""" | |
| builder = WebhookEventBuilder() | |
| events = [ | |
| builder.create_payment_succeeded("pi_1", 1000), | |
| builder.create_payment_failed("pi_2", "card_declined"), | |
| builder.create_subscription_created("sub_1"), | |
| builder.create_invoice_paid("in_1", 1000), | |
| ] | |
| # All should have unique event IDs | |
| event_ids = [e["id"] for e in events] | |
| assert len(event_ids) == len(set(event_ids)) | |
| def test_deduplication_prevents_double_processing(self): | |
| """ | |
| Deduplication cache should prevent same event from being processed twice. | |
| """ | |
| event_id = "evt_test_dedup" | |
| # First time - not a duplicate | |
| assert not is_duplicate_event(event_id) | |
| # Mark as processed | |
| mark_event_processed(event_id) | |
| # Second time - duplicate detected | |
| assert is_duplicate_event(event_id) | |
| class TestWebhookSimulator: | |
| """Tests for WebhookSimulator functionality.""" | |
| def test_webhook_simulator_initialization(self): | |
| """WebhookSimulator should initialize with endpoint URL.""" | |
| simulator = WebhookSimulator("http://localhost:8000/webhook") | |
| assert simulator.webhook_endpoint_url == "http://localhost:8000/webhook" | |
| assert simulator.client is not None | |
| simulator.close() | |
| def test_webhook_simulator_context_manager(self): | |
| """WebhookSimulator should work as context manager.""" | |
| with WebhookSimulator("http://localhost:8000/webhook") as simulator: | |
| assert simulator.webhook_endpoint_url == "http://localhost:8000/webhook" | |
| # Should close automatically | |
| def test_send_event_without_server(self): | |
| """Sending event without server should handle gracefully.""" | |
| simulator = WebhookSimulator("http://localhost:9999/no-server") | |
| event_builder = WebhookEventBuilder() | |
| event = event_builder.create_payment_succeeded("pi_test", 1000) | |
| # Should raise connection error (expected) | |
| with pytest.raises(Exception): | |
| simulator.send_event(event) | |
| simulator.close() | |
| def test_send_duplicate_events(self): | |
| """send_duplicate should send same event multiple times.""" | |
| # This test validates the method exists and has correct signature | |
| simulator = WebhookSimulator("http://localhost:9999/webhook") | |
| event = {"id": "evt_test"} | |
| # Mock to avoid connection error | |
| with pytest.MonkeyPatch.context() as m: | |
| mock_responses = [ | |
| MagicMock(status_code=200), | |
| MagicMock(status_code=200), | |
| ] | |
| call_count = [0] | |
| def mock_post(*args, **kwargs): | |
| response = mock_responses[call_count[0]] | |
| call_count[0] += 1 | |
| return response | |
| m.setattr(simulator.client, "post", mock_post) | |
| responses = simulator.send_duplicate(event, count=2) | |
| assert len(responses) == 2 | |
| simulator.close() | |