File: /home/xedaptot/be.naniguide.com/tests/Feature/SendingServerWebhooks/SendGridHttpFlowTest.php
<?php
use App\Model\Blacklist;
use App\Model\BounceLog;
use App\Model\Contact;
use App\Model\Customer;
use App\Model\FeedbackLog;
use App\Model\MailList;
use App\Model\SendingServer;
use App\Model\Subscriber;
use App\Model\TrackingLog;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
uses(TestCase::class, DatabaseTransactions::class);
/**
* End-to-end HTTP flow tests for SendGrid Event Webhook using REAL
* SendGrid-format payloads. Exercises:
*
* POST /webhook/sendgrid-api/{uid}
* ↓
* SendingServerWebhookController::handle
* ↓
* $server->driver()->verifyWebhook($req) (no-op for SendGrid — parity)
* ↓
* $server->driver()->parseWebhook($req)
* ↓
* yield BounceReceived | ComplaintReceived | DeliveryConfirmed | IgnorableWebhookEvent
* ↓
* event($event) → RecordBounce | RecordComplaint
*
* Two test modes:
* - Event::fake() to assert events dispatched
* - Without fake to exercise listener DB writes
*
* Parity contract source: docs/sending-server-polymorphism/SENDING-SERVER-POLYMORPHISM-COMPREHENSIVE-DESIGN.md §4.1.
*
* Note: unlike AWS, SendGrid driver doesn't validate signature → no test
* subclass needed. verifyWebhook() is a parity-preserving no-op.
*/
function sendgridFixturePayload(string $name): string
{
return file_get_contents(__DIR__ . '/../../Fixtures/Webhooks/sendgrid/' . $name);
}
function makeSendGridServerHttp(): SendingServer
{
$contact = Contact::factory()->create(['uid' => uniqid('cont_', true)]);
$customer = Customer::factory()->create([
'contact_id' => $contact->id,
'uid' => 'sgcust' . uniqid(),
]);
return SendingServer::factory()->create([
'type' => 'sendgrid-api',
'customer_id' => $customer->id,
]);
}
// ─── Event-bus dispatch verification (Event::fake) ───────────────────────────
test('POST /webhook/sendgrid-api/{uid} with bounce → 200 + BounceReceived dispatched (HARD, blacklist)', function () {
Event::fake([\App\SendingServers\Webhooks\BounceReceived::class]);
$server = makeSendGridServerHttp();
$response = $this->call('POST', "/webhook/sendgrid-api/{$server->uid}", [], [], [], [
'CONTENT_TYPE' => 'application/json',
], sendgridFixturePayload('bounce.json'));
$response->assertOk()->assertJson(['ok' => true]);
Event::assertDispatched(
\App\SendingServers\Webhooks\BounceReceived::class,
function ($event) {
return $event->runtimeMessageId === '[email protected]'
&& $event->bounceTypeRaw === \App\Model\BounceLog::HARD
&& $event->shouldBlacklist === true
&& $event->directBlacklistEmail === null; // Q4 is AWS-only
}
);
});
test('POST /webhook/sendgrid-api/{uid} with spamreport → ComplaintReceived (markSpam, NO blacklist — parity)', function () {
Event::fake([\App\SendingServers\Webhooks\ComplaintReceived::class]);
$server = makeSendGridServerHttp();
$this->call('POST', "/webhook/sendgrid-api/{$server->uid}", [], [], [], [
'CONTENT_TYPE' => 'application/json',
], sendgridFixturePayload('spamreport.json'))->assertOk();
Event::assertDispatched(
\App\SendingServers\Webhooks\ComplaintReceived::class,
function ($event) {
// PARITY: SendGrid spamreport ≠ blacklist (different from Mailgun Q7).
return $event->feedbackTypeRaw === 'spam'
&& $event->shouldMarkSpamReported === true
&& $event->shouldBlacklist === false;
}
);
});
test('POST /webhook/sendgrid-api/{uid} with delivered → DeliveryConfirmed dispatched (no DB action)', function () {
Event::fake([\App\SendingServers\Webhooks\DeliveryConfirmed::class]);
$server = makeSendGridServerHttp();
$this->call('POST', "/webhook/sendgrid-api/{$server->uid}", [], [], [], [
'CONTENT_TYPE' => 'application/json',
], sendgridFixturePayload('delivered.json'))->assertOk();
Event::assertDispatched(\App\SendingServers\Webhooks\DeliveryConfirmed::class);
});
test('POST /webhook/sendgrid-api/{uid} with click → IgnorableWebhookEvent (engagement, no DB write)', function () {
Event::fake();
$server = makeSendGridServerHttp();
$this->call('POST', "/webhook/sendgrid-api/{$server->uid}", [], [], [], [
'CONTENT_TYPE' => 'application/json',
], sendgridFixturePayload('click.json'))->assertOk();
Event::assertDispatched(
\App\SendingServers\Webhooks\IgnorableWebhookEvent::class,
function ($event) {
return $event->reason === 'sendgrid:click';
}
);
// Engagement events must not trigger bounce/complaint paths.
Event::assertNotDispatched(\App\SendingServers\Webhooks\BounceReceived::class);
Event::assertNotDispatched(\App\SendingServers\Webhooks\ComplaintReceived::class);
});
test('POST /webhook/sendgrid-api/{uid} multi-event batch dispatches each event in order', function () {
Event::fake();
$server = makeSendGridServerHttp();
$this->call('POST', "/webhook/sendgrid-api/{$server->uid}", [], [], [], [
'CONTENT_TYPE' => 'application/json',
], sendgridFixturePayload('multi_real.json'))->assertOk();
Event::assertDispatched(
\App\SendingServers\Webhooks\BounceReceived::class,
fn ($e) => $e->runtimeMessageId === '[email protected]'
);
Event::assertDispatched(
\App\SendingServers\Webhooks\DeliveryConfirmed::class,
fn ($e) => $e->runtimeMessageId === '[email protected]'
);
Event::assertDispatched(
\App\SendingServers\Webhooks\ComplaintReceived::class,
fn ($e) => $e->runtimeMessageId === '[email protected]'
);
Event::assertDispatched(
\App\SendingServers\Webhooks\IgnorableWebhookEvent::class,
fn ($e) => $e->reason === 'sendgrid:click'
);
});
test('POST /webhook/sendgrid-api/{uid} with malformed body → 200 + no events (parity skip)', function () {
Event::fake();
$server = makeSendGridServerHttp();
$this->call('POST', "/webhook/sendgrid-api/{$server->uid}", [], [], [], [
'CONTENT_TYPE' => 'application/json',
], 'this is not json at all')->assertOk();
// Legacy "pretend we are not here" — silent return 200, no events.
Event::assertNotDispatched(\App\SendingServers\Webhooks\BounceReceived::class);
Event::assertNotDispatched(\App\SendingServers\Webhooks\ComplaintReceived::class);
});
test('POST with type mismatch (sendgrid uid called via amazon-api URL) → 404 cross-tenant guard', function () {
Event::fake();
$server = makeSendGridServerHttp(); // type = sendgrid-api
// URL says amazon-api, but the resolved server is sendgrid-api — guard rejects.
$this->call('POST', "/webhook/amazon-api/{$server->uid}", [], [], [], [
'CONTENT_TYPE' => 'application/json',
], sendgridFixturePayload('bounce.json'))->assertNotFound();
Event::assertNotDispatched(\App\SendingServers\Webhooks\BounceReceived::class);
});
// ─── Full pipeline (no Event::fake) — verify DB write through listener ──────
test('SendGrid bounce full pipeline writes BounceLog row + blacklists subscriber', function () {
$contact = Contact::factory()->create(['uid' => uniqid('cont_', true)]);
$customer = Customer::factory()->create([
'contact_id' => $contact->id,
'uid' => 'sgpipe' . uniqid(),
]);
$list = MailList::forceCreate([
'uid' => 'sgp_' . uniqid(),
'customer_id' => $customer->id,
'name' => 'fixture',
'from_name' => 'Fixture',
'from_email' => '[email protected]',
]);
$subscriber = Subscriber::factory()->create([
'mail_list_id' => $list->id,
'email' => '[email protected]',
]);
$server = SendingServer::factory()->create([
'type' => 'sendgrid-api',
'customer_id' => $customer->id,
]);
$sgMsgId = '[email protected]';
cache()->put("runtime_message_id_{$sgMsgId}", $customer->id);
TrackingLog::forceCreate([
'runtime_message_id' => $sgMsgId,
'message_id' => 'tracking-sg-fixture',
'customer_id' => $customer->id,
'subscriber_id' => $subscriber->id,
'sending_server_id' => $server->id,
'status' => 'sent',
]);
$response = $this->call('POST', "/webhook/sendgrid-api/{$server->uid}", [], [], [], [
'CONTENT_TYPE' => 'application/json',
], sendgridFixturePayload('bounce.json'));
$response->assertOk();
$log = BounceLog::where('runtime_message_id', $sgMsgId)->firstOrFail();
expect($log->bounce_type)->toBe(BounceLog::HARD); // PARITY: legacy stored 'hard' constant
expect($log->raw)->toContain('"event":"bounce"')
->and($log->raw)->toContain('"reason":"550 5.1.1');
// Subscriber-path blacklist (tracking log present).
expect(Blacklist::where('email', $subscriber->email)->exists())->toBeTrue();
});
test('SendGrid bounce with NO tracking log → BounceLog row written, no Blacklist (Q4 AWS-only)', function () {
$contact = Contact::factory()->create(['uid' => uniqid('cont_', true)]);
$customer = Customer::factory()->create([
'contact_id' => $contact->id,
'uid' => 'sgnotrk' . uniqid(),
]);
$server = SendingServer::factory()->create([
'type' => 'sendgrid-api',
'customer_id' => $customer->id,
]);
$sgMsgId = '[email protected]';
cache()->put("runtime_message_id_{$sgMsgId}", $customer->id);
// NO TrackingLog created; SendGrid event has no directBlacklistEmail set
// (Q4 is AWS-only) → listener logs warning, no Blacklist row.
$this->call('POST', "/webhook/sendgrid-api/{$server->uid}", [], [], [], [
'CONTENT_TYPE' => 'application/json',
], sendgridFixturePayload('bounce.json'))->assertOk();
expect(BounceLog::where('runtime_message_id', $sgMsgId)->exists())->toBeTrue();
expect(Blacklist::where('email', '[email protected]')->exists())->toBeFalse();
});
test('SendGrid spamreport full pipeline writes FeedbackLog with raw=spam + markAsSpamReported on subscriber', function () {
$contact = Contact::factory()->create(['uid' => uniqid('cont_', true)]);
$customer = Customer::factory()->create([
'contact_id' => $contact->id,
'uid' => 'sgcpl' . uniqid(),
]);
$list = MailList::forceCreate([
'uid' => 'sgpc_' . uniqid(),
'customer_id' => $customer->id,
'name' => 'fixture',
'from_name' => 'Fixture',
'from_email' => '[email protected]',
]);
$subscriber = Subscriber::factory()->create([
'mail_list_id' => $list->id,
'email' => '[email protected]',
]);
$server = SendingServer::factory()->create([
'type' => 'sendgrid-api',
'customer_id' => $customer->id,
]);
$sgMsgId = '[email protected]';
cache()->put("runtime_message_id_{$sgMsgId}", $customer->id);
TrackingLog::forceCreate([
'runtime_message_id' => $sgMsgId,
'message_id' => 'tracking-sg-spam',
'customer_id' => $customer->id,
'subscriber_id' => $subscriber->id,
'sending_server_id' => $server->id,
'status' => 'sent',
]);
$this->call('POST', "/webhook/sendgrid-api/{$server->uid}", [], [], [], [
'CONTENT_TYPE' => 'application/json',
], sendgridFixturePayload('spamreport.json'))->assertOk();
$log = FeedbackLog::where('runtime_message_id', $sgMsgId)->firstOrFail();
expect($log->feedback_type)->toBe('spam'); // PARITY: legacy hardcoded 'spam'
expect($log->raw_feedback_content)->toContain('"event":"spamreport"');
// PARITY: SendGrid spamreport does NOT blacklist (different from Mailgun Q7).
expect(Blacklist::where('email', $subscriber->email)->exists())->toBeFalse();
});