From 528772fc96adedbd27292d4d7462aeefa602dcf9 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 22 Jul 2026 09:54:19 -0300 Subject: [PATCH] feat(notification): implement email notifications for user registration and purchase events --- .../Auth/Requests/RegisterUserRequest.php | 4 +- .../Auth/Services/RegisterUserService.php | 11 +- .../Notification/Events/TicketsAvailable.php | 20 +++ .../Notification/Events/UserRegistered.php | 17 +++ .../Listeners/SendPurchasePaidEmail.php | 25 ++++ .../Listeners/SendTicketsAvailableEmail.php | 25 ++++ .../Listeners/SendWelcomeEmail.php | 25 ++++ .../Services/NotificationMailService.php | 75 ++++++++++ .../GenerateTicketsForPaidPurchase.php | 10 +- app/Providers/AppServiceProvider.php | 8 + composer.json | 2 +- .../notifications/purchase-paid.blade.php | 12 ++ .../notifications/tickets-available.blade.php | 7 + .../mail/notifications/welcome.blade.php | 3 + tests/Feature/Auth/RegisterControllerTest.php | 59 +++++++- .../Integration/TelepagosWebhookTest.php | 2 + .../NotificationMailServiceTest.php | 138 ++++++++++++++++++ tests/Feature/Purchase/StorePurchaseTest.php | 8 + .../Ticket/TicketGeneratorServiceTest.php | 9 ++ 19 files changed, 450 insertions(+), 10 deletions(-) create mode 100644 app/Domains/Notification/Events/TicketsAvailable.php create mode 100644 app/Domains/Notification/Events/UserRegistered.php create mode 100644 app/Domains/Notification/Listeners/SendPurchasePaidEmail.php create mode 100644 app/Domains/Notification/Listeners/SendTicketsAvailableEmail.php create mode 100644 app/Domains/Notification/Listeners/SendWelcomeEmail.php create mode 100644 app/Domains/Notification/Services/NotificationMailService.php create mode 100644 resources/views/mail/notifications/purchase-paid.blade.php create mode 100644 resources/views/mail/notifications/tickets-available.blade.php create mode 100644 resources/views/mail/notifications/welcome.blade.php create mode 100644 tests/Feature/Notification/NotificationMailServiceTest.php diff --git a/app/Domains/Auth/Requests/RegisterUserRequest.php b/app/Domains/Auth/Requests/RegisterUserRequest.php index 56c02a6..8590fa1 100644 --- a/app/Domains/Auth/Requests/RegisterUserRequest.php +++ b/app/Domains/Auth/Requests/RegisterUserRequest.php @@ -4,6 +4,7 @@ namespace App\Domains\Auth\Requests; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Illuminate\Validation\Rules\Password; class RegisterUserRequest extends FormRequest { @@ -18,9 +19,10 @@ class RegisterUserRequest extends FormRequest public function rules(): array { return [ + 'tenant_codigo' => ['nullable', 'string', Rule::exists('tenants', 'codigo')], 'nombre_apellido' => ['required', 'string', 'max:255'], 'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')], - 'password' => ['required', 'string', 'confirmed', \Illuminate\Validation\Rules\Password::min(8)->mixedCase()->symbols()], + 'password' => ['required', 'string', 'confirmed', Password::min(8)->mixedCase()->symbols()], 'dni' => ['nullable', 'string', 'max:255'], 'telefono' => ['nullable', 'string', 'max:255'], ]; diff --git a/app/Domains/Auth/Services/RegisterUserService.php b/app/Domains/Auth/Services/RegisterUserService.php index 02bfe86..65ec58d 100644 --- a/app/Domains/Auth/Services/RegisterUserService.php +++ b/app/Domains/Auth/Services/RegisterUserService.php @@ -3,20 +3,27 @@ namespace App\Domains\Auth\Services; use App\Domains\Auth\Models\User; +use App\Domains\Notification\Events\UserRegistered; class RegisterUserService { /** - * @param array{nombre_apellido: string, email: string, password: string, dni?: string|null, telefono?: string|null} $data + * @param array{tenant_codigo?: string|null, nombre_apellido: string, email: string, password: string, dni?: string|null, telefono?: string|null} $data */ public function register(array $data): User { - return User::query()->create([ + $user = User::query()->create([ 'nombre_apellido' => $data['nombre_apellido'], 'email' => $data['email'], 'password' => $data['password'], 'dni' => $data['dni'] ?? null, 'telefono' => $data['telefono'] ?? null, ]); + + if (! empty($data['tenant_codigo'])) { + UserRegistered::dispatch($user, $data['tenant_codigo']); + } + + return $user; } } diff --git a/app/Domains/Notification/Events/TicketsAvailable.php b/app/Domains/Notification/Events/TicketsAvailable.php new file mode 100644 index 0000000..ff308dc --- /dev/null +++ b/app/Domains/Notification/Events/TicketsAvailable.php @@ -0,0 +1,20 @@ + $ticketIds + */ + public function __construct( + public readonly Purchase $purchase, + public readonly array $ticketIds, + ) {} +} diff --git a/app/Domains/Notification/Events/UserRegistered.php b/app/Domains/Notification/Events/UserRegistered.php new file mode 100644 index 0000000..8f3dbda --- /dev/null +++ b/app/Domains/Notification/Events/UserRegistered.php @@ -0,0 +1,17 @@ + */ + public array $backoff = [30, 120, 300]; + + public function handle(PurchasePaid $event): void + { + app(NotificationMailService::class)->sendPurchasePaid($event->purchase->getKey()); + } +} diff --git a/app/Domains/Notification/Listeners/SendTicketsAvailableEmail.php b/app/Domains/Notification/Listeners/SendTicketsAvailableEmail.php new file mode 100644 index 0000000..53a32a9 --- /dev/null +++ b/app/Domains/Notification/Listeners/SendTicketsAvailableEmail.php @@ -0,0 +1,25 @@ + */ + public array $backoff = [30, 120, 300]; + + public function handle(TicketsAvailable $event): void + { + app(NotificationMailService::class)->sendTicketsAvailable($event->purchase->getKey(), $event->ticketIds); + } +} diff --git a/app/Domains/Notification/Listeners/SendWelcomeEmail.php b/app/Domains/Notification/Listeners/SendWelcomeEmail.php new file mode 100644 index 0000000..55168d3 --- /dev/null +++ b/app/Domains/Notification/Listeners/SendWelcomeEmail.php @@ -0,0 +1,25 @@ + */ + public array $backoff = [30, 120, 300]; + + public function handle(UserRegistered $event): void + { + app(NotificationMailService::class)->sendWelcome($event->user->getKey(), $event->tenantCode); + } +} diff --git a/app/Domains/Notification/Services/NotificationMailService.php b/app/Domains/Notification/Services/NotificationMailService.php new file mode 100644 index 0000000..41c8367 --- /dev/null +++ b/app/Domains/Notification/Services/NotificationMailService.php @@ -0,0 +1,75 @@ +where('codigo', $tenantCode)->firstOrFail(); + $user = User::query()->findOrFail($userId); + + $this->mailService + ->forTenant($tenantCode) + ->send( + $user->email, + "Bienvenido a {$tenant->nombre}", + view('mail.notifications.welcome', compact('tenant', 'user'))->render(), + ); + } + + public function sendPurchasePaid(int $purchaseId): void + { + $purchase = Purchase::query() + ->with(['tenant', 'user', 'items']) + ->findOrFail($purchaseId); + + $this->mailService + ->forTenant($purchase->tenant_codigo) + ->send( + $this->recipientFor($purchase), + "Pago confirmado - Compra #{$purchase->getKey()}", + view('mail.notifications.purchase-paid', compact('purchase'))->render(), + ); + } + + /** @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) + ->get(); + + if ($tickets->isEmpty()) { + return; + } + + $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(), + ); + } + + private function recipientFor(Purchase $purchase): string + { + return (string) ($purchase->email ?: $purchase->user?->email); + } +} diff --git a/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php b/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php index 6d7fcb0..0befca2 100644 --- a/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php +++ b/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php @@ -3,6 +3,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\Ticket\Exceptions\TicketGenerationException; use App\Domains\Ticket\Services\TicketGeneratorService; @@ -20,6 +21,7 @@ class GenerateTicketsForPaidPurchase ->with(['user', 'items']) ->findOrFail($event->purchase->getKey()); $user = $purchase->user; + $ticketIds = []; foreach ($purchase->items as $purchaseItem) { $catalogItem = CatalogItem::query() @@ -38,12 +40,18 @@ class GenerateTicketsForPaidPurchase throw TicketGenerationException::purchaseWithoutUser($purchase); } - $this->ticketGenerator->generate( + $generatedTickets = $this->ticketGenerator->generate( $catalogItem, $user, $purchaseItem->cantidad, $purchaseItem->source_variant_id, ); + + array_push($ticketIds, ...$generatedTickets->pluck('id')->all()); + } + + if ($ticketIds !== []) { + TicketsAvailable::dispatch($purchase, $ticketIds); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 68d963e..8dbfd20 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,11 @@ namespace App\Providers; +use App\Domains\Notification\Events\TicketsAvailable; +use App\Domains\Notification\Events\UserRegistered; +use App\Domains\Notification\Listeners\SendPurchasePaidEmail; +use App\Domains\Notification\Listeners\SendTicketsAvailableEmail; +use App\Domains\Notification\Listeners\SendWelcomeEmail; use App\Domains\Purchase\Events\PurchasePaid; use App\Domains\Ticket\Listeners\GenerateTicketsForPaidPurchase; use Illuminate\Database\Eloquent\Builder; @@ -23,7 +28,10 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + Event::listen(PurchasePaid::class, SendPurchasePaidEmail::class); Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class); + Event::listen(TicketsAvailable::class, SendTicketsAvailableEmail::class); + Event::listen(UserRegistered::class, SendWelcomeEmail::class); Builder::macro('paginateFromRequest', function (int $defaultPerPage = 15, int $maxPerPage = 100, ?int $page = null) { /** @var Builder $this */ diff --git a/composer.json b/composer.json index 6a7fc40..0e52ae4 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,7 @@ ], "dev": [ "Composer\\Config::disableProcessTimeout", - "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" + "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --queue=emails,default --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" ], "test": [ "@php artisan config:clear --ansi @no_additional_args", diff --git a/resources/views/mail/notifications/purchase-paid.blade.php b/resources/views/mail/notifications/purchase-paid.blade.php new file mode 100644 index 0000000..0479420 --- /dev/null +++ b/resources/views/mail/notifications/purchase-paid.blade.php @@ -0,0 +1,12 @@ +

¡Recibimos tu pago!

+

La compra #{{ $purchase->id }} fue confirmada correctamente.

+ + @foreach ($purchase->items as $item) + + + + + + @endforeach +
{{ $item->item_nombre }}× {{ $item->cantidad }}${{ number_format((float) $item->total, 2, ',', '.') }}
+

Total pagado: ${{ number_format((float) $purchase->total, 2, ',', '.') }}

diff --git a/resources/views/mail/notifications/tickets-available.blade.php b/resources/views/mail/notifications/tickets-available.blade.php new file mode 100644 index 0000000..ab0bb53 --- /dev/null +++ b/resources/views/mail/notifications/tickets-available.blade.php @@ -0,0 +1,7 @@ +

Tus tickets ya están disponibles

+

Generamos {{ $tickets->count() }} {{ $tickets->count() === 1 ? 'ticket' : 'tickets' }} para la compra #{{ $purchase->id }}.

+
    + @foreach ($tickets as $ticket) +
  • {{ $ticket->name }}
  • + @endforeach +
diff --git a/resources/views/mail/notifications/welcome.blade.php b/resources/views/mail/notifications/welcome.blade.php new file mode 100644 index 0000000..aa541a6 --- /dev/null +++ b/resources/views/mail/notifications/welcome.blade.php @@ -0,0 +1,3 @@ +

¡Bienvenido a {{ $tenant->nombre }}!

+

Hola {{ $user->nombre_apellido }}, tu cuenta fue creada correctamente.

+

Ya podés ingresar y comenzar a comprar.

diff --git a/tests/Feature/Auth/RegisterControllerTest.php b/tests/Feature/Auth/RegisterControllerTest.php index e4351b7..2580c61 100644 --- a/tests/Feature/Auth/RegisterControllerTest.php +++ b/tests/Feature/Auth/RegisterControllerTest.php @@ -2,8 +2,13 @@ namespace Tests\Feature\Auth; +use App\Domains\Attachable\Enums\AttachmentType; +use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; +use App\Domains\Notification\Events\UserRegistered; +use App\Domains\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Event; use Tests\TestCase; class RegisterControllerTest extends TestCase @@ -15,8 +20,8 @@ class RegisterControllerTest extends TestCase $response = $this->postJson('/api/register', [ 'nombre_apellido' => 'Ada Lovelace', 'email' => 'ada@example.com', - 'password' => 'secret123', - 'password_confirmation' => 'secret123', + 'password' => 'Secret!123', + 'password_confirmation' => 'Secret!123', 'dni' => '12345678A', 'telefono' => '+541122334455', ]); @@ -38,7 +43,7 @@ class RegisterControllerTest extends TestCase $user = User::query()->where('email', 'ada@example.com')->firstOrFail(); - $this->assertNotSame('secret123', $user->password); + $this->assertNotSame('Secret!123', $user->password); } public function test_it_registers_a_user_without_optional_fields(): void @@ -46,8 +51,8 @@ class RegisterControllerTest extends TestCase $response = $this->postJson('/api/register', [ 'nombre_apellido' => 'Alan Turing', 'email' => 'alan@example.com', - 'password' => 'secret123', - 'password_confirmation' => 'secret123', + 'password' => 'Secret!123', + 'password_confirmation' => 'Secret!123', ]); $response @@ -66,6 +71,50 @@ class RegisterControllerTest extends TestCase ]); } + public function test_it_dispatches_the_welcome_notification_with_tenant_context(): void + { + Event::fake([UserRegistered::class]); + $header = Attachment::query()->create([ + 'path' => 'test/welcome-header.png', + 'filename' => 'header.png', + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + $footer = Attachment::query()->create([ + 'path' => 'test/welcome-footer.png', + 'filename' => 'footer.png', + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + $tenant = Tenant::query()->create([ + 'codigo' => 'welcome-tenant', + 'nombre' => 'Welcome Tenant', + 'dominio' => 'welcome.local', + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'success_color' => '#000000', + 'header_bg_color' => '#000000', + 'footer_bg_color' => '#000000', + 'header_logo_id' => $header->id, + 'footer_logo_id' => $footer->id, + ]); + + $this->postJson('/api/register', [ + 'tenant_codigo' => $tenant->codigo, + 'nombre_apellido' => 'Grace Hopper', + 'email' => 'grace@example.com', + 'password' => 'Secret!123', + 'password_confirmation' => 'Secret!123', + ])->assertCreated(); + + Event::assertDispatched( + UserRegistered::class, + fn (UserRegistered $event): bool => $event->tenantCode === $tenant->codigo + && $event->user->email === 'grace@example.com', + ); + } + public function test_it_validates_required_fields_and_unique_email(): void { User::factory()->create([ diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index 877bf10..a8e91c0 100644 --- a/tests/Feature/Integration/TelepagosWebhookTest.php +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -19,6 +19,7 @@ use App\Domains\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Str; use Tests\TestCase; @@ -32,6 +33,7 @@ class TelepagosWebhookTest extends TestCase config(['services.integrations.secret' => 'base64:'.base64_encode(random_bytes(32))]); Cache::flush(); + Queue::fake(); } public function test_transfer_payment_intent_requires_a_valid_transfer_payer_dni(): void diff --git a/tests/Feature/Notification/NotificationMailServiceTest.php b/tests/Feature/Notification/NotificationMailServiceTest.php new file mode 100644 index 0000000..e116511 --- /dev/null +++ b/tests/Feature/Notification/NotificationMailServiceTest.php @@ -0,0 +1,138 @@ +create([ + 'integration_code' => 'email', + 'name' => 'Email', + 'url' => null, + 'requires_tenant_configuration' => false, + 'integration_data_schema' => [], + ]); + $header = Attachment::query()->create([ + 'path' => 'test/mail-header.png', + 'filename' => 'header.png', + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + $footer = Attachment::query()->create([ + 'path' => 'test/mail-footer.png', + 'filename' => 'footer.png', + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + $this->tenant = Tenant::query()->create([ + 'codigo' => 'mail-tenant', + 'nombre' => 'Mail Tenant', + 'dominio' => 'mail.local', + 'primary_color' => '#112233', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'success_color' => '#000000', + 'header_bg_color' => '#000000', + 'footer_bg_color' => '#000000', + 'header_logo_id' => $header->id, + 'footer_logo_id' => $footer->id, + ]); + $this->user = User::factory()->create([ + 'nombre_apellido' => 'Ada Lovelace', + 'email' => 'ada@example.com', + ]); + } + + public function test_it_sends_a_branded_welcome_email(): void + { + app(NotificationMailService::class)->sendWelcome($this->user->id, $this->tenant->codigo); + + Mail::assertSent(Mailable::class, function (Mailable $mail): bool { + $mail->assertTo('ada@example.com'); + $mail->assertHasSubject('Bienvenido a Mail Tenant'); + + return str_contains($mail->render(), 'Ada Lovelace') + && str_contains($mail->render(), 'Mail Tenant'); + }); + } + + public function test_it_sends_purchase_and_ticket_emails_to_the_purchase_recipient(): void + { + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $this->tenant->codigo, + 'user_id' => $this->user->id, + 'status' => Purchase::STATUS_PAID, + 'payment_method' => 'transfer', + 'total' => 25, + 'email' => 'checkout@example.com', + ]); + $catalogItem = CatalogItem::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'slug' => 'entrada', + 'nombre' => 'Entrada', + 'descripcion' => 'Entrada general', + 'precio' => 25, + 'has_tickets' => true, + ]); + $purchase->items()->create([ + 'source_catalog_item_id' => $catalogItem->id, + 'nombre' => 'Entrada', + 'descripcion' => 'Entrada general', + 'slug' => 'entrada', + 'item_nombre' => 'Entrada general', + 'variant_attributes' => [], + 'cantidad' => 1, + 'precio_unitario' => 25, + 'total' => 25, + ]); + $ticket = Ticket::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'ticket' => fake()->uuid(), + 'name' => 'Entrada general', + 'description' => 'Entrada general', + 'user_id' => $this->user->id, + ]); + $service = app(NotificationMailService::class); + + $service->sendPurchasePaid($purchase->id); + $service->sendTicketsAvailable($purchase->id, [$ticket->id]); + + Mail::assertSent(Mailable::class, 2); + Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase): bool { + $mail->assertTo('checkout@example.com'); + + return $mail->subject === "Pago confirmado - Compra #{$purchase->id}" + && str_contains($mail->render(), 'Total pagado'); + }); + Mail::assertSent(Mailable::class, function (Mailable $mail): bool { + $mail->assertTo('checkout@example.com'); + + return $mail->subject === 'Tus tickets ya están disponibles' + && str_contains($mail->render(), 'Entrada general'); + }); + } +} diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index ccbf3a4..97ba2cb 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -15,6 +15,7 @@ use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Str; use Tests\TestCase; @@ -22,6 +23,13 @@ class StorePurchaseTest extends TestCase { use RefreshDatabase; + protected function setUp(): void + { + parent::setUp(); + + Queue::fake(); + } + public function test_it_creates_a_purchase_from_cart_id_without_persisting_items_yet(): void { $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); diff --git a/tests/Feature/Ticket/TicketGeneratorServiceTest.php b/tests/Feature/Ticket/TicketGeneratorServiceTest.php index 2cfbdd2..2d795c4 100644 --- a/tests/Feature/Ticket/TicketGeneratorServiceTest.php +++ b/tests/Feature/Ticket/TicketGeneratorServiceTest.php @@ -7,6 +7,7 @@ use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; use App\Domains\Catalog\Enums\CatalogItemType; use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Notification\Events\TicketsAvailable; use App\Domains\Purchase\Models\Purchase; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Exceptions\TicketGenerationException; @@ -14,6 +15,8 @@ use App\Domains\Ticket\Services\TicketGeneratorService; use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Str; use Tests\TestCase; @@ -32,6 +35,7 @@ class TicketGeneratorServiceTest extends TestCase parent::setUp(); Carbon::setTestNow('2026-07-21 10:00:00'); + Queue::fake(); $this->service = app(TicketGeneratorService::class); $this->tenant = $this->createTenant(); $this->user = User::factory()->create(); @@ -128,6 +132,7 @@ class TicketGeneratorServiceTest extends TestCase public function test_marking_a_purchase_as_paid_generates_its_tickets_once(): void { + Event::fake([TicketsAvailable::class]); $item = $this->createTicketableItem('paid-ticket'); $purchase = $this->createPurchase($item, 2); $purchase->setRelation('items', new EloquentCollection); @@ -136,10 +141,12 @@ class TicketGeneratorServiceTest extends TestCase $this->assertSame(Purchase::STATUS_PAID, $purchase->status); $this->assertDatabaseCount('tickets', 2); + Event::assertDispatchedTimes(TicketsAvailable::class, 1); $purchase->markAsPaid(); $this->assertDatabaseCount('tickets', 2); + Event::assertDispatchedTimes(TicketsAvailable::class, 1); } public function test_a_ticket_generated_from_a_purchase_keeps_its_source_ids(): void @@ -157,6 +164,7 @@ class TicketGeneratorServiceTest extends TestCase public function test_marking_a_purchase_as_paid_ignores_items_without_tickets(): void { + Event::fake([TicketsAvailable::class]); $item = $this->createTicketableItem('regular-product'); $item->update(['has_tickets' => false]); $purchase = $this->createPurchase($item->fresh(), 1); @@ -165,6 +173,7 @@ class TicketGeneratorServiceTest extends TestCase $this->assertSame(Purchase::STATUS_PAID, $purchase->status); $this->assertDatabaseCount('tickets', 0); + Event::assertNotDispatched(TicketsAvailable::class); } public function test_paid_status_is_rolled_back_when_ticket_generation_fails(): void