feat(notification): implement idempotent email delivery system and update related services
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user