fix: consolidate purchase confirmation emails
This commit is contained in:
@@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Notification\Events;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
|
|
||||||
class TicketsAvailable
|
|
||||||
{
|
|
||||||
use Dispatchable, SerializesModels;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, int> $ticketIds
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public readonly int $purchaseId,
|
|
||||||
public readonly array $ticketIds,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ use App\Domains\Purchase\Events\PurchasePaid;
|
|||||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
|
||||||
class SendPurchasePaidEmail implements ShouldQueueAfterCommit
|
class SendPurchaseConfirmedEmail implements ShouldQueueAfterCommit
|
||||||
{
|
{
|
||||||
use InteractsWithQueue;
|
use InteractsWithQueue;
|
||||||
|
|
||||||
@@ -20,6 +20,6 @@ class SendPurchasePaidEmail implements ShouldQueueAfterCommit
|
|||||||
|
|
||||||
public function handle(PurchasePaid $event): void
|
public function handle(PurchasePaid $event): void
|
||||||
{
|
{
|
||||||
app(NotificationMailService::class)->sendPurchasePaid($event->purchaseId);
|
app(NotificationMailService::class)->sendPurchaseConfirmed($event->purchaseId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
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\InteractsWithQueue;
|
|
||||||
|
|
||||||
class SendTicketsAvailableEmail implements ShouldQueueAfterCommit
|
|
||||||
{
|
|
||||||
use InteractsWithQueue;
|
|
||||||
|
|
||||||
public string $queue = 'emails';
|
|
||||||
|
|
||||||
public int $tries = 3;
|
|
||||||
|
|
||||||
/** @var array<int, int> */
|
|
||||||
public array $backoff = [30, 120, 300];
|
|
||||||
|
|
||||||
public function handle(TicketsAvailable $event): void
|
|
||||||
{
|
|
||||||
app(NotificationMailService::class)->sendTicketsAvailable($event->purchaseId, $event->ticketIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -119,55 +119,17 @@ class NotificationMailService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sendPurchasePaid(int $purchaseId): void
|
public function sendPurchaseConfirmed(int $purchaseId): void
|
||||||
{
|
{
|
||||||
$context = ['purchase_id' => $purchaseId];
|
$context = ['purchase_id' => $purchaseId];
|
||||||
|
|
||||||
$this->sendLogged('purchase_paid', $context, function () use ($purchaseId, $context): ?array {
|
$this->sendLogged('purchase_confirmed', $context, function () use ($purchaseId, $context): ?array {
|
||||||
$purchase = Purchase::query()
|
$purchase = Purchase::query()
|
||||||
->with(['tenant', 'user', 'items'])
|
->with(['tenant', 'user', 'items'])
|
||||||
->find($purchaseId);
|
->find($purchaseId);
|
||||||
|
|
||||||
if ($purchase === null) {
|
if ($purchase === null) {
|
||||||
$this->logSkipped('purchase_paid', array_merge($context, [
|
$this->logSkipped('purchase_confirmed', 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
|
|
||||||
{
|
|
||||||
$context = [
|
|
||||||
'purchase_id' => $purchaseId,
|
|
||||||
'requested_ticket_count' => count($ticketIds),
|
|
||||||
'requested_ticket_ids' => $ticketIds,
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->sendLogged('tickets_available', $context, function () use ($purchaseId, $ticketIds, $context): ?array {
|
|
||||||
$purchase = Purchase::query()->with(['tenant', 'user'])->find($purchaseId);
|
|
||||||
|
|
||||||
if ($purchase === null) {
|
|
||||||
$this->logSkipped('tickets_available', array_merge($context, [
|
|
||||||
'reason' => 'purchase_not_found',
|
'reason' => 'purchase_not_found',
|
||||||
'missing_model' => Purchase::class,
|
'missing_model' => Purchase::class,
|
||||||
]));
|
]));
|
||||||
@@ -177,41 +139,34 @@ class NotificationMailService
|
|||||||
|
|
||||||
/** @var Collection<int, Ticket> $tickets */
|
/** @var Collection<int, Ticket> $tickets */
|
||||||
$tickets = Ticket::query()
|
$tickets = Ticket::query()
|
||||||
|
->where('source_purchase_id', $purchase->getKey())
|
||||||
->where('tenant_code', $purchase->tenant_codigo)
|
->where('tenant_code', $purchase->tenant_codigo)
|
||||||
->where('user_id', $purchase->user_id)
|
|
||||||
->whereKey($ticketIds)
|
|
||||||
->with(TicketPresentationResolver::RELATIONS)
|
->with(TicketPresentationResolver::RELATIONS)
|
||||||
->get();
|
->get();
|
||||||
|
$attachments = $tickets->isEmpty()
|
||||||
if ($tickets->isEmpty()) {
|
? []
|
||||||
$this->logSkipped('tickets_available', array_merge($context, [
|
: [[
|
||||||
'reason' => 'tickets_not_found',
|
'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets),
|
||||||
'missing_model' => Ticket::class,
|
'name' => $this->ticketPdfService->filename($tickets),
|
||||||
'tenant_code' => $purchase->tenant_codigo,
|
'mime' => 'application/pdf',
|
||||||
'user_id' => $purchase->user_id,
|
]];
|
||||||
]));
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->mailService
|
$this->mailService
|
||||||
->forTenant($purchase->tenant_codigo)
|
->forTenant($purchase->tenant_codigo)
|
||||||
->send(
|
->send(
|
||||||
$this->recipientFor($purchase),
|
$this->recipientFor($purchase),
|
||||||
'Tus tickets ya están disponibles',
|
"Compra confirmada - Compra #{$purchase->getKey()}",
|
||||||
view('mail.notifications.tickets-available', compact('purchase', 'tickets'))->render(),
|
view('mail.notifications.purchase-confirmed', compact('purchase', 'tickets'))->render(),
|
||||||
attachments: [[
|
attachments: $attachments,
|
||||||
'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets),
|
|
||||||
'name' => $this->ticketPdfService->filename($tickets),
|
|
||||||
'mime' => 'application/pdf',
|
|
||||||
]],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tenant_code' => $purchase->tenant_codigo,
|
'tenant_code' => $purchase->tenant_codigo,
|
||||||
'user_id' => $purchase->user_id,
|
'user_id' => $purchase->user_id,
|
||||||
'sent_ticket_count' => $tickets->count(),
|
'purchase_status' => $purchase->status,
|
||||||
'sent_ticket_ids' => $tickets->modelKeys(),
|
'purchase_item_count' => $purchase->items->count(),
|
||||||
|
'ticket_count' => $tickets->count(),
|
||||||
|
'ticket_ids' => $tickets->modelKeys(),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,11 @@ Orquesta notificaciones de negocio por correo a partir de eventos de otros domin
|
|||||||
|
|
||||||
- `UserRegistered`: dispara el correo de bienvenida.
|
- `UserRegistered`: dispara el correo de bienvenida.
|
||||||
- `PasswordResetRequested`: envía el código de recuperación si el intento sigue pendiente.
|
- `PasswordResetRequested`: envía el código de recuperación si el intento sigue pendiente.
|
||||||
- `PurchasePaid`: envía la confirmación de pago.
|
- `PurchasePaid`: envía la confirmación de compra y adjunta los tickets generados, cuando corresponde.
|
||||||
- `TicketsAvailable`: informa y entrega la disponibilidad de tickets.
|
|
||||||
|
|
||||||
## Componentes
|
## Componentes
|
||||||
|
|
||||||
Los listeners `SendWelcomeEmail`, `SendPasswordResetEmail`, `SendPurchasePaidEmail` y `SendTicketsAvailableEmail` delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`.
|
Los listeners `SendWelcomeEmail`, `SendPasswordResetEmail` y `SendPurchaseConfirmedEmail` delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`.
|
||||||
|
|
||||||
## API y dependencias
|
## API y dependencias
|
||||||
|
|
||||||
@@ -24,5 +23,5 @@ No expone rutas HTTP. Consume datos de `Auth`, `Tenant`, `Purchase` y `Ticket`,
|
|||||||
- Los listeners reciben identificadores y vuelven a cargar los modelos, evitando transportar entidades obsoletas.
|
- Los listeners reciben identificadores y vuelven a cargar los modelos, evitando transportar entidades obsoletas.
|
||||||
- La recuperación no se envía si el intento dejó de estar pendiente.
|
- La recuperación no se envía si el intento dejó de estar pendiente.
|
||||||
- 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.
|
- 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.
|
||||||
- Los correos transaccionales (pago confirmado y tickets disponibles) usan la identidad visual del tenant/evento de la compra.
|
- 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.
|
- Los handlers deben permanecer idempotentes o tolerantes a reintentos de cola.
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Domains\Ticket\Listeners;
|
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\Purchase\Events\PurchasePaid;
|
use App\Domains\Purchase\Events\PurchasePaid;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Ticket\Exceptions\TicketGenerationException;
|
use App\Domains\Ticket\Exceptions\TicketGenerationException;
|
||||||
@@ -21,8 +20,6 @@ class GenerateTicketsForPaidPurchase
|
|||||||
->with(['user', 'items'])
|
->with(['user', 'items'])
|
||||||
->findOrFail($event->purchaseId);
|
->findOrFail($event->purchaseId);
|
||||||
$user = $purchase->user;
|
$user = $purchase->user;
|
||||||
$ticketIds = [];
|
|
||||||
|
|
||||||
foreach ($purchase->items as $purchaseItem) {
|
foreach ($purchase->items as $purchaseItem) {
|
||||||
$catalogItem = CatalogItem::query()
|
$catalogItem = CatalogItem::query()
|
||||||
->where('tenant_code', $purchase->tenant_codigo)
|
->where('tenant_code', $purchase->tenant_codigo)
|
||||||
@@ -40,19 +37,13 @@ class GenerateTicketsForPaidPurchase
|
|||||||
throw TicketGenerationException::purchaseWithoutUser($purchase);
|
throw TicketGenerationException::purchaseWithoutUser($purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
$generatedTickets = $this->ticketGenerator->generate(
|
$this->ticketGenerator->generate(
|
||||||
$catalogItem,
|
$catalogItem,
|
||||||
$user,
|
$user,
|
||||||
$purchaseItem->cantidad,
|
$purchaseItem->cantidad,
|
||||||
$purchaseItem->source_variant_id,
|
$purchaseItem->source_variant_id,
|
||||||
$purchase->getKey(),
|
$purchase->getKey(),
|
||||||
);
|
);
|
||||||
|
|
||||||
array_push($ticketIds, ...$generatedTickets->pluck('id')->all());
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($ticketIds !== []) {
|
|
||||||
TicketsAvailable::dispatch($purchase->getKey(), $ticketIds);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ vigente, vencido o usado, y resuelve sus fechas efectivas de inicio y fin sin pe
|
|||||||
1. `Purchase` emite `PurchasePaid` al confirmarse el pago.
|
1. `Purchase` emite `PurchasePaid` al confirmarse el pago.
|
||||||
2. `GenerateTicketsForPaidPurchase` atiende el evento.
|
2. `GenerateTicketsForPaidPurchase` atiende el evento.
|
||||||
3. `TicketGeneratorService` crea los tickets requeridos según ítems, cantidades y vigencia.
|
3. `TicketGeneratorService` crea los tickets requeridos según ítems, cantidades y vigencia.
|
||||||
4. El flujo puede emitir disponibilidad para que `Notification` informe al comprador.
|
4. `Notification` envía la confirmación de compra después de la generación y adjunta los tickets cuando existen.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Domains\Notification\Events\PasswordResetRequested;
|
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||||
use App\Domains\Notification\Events\TicketsAvailable;
|
|
||||||
use App\Domains\Notification\Events\UserRegistered;
|
use App\Domains\Notification\Events\UserRegistered;
|
||||||
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
|
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
|
||||||
use App\Domains\Notification\Listeners\SendPurchasePaidEmail;
|
use App\Domains\Notification\Listeners\SendPurchaseConfirmedEmail;
|
||||||
use App\Domains\Notification\Listeners\SendTicketsAvailableEmail;
|
|
||||||
use App\Domains\Notification\Listeners\SendWelcomeEmail;
|
use App\Domains\Notification\Listeners\SendWelcomeEmail;
|
||||||
use App\Domains\Purchase\Events\PurchasePaid;
|
use App\Domains\Purchase\Events\PurchasePaid;
|
||||||
use App\Domains\Ticket\Listeners\GenerateTicketsForPaidPurchase;
|
use App\Domains\Ticket\Listeners\GenerateTicketsForPaidPurchase;
|
||||||
@@ -33,9 +31,8 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
Event::listen(PurchasePaid::class, SendPurchasePaidEmail::class);
|
|
||||||
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
|
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
|
||||||
Event::listen(TicketsAvailable::class, SendTicketsAvailableEmail::class);
|
Event::listen(PurchasePaid::class, SendPurchaseConfirmedEmail::class);
|
||||||
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
|
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
|
||||||
Event::listen(PasswordResetRequested::class, SendPasswordResetEmail::class);
|
Event::listen(PasswordResetRequested::class, SendPasswordResetEmail::class);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<h1 style="margin: 0 0 20px;">¡Recibimos tu pago!</h1>
|
<h1 style="margin: 0 0 20px;">¡Compra realizada con éxito!</h1>
|
||||||
<p>La compra <strong>#{{ $purchase->id }}</strong> fue confirmada correctamente.</p>
|
<p>La compra <strong>#{{ $purchase->id }}</strong> fue confirmada correctamente.</p>
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||||
@foreach ($purchase->items as $item)
|
@foreach ($purchase->items as $item)
|
||||||
@@ -10,3 +10,6 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</table>
|
</table>
|
||||||
<p style="font-size: 18px;"><strong>Total pagado: ${{ number_format((float) $purchase->total, 2, ',', '.') }}</strong></p>
|
<p style="font-size: 18px;"><strong>Total pagado: ${{ number_format((float) $purchase->total, 2, ',', '.') }}</strong></p>
|
||||||
|
@if ($tickets->isNotEmpty())
|
||||||
|
<p><strong>Tus tickets ya están disponibles</strong></p>
|
||||||
|
@endif
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<h1 style="margin: 0 0 20px;">Tus tickets ya están disponibles</h1>
|
|
||||||
<p>Generamos {{ $tickets->count() }} {{ $tickets->count() === 1 ? 'ticket' : 'tickets' }} para la compra <strong>#{{ $purchase->id }}</strong>.</p>
|
|
||||||
<p>{{ $tickets->count() === 1 ? 'El ticket está adjunto' : 'Los tickets están adjuntos' }} a este correo en formato PDF.</p>
|
|
||||||
<ul style="padding-left: 20px;">
|
|
||||||
@foreach ($tickets as $ticket)
|
|
||||||
<li style="margin-bottom: 8px;">{{ $ticket->name }}</li>
|
|
||||||
@endforeach
|
|
||||||
</ul>
|
|
||||||
@@ -142,7 +142,7 @@ class NotificationMailServiceTest extends TestCase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_sends_purchase_and_ticket_emails_to_the_purchase_recipient(): void
|
public function test_it_sends_one_purchase_confirmation_with_generated_tickets_attached(): void
|
||||||
{
|
{
|
||||||
$this->useWebsiteTypeBranding();
|
$this->useWebsiteTypeBranding();
|
||||||
$purchase = Purchase::query()->create([
|
$purchase = Purchase::query()->create([
|
||||||
@@ -175,30 +175,23 @@ class NotificationMailServiceTest extends TestCase
|
|||||||
$ticket = Ticket::query()->create([
|
$ticket = Ticket::query()->create([
|
||||||
'tenant_code' => $this->tenant->codigo,
|
'tenant_code' => $this->tenant->codigo,
|
||||||
'ticket' => fake()->uuid(),
|
'ticket' => fake()->uuid(),
|
||||||
|
'source_purchase_id' => $purchase->id,
|
||||||
'source_catalog_item_id' => $catalogItem->id,
|
'source_catalog_item_id' => $catalogItem->id,
|
||||||
'user_id' => $this->user->id,
|
'user_id' => $this->user->id,
|
||||||
]);
|
]);
|
||||||
$service = app(NotificationMailService::class);
|
$service = app(NotificationMailService::class);
|
||||||
|
|
||||||
$service->sendPurchasePaid($purchase->id);
|
$service->sendPurchaseConfirmed($purchase->id);
|
||||||
$service->sendTicketsAvailable($purchase->id, [$ticket->id]);
|
|
||||||
|
|
||||||
Mail::assertSent(Mailable::class, 2);
|
Mail::assertSent(Mailable::class, 1);
|
||||||
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase): bool {
|
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase, $ticket): bool {
|
||||||
$mail->assertTo('checkout@example.com');
|
|
||||||
|
|
||||||
return $mail->subject === "Pago confirmado - Compra #{$purchase->id}"
|
|
||||||
&& str_contains($mail->render(), 'Total pagado')
|
|
||||||
&& str_contains($mail->render(), 'border-top: 4px solid #112233')
|
|
||||||
&& ! str_contains($mail->render(), 'border-top: 4px solid #ff7006');
|
|
||||||
});
|
|
||||||
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($ticket): bool {
|
|
||||||
$mail->assertTo('checkout@example.com');
|
$mail->assertTo('checkout@example.com');
|
||||||
$attachment = collect($mail->rawAttachments)->firstWhere('name', "tickets_{$ticket->id}.pdf");
|
$attachment = collect($mail->rawAttachments)->firstWhere('name', "tickets_{$ticket->id}.pdf");
|
||||||
|
|
||||||
return $mail->subject === 'Tus tickets ya están disponibles'
|
return $mail->subject === "Compra confirmada - Compra #{$purchase->id}"
|
||||||
&& str_contains($mail->render(), 'Entrada general')
|
&& str_contains($mail->render(), '¡Compra realizada con éxito!')
|
||||||
&& str_contains($mail->render(), 'El ticket está adjunto')
|
&& str_contains($mail->render(), 'Total pagado')
|
||||||
|
&& str_contains($mail->render(), 'Tus tickets ya están disponibles')
|
||||||
&& $attachment !== null
|
&& $attachment !== null
|
||||||
&& $attachment['options'] === ['mime' => 'application/pdf']
|
&& $attachment['options'] === ['mime' => 'application/pdf']
|
||||||
&& str_starts_with($attachment['data'], '%PDF-')
|
&& str_starts_with($attachment['data'], '%PDF-')
|
||||||
@@ -207,6 +200,26 @@ class NotificationMailServiceTest extends TestCase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_purchase_confirmation_omits_ticket_content_and_attachment_without_tickets(): void
|
||||||
|
{
|
||||||
|
$purchase = Purchase::query()->create([
|
||||||
|
'tenant_codigo' => $this->tenant->codigo,
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'status' => Purchase::STATUS_PAID,
|
||||||
|
'payment_method' => 'transfer',
|
||||||
|
'total' => 25,
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(NotificationMailService::class)->sendPurchaseConfirmed($purchase->id);
|
||||||
|
|
||||||
|
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase): bool {
|
||||||
|
return $mail->subject === "Compra confirmada - Compra #{$purchase->id}"
|
||||||
|
&& str_contains($mail->render(), '¡Compra realizada con éxito!')
|
||||||
|
&& ! str_contains($mail->render(), 'Tus tickets ya están disponibles')
|
||||||
|
&& $mail->rawAttachments === [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private function useWebsiteTypeBranding(): void
|
private function useWebsiteTypeBranding(): void
|
||||||
{
|
{
|
||||||
$websiteType = WebsiteType::query()->create([
|
$websiteType = WebsiteType::query()->create([
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
namespace Tests\Feature\Notification;
|
namespace Tests\Feature\Notification;
|
||||||
|
|
||||||
use App\Domains\Notification\Events\TicketsAvailable;
|
use App\Domains\Notification\Listeners\SendPurchaseConfirmedEmail;
|
||||||
use App\Domains\Notification\Listeners\SendPurchasePaidEmail;
|
|
||||||
use App\Domains\Notification\Listeners\SendTicketsAvailableEmail;
|
|
||||||
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 Mockery;
|
use Mockery;
|
||||||
@@ -12,35 +10,21 @@ use Tests\TestCase;
|
|||||||
|
|
||||||
class QueuedNotificationListenerTest extends TestCase
|
class QueuedNotificationListenerTest extends TestCase
|
||||||
{
|
{
|
||||||
public function test_purchase_paid_email_delegates_with_the_purchase_id(): void
|
public function test_purchase_confirmed_email_delegates_with_the_purchase_id(): void
|
||||||
{
|
{
|
||||||
$mailService = Mockery::mock(NotificationMailService::class);
|
$mailService = Mockery::mock(NotificationMailService::class);
|
||||||
$mailService->shouldReceive('sendPurchasePaid')
|
$mailService->shouldReceive('sendPurchaseConfirmed')
|
||||||
->once()
|
->once()
|
||||||
->with(123);
|
->with(123);
|
||||||
$this->app->instance(NotificationMailService::class, $mailService);
|
$this->app->instance(NotificationMailService::class, $mailService);
|
||||||
|
|
||||||
(new SendPurchasePaidEmail)->handle(new PurchasePaid(123));
|
(new SendPurchaseConfirmedEmail)->handle(new PurchasePaid(123));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_tickets_available_email_delegates_with_scalar_identifiers(): void
|
public function test_purchase_paid_event_only_serializes_the_purchase_id(): void
|
||||||
{
|
|
||||||
$mailService = Mockery::mock(NotificationMailService::class);
|
|
||||||
$mailService->shouldReceive('sendTicketsAvailable')
|
|
||||||
->once()
|
|
||||||
->with(123, [10, 11]);
|
|
||||||
$this->app->instance(NotificationMailService::class, $mailService);
|
|
||||||
|
|
||||||
(new SendTicketsAvailableEmail)->handle(new TicketsAvailable(123, [10, 11]));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_email_events_only_serialize_scalar_identifiers(): void
|
|
||||||
{
|
{
|
||||||
$purchasePaid = unserialize(serialize(new PurchasePaid(123)));
|
$purchasePaid = unserialize(serialize(new PurchasePaid(123)));
|
||||||
$ticketsAvailable = unserialize(serialize(new TicketsAvailable(123, [10, 11])));
|
|
||||||
|
|
||||||
$this->assertSame(123, $purchasePaid->purchaseId);
|
$this->assertSame(123, $purchasePaid->purchaseId);
|
||||||
$this->assertSame(123, $ticketsAvailable->purchaseId);
|
|
||||||
$this->assertSame([10, 11], $ticketsAvailable->ticketIds);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use App\Domains\Catalog\Models\CatalogItem;
|
|||||||
use App\Domains\Catalog\Models\Inventory;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
use App\Domains\Catalog\Services\CatalogService;
|
use App\Domains\Catalog\Services\CatalogService;
|
||||||
use App\Domains\Event\Models\EventDate;
|
use App\Domains\Event\Models\EventDate;
|
||||||
use App\Domains\Notification\Events\TicketsAvailable;
|
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Shared\Enums\FieldType;
|
use App\Domains\Shared\Enums\FieldType;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
@@ -22,7 +21,6 @@ use App\Domains\Ticket\Services\TicketGeneratorService;
|
|||||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Event;
|
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -220,7 +218,6 @@ class TicketGeneratorServiceTest extends TestCase
|
|||||||
|
|
||||||
public function test_marking_a_purchase_as_paid_generates_its_tickets_once(): void
|
public function test_marking_a_purchase_as_paid_generates_its_tickets_once(): void
|
||||||
{
|
{
|
||||||
Event::fake([TicketsAvailable::class]);
|
|
||||||
$item = $this->createTicketableItem('paid-ticket');
|
$item = $this->createTicketableItem('paid-ticket');
|
||||||
$purchase = $this->createPurchase($item, 2);
|
$purchase = $this->createPurchase($item, 2);
|
||||||
$purchase->setRelation('items', new EloquentCollection);
|
$purchase->setRelation('items', new EloquentCollection);
|
||||||
@@ -229,7 +226,6 @@ class TicketGeneratorServiceTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame(Purchase::STATUS_PAID, $purchase->status);
|
$this->assertSame(Purchase::STATUS_PAID, $purchase->status);
|
||||||
$this->assertDatabaseCount('tickets', 2);
|
$this->assertDatabaseCount('tickets', 2);
|
||||||
Event::assertDispatchedTimes(TicketsAvailable::class, 1);
|
|
||||||
|
|
||||||
$this->actingAs($this->user, 'sanctum')
|
$this->actingAs($this->user, 'sanctum')
|
||||||
->getJson("/api/tenants/{$this->tenant->codigo}/compras/{$purchase->id}")
|
->getJson("/api/tenants/{$this->tenant->codigo}/compras/{$purchase->id}")
|
||||||
@@ -240,7 +236,6 @@ class TicketGeneratorServiceTest extends TestCase
|
|||||||
$purchase->markAsPaid();
|
$purchase->markAsPaid();
|
||||||
|
|
||||||
$this->assertDatabaseCount('tickets', 2);
|
$this->assertDatabaseCount('tickets', 2);
|
||||||
Event::assertDispatchedTimes(TicketsAvailable::class, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_a_ticket_generated_from_a_purchase_keeps_its_source_ids(): void
|
public function test_a_ticket_generated_from_a_purchase_keeps_its_source_ids(): void
|
||||||
@@ -453,7 +448,6 @@ class TicketGeneratorServiceTest extends TestCase
|
|||||||
|
|
||||||
public function test_marking_a_purchase_as_paid_ignores_items_without_tickets(): void
|
public function test_marking_a_purchase_as_paid_ignores_items_without_tickets(): void
|
||||||
{
|
{
|
||||||
Event::fake([TicketsAvailable::class]);
|
|
||||||
$item = $this->createTicketableItem('regular-product');
|
$item = $this->createTicketableItem('regular-product');
|
||||||
$item->update(['has_tickets' => false]);
|
$item->update(['has_tickets' => false]);
|
||||||
$purchase = $this->createPurchase($item->fresh(), 1);
|
$purchase = $this->createPurchase($item->fresh(), 1);
|
||||||
@@ -462,7 +456,6 @@ class TicketGeneratorServiceTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame(Purchase::STATUS_PAID, $purchase->status);
|
$this->assertSame(Purchase::STATUS_PAID, $purchase->status);
|
||||||
$this->assertDatabaseCount('tickets', 0);
|
$this->assertDatabaseCount('tickets', 0);
|
||||||
Event::assertNotDispatched(TicketsAvailable::class);
|
|
||||||
|
|
||||||
$this->actingAs($this->user, 'sanctum')
|
$this->actingAs($this->user, 'sanctum')
|
||||||
->getJson("/api/tenants/{$this->tenant->codigo}/compras/{$purchase->id}")
|
->getJson("/api/tenants/{$this->tenant->codigo}/compras/{$purchase->id}")
|
||||||
|
|||||||
@@ -42,31 +42,11 @@ class NotificationMailServiceLoggingTest extends TestCase
|
|||||||
'purchase_id' => 123,
|
'purchase_id' => 123,
|
||||||
'reason' => 'purchase_not_found',
|
'reason' => 'purchase_not_found',
|
||||||
'missing_model' => Purchase::class,
|
'missing_model' => Purchase::class,
|
||||||
'email_type' => 'purchase_paid',
|
'email_type' => 'purchase_confirmed',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->service->sendPurchasePaid(123);
|
$this->service->sendPurchaseConfirmed(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
|
public function test_it_logs_successful_delivery_with_the_mailer(): void
|
||||||
@@ -97,13 +77,13 @@ class NotificationMailServiceLoggingTest extends TestCase
|
|||||||
Log::shouldReceive('error')->once()->with(
|
Log::shouldReceive('error')->once()->with(
|
||||||
'Notification email delivery failed.',
|
'Notification email delivery failed.',
|
||||||
Mockery::on(fn (array $context): bool => $context['purchase_id'] === 123
|
Mockery::on(fn (array $context): bool => $context['purchase_id'] === 123
|
||||||
&& $context['email_type'] === 'purchase_paid'
|
&& $context['email_type'] === 'purchase_confirmed'
|
||||||
&& $context['exception'] === $exception),
|
&& $context['exception'] === $exception),
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->expectExceptionObject($exception);
|
$this->expectExceptionObject($exception);
|
||||||
|
|
||||||
$this->sendLogged('purchase_paid', ['purchase_id' => 123], function () use ($exception): array {
|
$this->sendLogged('purchase_confirmed', ['purchase_id' => 123], function () use ($exception): array {
|
||||||
throw $exception;
|
throw $exception;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user