feat(notification): enhance email logging and refactor event handling to use scalar identifiers

This commit is contained in:
ncoronel 2026-08-26 09:38:19 -03:00
parent 28d09dedab
commit d0424c5589
13 changed files with 381 additions and 176 deletions

View File

@ -41,6 +41,8 @@ TELEPAGOS_LOG_LEVEL=info
TELEPAGOS_LOG_DAYS=30 TELEPAGOS_LOG_DAYS=30
COMMANDS_LOG_LEVEL=info COMMANDS_LOG_LEVEL=info
COMMANDS_LOG_DAYS=30 COMMANDS_LOG_DAYS=30
EMAILS_LOG_LEVEL=info
EMAILS_LOG_DAYS=30
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1

View File

@ -2,7 +2,6 @@
namespace App\Domains\Notification\Events; namespace App\Domains\Notification\Events;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
@ -14,7 +13,7 @@ class TicketsAvailable
* @param array<int, int> $ticketIds * @param array<int, int> $ticketIds
*/ */
public function __construct( public function __construct(
public readonly Purchase $purchase, public readonly int $purchaseId,
public readonly array $ticketIds, public readonly array $ticketIds,
) {} ) {}
} }

View File

@ -6,8 +6,6 @@ use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Notification\Services\NotificationMailService; use App\Domains\Notification\Services\NotificationMailService;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit; use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
use Throwable;
class SendPasswordResetEmail implements ShouldQueueAfterCommit class SendPasswordResetEmail implements ShouldQueueAfterCommit
{ {
@ -22,21 +20,10 @@ class SendPasswordResetEmail implements ShouldQueueAfterCommit
public function handle(PasswordResetRequested $event): void public function handle(PasswordResetRequested $event): void
{ {
try { app(NotificationMailService::class)->sendPasswordResetCode(
app(NotificationMailService::class)->sendPasswordResetCode( $event->attemptId,
$event->attemptId, $event->tenantCode,
$event->tenantCode, $event->channel,
$event->channel, );
);
} catch (Throwable $exception) {
Log::error('Failed to send password reset email.', [
'attempt_id' => $event->attemptId,
'tenant_code' => $event->tenantCode,
'channel' => $event->channel,
'exception' => $exception,
]);
throw $exception;
}
} }
} }

View File

@ -5,10 +5,8 @@ namespace App\Domains\Notification\Listeners;
use App\Domains\Notification\Services\NotificationMailService; use App\Domains\Notification\Services\NotificationMailService;
use App\Domains\Purchase\Events\PurchasePaid; use App\Domains\Purchase\Events\PurchasePaid;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit; use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\Attributes\DeleteWhenMissingModels;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
#[DeleteWhenMissingModels]
class SendPurchasePaidEmail implements ShouldQueueAfterCommit class SendPurchasePaidEmail implements ShouldQueueAfterCommit
{ {
use InteractsWithQueue; use InteractsWithQueue;
@ -22,6 +20,6 @@ class SendPurchasePaidEmail implements ShouldQueueAfterCommit
public function handle(PurchasePaid $event): void public function handle(PurchasePaid $event): void
{ {
app(NotificationMailService::class)->sendPurchasePaid($event->purchase->getKey()); app(NotificationMailService::class)->sendPurchasePaid($event->purchaseId);
} }
} }

View File

@ -5,10 +5,8 @@ namespace App\Domains\Notification\Listeners;
use App\Domains\Notification\Events\TicketsAvailable; use App\Domains\Notification\Events\TicketsAvailable;
use App\Domains\Notification\Services\NotificationMailService; use App\Domains\Notification\Services\NotificationMailService;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit; use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\Attributes\DeleteWhenMissingModels;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
#[DeleteWhenMissingModels]
class SendTicketsAvailableEmail implements ShouldQueueAfterCommit class SendTicketsAvailableEmail implements ShouldQueueAfterCommit
{ {
use InteractsWithQueue; use InteractsWithQueue;
@ -22,6 +20,6 @@ class SendTicketsAvailableEmail implements ShouldQueueAfterCommit
public function handle(TicketsAvailable $event): void public function handle(TicketsAvailable $event): void
{ {
app(NotificationMailService::class)->sendTicketsAvailable($event->purchase->getKey(), $event->ticketIds); app(NotificationMailService::class)->sendTicketsAvailable($event->purchaseId, $event->ticketIds);
} }
} }

View File

@ -11,8 +11,10 @@ use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Services\TicketPdfService; use App\Domains\Ticket\Services\TicketPdfService;
use App\Domains\Ticket\Services\TicketPresentationResolver; use App\Domains\Ticket\Services\TicketPresentationResolver;
use Closure;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Throwable;
class NotificationMailService class NotificationMailService
{ {
@ -23,18 +25,27 @@ class NotificationMailService
public function sendWelcome(int $userId, string $tenantCode): void public function sendWelcome(int $userId, string $tenantCode): void
{ {
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail(); $this->sendLogged('welcome', [
$user = User::query()->findOrFail($userId); 'user_id' => $userId,
$brand = $tenant->websiteType ?? $tenant; '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;
$this->mailService $this->mailService
->forTenant($tenantCode) ->forTenant($tenantCode)
->send( ->send(
$user->email, $user->email,
"Bienvenido a {$brand->nombre}", "Bienvenido a {$brand->nombre}",
view('mail.notifications.welcome', compact('brand', 'user'))->render(), view('mail.notifications.welcome', compact('brand', 'user'))->render(),
$brand, $brand,
); );
return [
'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type',
];
});
} }
public function sendPasswordResetCode( public function sendPasswordResetCode(
@ -42,106 +53,206 @@ class NotificationMailService
string $tenantCode, string $tenantCode,
string $channel = PasswordResetRequested::CHANNEL_STOREFRONT, string $channel = PasswordResetRequested::CHANNEL_STOREFRONT,
): void { ): void {
$tenant = Tenant::query() $context = [
->with('websiteType') 'attempt_id' => $attemptId,
->where('codigo', $tenantCode) 'tenant_code' => $tenantCode,
->firstOrFail(); 'channel' => $channel,
$attempt = ResetPasswordAttempt::query() ];
->with('user')
->findOrFail($attemptId);
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) { $this->sendLogged('password_reset', $context, function () use ($attemptId, $tenantCode, $channel, $context): ?array {
Log::warning('Password reset email was skipped because the attempt is no longer pending.', [ $tenant = Tenant::query()
'attempt_id' => $attemptId, ->with('websiteType')
'tenant_code' => $tenantCode, ->where('codigo', $tenantCode)
'attempt_status' => $attempt->status, ->firstOrFail();
]); $attempt = ResetPasswordAttempt::query()
->with('user')
->findOrFail($attemptId);
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,
]));
$recoveryDomain = match ($channel) { return null;
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;
$this->mailService $recoveryDomain = match ($channel) {
->forTenant($tenantCode) PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio,
->send( PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
$attempt->user->email, default => $tenant->dominio,
"Código para recuperar tu contraseña - {$brand->nombre}", };
view('mail.notifications.password-reset', [ $recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT
'attempt' => $attempt, && $tenant->base_path !== '/'
'recoveryUrl' => $recoveryUrl, ? $tenant->base_path
'brand' => $brand, : '';
])->render(), $recoveryQuery = ['email' => $attempt->user->email];
$brand, 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;
$this->mailService
->forTenant($tenantCode)
->send(
$attempt->user->email,
"Código para recuperar tu contraseña - {$brand->nombre}",
view('mail.notifications.password-reset', [
'attempt' => $attempt,
'recoveryUrl' => $recoveryUrl,
'brand' => $brand,
])->render(),
$brand,
);
return [
'user_id' => $attempt->user_id,
'recovery_domain_available' => $recoveryDomain !== null,
];
});
} }
public function sendPurchasePaid(int $purchaseId): void public function sendPurchasePaid(int $purchaseId): void
{ {
$purchase = Purchase::query() $context = ['purchase_id' => $purchaseId];
->with(['tenant', 'user', 'items'])
->findOrFail($purchaseId);
$this->mailService $this->sendLogged('purchase_paid', $context, function () use ($purchaseId, $context): ?array {
->forTenant($purchase->tenant_codigo) $purchase = Purchase::query()
->send( ->with(['tenant', 'user', 'items'])
$this->recipientFor($purchase), ->find($purchaseId);
"Pago confirmado - Compra #{$purchase->getKey()}",
view('mail.notifications.purchase-paid', compact('purchase'))->render(), if ($purchase === null) {
); $this->logSkipped('purchase_paid', array_merge($context, [
'reason' => 'purchase_not_found',
'missing_model' => Purchase::class,
]));
return null;
}
$this->mailService
->forTenant($purchase->tenant_codigo)
->send(
$this->recipientFor($purchase),
"Pago confirmado - Compra #{$purchase->getKey()}",
view('mail.notifications.purchase-paid', compact('purchase'))->render(),
);
return [
'tenant_code' => $purchase->tenant_codigo,
'user_id' => $purchase->user_id,
'purchase_status' => $purchase->status,
'purchase_item_count' => $purchase->items->count(),
];
});
} }
/** @param array<int, int> $ticketIds */ /** @param array<int, int> $ticketIds */
public function sendTicketsAvailable(int $purchaseId, array $ticketIds): void public function sendTicketsAvailable(int $purchaseId, array $ticketIds): void
{ {
$purchase = Purchase::query()->with(['tenant', 'user'])->findOrFail($purchaseId); $context = [
/** @var Collection<int, Ticket> $tickets */ 'purchase_id' => $purchaseId,
$tickets = Ticket::query() 'requested_ticket_count' => count($ticketIds),
->where('tenant_code', $purchase->tenant_codigo) 'requested_ticket_ids' => $ticketIds,
->where('user_id', $purchase->user_id) ];
->whereKey($ticketIds)
->with(TicketPresentationResolver::RELATIONS)
->get();
if ($tickets->isEmpty()) { $this->sendLogged('tickets_available', $context, function () use ($purchaseId, $ticketIds, $context): ?array {
return; $purchase = Purchase::query()->with(['tenant', 'user'])->find($purchaseId);
}
$this->mailService if ($purchase === null) {
->forTenant($purchase->tenant_codigo) $this->logSkipped('tickets_available', array_merge($context, [
->send( 'reason' => 'purchase_not_found',
$this->recipientFor($purchase), 'missing_model' => Purchase::class,
'Tus tickets ya están disponibles', ]));
view('mail.notifications.tickets-available', compact('purchase', 'tickets'))->render(),
attachments: [[ return null;
'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets), }
'name' => $this->ticketPdfService->filename($tickets),
'mime' => 'application/pdf', /** @var Collection<int, Ticket> $tickets */
]], $tickets = Ticket::query()
); ->where('tenant_code', $purchase->tenant_codigo)
->where('user_id', $purchase->user_id)
->whereKey($ticketIds)
->with(TicketPresentationResolver::RELATIONS)
->get();
if ($tickets->isEmpty()) {
$this->logSkipped('tickets_available', array_merge($context, [
'reason' => 'tickets_not_found',
'missing_model' => Ticket::class,
'tenant_code' => $purchase->tenant_codigo,
'user_id' => $purchase->user_id,
]));
return null;
}
$this->mailService
->forTenant($purchase->tenant_codigo)
->send(
$this->recipientFor($purchase),
'Tus tickets ya están disponibles',
view('mail.notifications.tickets-available', compact('purchase', 'tickets'))->render(),
attachments: [[
'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets),
'name' => $this->ticketPdfService->filename($tickets),
'mime' => 'application/pdf',
]],
);
return [
'tenant_code' => $purchase->tenant_codigo,
'user_id' => $purchase->user_id,
'sent_ticket_count' => $tickets->count(),
'sent_ticket_ids' => $tickets->modelKeys(),
];
});
} }
private function recipientFor(Purchase $purchase): string private function recipientFor(Purchase $purchase): string
{ {
return (string) ($purchase->email ?: $purchase->user?->email); return (string) ($purchase->email ?: $purchase->user?->email);
} }
/**
* @param array<string, mixed> $context
* @param Closure(): (array<string, mixed>|null) $send
*/
private function sendLogged(string $emailType, array $context, Closure $send): void
{
try {
$resultContext = $send();
if ($resultContext === null) {
return;
}
Log::channel('emails')->info('Notification email sent.', array_merge($context, $resultContext, [
'email_type' => $emailType,
'mailer' => $this->mailService->mailerName(),
]));
} catch (Throwable $exception) {
Log::channel('emails')->error('Notification email delivery failed.', array_merge($context, [
'email_type' => $emailType,
'exception' => $exception,
]));
throw $exception;
}
}
/** @param array<string, mixed> $context */
private function logSkipped(string $emailType, array $context): void
{
Log::channel('emails')->warning('Notification email skipped.', array_merge($context, [
'email_type' => $emailType,
]));
}
} }

View File

@ -2,7 +2,6 @@
namespace App\Domains\Purchase\Events; namespace App\Domains\Purchase\Events;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
@ -10,5 +9,5 @@ class PurchasePaid
{ {
use Dispatchable, SerializesModels; use Dispatchable, SerializesModels;
public function __construct(public readonly Purchase $purchase) {} public function __construct(public readonly int $purchaseId) {}
} }

View File

@ -193,7 +193,7 @@ class Purchase extends Model
'status' => self::STATUS_PAID, 'status' => self::STATUS_PAID,
]); ]);
PurchasePaid::dispatch($this); PurchasePaid::dispatch($this->getKey());
}); });
} }
} }

View File

@ -5,6 +5,7 @@ namespace App\Domains\Ticket\Listeners;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Notification\Events\TicketsAvailable; use App\Domains\Notification\Events\TicketsAvailable;
use App\Domains\Purchase\Events\PurchasePaid; use App\Domains\Purchase\Events\PurchasePaid;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Ticket\Exceptions\TicketGenerationException; use App\Domains\Ticket\Exceptions\TicketGenerationException;
use App\Domains\Ticket\Services\TicketGeneratorService; use App\Domains\Ticket\Services\TicketGeneratorService;
@ -16,10 +17,9 @@ class GenerateTicketsForPaidPurchase
public function handle(PurchasePaid $event): void public function handle(PurchasePaid $event): void
{ {
$purchase = $event->purchase $purchase = Purchase::query()
->newQuery()
->with(['user', 'items']) ->with(['user', 'items'])
->findOrFail($event->purchase->getKey()); ->findOrFail($event->purchaseId);
$user = $purchase->user; $user = $purchase->user;
$ticketIds = []; $ticketIds = [];
@ -52,7 +52,7 @@ class GenerateTicketsForPaidPurchase
} }
if ($ticketIds !== []) { if ($ticketIds !== []) {
TicketsAvailable::dispatch($purchase, $ticketIds); TicketsAvailable::dispatch($purchase->getKey(), $ticketIds);
} }
} }

View File

@ -89,6 +89,14 @@ return [
'replace_placeholders' => true, 'replace_placeholders' => true,
], ],
'emails' => [
'driver' => 'daily',
'path' => storage_path('logs/emails/emails.log'),
'level' => env('EMAILS_LOG_LEVEL', 'info'),
'days' => env('EMAILS_LOG_DAYS', 30),
'replace_placeholders' => true,
],
'slack' => [ 'slack' => [
'driver' => 'slack', 'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'), 'url' => env('LOG_SLACK_WEBHOOK_URL'),

View File

@ -5,60 +5,42 @@ namespace Tests\Feature\Notification;
use App\Domains\Notification\Events\TicketsAvailable; use App\Domains\Notification\Events\TicketsAvailable;
use App\Domains\Notification\Listeners\SendPurchasePaidEmail; use App\Domains\Notification\Listeners\SendPurchasePaidEmail;
use App\Domains\Notification\Listeners\SendTicketsAvailableEmail; use App\Domains\Notification\Listeners\SendTicketsAvailableEmail;
use App\Domains\Notification\Services\NotificationMailService;
use App\Domains\Purchase\Events\PurchasePaid; use App\Domains\Purchase\Events\PurchasePaid;
use App\Domains\Purchase\Models\Purchase; use Mockery;
use Illuminate\Events\CallQueuedListener;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase; use Tests\TestCase;
class QueuedNotificationListenerTest extends TestCase class QueuedNotificationListenerTest extends TestCase
{ {
public function test_purchase_paid_email_is_discarded_when_the_purchase_no_longer_exists(): void public function test_purchase_paid_email_delegates_with_the_purchase_id(): void
{ {
$job = $this->dispatchQueuedListener( $mailService = Mockery::mock(NotificationMailService::class);
PurchasePaid::class, $mailService->shouldReceive('sendPurchasePaid')
SendPurchasePaidEmail::class, ->once()
new PurchasePaid($this->purchase()), ->with(123);
); $this->app->instance(NotificationMailService::class, $mailService);
$this->assertTrue($job->deleteWhenMissingModels); (new SendPurchasePaidEmail)->handle(new PurchasePaid(123));
} }
public function test_tickets_available_email_is_discarded_when_the_purchase_no_longer_exists(): void public function test_tickets_available_email_delegates_with_scalar_identifiers(): void
{ {
$job = $this->dispatchQueuedListener( $mailService = Mockery::mock(NotificationMailService::class);
TicketsAvailable::class, $mailService->shouldReceive('sendTicketsAvailable')
SendTicketsAvailableEmail::class, ->once()
new TicketsAvailable($this->purchase(), [10, 11]), ->with(123, [10, 11]);
); $this->app->instance(NotificationMailService::class, $mailService);
$this->assertTrue($job->deleteWhenMissingModels); (new SendTicketsAvailableEmail)->handle(new TicketsAvailable(123, [10, 11]));
} }
private function dispatchQueuedListener(string $event, string $listener, object $payload): CallQueuedListener public function test_email_events_only_serialize_scalar_identifiers(): void
{ {
Queue::fake(); $purchasePaid = unserialize(serialize(new PurchasePaid(123)));
Event::forget($event); $ticketsAvailable = unserialize(serialize(new TicketsAvailable(123, [10, 11])));
Event::listen($event, $listener);
event($payload); $this->assertSame(123, $purchasePaid->purchaseId);
$this->assertSame(123, $ticketsAvailable->purchaseId);
$queuedListener = null; $this->assertSame([10, 11], $ticketsAvailable->ticketIds);
Queue::assertPushed(CallQueuedListener::class, function (CallQueuedListener $job) use (&$queuedListener): bool {
$queuedListener = $job;
return true;
});
$this->assertInstanceOf(CallQueuedListener::class, $queuedListener);
return $queuedListener;
}
private function purchase(): Purchase
{
return (new Purchase)->forceFill(['id' => 123]);
} }
} }

View File

@ -0,0 +1,132 @@
<?php
namespace Tests\Unit\Notification;
use App\Domains\Integration\Services\MailService;
use App\Domains\Notification\Services\NotificationMailService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Ticket\Services\TicketPdfService;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Mockery;
use ReflectionMethod;
use RuntimeException;
use Tests\TestCase;
class NotificationMailServiceLoggingTest extends TestCase
{
private NotificationMailService $service;
private MailService $mailService;
protected function setUp(): void
{
parent::setUp();
$this->mailService = Mockery::mock(MailService::class);
$this->service = new NotificationMailService(
$this->mailService,
Mockery::mock(TicketPdfService::class),
);
}
public function test_it_logs_and_swallows_a_missing_purchase(): void
{
$this->createEmptyPurchasesTable();
Log::shouldReceive('channel')->once()->with('emails')->andReturnSelf();
Log::shouldReceive('warning')->once()->with(
'Notification email skipped.',
[
'purchase_id' => 123,
'reason' => 'purchase_not_found',
'missing_model' => Purchase::class,
'email_type' => 'purchase_paid',
],
);
$this->service->sendPurchasePaid(123);
}
public function test_missing_purchase_log_includes_the_requested_ticket_ids(): void
{
$this->createEmptyPurchasesTable();
Log::shouldReceive('channel')->once()->with('emails')->andReturnSelf();
Log::shouldReceive('warning')->once()->with(
'Notification email skipped.',
[
'purchase_id' => 123,
'requested_ticket_count' => 2,
'requested_ticket_ids' => [10, 11],
'reason' => 'purchase_not_found',
'missing_model' => Purchase::class,
'email_type' => 'tickets_available',
],
);
$this->service->sendTicketsAvailable(123, [10, 11]);
}
public function test_it_logs_successful_delivery_with_the_mailer(): void
{
$this->mailService->shouldReceive('mailerName')->once()->andReturn('smtp');
Log::shouldReceive('channel')->once()->with('emails')->andReturnSelf();
Log::shouldReceive('info')->once()->with(
'Notification email sent.',
[
'user_id' => 10,
'tenant_code' => 'tenant-test',
'email_type' => 'welcome',
'mailer' => 'smtp',
],
);
$this->sendLogged(
'welcome',
['user_id' => 10],
fn (): array => ['tenant_code' => 'tenant-test'],
);
}
public function test_it_logs_and_rethrows_delivery_failures(): void
{
$exception = new RuntimeException('SMTP unavailable.');
Log::shouldReceive('channel')->once()->with('emails')->andReturnSelf();
Log::shouldReceive('error')->once()->with(
'Notification email delivery failed.',
Mockery::on(fn (array $context): bool => $context['purchase_id'] === 123
&& $context['email_type'] === 'purchase_paid'
&& $context['exception'] === $exception),
);
$this->expectExceptionObject($exception);
$this->sendLogged('purchase_paid', ['purchase_id' => 123], function () use ($exception): array {
throw $exception;
});
}
/** @param array<string, mixed> $context */
private function sendLogged(string $emailType, array $context, callable $send): void
{
(new ReflectionMethod($this->service, 'sendLogged'))->invoke(
$this->service,
$emailType,
$context,
$send,
);
}
private function createEmptyPurchasesTable(): void
{
config([
'database.default' => 'sqlite',
'database.connections.sqlite.database' => ':memory:',
]);
Schema::connection('sqlite')->create('compras', function (Blueprint $table): void {
$table->id();
});
}
}

View File

@ -5,13 +5,12 @@ namespace Tests\Unit\Notification;
use App\Domains\Notification\Events\PasswordResetRequested; use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Notification\Listeners\SendPasswordResetEmail; use App\Domains\Notification\Listeners\SendPasswordResetEmail;
use App\Domains\Notification\Services\NotificationMailService; use App\Domains\Notification\Services\NotificationMailService;
use Illuminate\Support\Facades\Log;
use RuntimeException; use RuntimeException;
use Tests\TestCase; use Tests\TestCase;
class SendPasswordResetEmailTest extends TestCase class SendPasswordResetEmailTest extends TestCase
{ {
public function test_it_logs_and_rethrows_mail_failures(): void public function test_it_delegates_mail_failures_to_the_notification_service(): void
{ {
$exception = new RuntimeException('SMTP unavailable.'); $exception = new RuntimeException('SMTP unavailable.');
$mailService = \Mockery::mock(NotificationMailService::class); $mailService = \Mockery::mock(NotificationMailService::class);
@ -21,16 +20,6 @@ class SendPasswordResetEmailTest extends TestCase
->andThrow($exception); ->andThrow($exception);
$this->app->instance(NotificationMailService::class, $mailService); $this->app->instance(NotificationMailService::class, $mailService);
Log::shouldReceive('error')
->once()
->with(
'Failed to send password reset email.',
\Mockery::on(fn (array $context): bool => $context['attempt_id'] === 10
&& $context['tenant_code'] === 'tenant-test'
&& $context['channel'] === PasswordResetRequested::CHANNEL_STOREFRONT
&& $context['exception'] === $exception),
);
$this->expectExceptionObject($exception); $this->expectExceptionObject($exception);
(new SendPasswordResetEmail)->handle( (new SendPasswordResetEmail)->handle(