Add typed error handling, retries, and webhook receiver
- Typed exceptions: LeadMailException base with LeadMailRequestException (structured statusCode/errorCode/logId/validationErrors) and LeadMailConnectionException; sendEmail/getDomains now throw these instead of raw Guzzle exceptions, and a malformed body is no longer a silent null. - Automatic retry with exponential backoff on idempotent calls (getDomains, verifyEmail); sends are never retried to avoid duplicates. - Webhook receiver: auto-registered route + LeadMailWebhookController that verifies the HMAC signature, logs failures, and dispatches a LeadMailWebhookReceived event. WebhookSignature/WebhookEvent/LeadMailWebhook helpers for manual handling. - Webhook self-registration client methods (registerWebhook/getWebhook/ deleteWebhook) and a promptless `leadmail:install` command that registers the URL and writes LEADMAIL_WEBHOOK_SECRET to .env. - Null-safe client binding when LEADMAIL_TOKEN is unset. - Test suite (Pest + Testbench) covering all of the above.
This commit is contained in:
69
tests/Feature/WebhookRouteTest.php
Normal file
69
tests/Feature/WebhookRouteTest.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use LeadM\LeadMail\Events\LeadMailWebhookReceived;
|
||||
|
||||
const ROUTE_SECRET = 'whsec_route_secret';
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('leadmail.webhook_secret', ROUTE_SECRET);
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string} [body, signature]
|
||||
*/
|
||||
function signedBody(array $payload, string $secret = ROUTE_SECRET): array
|
||||
{
|
||||
$body = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return [$body, 'sha256='.hash_hmac('sha256', $body, $secret)];
|
||||
}
|
||||
|
||||
function postWebhook(string $body, ?string $signature)
|
||||
{
|
||||
$server = ['CONTENT_TYPE' => 'application/json'];
|
||||
|
||||
if ($signature !== null) {
|
||||
$server['HTTP_X_LEADMAIL_SIGNATURE'] = $signature;
|
||||
}
|
||||
|
||||
return test()->call('POST', '/webhooks/leadmail', [], [], [], $server, $body);
|
||||
}
|
||||
|
||||
it('auto-registers the webhook route', function () {
|
||||
expect(Route::has('leadmail.webhook'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts a validly signed webhook and dispatches the event', function () {
|
||||
Event::fake([LeadMailWebhookReceived::class]);
|
||||
|
||||
[$body, $signature] = signedBody([
|
||||
'event' => 'email.failed',
|
||||
'log_id' => 85,
|
||||
'error' => ['code' => 'TRANSPORT_ERROR', 'message' => 'Sender address not verified'],
|
||||
'to' => ['lead@example.com'],
|
||||
]);
|
||||
|
||||
postWebhook($body, $signature)->assertNoContent();
|
||||
|
||||
Event::assertDispatched(LeadMailWebhookReceived::class, function ($e) {
|
||||
return $e->event->logId === 85 && $e->event->isFailure();
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an invalid signature with 403 and dispatches nothing', function () {
|
||||
Event::fake([LeadMailWebhookReceived::class]);
|
||||
|
||||
[$body] = signedBody(['event' => 'email.failed', 'log_id' => 1]);
|
||||
|
||||
postWebhook($body, 'sha256=deadbeef')->assertForbidden();
|
||||
|
||||
Event::assertNotDispatched(LeadMailWebhookReceived::class);
|
||||
});
|
||||
|
||||
it('rejects a request with no signature header', function () {
|
||||
[$body] = signedBody(['event' => 'email.failed', 'log_id' => 1]);
|
||||
|
||||
postWebhook($body, null)->assertForbidden();
|
||||
});
|
||||
Reference in New Issue
Block a user