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

@@ -45,6 +45,7 @@ COMMANDS_LOG_LEVEL=info
COMMANDS_LOG_DAYS=30
EMAILS_LOG_LEVEL=info
EMAILS_LOG_DAYS=30
EMAIL_DELIVERY_LEASE_SECONDS=300
DB_CONNECTION=mysql
DB_HOST=127.0.0.1

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Domains\Notification\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
'idempotency_key',
'email_type',
'tenant_code',
'status',
'attempts',
'context',
'recipient_fingerprint',
'claim_token',
'claimed_at',
'lease_expires_at',
'sent_at',
'failed_at',
'last_error',
])]
class EmailDelivery extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_PROCESSING = 'processing';
public const STATUS_SENT = 'sent';
public const STATUS_FAILED = 'failed';
protected function casts(): array
{
return [
'attempts' => 'integer',
'context' => 'array',
'claimed_at' => 'datetime',
'lease_expires_at' => 'datetime',
'sent_at' => 'datetime',
'failed_at' => 'datetime',
];
}
}

View File

@@ -1,28 +0,0 @@
<?php
namespace App\Domains\Notification\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
'notification_key',
'notification_type',
'tenant_code',
'event_date_id',
'destination_event_date_id',
'purchase_id',
'sent_at',
])]
class EventDateNotificationDelivery extends Model
{
protected function casts(): array
{
return [
'event_date_id' => 'integer',
'destination_event_date_id' => 'integer',
'purchase_id' => 'integer',
'sent_at' => 'datetime',
];
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Domains\Notification\Services;
use App\Domains\Notification\Models\EmailDelivery;
use Closure;
use Illuminate\Database\Query\Expression;
use Illuminate\Support\Str;
use Throwable;
class IdempotentEmailDeliveryService
{
/**
* @param array<string, mixed> $context
* @param Closure(): void $send
*/
public function sendOnce(
string $key,
string $type,
?string $tenantCode,
array $context,
string $recipient,
Closure $send,
): bool {
$now = now();
EmailDelivery::query()->insertOrIgnore([
'idempotency_key' => $key,
'email_type' => $type,
'tenant_code' => $tenantCode,
'status' => EmailDelivery::STATUS_PENDING,
'attempts' => 0,
'context' => json_encode($context, JSON_THROW_ON_ERROR),
'recipient_fingerprint' => $this->recipientFingerprint($recipient),
'created_at' => $now,
'updated_at' => $now,
]);
$claimToken = (string) Str::uuid();
$leaseExpiresAt = $now->copy()->addSeconds(
max(1, (int) config('mail.delivery_lease_seconds', 300)),
);
$claimed = EmailDelivery::query()
->where('idempotency_key', $key)
->where(function ($query) use ($now): void {
$query->whereIn('status', [
EmailDelivery::STATUS_PENDING,
EmailDelivery::STATUS_FAILED,
])->orWhere(function ($query) use ($now): void {
$query->where('status', EmailDelivery::STATUS_PROCESSING)
->where('lease_expires_at', '<=', $now);
});
})
->update([
'status' => EmailDelivery::STATUS_PROCESSING,
'attempts' => new Expression('attempts + 1'),
'context' => json_encode($context, JSON_THROW_ON_ERROR),
'recipient_fingerprint' => $this->recipientFingerprint($recipient),
'claim_token' => $claimToken,
'claimed_at' => $now,
'lease_expires_at' => $leaseExpiresAt,
'failed_at' => null,
'last_error' => null,
'updated_at' => $now,
]) === 1;
if (! $claimed) {
return false;
}
try {
$send();
EmailDelivery::query()
->where('idempotency_key', $key)
->where('claim_token', $claimToken)
->update([
'status' => EmailDelivery::STATUS_SENT,
'claim_token' => null,
'lease_expires_at' => null,
'sent_at' => now(),
'updated_at' => now(),
]);
} catch (Throwable $exception) {
EmailDelivery::query()
->where('idempotency_key', $key)
->where('claim_token', $claimToken)
->update([
'status' => EmailDelivery::STATUS_FAILED,
'claim_token' => null,
'lease_expires_at' => null,
'failed_at' => now(),
'last_error' => Str::limit($exception::class, 2000, ''),
'updated_at' => now(),
]);
throw $exception;
}
return true;
}
private function recipientFingerprint(string $recipient): string
{
return hash_hmac(
'sha256',
mb_strtolower(trim($recipient)),
(string) config('app.key'),
);
}
}

View File

@@ -6,7 +6,6 @@ use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Integration\Services\MailService;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Notification\Models\EventDateNotificationDelivery;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
@@ -23,32 +22,42 @@ class NotificationMailService
public function __construct(
private readonly MailService $mailService,
private readonly TicketPdfService $ticketPdfService,
private readonly IdempotentEmailDeliveryService $emailDeliveryService,
) {}
public function sendWelcome(int $userId, string $tenantCode): void
{
$this->sendLogged('welcome', [
$context = [
'user_id' => $userId,
'tenant_code' => $tenantCode,
], function () use ($userId, $tenantCode): array {
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail();
$user = User::query()->findOrFail($userId);
$brand = $tenant->websiteType ?? $tenant;
$tenantUrl = 'https://'.$tenant->dominio.$tenant->base_path;
];
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail();
$user = User::query()->findOrFail($userId);
$this->mailService
->forTenant($tenantCode)
->send(
$user->email,
"Bienvenido a {$brand->nombre}",
view('mail.notifications.welcome', compact('brand', 'user', 'tenantUrl'))->render(),
$brand,
);
$this->sendIdempotently(
"welcome:{$tenantCode}:{$userId}",
'welcome',
$tenantCode,
$context,
$user->email,
function () use ($user, $tenant, $tenantCode): array {
$brand = $tenant->websiteType ?? $tenant;
$tenantUrl = 'https://'.$tenant->dominio.$tenant->base_path;
return [
'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type',
];
});
$this->mailService
->forTenant($tenantCode)
->send(
$user->email,
"Bienvenido a {$brand->nombre}",
view('mail.notifications.welcome', compact('brand', 'user', 'tenantUrl'))->render(),
$brand,
);
return [
'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type',
];
},
);
}
public function sendPasswordResetCode(
@@ -62,128 +71,149 @@ class NotificationMailService
'channel' => $channel,
];
$this->sendLogged('password_reset', $context, function () use ($attemptId, $tenantCode, $channel, $context): ?array {
$tenant = Tenant::query()
->with('websiteType')
->where('codigo', $tenantCode)
->firstOrFail();
$attempt = ResetPasswordAttempt::query()
->with('user')
->findOrFail($attemptId);
$tenant = Tenant::query()
->with('websiteType')
->where('codigo', $tenantCode)
->firstOrFail();
$attempt = ResetPasswordAttempt::query()
->with('user')
->findOrFail($attemptId);
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
$this->logSkipped('password_reset', array_merge($context, [
'reason' => 'attempt_not_pending',
'attempt_status' => $attempt->status,
'user_id' => $attempt->user_id,
]));
return null;
}
$recoveryDomain = match ($channel) {
PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio,
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
default => $tenant->dominio,
};
$recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT
&& $tenant->base_path !== '/'
? $tenant->base_path
: '';
$recoveryQuery = ['email' => $attempt->user->email];
if (
$channel === PasswordResetRequested::CHANNEL_SCANNER
&& $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED
) {
$recoveryQuery['code'] = $attempt->codigo;
}
$recoveryUrl = $recoveryDomain === null
? null
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
$brand = $tenant->websiteType ?? $tenant;
[$subject, $template] = match ($attempt->reason) {
ResetPasswordAttempt::REASON_STAFF_CREATED => [
'Tu cuenta de escáner está lista', 'scanner-created',
],
ResetPasswordAttempt::REASON_ADMINISTRATOR_CREATED => [
'Tu cuenta de administrador está lista', 'administrator-created',
],
ResetPasswordAttempt::REASON_ACCOUNT_LOCKED => [
'Desbloqueá tu cuenta', 'account-locked',
],
default => ['Código para recuperar tu contraseña', 'password-reset'],
};
$this->mailService
->forTenant($tenantCode)
->send(
$attempt->user->email,
"{$subject} - {$brand->nombre}",
view("mail.notifications.{$template}", [
'attempt' => $attempt,
'recoveryUrl' => $recoveryUrl,
'brand' => $brand,
])->render(),
$brand,
);
return [
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
$this->logSkipped('password_reset', array_merge($context, [
'reason' => 'attempt_not_pending',
'attempt_status' => $attempt->status,
'user_id' => $attempt->user_id,
'recovery_domain_available' => $recoveryDomain !== null,
];
});
]));
return;
}
$this->sendIdempotently(
"password-reset:{$attemptId}",
'password_reset',
$tenantCode,
$context,
$attempt->user->email,
function () use ($attempt, $tenant, $tenantCode, $channel): array {
$recoveryDomain = match ($channel) {
PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio,
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
default => $tenant->dominio,
};
$recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT
&& $tenant->base_path !== '/'
? $tenant->base_path
: '';
$recoveryQuery = ['email' => $attempt->user->email];
if (
$channel === PasswordResetRequested::CHANNEL_SCANNER
&& $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED
) {
$recoveryQuery['code'] = $attempt->codigo;
}
$recoveryUrl = $recoveryDomain === null
? null
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
$brand = $tenant->websiteType ?? $tenant;
[$subject, $template] = match ($attempt->reason) {
ResetPasswordAttempt::REASON_STAFF_CREATED => [
'Tu cuenta de escáner está lista', 'scanner-created',
],
ResetPasswordAttempt::REASON_ADMINISTRATOR_CREATED => [
'Tu cuenta de administrador está lista', 'administrator-created',
],
ResetPasswordAttempt::REASON_ACCOUNT_LOCKED => [
'Desbloqueá tu cuenta', 'account-locked',
],
default => ['Código para recuperar tu contraseña', 'password-reset'],
};
$this->mailService
->forTenant($tenantCode)
->send(
$attempt->user->email,
"{$subject} - {$brand->nombre}",
view("mail.notifications.{$template}", [
'attempt' => $attempt,
'recoveryUrl' => $recoveryUrl,
'brand' => $brand,
])->render(),
$brand,
);
return [
'user_id' => $attempt->user_id,
'recovery_domain_available' => $recoveryDomain !== null,
];
},
);
}
public function sendPurchaseConfirmed(int $purchaseId): void
{
$context = ['purchase_id' => $purchaseId];
$this->sendLogged('purchase_confirmed', $context, function () use ($purchaseId, $context): ?array {
$purchase = Purchase::query()
->with(['tenant', 'user', 'items'])
->find($purchaseId);
$purchase = Purchase::query()
->with(['tenant', 'user', 'items'])
->find($purchaseId);
if ($purchase === null) {
$this->logSkipped('purchase_confirmed', array_merge($context, [
'reason' => 'purchase_not_found',
'missing_model' => Purchase::class,
]));
if ($purchase === null) {
$this->logSkipped('purchase_confirmed', array_merge($context, [
'reason' => 'purchase_not_found',
'missing_model' => Purchase::class,
]));
return null;
}
return;
}
/** @var Collection<int, Ticket> $tickets */
$tickets = $purchase->tickets()
->where('tenant_code', $purchase->tenant_codigo)
->with(TicketPresentationResolver::RELATIONS)
->get();
$attachments = $tickets->isEmpty()
? []
: [[
'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets),
'name' => $this->ticketPdfService->filename($tickets),
'mime' => 'application/pdf',
]];
$recipient = $this->recipientFor($purchase);
if ($recipient === '') {
$this->logSkipped('purchase_confirmed', array_merge($context, ['reason' => 'missing_recipient']));
$this->mailService
->forTenant($purchase->tenant_codigo)
->send(
$this->recipientFor($purchase),
"Compra confirmada - Compra #{$purchase->getKey()}",
view('mail.notifications.purchase-confirmed', compact('purchase', 'tickets'))->render(),
attachments: $attachments,
);
return;
}
return [
'tenant_code' => $purchase->tenant_codigo,
'user_id' => $purchase->user_id,
'purchase_status' => $purchase->status,
'purchase_item_count' => $purchase->items->count(),
'ticket_count' => $tickets->count(),
'ticket_ids' => $tickets->modelKeys(),
];
});
$this->sendIdempotently(
"purchase-confirmed:{$purchaseId}",
'purchase_confirmed',
$purchase->tenant_codigo,
$context,
$recipient,
function () use ($purchase, $recipient): array {
/** @var Collection<int, Ticket> $tickets */
$tickets = $purchase->tickets()
->where('tenant_code', $purchase->tenant_codigo)
->with(TicketPresentationResolver::RELATIONS)
->get();
$attachments = $tickets->isEmpty()
? []
: [[
'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets),
'name' => $this->ticketPdfService->filename($tickets),
'mime' => 'application/pdf',
]];
$this->mailService
->forTenant($purchase->tenant_codigo)
->send(
$recipient,
"Compra confirmada - Compra #{$purchase->getKey()}",
view('mail.notifications.purchase-confirmed', compact('purchase', 'tickets'))->render(),
attachments: $attachments,
);
return [
'tenant_code' => $purchase->tenant_codigo,
'user_id' => $purchase->user_id,
'purchase_status' => $purchase->status,
'purchase_item_count' => $purchase->items->count(),
'ticket_count' => $tickets->count(),
'ticket_ids' => $tickets->modelKeys(),
];
},
);
}
/**
@@ -247,23 +277,13 @@ class NotificationMailService
}
$deliveryKey = "event-date-rescheduled:{$sourceEventDateId}:{$destinationEventDateId}:{$purchaseId}";
if (! $this->claimDelivery(
$this->sendIdempotently(
$deliveryKey,
'event_date_rescheduled',
$tenantCode,
$sourceEventDateId,
$destinationEventDateId,
$purchaseId,
)) {
$this->logSkipped('event_date_rescheduled', array_merge($context, [
'reason' => 'already_sent',
]));
continue;
}
try {
$this->sendLogged('event_date_rescheduled', $context, function () use (
$context,
$recipient,
function () use (
$tenantCode,
$purchase,
$recipient,
@@ -285,13 +305,8 @@ class NotificationMailService
);
return ['ticket_count' => $tickets->count()];
});
$this->markDeliverySent($deliveryKey);
} catch (Throwable $exception) {
$this->releaseDelivery($deliveryKey);
throw $exception;
}
},
);
}
}
@@ -356,21 +371,6 @@ class NotificationMailService
}
$deliveryKey = "event-date-suspended:{$eventDateId}:{$purchaseId}";
if (! $this->claimDelivery(
$deliveryKey,
'event_date_suspended',
$tenantCode,
$eventDateId,
null,
$purchaseId,
)) {
$this->logSkipped('event_date_suspended', array_merge($context, [
'reason' => 'already_sent',
]));
continue;
}
$disabledTickets = $tickets
->filter(fn (Ticket $ticket): bool => $ticket->status === Ticket::STATUS_DISABLED)
->values();
@@ -378,8 +378,13 @@ class NotificationMailService
->filter(fn (Ticket $ticket): bool => $ticket->status === Ticket::STATUS_ACTIVE)
->values();
try {
$this->sendLogged('event_date_suspended', $context, function () use (
$this->sendIdempotently(
$deliveryKey,
'event_date_suspended',
$tenantCode,
$context,
$recipient,
function () use (
$tenantCode,
$purchase,
$recipient,
@@ -405,13 +410,8 @@ class NotificationMailService
'disabled_ticket_ids' => $disabledTickets->modelKeys(),
'active_ticket_ids' => $activeTickets->modelKeys(),
];
});
$this->markDeliverySent($deliveryKey);
} catch (Throwable $exception) {
$this->releaseDelivery($deliveryKey);
throw $exception;
}
},
);
}
}
@@ -423,45 +423,39 @@ class NotificationMailService
->find($purchaseId);
}
private function claimDelivery(
string $deliveryKey,
string $type,
string $tenantCode,
int $eventDateId,
?int $destinationEventDateId,
int $purchaseId,
): bool {
return EventDateNotificationDelivery::query()->insertOrIgnore([
'notification_key' => $deliveryKey,
'notification_type' => $type,
'tenant_code' => $tenantCode,
'event_date_id' => $eventDateId,
'destination_event_date_id' => $destinationEventDateId,
'purchase_id' => $purchaseId,
'created_at' => now(),
'updated_at' => now(),
]) === 1;
}
private function markDeliverySent(string $deliveryKey): void
{
EventDateNotificationDelivery::query()
->where('notification_key', $deliveryKey)
->update(['sent_at' => now()]);
}
private function releaseDelivery(string $deliveryKey): void
{
EventDateNotificationDelivery::query()
->where('notification_key', $deliveryKey)
->delete();
}
private function recipientFor(Purchase $purchase): string
{
return (string) ($purchase->email ?: $purchase->user?->email);
}
/**
* @param array<string, mixed> $context
* @param Closure(): array<string, mixed> $send
*/
private function sendIdempotently(
string $key,
string $emailType,
?string $tenantCode,
array $context,
string $recipient,
Closure $send,
): void {
$sent = $this->emailDeliveryService->sendOnce(
$key,
$emailType,
$tenantCode,
$context,
$recipient,
function () use ($emailType, $context, $send): void {
$this->sendLogged($emailType, $context, $send);
},
);
if (! $sent) {
$this->logSkipped($emailType, array_merge($context, ['reason' => 'already_claimed']));
}
}
/**
* @param array<string, mixed> $context
* @param Closure(): (array<string, mixed>|null) $send

View File

@@ -12,7 +12,19 @@ Orquesta notificaciones de negocio por correo a partir de eventos de otros domin
## Componentes
Los listeners `SendWelcomeEmail`, `SendPasswordResetEmail` y `SendPurchaseConfirmedEmail` delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`.
Los listeners delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`.
`IdempotentEmailDeliveryService` coordina los envíos automáticos mediante la tabla
`email_deliveries`. Cada correo utiliza una clave de negocio única:
- bienvenida: `welcome:{tenant_code}:{user_id}`;
- recuperación: `password-reset:{attempt_id}`;
- compra confirmada: `purchase-confirmed:{purchase_id}`;
- reprogramación: `event-date-rescheduled:{source_event_date_id}:{destination_event_date_id}:{purchase_id}`;
- suspensión: `event-date-suspended:{event_date_id}:{purchase_id}`.
Los correos de prueba y de validación de una integración SMTP no usan esta capa,
porque su reenvío explícito es parte de su comportamiento esperado.
## API y dependencias
@@ -25,3 +37,12 @@ No expone rutas HTTP. Consume datos de `Auth`, `Tenant`, `Purchase` y `Ticket`,
- Los correos de cuenta (bienvenida y recuperación de contraseña) usan la identidad visual del `WebsiteType` asociado al tenant, con fallback al tenant si no tiene uno configurado.
- El correo transaccional de compra confirmada usa la identidad visual del tenant y adjunta un único PDF cuando la compra generó tickets.
- Los handlers deben permanecer idempotentes o tolerantes a reintentos de cola.
- Una entrega queda en estado `processing` mientras un worker posee su claim. Si
el worker se interrumpe, el claim vence según `EMAIL_DELIVERY_LEASE_SECONDS` y
otro intento puede recuperarlo.
- Los fallos quedan registrados como `failed` y pueden ser retomados por los
reintentos de la cola. Los envíos exitosos permanecen como `sent` y las llamadas
posteriores con la misma clave no vuelven a enviar el correo.
- SMTP no ofrece una confirmación transaccional junto con la base de datos. Una
interrupción ocurrida después de entregar el correo y antes de registrar
`sent` puede producir un duplicado excepcional al recuperar el claim.

View File

@@ -16,6 +16,8 @@ return [
'default' => env('MAIL_MAILER', 'log'),
'delivery_lease_seconds' => (int) env('EMAIL_DELIVERY_LEASE_SECONDS', 300),
/*
|--------------------------------------------------------------------------
| Mailer Configurations

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('email_deliveries', function (Blueprint $table): void {
$table->id();
$table->string('idempotency_key')->unique();
$table->string('email_type')->index();
$table->string('tenant_code')->nullable()->index();
$table->string('status')->index();
$table->unsignedInteger('attempts')->default(0);
$table->json('context')->nullable();
$table->string('recipient_fingerprint', 64)->nullable();
$table->uuid('claim_token')->nullable()->index();
$table->timestamp('claimed_at')->nullable();
$table->timestamp('lease_expires_at')->nullable()->index();
$table->timestamp('sent_at')->nullable();
$table->timestamp('failed_at')->nullable();
$table->text('last_error')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('email_deliveries');
}
};

View File

@@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('event_date_notification_deliveries', function (Blueprint $table): void {
$table->id();
$table->string('notification_key')->unique();
$table->string('notification_type');
$table->string('tenant_code');
$table->unsignedBigInteger('event_date_id');
$table->unsignedBigInteger('destination_event_date_id')->nullable();
$table->unsignedBigInteger('purchase_id');
$table->timestamp('sent_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('event_date_notification_deliveries');
}
};

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),
);
}