HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/ai.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();
});