Files
leadmail-sdk/tests/Feature/InstallCommandTest.php
netlas b2b61a26ed 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.
2026-06-09 11:14:10 +03:00

119 lines
3.8 KiB
PHP

<?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();
});