File: /home/xedaptot/ai.naniguide.com/tests/Unit/SendingServers/Webhooks/SendGridParseWebhookTest.php
<?php
use App\Model\BounceLog;
use App\Model\SendingServer;
use App\SendingServers\Drivers\Vendors\SendGrid\SendGridApiDriver;
use App\SendingServers\Webhooks\BounceReceived;
use App\SendingServers\Webhooks\BounceType;
use App\SendingServers\Webhooks\ComplaintReceived;
use App\SendingServers\Webhooks\DeliveryConfirmed;
use App\SendingServers\Webhooks\FeedbackType;
use App\SendingServers\Webhooks\IgnorableWebhookEvent;
use Illuminate\Http\Request;
use Tests\TestCase;
uses(TestCase::class);
/**
* Parity test: SendGridApiDriver::parseWebhook() must yield domain events
* whose shape matches the byte-for-byte DB writes that the legacy
* DeliveryController::handleSendGrid() produced.
*
* No DB / Eloquent boot needed — driver `parseWebhook` is pure (translates
* payload → events). The DB write happens in listeners (RecordBounce /
* RecordComplaint), tested separately.
*
* Parity contract source: docs/sending-server-polymorphism/SENDING-SERVER-POLYMORPHISM-COMPREHENSIVE-DESIGN.md §4.1.
*/
function makeSendGridApiDriver(): SendGridApiDriver
{
$server = new SendingServer(['type' => 'sendgrid-api', 'name' => 'fixture']);
$server->uid = 'fixture-uid';
return new SendGridApiDriver($server);
}
function buildRequest(string $fixturePath): Request
{
$body = file_get_contents(__DIR__ . '/../../../Fixtures/Webhooks/sendgrid/' . $fixturePath);
return Request::create('/webhook/sendgrid-api/fixture-uid', 'POST', [], [], [], [
'CONTENT_TYPE' => 'application/json',
], $body);
}
test('sendgrid bounce → BounceReceived (HARD, raw=BounceLog::HARD, blacklist)', function () {
$driver = makeSendGridApiDriver();
$events = iterator_to_array($driver->parseWebhook(buildRequest('bounce.json')));
expect($events)->toHaveCount(1)
->and($events[0])->toBeInstanceOf(BounceReceived::class)
->and($events[0]->runtimeMessageId)->toBe('[email protected]')
->and($events[0]->type)->toBe(BounceType::HARD)
->and($events[0]->bounceTypeRaw)->toBe(BounceLog::HARD) // PARITY: legacy stored 'hard' constant
->and($events[0]->shouldBlacklist)->toBeTrue(); // PARITY: legacy always blacklisted
});
test('sendgrid dropped → BounceReceived (same as bounce)', function () {
$driver = makeSendGridApiDriver();
$events = iterator_to_array($driver->parseWebhook(buildRequest('dropped.json')));
expect($events)->toHaveCount(1)
->and($events[0])->toBeInstanceOf(BounceReceived::class)
->and($events[0]->type)->toBe(BounceType::HARD);
});
test('sendgrid spamreport → ComplaintReceived (no blacklist, mark spam-reported)', function () {
$driver = makeSendGridApiDriver();
$events = iterator_to_array($driver->parseWebhook(buildRequest('spamreport.json')));
expect($events)->toHaveCount(1)
->and($events[0])->toBeInstanceOf(ComplaintReceived::class)
->and($events[0]->type)->toBe(FeedbackType::SPAM)
->and($events[0]->feedbackTypeRaw)->toBe('spam') // PARITY: legacy hardcoded 'spam'
->and($events[0]->shouldBlacklist)->toBeFalse() // PARITY: SendGrid spamreport ≠ blacklist
->and($events[0]->shouldMarkSpamReported)->toBeTrue();
});
test('sendgrid delivered → DeliveryConfirmed (no DB action by listener)', function () {
$driver = makeSendGridApiDriver();
$events = iterator_to_array($driver->parseWebhook(buildRequest('delivered.json')));
expect($events)->toHaveCount(1)
->and($events[0])->toBeInstanceOf(DeliveryConfirmed::class);
});
test('sendgrid multi-event payload yields one event per element in order', function () {
$driver = makeSendGridApiDriver();
$events = iterator_to_array($driver->parseWebhook(buildRequest('multi_event.json')));
expect($events)->toHaveCount(6)
->and($events[0])->toBeInstanceOf(BounceReceived::class)
->and($events[1])->toBeInstanceOf(IgnorableWebhookEvent::class)
->and($events[2])->toBeInstanceOf(IgnorableWebhookEvent::class)
->and($events[3])->toBeInstanceOf(ComplaintReceived::class)
->and($events[4])->toBeInstanceOf(DeliveryConfirmed::class)
->and($events[5])->toBeInstanceOf(BounceReceived::class);
});
test('sendgrid missing runtime_message_id → silently skipped (parity)', function () {
$driver = makeSendGridApiDriver();
$events = iterator_to_array($driver->parseWebhook(buildRequest('missing_msgid.json')));
// PARITY: legacy DeliveryController::handleSendGrid did `continue` for
// events without runtime_message_id. We yield zero events (no DB write).
expect($events)->toBeEmpty();
});
test('sendgrid unknown event → IgnorableWebhookEvent (Q1 parity, FOLLOW-UP fix)', function () {
$driver = makeSendGridApiDriver();
$req = Request::create('/webhook/sendgrid-api/fixture-uid', 'POST', [], [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([['event' => 'made-up-event', 'runtime_message_id' => 'x']]));
$events = iterator_to_array($driver->parseWebhook($req));
// Q1 (doc §4.2): legacy `default: // nothing` silently skipped.
// We yield IgnorableWebhookEvent — preserves "no DB write" parity but logs.
expect($events)->toHaveCount(1)
->and($events[0])->toBeInstanceOf(IgnorableWebhookEvent::class)
->and($events[0]->reason)->toContain('unknown');
});
test('sendgrid raw payload preserved verbatim on event', function () {
$driver = makeSendGridApiDriver();
$events = iterator_to_array($driver->parseWebhook(buildRequest('bounce.json')));
expect($events[0]->rawPayload)->toMatchArray([
'event' => 'bounce',
'runtime_message_id' => '[email protected]',
'reason' => '550 5.1.1 The email account that you tried to reach does not exist',
]);
});
// ─── Real-format SendGrid Event Webhook coverage ─────────────────────────────
test('sendgrid real bounce fixture has full Event Webhook envelope (smtp-id, sg_event_id, sg_message_id, ip, tls)', function () {
$driver = makeSendGridApiDriver();
$events = iterator_to_array($driver->parseWebhook(buildRequest('bounce.json')));
// Sanity check fixture mirrors real SendGrid POST body shape.
expect($events[0]->rawPayload)->toMatchArray([
'smtp-id' => '<14c5d75ce93.dfd.64b469@ismtpd-fixture>',
'sg_event_id' => 'rbtnWrG1DVDGGG-fixture-bounce-001',
'sg_message_id' => '14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88819.0',
'ip' => '203.0.113.10',
'tls' => 1,
'cert_err' => 0,
]);
// Domain event surface uses runtime_message_id, not sg_message_id.
expect($events[0]->runtimeMessageId)->toBe('[email protected]');
});
test('sendgrid click → IgnorableWebhookEvent (engagement events not tracked here)', function () {
$driver = makeSendGridApiDriver();
$events = iterator_to_array($driver->parseWebhook(buildRequest('click.json')));
expect($events)->toHaveCount(1)
->and($events[0])->toBeInstanceOf(IgnorableWebhookEvent::class)
->and($events[0]->reason)->toBe('sendgrid:click')
->and($events[0]->rawPayload['url'])->toBe('https://example.com/promo');
});
test('sendgrid unsubscribe → IgnorableWebhookEvent (no DB write parity)', function () {
$driver = makeSendGridApiDriver();
$events = iterator_to_array($driver->parseWebhook(buildRequest('unsubscribe.json')));
expect($events)->toHaveCount(1)
->and($events[0])->toBeInstanceOf(IgnorableWebhookEvent::class)
->and($events[0]->reason)->toBe('sendgrid:unsubscribe');
});
test('sendgrid multi_real (real-format batch) yields ordered domain events', function () {
$driver = makeSendGridApiDriver();
$events = iterator_to_array($driver->parseWebhook(buildRequest('multi_real.json')));
expect($events)->toHaveCount(4)
->and($events[0])->toBeInstanceOf(BounceReceived::class)
->and($events[0]->runtimeMessageId)->toBe('[email protected]')
->and($events[1])->toBeInstanceOf(DeliveryConfirmed::class)
->and($events[1]->runtimeMessageId)->toBe('[email protected]')
->and($events[2])->toBeInstanceOf(ComplaintReceived::class)
->and($events[2]->runtimeMessageId)->toBe('[email protected]')
->and($events[3])->toBeInstanceOf(IgnorableWebhookEvent::class)
->and($events[3]->reason)->toBe('sendgrid:click');
});