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:
118
tests/Feature/InstallCommandTest.php
Normal file
118
tests/Feature/InstallCommandTest.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use LeadM\LeadMail\LeadMailClient;
|
||||
|
||||
/**
|
||||
* Bind a client backed by canned HTTP responses into the container so the
|
||||
* command resolves it instead of making real requests.
|
||||
*
|
||||
* @param array<int, mixed> $queue
|
||||
*/
|
||||
function bindMockedClient(array $queue): void
|
||||
{
|
||||
app()->instance(LeadMailClient::class, new LeadMailClient(
|
||||
baseUrl: 'https://mail.test',
|
||||
token: 'lm_test',
|
||||
retryDelayMs: 0,
|
||||
handlerStack: HandlerStack::create(new MockHandler($queue)),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the application at a throwaway .env file and return its path.
|
||||
*/
|
||||
function useTempEnv(string $contents = "APP_NAME=Test\n"): string
|
||||
{
|
||||
$dir = sys_get_temp_dir().'/leadmail-test-'.uniqid();
|
||||
mkdir($dir);
|
||||
file_put_contents($dir.'/.env', $contents);
|
||||
app()->useEnvironmentPath($dir);
|
||||
|
||||
return $dir.'/.env';
|
||||
}
|
||||
|
||||
function registrationResponse(?string $secret): Response
|
||||
{
|
||||
$data = ['url' => 'https://app.test/webhooks/leadmail', 'has_secret' => true];
|
||||
|
||||
if ($secret !== null) {
|
||||
$data['secret'] = $secret;
|
||||
}
|
||||
|
||||
return new Response(200, [], json_encode(['success' => true, 'data' => $data]));
|
||||
}
|
||||
|
||||
it('derives the webhook url from app.url and the configured route without prompting', function () {
|
||||
config()->set('leadmail.token', 'lm_test');
|
||||
config()->set('app.url', 'https://helios.test');
|
||||
config()->set('leadmail.webhook_route', '/webhooks/leadmail');
|
||||
useTempEnv();
|
||||
|
||||
$mock = new MockHandler([registrationResponse('whsec_x')]);
|
||||
app()->instance(LeadMailClient::class, new LeadMailClient(
|
||||
baseUrl: 'https://mail.test',
|
||||
token: 'lm_test',
|
||||
retryDelayMs: 0,
|
||||
handlerStack: HandlerStack::create($mock),
|
||||
));
|
||||
|
||||
$this->artisan('leadmail:install', ['--no-publish' => true])->assertSuccessful();
|
||||
|
||||
$sent = json_decode((string) $mock->getLastRequest()->getBody(), true);
|
||||
expect($sent['url'])->toBe('https://helios.test/webhooks/leadmail');
|
||||
});
|
||||
|
||||
it('registers the webhook and writes the secret to .env', function () {
|
||||
config()->set('leadmail.token', 'lm_test');
|
||||
$envPath = useTempEnv();
|
||||
bindMockedClient([registrationResponse('whsec_written')]);
|
||||
|
||||
$this->artisan('leadmail:install', [
|
||||
'--url' => 'https://app.test/webhooks/leadmail',
|
||||
'--no-publish' => true,
|
||||
])->assertSuccessful();
|
||||
|
||||
expect(file_get_contents($envPath))->toContain('LEADMAIL_WEBHOOK_SECRET=whsec_written');
|
||||
});
|
||||
|
||||
it('replaces an existing secret line instead of duplicating it', function () {
|
||||
config()->set('leadmail.token', 'lm_test');
|
||||
$envPath = useTempEnv("APP_NAME=Test\nLEADMAIL_WEBHOOK_SECRET=whsec_old\n");
|
||||
bindMockedClient([registrationResponse('whsec_new')]);
|
||||
|
||||
$this->artisan('leadmail:install', [
|
||||
'--url' => 'https://app.test/webhooks/leadmail',
|
||||
'--no-publish' => true,
|
||||
])->assertSuccessful();
|
||||
|
||||
$contents = file_get_contents($envPath);
|
||||
expect($contents)->toContain('whsec_new')
|
||||
->and($contents)->not->toContain('whsec_old')
|
||||
->and(substr_count($contents, 'LEADMAIL_WEBHOOK_SECRET='))->toBe(1);
|
||||
});
|
||||
|
||||
it('does not touch .env when the url is updated without a new secret', function () {
|
||||
config()->set('leadmail.token', 'lm_test');
|
||||
$envPath = useTempEnv();
|
||||
bindMockedClient([registrationResponse(null)]);
|
||||
|
||||
$this->artisan('leadmail:install', [
|
||||
'--url' => 'https://app.test/webhooks/leadmail',
|
||||
'--no-publish' => true,
|
||||
])->assertSuccessful();
|
||||
|
||||
expect(file_get_contents($envPath))->not->toContain('LEADMAIL_WEBHOOK_SECRET');
|
||||
});
|
||||
|
||||
it('fails when the api token is not configured', function () {
|
||||
config()->set('leadmail.token', null);
|
||||
useTempEnv();
|
||||
|
||||
$this->artisan('leadmail:install', [
|
||||
'--url' => 'https://app.test/webhooks/leadmail',
|
||||
'--no-publish' => true,
|
||||
])->assertFailed();
|
||||
});
|
||||
Reference in New Issue
Block a user