feat(notification): implement idempotent email delivery system and update related services

This commit is contained in:
2026-09-14 09:41:40 -03:00
parent 756f4dad0a
commit 9c14153199
12 changed files with 604 additions and 267 deletions

View File

@@ -0,0 +1,136 @@
<?php
namespace Tests\Feature\Notification;
use App\Domains\Notification\Models\EmailDelivery;
use App\Domains\Notification\Services\IdempotentEmailDeliveryService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use RuntimeException;
use Tests\TestCase;
class IdempotentEmailDeliveryServiceTest extends TestCase
{
use RefreshDatabase;
public function test_it_sends_once_for_the_same_business_key(): void
{
$calls = 0;
$service = app(IdempotentEmailDeliveryService::class);
$first = $service->sendOnce(
'welcome:tenant:10',
'welcome',
'tenant',
['user_id' => 10],
'ada@example.com',
function () use (&$calls): void {
$calls++;
},
);
$second = $service->sendOnce(
'welcome:tenant:10',
'welcome',
'tenant',
['user_id' => 10],
'ada@example.com',
function () use (&$calls): void {
$calls++;
},
);
$this->assertTrue($first);
$this->assertFalse($second);
$this->assertSame(1, $calls);
$this->assertDatabaseHas('email_deliveries', [
'idempotency_key' => 'welcome:tenant:10',
'status' => EmailDelivery::STATUS_SENT,
'attempts' => 1,
]);
}
public function test_it_records_a_failure_and_allows_a_retry(): void
{
$service = app(IdempotentEmailDeliveryService::class);
try {
$service->sendOnce(
'purchase-confirmed:20',
'purchase_confirmed',
'tenant',
['purchase_id' => 20],
'buyer@example.com',
fn () => throw new RuntimeException('Sensitive SMTP detail'),
);
$this->fail('The delivery exception was not rethrown.');
} catch (RuntimeException) {
$this->assertDatabaseHas('email_deliveries', [
'idempotency_key' => 'purchase-confirmed:20',
'status' => EmailDelivery::STATUS_FAILED,
'attempts' => 1,
'last_error' => RuntimeException::class,
]);
}
$sent = $service->sendOnce(
'purchase-confirmed:20',
'purchase_confirmed',
'tenant',
['purchase_id' => 20],
'buyer@example.com',
static function (): void {},
);
$this->assertTrue($sent);
$this->assertDatabaseHas('email_deliveries', [
'idempotency_key' => 'purchase-confirmed:20',
'status' => EmailDelivery::STATUS_SENT,
'attempts' => 2,
'last_error' => null,
]);
}
public function test_it_recovers_an_expired_claim_but_not_an_active_one(): void
{
config(['mail.delivery_lease_seconds' => 300]);
$service = app(IdempotentEmailDeliveryService::class);
$delivery = EmailDelivery::query()->create([
'idempotency_key' => 'password-reset:30',
'email_type' => 'password_reset',
'tenant_code' => 'tenant',
'status' => EmailDelivery::STATUS_PROCESSING,
'attempts' => 1,
'context' => ['attempt_id' => 30],
'recipient_fingerprint' => str_repeat('a', 64),
'claim_token' => fake()->uuid(),
'claimed_at' => now(),
'lease_expires_at' => now()->addMinute(),
]);
$activeClaim = $service->sendOnce(
$delivery->idempotency_key,
$delivery->email_type,
$delivery->tenant_code,
$delivery->context,
'ada@example.com',
static function (): void {},
);
$this->assertFalse($activeClaim);
$delivery->update(['lease_expires_at' => now()->subSecond()]);
$expiredClaim = $service->sendOnce(
$delivery->idempotency_key,
$delivery->email_type,
$delivery->tenant_code,
$delivery->context,
'ada@example.com',
static function (): void {},
);
$this->assertTrue($expiredClaim);
$this->assertDatabaseHas('email_deliveries', [
'idempotency_key' => 'password-reset:30',
'status' => EmailDelivery::STATUS_SENT,
'attempts' => 2,
]);
}
}

View File

@@ -14,6 +14,7 @@ use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Ticket\Models\Ticket;
use Barryvdh\DomPDF\ServiceProvider as DomPdfServiceProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Mail\Mailable;
use Illuminate\Support\Facades\Mail;
@@ -31,6 +32,7 @@ class NotificationMailServiceTest extends TestCase
{
parent::setUp();
$this->app->register(DomPdfServiceProvider::class);
Mail::fake();
Integration::query()->create([
'integration_code' => 'email',
@@ -75,6 +77,9 @@ class NotificationMailServiceTest extends TestCase
$this->useWebsiteTypeBranding();
app(NotificationMailService::class)->sendWelcome($this->user->id, $this->tenant->codigo);
app(NotificationMailService::class)->sendWelcome($this->user->id, $this->tenant->codigo);
Mail::assertSent(Mailable::class, 1);
Mail::assertSent(Mailable::class, function (Mailable $mail): bool {
$mail->assertTo('ada@example.com');
@@ -98,6 +103,12 @@ class NotificationMailServiceTest extends TestCase
$attempt->id,
$this->tenant->codigo,
);
app(NotificationMailService::class)->sendPasswordResetCode(
$attempt->id,
$this->tenant->codigo,
);
Mail::assertSent(Mailable::class, 1);
Mail::assertSent(Mailable::class, function (Mailable $mail): bool {
$mail->assertTo('ada@example.com');
@@ -113,6 +124,27 @@ class NotificationMailServiceTest extends TestCase
});
}
public function test_password_reset_idempotency_is_scoped_to_each_attempt(): void
{
$firstAttempt = $this->user->resetPasswordAttempts()->create(['codigo' => '0123']);
$secondAttempt = $this->user->resetPasswordAttempts()->create(['codigo' => '4567']);
$service = app(NotificationMailService::class);
$service->sendPasswordResetCode($firstAttempt->id, $this->tenant->codigo);
$service->sendPasswordResetCode($firstAttempt->id, $this->tenant->codigo);
$service->sendPasswordResetCode($secondAttempt->id, $this->tenant->codigo);
Mail::assertSent(Mailable::class, 2);
$this->assertDatabaseHas('email_deliveries', [
'idempotency_key' => "password-reset:{$firstAttempt->id}",
'status' => 'sent',
]);
$this->assertDatabaseHas('email_deliveries', [
'idempotency_key' => "password-reset:{$secondAttempt->id}",
'status' => 'sent',
]);
}
public function test_it_links_scanner_password_resets_to_the_scanner_domain(): void
{
$websiteType = WebsiteType::query()->create([
@@ -261,6 +293,9 @@ class NotificationMailServiceTest extends TestCase
]);
app(NotificationMailService::class)->sendPurchaseConfirmed($purchase->id);
app(NotificationMailService::class)->sendPurchaseConfirmed($purchase->id);
Mail::assertSent(Mailable::class, 1);
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase): bool {
return $mail->subject === "Compra confirmada - Compra #{$purchase->id}"
@@ -323,9 +358,10 @@ class NotificationMailServiceTest extends TestCase
&& str_contains($mail->render(), '#'.$firstTicket->id)
&& str_contains($mail->render(), '#'.$secondTicket->id);
});
$this->assertDatabaseHas('event_date_notification_deliveries', [
'notification_key' => "event-date-rescheduled:10:20:{$purchase->id}",
'purchase_id' => $purchase->id,
$this->assertDatabaseHas('email_deliveries', [
'idempotency_key' => "event-date-rescheduled:10:20:{$purchase->id}",
'email_type' => 'event_date_rescheduled',
'status' => 'sent',
]);
}
@@ -362,6 +398,17 @@ class NotificationMailServiceTest extends TestCase
'ticket_ids' => [$disabledTicket->id, $activeTicket->id],
]],
);
app(NotificationMailService::class)->sendEventDateSuspended(
$this->tenant->codigo,
10,
'09/10/2027',
[[
'purchase_id' => $purchase->id,
'ticket_ids' => [$disabledTicket->id, $activeTicket->id],
]],
);
Mail::assertSent(Mailable::class, 1);
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($disabledTicket, $activeTicket): bool {
$mail->assertTo('ada@example.com');

View File

@@ -3,6 +3,7 @@
namespace Tests\Unit\Notification;
use App\Domains\Integration\Services\MailService;
use App\Domains\Notification\Services\IdempotentEmailDeliveryService;
use App\Domains\Notification\Services\NotificationMailService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Ticket\Services\TicketPdfService;
@@ -28,6 +29,7 @@ class NotificationMailServiceLoggingTest extends TestCase
$this->service = new NotificationMailService(
$this->mailService,
Mockery::mock(TicketPdfService::class),
Mockery::mock(IdempotentEmailDeliveryService::class),
);
}