File: /home/xedaptot/be.naniguide.com/tests/Feature/SendingServerWebhooks/RecordBounceListenerTest.php
<?php
use App\Listeners\SendingServerWebhooks\RecordBounce;
use App\Model\Blacklist;
use App\Model\BounceLog;
use App\Model\Contact;
use App\Model\Customer;
use App\Model\MailList;
use App\Model\SendingServer;
use App\Model\Subscriber;
use App\Model\TrackingLog;
use App\SendingServers\Webhooks\BounceReceived;
use App\SendingServers\Webhooks\BounceType;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
uses(TestCase::class, DatabaseTransactions::class);
/**
* Listener integration tests: BounceReceived → BounceLog row written + blacklist
* applied when subscriber found. Exercises the FULL DB write path that the
* legacy DeliveryController vendor handlers used to perform.
*
* Fixture pattern: messageId formatted as `<random>.<customerUid>@<host>` so
* `Customer::getCustomerFromKey` resolves via `extractCustomerUidFromMessageId`
* (regex `/\.(?<CustomerUid>[a-z0-9]+)@/`).
*/
function makeBounceFixture(): array
{
$contact = Contact::factory()->create();
$customer = Customer::factory()->create([
'contact_id' => $contact->id,
'uid' => 'rblstcust' . uniqid(),
]);
$list = MailList::forceCreate([
'uid' => 'rblst_' . 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]',
]);
$msgId = 'fix' . uniqid() . '.' . $customer->uid . '@example.com';
$server = SendingServer::factory()->create();
$trackingLog = TrackingLog::forceCreate([
'runtime_message_id' => $msgId,
'message_id' => 'vendor-msg-id-fixture',
'customer_id' => $customer->id,
'subscriber_id' => $subscriber->id,
'sending_server_id' => $server->id,
'status' => 'sent',
]);
return compact('customer', 'list', 'subscriber', 'msgId', 'trackingLog', 'server');
}
test('BounceReceived → BounceLog row with vendor-supplied raw bounce_type', function () {
$f = makeBounceFixture();
$event = new BounceReceived(
runtimeMessageId: $f['msgId'],
type: BounceType::HARD,
bounceTypeRaw: BounceLog::HARD,
shouldBlacklist: false, // skip blacklist path for this test
rawForDb: '{"raw":"payload"}',
rawPayload: ['parsed' => 'payload'],
server: $f['server'],
);
(new RecordBounce())->handle($event);
$log = BounceLog::where('runtime_message_id', $f['msgId'])->firstOrFail();
expect($log->bounce_type)->toBe(BounceLog::HARD)
->and($log->raw)->toBe('{"raw":"payload"}')
->and($log->tracking_log_id)->toBe($f['trackingLog']->id)
->and($log->message_id)->toBe('vendor-msg-id-fixture'); // from tracking log
});
test('BounceReceived with shouldBlacklist=true → subscriber blacklisted', function () {
$f = makeBounceFixture();
$event = new BounceReceived(
runtimeMessageId: $f['msgId'],
type: BounceType::HARD,
bounceTypeRaw: BounceLog::HARD,
shouldBlacklist: true,
rawForDb: '{"raw":"payload"}',
rawPayload: [],
server: $f['server'],
);
(new RecordBounce())->handle($event);
$blacklisted = Blacklist::where('email', $f['subscriber']->email)->exists();
expect($blacklisted)->toBeTrue();
});
test('BounceReceived with messageIdAlwaysRuntime=true overrides tracking_log message_id (Mailgun parity)', function () {
$f = makeBounceFixture();
$event = new BounceReceived(
runtimeMessageId: $f['msgId'],
type: BounceType::HARD,
bounceTypeRaw: BounceLog::HARD,
shouldBlacklist: false,
rawForDb: '{}',
rawPayload: [],
server: $f['server'],
messageIdAlwaysRuntime: true,
);
(new RecordBounce())->handle($event);
$log = BounceLog::where('runtime_message_id', $f['msgId'])->firstOrFail();
// PARITY: legacy SendingServerMailgun set message_id := runtime_message_id,
// OVERRIDING the tracking-log's message_id.
expect($log->message_id)->toBe($f['msgId']);
});
test('BounceReceived with directBlacklistEmail (AWS Q4) → direct Blacklist row when no subscriber match', function () {
// No subscriber linked to this msgId — Q4 path: AWS knows the email
// from SNS payload and writes directly to Blacklist.
$contact = Contact::factory()->create();
$customer = Customer::factory()->create(['uid' => 'awscust' . uniqid(), 'contact_id' => $contact->id]);
$msgId = 'aws-msg-' . uniqid() . '.' . $customer->uid . '@aws.example.com';
// Note: NO TrackingLog created (Q4 happens when tracking log is missing).
$server = new SendingServer(['type' => 'amazon-api', 'name' => 'fixture']);
$server->uid = 'srv-aws-fixture';
$event = new BounceReceived(
runtimeMessageId: $msgId,
type: BounceType::HARD,
bounceTypeRaw: 'Permanent', // PARITY: AWS raw string, not BounceLog::HARD
shouldBlacklist: true,
rawForDb: '{"sns":"payload"}',
rawPayload: [],
server: $server,
directBlacklistEmail: '[email protected]',
);
(new RecordBounce())->handle($event);
expect(BounceLog::where('runtime_message_id', $msgId)->exists())->toBeTrue();
expect(Blacklist::where('email', '[email protected]')->exists())->toBeTrue();
});
test('BounceReceived for unknown customer → log + skip (no DB write)', function () {
$server = new SendingServer(['type' => 'sendgrid-api', 'name' => 'fixture']);
$server->uid = 'srv-unknown';
$event = new BounceReceived(
runtimeMessageId: '[email protected]',
type: BounceType::HARD,
bounceTypeRaw: BounceLog::HARD,
shouldBlacklist: true,
rawForDb: '{}',
rawPayload: [],
server: $server,
);
(new RecordBounce())->handle($event);
expect(BounceLog::where('runtime_message_id', '[email protected]')->exists())->toBeFalse();
});
test('BounceReceived with useLegacyRecordHardBounce=true delegates to BounceLog::recordHardBounce (Blastengine parity)', function () {
$f = makeBounceFixture();
$event = new BounceReceived(
runtimeMessageId: $f['msgId'],
type: BounceType::HARD,
bounceTypeRaw: BounceLog::HARD,
shouldBlacklist: true,
rawForDb: '{"blastengine":true}',
rawPayload: [],
server: $f['server'],
useLegacyRecordHardBounce: true,
);
(new RecordBounce())->handle($event);
// recordHardBounce writes a BounceLog row too — verify path produced one.
expect(BounceLog::where('runtime_message_id', $f['msgId'])->exists())->toBeTrue();
});