feat(notification): implement email notifications for user registration and purchase events
This commit is contained in:
@@ -4,6 +4,7 @@ namespace App\Domains\Auth\Requests;
|
|||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
class RegisterUserRequest extends FormRequest
|
class RegisterUserRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -18,9 +19,10 @@ class RegisterUserRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'tenant_codigo' => ['nullable', 'string', Rule::exists('tenants', 'codigo')],
|
||||||
'nombre_apellido' => ['required', 'string', 'max:255'],
|
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
|
'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'],
|
'dni' => ['nullable', 'string', 'max:255'],
|
||||||
'telefono' => ['nullable', 'string', 'max:255'],
|
'telefono' => ['nullable', 'string', 'max:255'],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,20 +3,27 @@
|
|||||||
namespace App\Domains\Auth\Services;
|
namespace App\Domains\Auth\Services;
|
||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Notification\Events\UserRegistered;
|
||||||
|
|
||||||
class RegisterUserService
|
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
|
public function register(array $data): User
|
||||||
{
|
{
|
||||||
return User::query()->create([
|
$user = User::query()->create([
|
||||||
'nombre_apellido' => $data['nombre_apellido'],
|
'nombre_apellido' => $data['nombre_apellido'],
|
||||||
'email' => $data['email'],
|
'email' => $data['email'],
|
||||||
'password' => $data['password'],
|
'password' => $data['password'],
|
||||||
'dni' => $data['dni'] ?? null,
|
'dni' => $data['dni'] ?? null,
|
||||||
'telefono' => $data['telefono'] ?? null,
|
'telefono' => $data['telefono'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (! empty($data['tenant_codigo'])) {
|
||||||
|
UserRegistered::dispatch($user, $data['tenant_codigo']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
app/Domains/Notification/Events/TicketsAvailable.php
Normal file
20
app/Domains/Notification/Events/TicketsAvailable.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Notification\Events;
|
||||||
|
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class TicketsAvailable
|
||||||
|
{
|
||||||
|
use Dispatchable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, int> $ticketIds
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly Purchase $purchase,
|
||||||
|
public readonly array $ticketIds,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
17
app/Domains/Notification/Events/UserRegistered.php
Normal file
17
app/Domains/Notification/Events/UserRegistered.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Notification\Events;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class UserRegistered
|
||||||
|
{
|
||||||
|
use Dispatchable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly User $user,
|
||||||
|
public readonly string $tenantCode,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
25
app/Domains/Notification/Listeners/SendPurchasePaidEmail.php
Normal file
25
app/Domains/Notification/Listeners/SendPurchasePaidEmail.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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\InteractsWithQueue;
|
||||||
|
|
||||||
|
class SendPurchasePaidEmail 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(PurchasePaid $event): void
|
||||||
|
{
|
||||||
|
app(NotificationMailService::class)->sendPurchasePaid($event->purchase->getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?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->purchase->getKey(), $event->ticketIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Domains/Notification/Listeners/SendWelcomeEmail.php
Normal file
25
app/Domains/Notification/Listeners/SendWelcomeEmail.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Notification\Listeners;
|
||||||
|
|
||||||
|
use App\Domains\Notification\Events\UserRegistered;
|
||||||
|
use App\Domains\Notification\Services\NotificationMailService;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
|
||||||
|
class SendWelcomeEmail 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(UserRegistered $event): void
|
||||||
|
{
|
||||||
|
app(NotificationMailService::class)->sendWelcome($event->user->getKey(), $event->tenantCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Notification\Services;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Integration\Services\MailService;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class NotificationMailService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MailService $mailService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function sendWelcome(int $userId, string $tenantCode): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::query()->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<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)
|
||||||
|
->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
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\Ticket\Exceptions\TicketGenerationException;
|
use App\Domains\Ticket\Exceptions\TicketGenerationException;
|
||||||
use App\Domains\Ticket\Services\TicketGeneratorService;
|
use App\Domains\Ticket\Services\TicketGeneratorService;
|
||||||
@@ -20,6 +21,7 @@ class GenerateTicketsForPaidPurchase
|
|||||||
->with(['user', 'items'])
|
->with(['user', 'items'])
|
||||||
->findOrFail($event->purchase->getKey());
|
->findOrFail($event->purchase->getKey());
|
||||||
$user = $purchase->user;
|
$user = $purchase->user;
|
||||||
|
$ticketIds = [];
|
||||||
|
|
||||||
foreach ($purchase->items as $purchaseItem) {
|
foreach ($purchase->items as $purchaseItem) {
|
||||||
$catalogItem = CatalogItem::query()
|
$catalogItem = CatalogItem::query()
|
||||||
@@ -38,12 +40,18 @@ class GenerateTicketsForPaidPurchase
|
|||||||
throw TicketGenerationException::purchaseWithoutUser($purchase);
|
throw TicketGenerationException::purchaseWithoutUser($purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->ticketGenerator->generate(
|
$generatedTickets = $this->ticketGenerator->generate(
|
||||||
$catalogItem,
|
$catalogItem,
|
||||||
$user,
|
$user,
|
||||||
$purchaseItem->cantidad,
|
$purchaseItem->cantidad,
|
||||||
$purchaseItem->source_variant_id,
|
$purchaseItem->source_variant_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
array_push($ticketIds, ...$generatedTickets->pluck('id')->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticketIds !== []) {
|
||||||
|
TicketsAvailable::dispatch($purchase, $ticketIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
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\Purchase\Events\PurchasePaid;
|
||||||
use App\Domains\Ticket\Listeners\GenerateTicketsForPaidPurchase;
|
use App\Domains\Ticket\Listeners\GenerateTicketsForPaidPurchase;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@@ -23,7 +28,10 @@ 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(UserRegistered::class, SendWelcomeEmail::class);
|
||||||
|
|
||||||
Builder::macro('paginateFromRequest', function (int $defaultPerPage = 15, int $maxPerPage = 100, ?int $page = null) {
|
Builder::macro('paginateFromRequest', function (int $defaultPerPage = 15, int $maxPerPage = 100, ?int $page = null) {
|
||||||
/** @var Builder $this */
|
/** @var Builder $this */
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
],
|
],
|
||||||
"dev": [
|
"dev": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"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": [
|
"test": [
|
||||||
"@php artisan config:clear --ansi @no_additional_args",
|
"@php artisan config:clear --ansi @no_additional_args",
|
||||||
|
|||||||
12
resources/views/mail/notifications/purchase-paid.blade.php
Normal file
12
resources/views/mail/notifications/purchase-paid.blade.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<h1 style="margin: 0 0 20px;">¡Recibimos tu pago!</h1>
|
||||||
|
<p>La compra <strong>#{{ $purchase->id }}</strong> fue confirmada correctamente.</p>
|
||||||
|
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||||
|
@foreach ($purchase->items as $item)
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #e2e8f0;">{{ $item->item_nombre }}</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #e2e8f0; text-align: center;">× {{ $item->cantidad }}</td>
|
||||||
|
<td style="padding: 8px 0; border-bottom: 1px solid #e2e8f0; text-align: right;">${{ number_format((float) $item->total, 2, ',', '.') }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</table>
|
||||||
|
<p style="font-size: 18px;"><strong>Total pagado: ${{ number_format((float) $purchase->total, 2, ',', '.') }}</strong></p>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<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>
|
||||||
|
<ul style="padding-left: 20px;">
|
||||||
|
@foreach ($tickets as $ticket)
|
||||||
|
<li style="margin-bottom: 8px;">{{ $ticket->name }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
3
resources/views/mail/notifications/welcome.blade.php
Normal file
3
resources/views/mail/notifications/welcome.blade.php
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<h1 style="margin: 0 0 20px;">¡Bienvenido a {{ $tenant->nombre }}!</h1>
|
||||||
|
<p>Hola {{ $user->nombre_apellido }}, tu cuenta fue creada correctamente.</p>
|
||||||
|
<p>Ya podés ingresar y comenzar a comprar.</p>
|
||||||
@@ -2,8 +2,13 @@
|
|||||||
|
|
||||||
namespace Tests\Feature\Auth;
|
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\Auth\Models\User;
|
||||||
|
use App\Domains\Notification\Events\UserRegistered;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class RegisterControllerTest extends TestCase
|
class RegisterControllerTest extends TestCase
|
||||||
@@ -15,8 +20,8 @@ class RegisterControllerTest extends TestCase
|
|||||||
$response = $this->postJson('/api/register', [
|
$response = $this->postJson('/api/register', [
|
||||||
'nombre_apellido' => 'Ada Lovelace',
|
'nombre_apellido' => 'Ada Lovelace',
|
||||||
'email' => 'ada@example.com',
|
'email' => 'ada@example.com',
|
||||||
'password' => 'secret123',
|
'password' => 'Secret!123',
|
||||||
'password_confirmation' => 'secret123',
|
'password_confirmation' => 'Secret!123',
|
||||||
'dni' => '12345678A',
|
'dni' => '12345678A',
|
||||||
'telefono' => '+541122334455',
|
'telefono' => '+541122334455',
|
||||||
]);
|
]);
|
||||||
@@ -38,7 +43,7 @@ class RegisterControllerTest extends TestCase
|
|||||||
|
|
||||||
$user = User::query()->where('email', 'ada@example.com')->firstOrFail();
|
$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
|
public function test_it_registers_a_user_without_optional_fields(): void
|
||||||
@@ -46,8 +51,8 @@ class RegisterControllerTest extends TestCase
|
|||||||
$response = $this->postJson('/api/register', [
|
$response = $this->postJson('/api/register', [
|
||||||
'nombre_apellido' => 'Alan Turing',
|
'nombre_apellido' => 'Alan Turing',
|
||||||
'email' => 'alan@example.com',
|
'email' => 'alan@example.com',
|
||||||
'password' => 'secret123',
|
'password' => 'Secret!123',
|
||||||
'password_confirmation' => 'secret123',
|
'password_confirmation' => 'Secret!123',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response
|
$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
|
public function test_it_validates_required_fields_and_unique_email(): void
|
||||||
{
|
{
|
||||||
User::factory()->create([
|
User::factory()->create([
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use App\Domains\Tenant\Models\Tenant;
|
|||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ class TelepagosWebhookTest extends TestCase
|
|||||||
|
|
||||||
config(['services.integrations.secret' => 'base64:'.base64_encode(random_bytes(32))]);
|
config(['services.integrations.secret' => 'base64:'.base64_encode(random_bytes(32))]);
|
||||||
Cache::flush();
|
Cache::flush();
|
||||||
|
Queue::fake();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_transfer_payment_intent_requires_a_valid_transfer_payer_dni(): void
|
public function test_transfer_payment_intent_requires_a_valid_transfer_payer_dni(): void
|
||||||
|
|||||||
138
tests/Feature/Notification/NotificationMailServiceTest.php
Normal file
138
tests/Feature/Notification/NotificationMailServiceTest.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Notification;
|
||||||
|
|
||||||
|
use App\Domains\Attachable\Enums\AttachmentType;
|
||||||
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Integration\Models\Integration;
|
||||||
|
use App\Domains\Notification\Services\NotificationMailService;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class NotificationMailServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private Tenant $tenant;
|
||||||
|
|
||||||
|
private User $user;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Mail::fake();
|
||||||
|
Integration::query()->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ use App\Domains\Purchase\Models\Purchase;
|
|||||||
use App\Domains\Purchase\Services\CheckoutService;
|
use App\Domains\Purchase\Services\CheckoutService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -22,6 +23,13 @@ class StorePurchaseTest extends TestCase
|
|||||||
{
|
{
|
||||||
use RefreshDatabase;
|
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
|
public function test_it_creates_a_purchase_from_cart_id_without_persisting_items_yet(): void
|
||||||
{
|
{
|
||||||
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Domains\Attachable\Models\Attachment;
|
|||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Catalog\Enums\CatalogItemType;
|
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Notification\Events\TicketsAvailable;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Ticket\Exceptions\TicketGenerationException;
|
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\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\Str;
|
use Illuminate\Support\Str;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -32,6 +35,7 @@ class TicketGeneratorServiceTest extends TestCase
|
|||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
|
||||||
Carbon::setTestNow('2026-07-21 10:00:00');
|
Carbon::setTestNow('2026-07-21 10:00:00');
|
||||||
|
Queue::fake();
|
||||||
$this->service = app(TicketGeneratorService::class);
|
$this->service = app(TicketGeneratorService::class);
|
||||||
$this->tenant = $this->createTenant();
|
$this->tenant = $this->createTenant();
|
||||||
$this->user = User::factory()->create();
|
$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
|
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);
|
||||||
@@ -136,10 +141,12 @@ 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);
|
||||||
|
|
||||||
$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
|
||||||
@@ -157,6 +164,7 @@ 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);
|
||||||
@@ -165,6 +173,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_paid_status_is_rolled_back_when_ticket_generation_fails(): void
|
public function test_paid_status_is_rolled_back_when_ticket_generation_fails(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user