File: /home/xedaptot/ai.naniguide.com/tests/Unit/SendingServers/Webhooks/SparkPostParseWebhookTest.php
<?php
use App\Model\BounceLog;
use App\Model\SendingServer;
use App\SendingServers\Drivers\Vendors\SparkPost\SparkPostApiDriver;
use App\SendingServers\Webhooks\BounceReceived;
use App\SendingServers\Webhooks\BounceType;
use App\SendingServers\Webhooks\ComplaintReceived;
use App\SendingServers\Webhooks\FeedbackType;
use App\SendingServers\Webhooks\IgnorableWebhookEvent;
use Illuminate\Http\Request;
use Tests\TestCase;
uses(TestCase::class);
/**
* Wave D.3 parity: SparkPost has special quirks
* - Ping handshake body `[{"msys":{}}]` (json_decode → [['msys' => []]]).
* - Three different event types ('bounce','out_of_band','policy_rejection')
* ALL map to BounceLog::HARD historically (legacy parity).
* - Missing rcpt_meta.runtime_message_id silently swallowed (Q3 quirk).
*/
function makeSparkPostDriver(): SparkPostApiDriver
{
$server = new SendingServer(['type' => 'sparkpost-api', 'name' => 'fixture']);
$server->uid = 'fixture-uid';
return new SparkPostApiDriver($server);
}
function spReq(array $body): Request
{
return Request::create('/webhook/sparkpost-api/fixture-uid', 'POST', [], [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode($body));
}
function spEvent(string $type, ?string $msgId = 'fixture-msg'): array
{
return [
'msys' => [
'message_event' => [
'type' => $type,
'rcpt_meta' => $msgId !== null ? ['runtime_message_id' => $msgId] : [],
],
],
];
}
test('sparkpost ping body [[msys:[]]] → no events (handshake)', function () {
$driver = makeSparkPostDriver();
$events = iterator_to_array($driver->parseWebhook(spReq([['msys' => []]])));
expect($events)->toBeEmpty();
});
test('sparkpost bounce → BounceReceived (HARD)', function () {
$driver = makeSparkPostDriver();
$events = iterator_to_array($driver->parseWebhook(spReq([spEvent('bounce')])));
expect($events)->toHaveCount(1)
->and($events[0])->toBeInstanceOf(BounceReceived::class)
->and($events[0]->type)->toBe(BounceType::HARD)
->and($events[0]->bounceTypeRaw)->toBe(BounceLog::HARD);
});
test('sparkpost out_of_band → BounceReceived HARD (legacy parity)', function () {
$driver = makeSparkPostDriver();
$events = iterator_to_array($driver->parseWebhook(spReq([spEvent('out_of_band')])));
expect($events[0])->toBeInstanceOf(BounceReceived::class)
->and($events[0]->type)->toBe(BounceType::HARD);
});
test('sparkpost policy_rejection → BounceReceived HARD (legacy parity)', function () {
$driver = makeSparkPostDriver();
$events = iterator_to_array($driver->parseWebhook(spReq([spEvent('policy_rejection')])));
expect($events[0])->toBeInstanceOf(BounceReceived::class)
->and($events[0]->type)->toBe(BounceType::HARD);
});
test('sparkpost spam_complaint → ComplaintReceived', function () {
$driver = makeSparkPostDriver();
$events = iterator_to_array($driver->parseWebhook(spReq([spEvent('spam_complaint')])));
expect($events[0])->toBeInstanceOf(ComplaintReceived::class)
->and($events[0]->type)->toBe(FeedbackType::SPAM)
->and($events[0]->shouldBlacklist)->toBeFalse()
->and($events[0]->shouldMarkSpamReported)->toBeTrue();
});
test('sparkpost missing rcpt_meta runtime_message_id → silent skip (Q3 quirk)', function () {
$driver = makeSparkPostDriver();
$events = iterator_to_array($driver->parseWebhook(spReq([spEvent('bounce', null)])));
expect($events)->toBeEmpty();
});
test('sparkpost event without message_event → log warning + skip', function () {
$driver = makeSparkPostDriver();
$events = iterator_to_array($driver->parseWebhook(spReq([['msys' => ['track_event' => ['type' => 'open']]]])));
expect($events)->toBeEmpty();
});
test('sparkpost unknown event type → IgnorableWebhookEvent', function () {
$driver = makeSparkPostDriver();
$events = iterator_to_array($driver->parseWebhook(spReq([spEvent('made_up_event')])));
expect($events[0])->toBeInstanceOf(IgnorableWebhookEvent::class);
});