feat(notification): enhance email logging and refactor event handling to use scalar identifiers
This commit is contained in:
parent
28d09dedab
commit
d0424c5589
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<int, int> $ticketIds
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Purchase $purchase,
|
||||
public readonly int $purchaseId,
|
||||
public readonly array $ticketIds,
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<int, int> $ticketIds */
|
||||
public function sendTicketsAvailable(int $purchaseId, array $ticketIds): void
|
||||
{
|
||||
$purchase = Purchase::query()->with(['tenant', 'user'])->findOrFail($purchaseId);
|
||||
/** @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();
|
||||
$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<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
|
||||
{
|
||||
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,
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ class Purchase extends Model
|
|||
'status' => self::STATUS_PAID,
|
||||
]);
|
||||
|
||||
PurchasePaid::dispatch($this);
|
||||
PurchasePaid::dispatch($this->getKey());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue