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/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');
});