From d0424c5589edfae3cff05ea50ef963fd2b6b8e31 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 26 Aug 2026 09:38:19 -0300 Subject: [PATCH] feat(notification): enhance email logging and refactor event handling to use scalar identifiers --- .env.example | 2 + .../Notification/Events/TicketsAvailable.php | 3 +- .../Listeners/SendPasswordResetEmail.php | 23 +- .../Listeners/SendPurchasePaidEmail.php | 4 +- .../Listeners/SendTicketsAvailableEmail.php | 4 +- .../Services/NotificationMailService.php | 293 ++++++++++++------ app/Domains/Purchase/Events/PurchasePaid.php | 3 +- app/Domains/Purchase/Models/Purchase.php | 2 +- .../GenerateTicketsForPaidPurchase.php | 8 +- config/logging.php | 8 + .../QueuedNotificationListenerTest.php | 62 ++-- .../NotificationMailServiceLoggingTest.php | 132 ++++++++ .../SendPasswordResetEmailTest.php | 13 +- 13 files changed, 381 insertions(+), 176 deletions(-) create mode 100644 tests/Unit/Notification/NotificationMailServiceLoggingTest.php diff --git a/.env.example b/.env.example index 1782db8..271a397 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,8 @@ TELEPAGOS_LOG_LEVEL=info TELEPAGOS_LOG_DAYS=30 COMMANDS_LOG_LEVEL=info COMMANDS_LOG_DAYS=30 +EMAILS_LOG_LEVEL=info +EMAILS_LOG_DAYS=30 DB_CONNECTION=mysql DB_HOST=127.0.0.1 diff --git a/app/Domains/Notification/Events/TicketsAvailable.php b/app/Domains/Notification/Events/TicketsAvailable.php index ff308dc..3afb501 100644 --- a/app/Domains/Notification/Events/TicketsAvailable.php +++ b/app/Domains/Notification/Events/TicketsAvailable.php @@ -2,7 +2,6 @@ namespace App\Domains\Notification\Events; -use App\Domains\Purchase\Models\Purchase; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; @@ -14,7 +13,7 @@ class TicketsAvailable * @param array $ticketIds */ public function __construct( - public readonly Purchase $purchase, + public readonly int $purchaseId, public readonly array $ticketIds, ) {} } diff --git a/app/Domains/Notification/Listeners/SendPasswordResetEmail.php b/app/Domains/Notification/Listeners/SendPasswordResetEmail.php index af7d8f3..69a4dfe 100644 --- a/app/Domains/Notification/Listeners/SendPasswordResetEmail.php +++ b/app/Domains/Notification/Listeners/SendPasswordResetEmail.php @@ -6,8 +6,6 @@ use App\Domains\Notification\Events\PasswordResetRequested; use App\Domains\Notification\Services\NotificationMailService; use Illuminate\Contracts\Queue\ShouldQueueAfterCommit; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Support\Facades\Log; -use Throwable; class SendPasswordResetEmail implements ShouldQueueAfterCommit { @@ -22,21 +20,10 @@ class SendPasswordResetEmail implements ShouldQueueAfterCommit public function handle(PasswordResetRequested $event): void { - try { - app(NotificationMailService::class)->sendPasswordResetCode( - $event->attemptId, - $event->tenantCode, - $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; - } + app(NotificationMailService::class)->sendPasswordResetCode( + $event->attemptId, + $event->tenantCode, + $event->channel, + ); } } diff --git a/app/Domains/Notification/Listeners/SendPurchasePaidEmail.php b/app/Domains/Notification/Listeners/SendPurchasePaidEmail.php index d7304fb..2205d8e 100644 --- a/app/Domains/Notification/Listeners/SendPurchasePaidEmail.php +++ b/app/Domains/Notification/Listeners/SendPurchasePaidEmail.php @@ -5,10 +5,8 @@ namespace App\Domains\Notification\Listeners; use App\Domains\Notification\Services\NotificationMailService; use App\Domains\Purchase\Events\PurchasePaid; use Illuminate\Contracts\Queue\ShouldQueueAfterCommit; -use Illuminate\Queue\Attributes\DeleteWhenMissingModels; use Illuminate\Queue\InteractsWithQueue; -#[DeleteWhenMissingModels] class SendPurchasePaidEmail implements ShouldQueueAfterCommit { use InteractsWithQueue; @@ -22,6 +20,6 @@ class SendPurchasePaidEmail implements ShouldQueueAfterCommit public function handle(PurchasePaid $event): void { - app(NotificationMailService::class)->sendPurchasePaid($event->purchase->getKey()); + app(NotificationMailService::class)->sendPurchasePaid($event->purchaseId); } } diff --git a/app/Domains/Notification/Listeners/SendTicketsAvailableEmail.php b/app/Domains/Notification/Listeners/SendTicketsAvailableEmail.php index 649ae97..75c3ff1 100644 --- a/app/Domains/Notification/Listeners/SendTicketsAvailableEmail.php +++ b/app/Domains/Notification/Listeners/SendTicketsAvailableEmail.php @@ -5,10 +5,8 @@ namespace App\Domains\Notification\Listeners; use App\Domains\Notification\Events\TicketsAvailable; use App\Domains\Notification\Services\NotificationMailService; use Illuminate\Contracts\Queue\ShouldQueueAfterCommit; -use Illuminate\Queue\Attributes\DeleteWhenMissingModels; use Illuminate\Queue\InteractsWithQueue; -#[DeleteWhenMissingModels] class SendTicketsAvailableEmail implements ShouldQueueAfterCommit { use InteractsWithQueue; @@ -22,6 +20,6 @@ class SendTicketsAvailableEmail implements ShouldQueueAfterCommit public function handle(TicketsAvailable $event): void { - app(NotificationMailService::class)->sendTicketsAvailable($event->purchase->getKey(), $event->ticketIds); + app(NotificationMailService::class)->sendTicketsAvailable($event->purchaseId, $event->ticketIds); } } diff --git a/app/Domains/Notification/Services/NotificationMailService.php b/app/Domains/Notification/Services/NotificationMailService.php index 69561e8..dc24276 100644 --- a/app/Domains/Notification/Services/NotificationMailService.php +++ b/app/Domains/Notification/Services/NotificationMailService.php @@ -11,8 +11,10 @@ use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Services\TicketPdfService; use App\Domains\Ticket\Services\TicketPresentationResolver; +use Closure; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; +use Throwable; class NotificationMailService { @@ -23,18 +25,27 @@ class NotificationMailService public function sendWelcome(int $userId, string $tenantCode): void { - $tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail(); - $user = User::query()->findOrFail($userId); - $brand = $tenant->websiteType ?? $tenant; + $this->sendLogged('welcome', [ + '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; - $this->mailService - ->forTenant($tenantCode) - ->send( - $user->email, - "Bienvenido a {$brand->nombre}", - view('mail.notifications.welcome', compact('brand', 'user'))->render(), - $brand, - ); + $this->mailService + ->forTenant($tenantCode) + ->send( + $user->email, + "Bienvenido a {$brand->nombre}", + view('mail.notifications.welcome', compact('brand', 'user'))->render(), + $brand, + ); + + return [ + 'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type', + ]; + }); } public function sendPasswordResetCode( @@ -42,106 +53,206 @@ class NotificationMailService string $tenantCode, string $channel = PasswordResetRequested::CHANNEL_STOREFRONT, ): void { - $tenant = Tenant::query() - ->with('websiteType') - ->where('codigo', $tenantCode) - ->firstOrFail(); - $attempt = ResetPasswordAttempt::query() - ->with('user') - ->findOrFail($attemptId); + $context = [ + 'attempt_id' => $attemptId, + 'tenant_code' => $tenantCode, + 'channel' => $channel, + ]; - if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) { - Log::warning('Password reset email was skipped because the attempt is no longer pending.', [ - 'attempt_id' => $attemptId, - 'tenant_code' => $tenantCode, - 'attempt_status' => $attempt->status, - ]); + $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); - 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) { - 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; + return null; + } - $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, - ); + $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; + + $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 { - $purchase = Purchase::query() - ->with(['tenant', 'user', 'items']) - ->findOrFail($purchaseId); + $context = ['purchase_id' => $purchaseId]; - $this->mailService - ->forTenant($purchase->tenant_codigo) - ->send( - $this->recipientFor($purchase), - "Pago confirmado - Compra #{$purchase->getKey()}", - view('mail.notifications.purchase-paid', compact('purchase'))->render(), - ); + $this->sendLogged('purchase_paid', $context, function () use ($purchaseId, $context): ?array { + $purchase = Purchase::query() + ->with(['tenant', 'user', 'items']) + ->find($purchaseId); + + 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 $ticketIds */ public function sendTicketsAvailable(int $purchaseId, array $ticketIds): void { - $purchase = Purchase::query()->with(['tenant', 'user'])->findOrFail($purchaseId); - /** @var Collection $tickets */ - $tickets = Ticket::query() - ->where('tenant_code', $purchase->tenant_codigo) - ->where('user_id', $purchase->user_id) - ->whereKey($ticketIds) - ->with(TicketPresentationResolver::RELATIONS) - ->get(); + $context = [ + 'purchase_id' => $purchaseId, + 'requested_ticket_count' => count($ticketIds), + 'requested_ticket_ids' => $ticketIds, + ]; - if ($tickets->isEmpty()) { - return; - } + $this->sendLogged('tickets_available', $context, function () use ($purchaseId, $ticketIds, $context): ?array { + $purchase = Purchase::query()->with(['tenant', 'user'])->find($purchaseId); - $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', - ]], - ); + if ($purchase === null) { + $this->logSkipped('tickets_available', array_merge($context, [ + 'reason' => 'purchase_not_found', + 'missing_model' => Purchase::class, + ])); + + return null; + } + + /** @var Collection $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 { return (string) ($purchase->email ?: $purchase->user?->email); } + + /** + * @param array $context + * @param Closure(): (array|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 $context */ + private function logSkipped(string $emailType, array $context): void + { + Log::channel('emails')->warning('Notification email skipped.', array_merge($context, [ + 'email_type' => $emailType, + ])); + } } diff --git a/app/Domains/Purchase/Events/PurchasePaid.php b/app/Domains/Purchase/Events/PurchasePaid.php index 3eed891..4b72ab2 100644 --- a/app/Domains/Purchase/Events/PurchasePaid.php +++ b/app/Domains/Purchase/Events/PurchasePaid.php @@ -2,7 +2,6 @@ namespace App\Domains\Purchase\Events; -use App\Domains\Purchase\Models\Purchase; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; @@ -10,5 +9,5 @@ class PurchasePaid { use Dispatchable, SerializesModels; - public function __construct(public readonly Purchase $purchase) {} + public function __construct(public readonly int $purchaseId) {} } diff --git a/app/Domains/Purchase/Models/Purchase.php b/app/Domains/Purchase/Models/Purchase.php index 9a37c36..447db78 100644 --- a/app/Domains/Purchase/Models/Purchase.php +++ b/app/Domains/Purchase/Models/Purchase.php @@ -193,7 +193,7 @@ class Purchase extends Model 'status' => self::STATUS_PAID, ]); - PurchasePaid::dispatch($this); + PurchasePaid::dispatch($this->getKey()); }); } } diff --git a/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php b/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php index 29899d2..3183405 100644 --- a/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php +++ b/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php @@ -5,6 +5,7 @@ namespace App\Domains\Ticket\Listeners; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Notification\Events\TicketsAvailable; use App\Domains\Purchase\Events\PurchasePaid; +use App\Domains\Purchase\Models\Purchase; use App\Domains\Ticket\Exceptions\TicketGenerationException; use App\Domains\Ticket\Services\TicketGeneratorService; @@ -16,10 +17,9 @@ class GenerateTicketsForPaidPurchase public function handle(PurchasePaid $event): void { - $purchase = $event->purchase - ->newQuery() + $purchase = Purchase::query() ->with(['user', 'items']) - ->findOrFail($event->purchase->getKey()); + ->findOrFail($event->purchaseId); $user = $purchase->user; $ticketIds = []; @@ -52,7 +52,7 @@ class GenerateTicketsForPaidPurchase } if ($ticketIds !== []) { - TicketsAvailable::dispatch($purchase, $ticketIds); + TicketsAvailable::dispatch($purchase->getKey(), $ticketIds); } } diff --git a/config/logging.php b/config/logging.php index 61a000b..89a1381 100644 --- a/config/logging.php +++ b/config/logging.php @@ -89,6 +89,14 @@ return [ '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' => [ 'driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), diff --git a/tests/Feature/Notification/QueuedNotificationListenerTest.php b/tests/Feature/Notification/QueuedNotificationListenerTest.php index dc04d47..89fcdfc 100644 --- a/tests/Feature/Notification/QueuedNotificationListenerTest.php +++ b/tests/Feature/Notification/QueuedNotificationListenerTest.php @@ -5,60 +5,42 @@ namespace Tests\Feature\Notification; use App\Domains\Notification\Events\TicketsAvailable; use App\Domains\Notification\Listeners\SendPurchasePaidEmail; use App\Domains\Notification\Listeners\SendTicketsAvailableEmail; +use App\Domains\Notification\Services\NotificationMailService; use App\Domains\Purchase\Events\PurchasePaid; -use App\Domains\Purchase\Models\Purchase; -use Illuminate\Events\CallQueuedListener; -use Illuminate\Support\Facades\Event; -use Illuminate\Support\Facades\Queue; +use Mockery; use Tests\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( - PurchasePaid::class, - SendPurchasePaidEmail::class, - new PurchasePaid($this->purchase()), - ); + $mailService = Mockery::mock(NotificationMailService::class); + $mailService->shouldReceive('sendPurchasePaid') + ->once() + ->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( - TicketsAvailable::class, - SendTicketsAvailableEmail::class, - new TicketsAvailable($this->purchase(), [10, 11]), - ); + $mailService = Mockery::mock(NotificationMailService::class); + $mailService->shouldReceive('sendTicketsAvailable') + ->once() + ->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(); - Event::forget($event); - Event::listen($event, $listener); + $purchasePaid = unserialize(serialize(new PurchasePaid(123))); + $ticketsAvailable = unserialize(serialize(new TicketsAvailable(123, [10, 11]))); - event($payload); - - $queuedListener = null; - - 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]); + $this->assertSame(123, $purchasePaid->purchaseId); + $this->assertSame(123, $ticketsAvailable->purchaseId); + $this->assertSame([10, 11], $ticketsAvailable->ticketIds); } } diff --git a/tests/Unit/Notification/NotificationMailServiceLoggingTest.php b/tests/Unit/Notification/NotificationMailServiceLoggingTest.php new file mode 100644 index 0000000..18003a2 --- /dev/null +++ b/tests/Unit/Notification/NotificationMailServiceLoggingTest.php @@ -0,0 +1,132 @@ +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 $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(); + }); + } +} diff --git a/tests/Unit/Notification/SendPasswordResetEmailTest.php b/tests/Unit/Notification/SendPasswordResetEmailTest.php index f52035c..41a5ec7 100644 --- a/tests/Unit/Notification/SendPasswordResetEmailTest.php +++ b/tests/Unit/Notification/SendPasswordResetEmailTest.php @@ -5,13 +5,12 @@ namespace Tests\Unit\Notification; use App\Domains\Notification\Events\PasswordResetRequested; use App\Domains\Notification\Listeners\SendPasswordResetEmail; use App\Domains\Notification\Services\NotificationMailService; -use Illuminate\Support\Facades\Log; use RuntimeException; use Tests\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.'); $mailService = \Mockery::mock(NotificationMailService::class); @@ -21,16 +20,6 @@ class SendPasswordResetEmailTest extends TestCase ->andThrow($exception); $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); (new SendPasswordResetEmail)->handle(