diff --git a/tests/Feature/EntitlementWebhookServiceTest.php b/tests/Feature/EntitlementWebhookServiceTest.php new file mode 100644 index 0000000..af83686 --- /dev/null +++ b/tests/Feature/EntitlementWebhookServiceTest.php @@ -0,0 +1,896 @@ +workspace = Workspace::create([ + 'name' => 'Test Workspace', + 'slug' => 'test-workspace', + 'domain' => 'test.host.uk.com', + 'type' => 'cms', + 'is_active' => true, + 'sort_order' => 1, + ]); + + $this->service = app(EntitlementWebhookService::class); + } + + // ------------------------------------------------------------------------- + // Helper + // ------------------------------------------------------------------------- + + protected function createWebhook(array $overrides = []): EntitlementWebhook + { + return EntitlementWebhook::create(array_merge([ + 'workspace_id' => $this->workspace->id, + 'name' => 'Test Webhook', + 'url' => 'https://example.com/webhook', + 'secret' => 'test-secret', + 'events' => ['limit_warning'], + 'is_active' => true, + 'max_attempts' => 3, + ], $overrides)); + } + + protected function createFeature(): Feature + { + return Feature::create([ + 'code' => 'test.feature.'.Str::random(6), + 'name' => 'Test Feature', + 'type' => Feature::TYPE_LIMIT, + 'reset_type' => Feature::RESET_MONTHLY, + 'is_active' => true, + 'sort_order' => 1, + ]); + } + + protected function createDelivery(EntitlementWebhook $webhook, array $overrides = []): EntitlementWebhookDelivery + { + return $webhook->deliveries()->create(array_merge([ + 'uuid' => Str::uuid(), + 'event' => 'limit_warning', + 'attempts' => 1, + 'status' => WebhookDeliveryStatus::FAILED, + 'http_status' => 500, + 'payload' => ['event' => 'limit_warning', 'data' => ['test' => true]], + 'response' => ['error' => 'Server Error'], + 'created_at' => now(), + ], $overrides)); + } + + // ------------------------------------------------------------------------- + // Webhook Registration + // ------------------------------------------------------------------------- + + public function test_it_registers_a_webhook_with_valid_https_url(): void + { + $webhook = $this->service->register( + workspace: $this->workspace, + name: 'Test Webhook', + url: 'https://example.com/webhook', + events: ['limit_warning', 'limit_reached'], + ); + + $this->assertInstanceOf(EntitlementWebhook::class, $webhook); + $this->assertSame('Test Webhook', $webhook->name); + $this->assertSame('https://example.com/webhook', $webhook->url); + $this->assertSame(['limit_warning', 'limit_reached'], $webhook->events); + $this->assertTrue($webhook->is_active); + $this->assertSame(3, $webhook->max_attempts); + $this->assertSame($this->workspace->id, $webhook->workspace_id); + + $this->assertDatabaseHas('entitlement_webhooks', [ + 'workspace_id' => $this->workspace->id, + 'name' => 'Test Webhook', + 'url' => 'https://example.com/webhook', + 'is_active' => true, + ]); + } + + public function test_it_generates_a_secret_when_none_is_provided(): void + { + $webhook = $this->service->register( + workspace: $this->workspace, + name: 'Auto Secret Webhook', + url: 'https://example.com/webhook', + events: ['limit_warning'], + ); + + // Secret should be a 64-character hex string (32 bytes) + $this->assertNotNull($webhook->secret); + $this->assertSame(64, strlen($webhook->secret)); + } + + public function test_it_uses_the_provided_secret_when_given(): void + { + $secret = 'my-custom-secret-key'; + + $webhook = $this->service->register( + workspace: $this->workspace, + name: 'Custom Secret Webhook', + url: 'https://example.com/webhook', + events: ['limit_warning'], + secret: $secret, + ); + + $this->assertSame($secret, $webhook->secret); + } + + public function test_it_filters_events_to_only_allowed_values_during_registration(): void + { + $webhook = $this->service->register( + workspace: $this->workspace, + name: 'Filtered Events Webhook', + url: 'https://example.com/webhook', + events: ['limit_warning', 'invalid_event', 'boost_activated', 'nonexistent'], + ); + + $this->assertEquals(['limit_warning', 'boost_activated'], array_values($webhook->events)); + } + + public function test_it_stores_metadata_during_registration(): void + { + $metadata = ['source' => 'api', 'version' => '1.0']; + + $webhook = $this->service->register( + workspace: $this->workspace, + name: 'Metadata Webhook', + url: 'https://example.com/webhook', + events: ['limit_warning'], + metadata: $metadata, + ); + + $this->assertSame($metadata, $webhook->metadata); + } + + // ------------------------------------------------------------------------- + // Webhook Unregistration + // ------------------------------------------------------------------------- + + public function test_it_unregisters_a_webhook(): void + { + $webhook = $this->service->register( + workspace: $this->workspace, + name: 'To Delete', + url: 'https://example.com/webhook', + events: ['limit_warning'], + ); + + $result = $this->service->unregister($webhook); + + $this->assertTrue($result); + $this->assertDatabaseMissing('entitlement_webhooks', ['id' => $webhook->id]); + } + + // ------------------------------------------------------------------------- + // Webhook Updates + // ------------------------------------------------------------------------- + + public function test_it_updates_webhook_attributes(): void + { + $webhook = $this->service->register( + workspace: $this->workspace, + name: 'Original Name', + url: 'https://example.com/webhook', + events: ['limit_warning'], + ); + + $updated = $this->service->update($webhook, [ + 'name' => 'Updated Name', + 'events' => ['limit_reached', 'boost_activated'], + ]); + + $this->assertSame('Updated Name', $updated->name); + $this->assertSame(['limit_reached', 'boost_activated'], $updated->events); + } + + public function test_it_validates_the_url_when_updating_to_a_new_url(): void + { + $webhook = $this->service->register( + workspace: $this->workspace, + name: 'URL Change Test', + url: 'https://example.com/webhook', + events: ['limit_warning'], + ); + + $this->expectException(InvalidWebhookUrlException::class); + + $this->service->update($webhook, [ + 'url' => 'http://localhost/evil', + ]); + } + + public function test_it_skips_ssrf_validation_when_url_has_not_changed(): void + { + $webhook = $this->service->register( + workspace: $this->workspace, + name: 'Same URL Test', + url: 'https://example.com/webhook', + events: ['limit_warning'], + ); + + $updated = $this->service->update($webhook, [ + 'name' => 'Renamed', + 'url' => 'https://example.com/webhook', + ]); + + $this->assertSame('Renamed', $updated->name); + $this->assertSame('https://example.com/webhook', $updated->url); + } + + public function test_it_filters_events_to_only_allowed_values_during_update(): void + { + $webhook = $this->service->register( + workspace: $this->workspace, + name: 'Event Filter Test', + url: 'https://example.com/webhook', + events: ['limit_warning'], + ); + + $updated = $this->service->update($webhook, [ + 'events' => ['boost_expired', 'fake_event', 'package_changed'], + ]); + + $this->assertEquals(['boost_expired', 'package_changed'], array_values($updated->events)); + } + + // ------------------------------------------------------------------------- + // Signature Verification + // ------------------------------------------------------------------------- + + public function test_it_signs_a_payload_with_hmac_sha256(): void + { + $payload = ['event' => 'test', 'data' => ['key' => 'value']]; + $secret = 'test-secret'; + + $signature = $this->service->sign($payload, $secret); + + $expected = hash_hmac('sha256', json_encode($payload), $secret); + $this->assertSame($expected, $signature); + } + + public function test_it_verifies_a_valid_signature(): void + { + $payload = ['event' => 'test', 'data' => ['key' => 'value']]; + $secret = 'test-secret'; + + $signature = $this->service->sign($payload, $secret); + $result = $this->service->verifySignature($payload, $signature, $secret); + + $this->assertTrue($result); + } + + public function test_it_rejects_an_invalid_signature(): void + { + $payload = ['event' => 'test', 'data' => ['key' => 'value']]; + $secret = 'test-secret'; + + $result = $this->service->verifySignature($payload, 'invalid-signature', $secret); + + $this->assertFalse($result); + } + + public function test_it_rejects_a_signature_computed_with_a_different_secret(): void + { + $payload = ['event' => 'test', 'data' => ['key' => 'value']]; + + $signature = $this->service->sign($payload, 'secret-a'); + $result = $this->service->verifySignature($payload, $signature, 'secret-b'); + + $this->assertFalse($result); + } + + public function test_it_rejects_a_signature_computed_from_a_different_payload(): void + { + $secret = 'test-secret'; + + $signature = $this->service->sign(['event' => 'original'], $secret); + $result = $this->service->verifySignature(['event' => 'tampered'], $signature, $secret); + + $this->assertFalse($result); + } + + // ------------------------------------------------------------------------- + // Dispatch (async) + // ------------------------------------------------------------------------- + + public function test_it_dispatches_webhooks_asynchronously_via_job_queue(): void + { + Bus::fake([DispatchEntitlementWebhook::class]); + + $feature = $this->createFeature(); + $webhook = $this->createWebhook(); + + $event = new LimitWarningEvent( + workspace: $this->workspace, + feature: $feature, + used: 80, + limit: 100, + ); + + $results = $this->service->dispatch($this->workspace, $event, async: true); + + $this->assertCount(1, $results); + $this->assertSame($webhook->id, $results[0]['webhook_id']); + $this->assertTrue($results[0]['success']); + $this->assertTrue($results[0]['queued']); + + Bus::assertDispatched(DispatchEntitlementWebhook::class); + } + + public function test_it_dispatches_webhooks_synchronously_when_async_is_false(): void + { + Http::fake([ + 'https://example.com/webhook' => Http::response(['ok' => true], 200), + ]); + + $feature = $this->createFeature(); + $webhook = $this->createWebhook(); + + $event = new LimitWarningEvent( + workspace: $this->workspace, + feature: $feature, + used: 80, + limit: 100, + ); + + $results = $this->service->dispatch($this->workspace, $event, async: false); + + $this->assertCount(1, $results); + $this->assertSame($webhook->id, $results[0]['webhook_id']); + $this->assertTrue($results[0]['success']); + $this->assertNotNull($results[0]['delivery_id']); + } + + public function test_it_only_dispatches_to_active_webhooks_subscribed_to_the_event(): void + { + Bus::fake([DispatchEntitlementWebhook::class]); + + $feature = $this->createFeature(); + + // Active and subscribed + $this->createWebhook(['name' => 'Active Subscribed', 'url' => 'https://example.com/webhook-1']); + + // Active but not subscribed to this event + $this->createWebhook([ + 'name' => 'Active Unsubscribed', + 'url' => 'https://example.com/webhook-2', + 'events' => ['boost_activated'], + ]); + + // Inactive but subscribed + $this->createWebhook([ + 'name' => 'Inactive Subscribed', + 'url' => 'https://example.com/webhook-3', + 'is_active' => false, + ]); + + $event = new LimitWarningEvent( + workspace: $this->workspace, + feature: $feature, + used: 80, + limit: 100, + ); + + $results = $this->service->dispatch($this->workspace, $event, async: true); + + $this->assertCount(1, $results); + Bus::assertDispatchedTimes(DispatchEntitlementWebhook::class, 1); + } + + public function test_it_returns_empty_results_when_no_webhooks_match(): void + { + Bus::fake([DispatchEntitlementWebhook::class]); + + $feature = $this->createFeature(); + + $event = new LimitWarningEvent( + workspace: $this->workspace, + feature: $feature, + used: 80, + limit: 100, + ); + + $results = $this->service->dispatch($this->workspace, $event); + + $this->assertEmpty($results); + Bus::assertNotDispatched(DispatchEntitlementWebhook::class); + } + + public function test_it_handles_synchronous_dispatch_failure_gracefully(): void + { + Http::fake([ + 'https://example.com/webhook' => Http::response(['error' => 'Server Error'], 500), + ]); + + $feature = $this->createFeature(); + $webhook = $this->createWebhook(); + + $event = new LimitWarningEvent( + workspace: $this->workspace, + feature: $feature, + used: 80, + limit: 100, + ); + + $results = $this->service->dispatch($this->workspace, $event, async: false); + + $this->assertCount(1, $results); + $this->assertSame($webhook->id, $results[0]['webhook_id']); + $this->assertFalse($results[0]['success']); + } + + // ------------------------------------------------------------------------- + // Test Webhook + // ------------------------------------------------------------------------- + + public function test_it_sends_a_test_webhook_delivery(): void + { + Http::fake([ + 'https://example.com/webhook' => Http::response(['received' => true], 200), + ]); + + $webhook = $this->createWebhook(); + $delivery = $this->service->testWebhook($webhook); + + $this->assertInstanceOf(EntitlementWebhookDelivery::class, $delivery); + $this->assertSame('test', $delivery->event); + $this->assertSame(WebhookDeliveryStatus::SUCCESS, $delivery->status); + $this->assertSame(200, $delivery->http_status); + $this->assertSame('test', $delivery->payload['event']); + $this->assertSame($webhook->id, $delivery->payload['data']['webhook_id']); + + Http::assertSent(function ($request) { + return $request->hasHeader('X-Signature') + && $request->hasHeader('X-Test-Webhook'); + }); + } + + public function test_it_records_failed_test_webhook_delivery(): void + { + Http::fake([ + 'https://example.com/webhook' => Http::response(['error' => 'Bad'], 500), + ]); + + $webhook = $this->createWebhook(); + $delivery = $this->service->testWebhook($webhook); + + $this->assertSame(WebhookDeliveryStatus::FAILED, $delivery->status); + $this->assertSame(500, $delivery->http_status); + } + + public function test_it_records_delivery_when_test_webhook_throws_exception(): void + { + Http::fake(fn () => throw new \RuntimeException('Connection timed out')); + + $webhook = $this->createWebhook(); + $delivery = $this->service->testWebhook($webhook); + + $this->assertSame(WebhookDeliveryStatus::FAILED, $delivery->status); + $this->assertArrayHasKey('error', $delivery->response); + } + + // ------------------------------------------------------------------------- + // Retry Delivery + // ------------------------------------------------------------------------- + + public function test_it_retries_a_failed_delivery_successfully(): void + { + Http::fake([ + 'https://example.com/webhook' => Http::response(['ok' => true], 200), + ]); + + $webhook = $this->createWebhook(['failure_count' => 1]); + $delivery = $this->createDelivery($webhook); + + $retried = $this->service->retryDelivery($delivery); + + $this->assertSame(WebhookDeliveryStatus::SUCCESS, $retried->status); + $this->assertSame(2, $retried->attempts); + $this->assertTrue($retried->resent_manually); + $this->assertSame(200, $retried->http_status); + + // Failure count should be reset on success + $webhook->refresh(); + $this->assertSame(0, $webhook->failure_count); + } + + public function test_it_increments_failure_count_when_retry_fails(): void + { + Http::fake([ + 'https://example.com/webhook' => Http::response(['error' => 'Still failing'], 502), + ]); + + $webhook = $this->createWebhook(['failure_count' => 1]); + $delivery = $this->createDelivery($webhook); + + $retried = $this->service->retryDelivery($delivery); + + $this->assertSame(WebhookDeliveryStatus::FAILED, $retried->status); + $this->assertSame(2, $retried->attempts); + $this->assertTrue($retried->resent_manually); + + $webhook->refresh(); + $this->assertSame(2, $webhook->failure_count); + } + + public function test_it_throws_when_retrying_delivery_for_inactive_webhook(): void + { + $webhook = $this->createWebhook(['is_active' => false]); + $delivery = $this->createDelivery($webhook); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot retry delivery for inactive webhook'); + + $this->service->retryDelivery($delivery); + } + + public function test_it_handles_exception_during_retry_gracefully(): void + { + Http::fake(fn () => throw new \RuntimeException('Connection timed out')); + + $webhook = $this->createWebhook(['failure_count' => 0]); + $delivery = $this->createDelivery($webhook); + + $retried = $this->service->retryDelivery($delivery); + + $this->assertSame(WebhookDeliveryStatus::FAILED, $retried->status); + $this->assertSame(2, $retried->attempts); + $this->assertTrue($retried->resent_manually); + $this->assertArrayHasKey('error', $retried->response); + + $webhook->refresh(); + $this->assertSame(1, $webhook->failure_count); + } + + // ------------------------------------------------------------------------- + // Circuit Breaker + // ------------------------------------------------------------------------- + + public function test_it_auto_disables_webhook_after_reaching_max_failures(): void + { + $webhook = $this->createWebhook([ + 'failure_count' => EntitlementWebhook::MAX_FAILURES - 1, + ]); + + // One more failure should trigger the circuit breaker + $webhook->incrementFailureCount(); + + $webhook->refresh(); + $this->assertFalse($webhook->is_active); + $this->assertTrue($webhook->isCircuitBroken()); + $this->assertSame(EntitlementWebhook::MAX_FAILURES, $webhook->failure_count); + } + + public function test_it_resets_the_circuit_breaker(): void + { + $webhook = $this->createWebhook([ + 'is_active' => false, + 'failure_count' => EntitlementWebhook::MAX_FAILURES, + ]); + + $this->assertTrue($webhook->isCircuitBroken()); + + $this->service->resetCircuitBreaker($webhook); + + $webhook->refresh(); + $this->assertTrue($webhook->is_active); + $this->assertSame(0, $webhook->failure_count); + $this->assertFalse($webhook->isCircuitBroken()); + } + + public function test_it_resets_failure_count_on_successful_delivery(): void + { + Http::fake([ + 'https://example.com/webhook' => Http::response(['ok' => true], 200), + ]); + + $webhook = $this->createWebhook(['failure_count' => 3]); + $delivery = $this->createDelivery($webhook); + + $retried = $this->service->retryDelivery($delivery); + + $this->assertSame(WebhookDeliveryStatus::SUCCESS, $retried->status); + + $webhook->refresh(); + $this->assertSame(0, $webhook->failure_count); + } + + // ------------------------------------------------------------------------- + // SSRF Protection + // ------------------------------------------------------------------------- + + public function test_it_blocks_webhook_registration_with_http_url(): void + { + $this->expectException(InvalidWebhookUrlException::class); + + $this->service->register( + workspace: $this->workspace, + name: 'HTTP Webhook', + url: 'http://example.com/webhook', + events: ['limit_warning'], + ); + } + + public function test_it_blocks_webhook_registration_with_localhost_url(): void + { + $this->expectException(InvalidWebhookUrlException::class); + + $this->service->register( + workspace: $this->workspace, + name: 'Localhost Webhook', + url: 'https://localhost/webhook', + events: ['limit_warning'], + ); + } + + public function test_it_blocks_webhook_registration_with_private_ip_url(): void + { + $this->expectException(InvalidWebhookUrlException::class); + + $this->service->register( + workspace: $this->workspace, + name: 'Private IP Webhook', + url: 'https://192.168.1.1/webhook', + events: ['limit_warning'], + ); + } + + public function test_it_blocks_webhook_registration_with_10_range_url(): void + { + $this->expectException(InvalidWebhookUrlException::class); + + $this->service->register( + workspace: $this->workspace, + name: 'Private 10 Webhook', + url: 'https://10.0.0.1/webhook', + events: ['limit_warning'], + ); + } + + public function test_it_blocks_webhook_registration_with_loopback_ip_url(): void + { + $this->expectException(InvalidWebhookUrlException::class); + + $this->service->register( + workspace: $this->workspace, + name: 'Loopback Webhook', + url: 'https://127.0.0.1/webhook', + events: ['limit_warning'], + ); + } + + public function test_it_blocks_webhook_registration_with_local_domain(): void + { + $this->expectException(InvalidWebhookUrlException::class); + + $this->service->register( + workspace: $this->workspace, + name: 'Local Domain Webhook', + url: 'https://myserver.local/webhook', + events: ['limit_warning'], + ); + } + + public function test_it_blocks_webhook_registration_with_internal_domain(): void + { + $this->expectException(InvalidWebhookUrlException::class); + + $this->service->register( + workspace: $this->workspace, + name: 'Internal Domain Webhook', + url: 'https://api.internal/webhook', + events: ['limit_warning'], + ); + } + + public function test_it_blocks_webhook_registration_with_localhost_tld(): void + { + $this->expectException(InvalidWebhookUrlException::class); + + $this->service->register( + workspace: $this->workspace, + name: 'Localhost TLD Webhook', + url: 'https://app.localhost/webhook', + events: ['limit_warning'], + ); + } + + public function test_it_blocks_webhook_update_with_ssrf_url(): void + { + $webhook = $this->service->register( + workspace: $this->workspace, + name: 'SSRF Update Test', + url: 'https://example.com/webhook', + events: ['limit_warning'], + ); + + $this->expectException(InvalidWebhookUrlException::class); + + $this->service->update($webhook, [ + 'url' => 'https://192.168.0.1/evil', + ]); + } + + public function test_it_blocks_test_webhook_with_ssrf_url(): void + { + // Directly create a webhook with a private URL to test the SSRF guard + $webhook = $this->createWebhook(['url' => 'https://10.0.0.1/webhook']); + + $this->expectException(InvalidWebhookUrlException::class); + + $this->service->testWebhook($webhook); + } + + public function test_it_blocks_retry_with_ssrf_url(): void + { + $webhook = $this->createWebhook(['url' => 'https://127.0.0.1/webhook']); + $delivery = $this->createDelivery($webhook); + + $this->expectException(InvalidWebhookUrlException::class); + + $this->service->retryDelivery($delivery); + } + + public function test_it_allows_trusted_webhook_domains(): void + { + $webhook = $this->service->register( + workspace: $this->workspace, + name: 'Discord Webhook', + url: 'https://discord.com/api/webhooks/123/abc', + events: ['limit_warning'], + ); + + $this->assertSame('https://discord.com/api/webhooks/123/abc', $webhook->url); + } + + public function test_it_allows_trusted_subdomain_of_webhook_domains(): void + { + $webhook = $this->service->register( + workspace: $this->workspace, + name: 'Slack Webhook', + url: 'https://hooks.slack.com/services/T00/B00/xxx', + events: ['limit_warning'], + ); + + $this->assertSame('https://hooks.slack.com/services/T00/B00/xxx', $webhook->url); + } + + // ------------------------------------------------------------------------- + // Available Events + // ------------------------------------------------------------------------- + + public function test_it_returns_all_available_webhook_event_types(): void + { + $events = $this->service->getAvailableEvents(); + + $this->assertArrayHasKey('limit_warning', $events); + $this->assertArrayHasKey('limit_reached', $events); + $this->assertArrayHasKey('package_changed', $events); + $this->assertArrayHasKey('boost_activated', $events); + $this->assertArrayHasKey('boost_expired', $events); + + $this->assertArrayHasKey('name', $events['limit_warning']); + $this->assertArrayHasKey('description', $events['limit_warning']); + $this->assertArrayHasKey('class', $events['limit_warning']); + $this->assertSame(LimitWarningEvent::class, $events['limit_warning']['class']); + } + + public function test_it_returns_event_options_as_key_value_pairs(): void + { + $options = $this->service->getEventOptions(); + + $this->assertIsArray($options); + $this->assertCount(5, $options); + $this->assertArrayHasKey('limit_warning', $options); + $this->assertArrayHasKey('limit_reached', $options); + $this->assertArrayHasKey('package_changed', $options); + $this->assertArrayHasKey('boost_activated', $options); + $this->assertArrayHasKey('boost_expired', $options); + } + + // ------------------------------------------------------------------------- + // Workspace Queries + // ------------------------------------------------------------------------- + + public function test_it_retrieves_webhooks_for_a_workspace(): void + { + $this->createWebhook(['name' => 'Webhook A', 'url' => 'https://example.com/a']); + $this->createWebhook(['name' => 'Webhook B', 'url' => 'https://example.com/b', 'events' => ['boost_activated']]); + + // Webhook for a different workspace + $otherWorkspace = Workspace::create([ + 'name' => 'Other Workspace', + 'slug' => 'other-workspace', + 'type' => 'cms', + 'is_active' => true, + 'sort_order' => 2, + ]); + EntitlementWebhook::create([ + 'workspace_id' => $otherWorkspace->id, + 'name' => 'Other Workspace Webhook', + 'url' => 'https://example.com/other', + 'secret' => 'secret', + 'events' => ['limit_warning'], + 'is_active' => true, + 'max_attempts' => 3, + ]); + + $webhooks = $this->service->getWebhooksForWorkspace($this->workspace); + + $this->assertCount(2, $webhooks); + $names = $webhooks->pluck('name')->toArray(); + $this->assertContains('Webhook A', $names); + $this->assertContains('Webhook B', $names); + $this->assertNotContains('Other Workspace Webhook', $names); + } + + public function test_it_retrieves_delivery_history_for_a_webhook(): void + { + $webhook = $this->createWebhook(); + + for ($i = 0; $i < 5; $i++) { + $webhook->deliveries()->create([ + 'uuid' => Str::uuid(), + 'event' => 'limit_warning', + 'status' => $i % 2 === 0 ? WebhookDeliveryStatus::SUCCESS : WebhookDeliveryStatus::FAILED, + 'payload' => ['event' => 'limit_warning', 'data' => ['index' => $i]], + 'created_at' => now()->subMinutes(5 - $i), + ]); + } + + $history = $this->service->getDeliveryHistory($webhook); + + $this->assertCount(5, $history); + } + + public function test_it_limits_delivery_history_results(): void + { + $webhook = $this->createWebhook(); + + for ($i = 0; $i < 10; $i++) { + $webhook->deliveries()->create([ + 'uuid' => Str::uuid(), + 'event' => 'limit_warning', + 'status' => WebhookDeliveryStatus::SUCCESS, + 'payload' => ['event' => 'limit_warning', 'data' => ['index' => $i]], + 'created_at' => now()->subMinutes(10 - $i), + ]); + } + + $history = $this->service->getDeliveryHistory($webhook, limit: 3); + + $this->assertCount(3, $history); + } +}