File: /home/xedaptot/be.naniguide.com/tests/Feature/SendingServerWebhooks/AwsSnsHttpFlowTest.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 AWS SNS webhooks using REAL AWS-format
* payloads. Exercises:
*
* POST /webhook/amazon-api/{uid}
* ↓
* SendingServerWebhookController::handle
* ↓
* $server->driver()->verifyWebhook($req) (no-op for AWS — parity)
* ↓
* $server->driver()->parseWebhook($req)
* ↓
* yield BounceReceived | ComplaintReceived | WebhookHandshake | IgnorableWebhookEvent
* ↓
* event($event) → RecordBounce | RecordComplaint | ConfirmSnsSubscription
*
* Two test modes:
* - Event::fake() to assert events dispatched
* - Without fake to exercise listener DB writes
*
* Note: production verifyWebhook validates SNS signature against AWS cert.
* Test fixtures are unsigned. We rebind the driver class via
* DriverRegistry to a test subclass that bypasses validation.
*/
class HttpTestAwsDriver extends \App\SendingServers\Drivers\Vendors\Amazon\AmazonApiDriver
{
protected function buildAndValidateSnsMessage(array $data): \Aws\Sns\Message
{
return new \Aws\Sns\Message($data); // skip signature validate
}
}
beforeEach(function () {
\App\SendingServers\DriverRegistry::register('amazon-api', HttpTestAwsDriver::class);
});
afterEach(function () {
\App\SendingServers\DriverRegistry::register(
'amazon-api',
\App\SendingServers\Drivers\Vendors\Amazon\AmazonApiDriver::class
);
});
function awsFixturePayload(string $name): string
{
return file_get_contents(__DIR__ . '/../../Fixtures/Webhooks/aws/' . $name);
}
function makeAwsServerHttp(): SendingServer
{
$contact = Contact::factory()->create(['uid' => uniqid('cont_', true)]);
$customer = Customer::factory()->create([
'contact_id' => $contact->id,
'uid' => 'awscust' . uniqid(),
]);
return SendingServer::factory()->create([
'type' => 'amazon-api',
'customer_id' => $customer->id,
]);
}
// ─── Event-bus dispatch verification (Event::fake) ───────────────────────────
test('POST /webhook/amazon-api/{uid} with Permanent bounce → 200 + BounceReceived dispatched', function () {
Event::fake([\App\SendingServers\Webhooks\BounceReceived::class]);
$server = makeAwsServerHttp();
$response = $this->call('POST', "/webhook/amazon-api/{$server->uid}", [], [], [], [
'CONTENT_TYPE' => 'application/json',
], awsFixturePayload('bounce_permanent.json'));
$response->assertOk()->assertJson(['ok' => true]);
Event::assertDispatched(
\App\SendingServers\Webhooks\BounceReceived::class,
function ($event) {
return $event->bounceTypeRaw === 'Permanent'
&& $event->shouldBlacklist === true
&& $event->directBlacklistEmail === '[email protected]';
}
);
});
test('POST /webhook/amazon-api/{uid} with Transient bounce → BounceReceived (SOFT, no blacklist)', function () {
Event::fake([\App\SendingServers\Webhooks\BounceReceived::class]);
$server = makeAwsServerHttp();
$this->call('POST', "/webhook/amazon-api/{$server->uid}", [], [], [], [
'CONTENT_TYPE' => 'application/json',
], awsFixturePayload('bounce_transient.json'))->assertOk();
Event::assertDispatched(
\App\SendingServers\Webhooks\BounceReceived::class,
function ($event) {
return $event->type === \App\SendingServers\Webhooks\BounceType::SOFT
&& $event->bounceTypeRaw === 'Transient'
&& $event->shouldBlacklist === false;
}
);
});
test('POST /webhook/amazon-api/{uid} with Complaint abuse → ComplaintReceived (markSpam, no blacklist)', function () {
Event::fake([\App\SendingServers\Webhooks\ComplaintReceived::class]);
$server = makeAwsServerHttp();
$this->call('POST', "/webhook/amazon-api/{$server->uid}", [], [], [], [
'CONTENT_TYPE' => 'application/json',
], awsFixturePayload('complaint_abuse.json'))->assertOk();
Event::assertDispatched(
\App\SendingServers\Webhooks\ComplaintReceived::class,
function ($event) {
return $event->feedbackTypeRaw === 'abuse'
&& $event->shouldMarkSpamReported === true
&& $event->shouldBlacklist === false;
}
);
});
test('POST /webhook/amazon-api/{uid} with SubscriptionConfirmation → WebhookHandshake dispatched', function () {
Event::fake([\App\SendingServers\Webhooks\WebhookHandshake::class]);
$server = makeAwsServerHttp();
$this->call('POST', "/webhook/amazon-api/{$server->uid}", [], [], [], [
'CONTENT_TYPE' => 'application/json',
], awsFixturePayload('subscription_confirmation.json'))->assertOk();
Event::assertDispatched(
\App\SendingServers\Webhooks\WebhookHandshake::class,
function ($event) {
return str_contains($event->confirmUrl, 'Action=ConfirmSubscription')
&& str_contains($event->confirmUrl, 'Token=2336412f37fb687f5d51e6e2425fixture-token');
}
);
});
test('POST /webhook/amazon-api/{uid} with malformed body → 200 + no events (parity skip)', function () {
Event::fake();
$server = makeAwsServerHttp();
$this->call('POST', "/webhook/amazon-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);
});
// ─── Full pipeline (no Event::fake) — verify DB write through listener ──────
test('AWS Permanent bounce full pipeline writes BounceLog row + blacklists subscriber', function () {
// Build a real customer + tracking log so listener can resolve customer
// and find the subscriber. messageId in fixture is
// "01000168f0f9e3f4-fixture-msg-permanent" — driver uses it as runtime_message_id
// for the event. Listener looks up Customer::getCustomerFromKey(msgId).
$contact = Contact::factory()->create(['uid' => uniqid('cont_', true)]);
$customer = Customer::factory()->create([
'contact_id' => $contact->id,
'uid' => 'awspipe' . uniqid(),
]);
$list = MailList::forceCreate([
'uid' => 'awsp_' . 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' => 'amazon-api',
'customer_id' => $customer->id,
]);
// The fixture's runtime_message_id is the AWS SES messageId. To make
// Customer::getCustomerFromKey resolve to OUR test customer, override
// the cache so getCustomerFromKey returns it.
$awsMsgId = '01000168f0f9e3f4-fixture-msg-permanent';
cache()->put("runtime_message_id_{$awsMsgId}", $customer->id);
TrackingLog::forceCreate([
'runtime_message_id' => $awsMsgId,
'message_id' => 'tracking-aws-fixture',
'customer_id' => $customer->id,
'subscriber_id' => $subscriber->id,
'sending_server_id' => $server->id,
'status' => 'sent',
]);
$response = $this->call('POST', "/webhook/amazon-api/{$server->uid}", [], [], [], [
'CONTENT_TYPE' => 'application/json',
], awsFixturePayload('bounce_permanent.json'));
$response->assertOk();
// Listener wrote the BounceLog row.
$log = BounceLog::where('runtime_message_id', $awsMsgId)->firstOrFail();
expect($log->bounce_type)->toBe('Permanent'); // PARITY: raw AWS string
expect($log->raw)->toContain('"notificationType":"Bounce"')
->and($log->raw)->not->toContain('"Type":"Notification"'); // inner Message only
// Listener blacklisted via subscriber path (tracking log present).
expect(Blacklist::where('email', $subscriber->email)->exists())->toBeTrue();
});
test('AWS Permanent bounce with NO tracking log → Q4 path: direct Blacklist by destination email', function () {
$contact = Contact::factory()->create(['uid' => uniqid('cont_', true)]);
$customer = Customer::factory()->create([
'contact_id' => $contact->id,
'uid' => 'awsq4' . uniqid(),
]);
$server = SendingServer::factory()->create([
'type' => 'amazon-api',
'customer_id' => $customer->id,
]);
$awsMsgId = '01000168f0f9e3f4-fixture-msg-permanent';
cache()->put("runtime_message_id_{$awsMsgId}", $customer->id);
// NO TrackingLog created → Q4 path triggers.
$this->call('POST', "/webhook/amazon-api/{$server->uid}", [], [], [], [
'CONTENT_TYPE' => 'application/json',
], awsFixturePayload('bounce_permanent.json'))->assertOk();
// Q4: direct Blacklist row keyed by mail.destination[0] = '[email protected]'.
expect(Blacklist::where('email', '[email protected]')->exists())->toBeTrue();
});
test('AWS Complaint full pipeline writes FeedbackLog with raw=abuse + markAsSpamReported on subscriber', function () {
$contact = Contact::factory()->create(['uid' => uniqid('cont_', true)]);
$customer = Customer::factory()->create([
'contact_id' => $contact->id,
'uid' => 'awscpl' . uniqid(),
]);
$list = MailList::forceCreate([
'uid' => 'awspc_' . 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' => 'amazon-api',
'customer_id' => $customer->id,
]);
$awsMsgId = '01000168f0f9e3f4-fixture-msg-complaint-abuse';
cache()->put("runtime_message_id_{$awsMsgId}", $customer->id);
TrackingLog::forceCreate([
'runtime_message_id' => $awsMsgId,
'message_id' => 'tracking-cpl-fixture',
'customer_id' => $customer->id,
'subscriber_id' => $subscriber->id,
'sending_server_id' => $server->id,
'status' => 'sent',
]);
$this->call('POST', "/webhook/amazon-api/{$server->uid}", [], [], [], [
'CONTENT_TYPE' => 'application/json',
], awsFixturePayload('complaint_abuse.json'))->assertOk();
$log = FeedbackLog::where('runtime_message_id', $awsMsgId)->firstOrFail();
expect($log->feedback_type)->toBe('abuse'); // PARITY: raw vendor type, not 'spam'
expect($log->raw_feedback_content)->toContain('"notificationType":"Complaint"')
->and($log->raw_feedback_content)->toContain('"complaintFeedbackType":"abuse"');
// PARITY: AWS Complaint does NOT blacklist (different from Mailgun Q7).
expect(Blacklist::where('email', $subscriber->email)->exists())->toBeFalse();
});