From 5ad61eb2170a8385da5a6b398d8126d73e0f6e4a Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 6 Aug 2026 16:37:48 -0300 Subject: [PATCH] feat(catalog): add max_units_per_user field to catalog items and implement purchase limit validation --- app/Domains/Catalog/Models/CatalogItem.php | 2 + .../Requests/StoreCatalogItemRequest.php | 1 + .../Resources/CatalogItemDetailResource.php | 1 + .../Catalog/Resources/CatalogItemResource.php | 1 + .../Purchase/Services/CheckoutService.php | 91 ++++++++++++++ ...dd_max_units_per_user_to_catalog_items.php | 22 ++++ database/seeders/AttributeSeeder.php | 96 +++++++++++++- lang/en/api.php | 1 + lang/es/api.php | 1 + .../Catalog/CatalogItemControllerTest.php | 18 +++ tests/Feature/Catalog/CatalogSchemaTest.php | 1 + tests/Feature/Purchase/StorePurchaseTest.php | 118 ++++++++++++++++++ .../FiestaFutbolInfantilProductSeederTest.php | 32 ++++- 13 files changed, 378 insertions(+), 7 deletions(-) create mode 100644 database/migrations/2026_08_06_000500_add_max_units_per_user_to_catalog_items.php diff --git a/app/Domains/Catalog/Models/CatalogItem.php b/app/Domains/Catalog/Models/CatalogItem.php index 4fb0fc3..4d7034c 100644 --- a/app/Domains/Catalog/Models/CatalogItem.php +++ b/app/Domains/Catalog/Models/CatalogItem.php @@ -31,6 +31,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'descripcion', 'precio', 'inventory_policy', + 'max_units_per_user', 'has_tickets', 'validity_time_id', ])] @@ -59,6 +60,7 @@ class CatalogItem extends Model 'type' => CatalogItemType::class, 'precio' => 'decimal:2', 'inventory_policy' => InventoryPolicy::class, + 'max_units_per_user' => 'integer', 'has_tickets' => 'boolean', 'validity_time_id' => 'integer', ]; diff --git a/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php b/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php index 6c5eff1..248be56 100644 --- a/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php +++ b/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php @@ -68,6 +68,7 @@ class StoreCatalogItemRequest extends FormRequest 'descripcion' => ['sometimes', 'nullable', 'string'], 'precio' => ['required', 'numeric', 'min:0'], 'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)], + 'max_units_per_user' => ['sometimes', 'nullable', 'integer', 'min:1'], 'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'], 'validity_time_id' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'integer', Rule::exists('validity_times', 'id')], 'real_stock' => [Rule::prohibitedIf($isBundle), 'sometimes', 'integer', 'min:0'], diff --git a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php index 3132411..0e6dd1d 100644 --- a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php @@ -34,6 +34,7 @@ class CatalogItemDetailResource extends JsonResource 'category' => $this->category?->nombre, 'brand' => $this->brand?->nombre, 'inventory_policy' => $this->inventory_policy?->value, + 'max_units_per_user' => $this->max_units_per_user, 'has_tickets' => $this->has_tickets, 'validity_time_id' => $this->validity_time_id, 'validity_time' => ValidityTimeResource::make($this->validityTime), diff --git a/app/Domains/Catalog/Resources/CatalogItemResource.php b/app/Domains/Catalog/Resources/CatalogItemResource.php index cc65946..ab642d5 100644 --- a/app/Domains/Catalog/Resources/CatalogItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemResource.php @@ -25,6 +25,7 @@ class CatalogItemResource extends JsonResource 'descripcion' => $this->descripcion, 'precio' => $this->precio, 'inventory_policy' => $this->inventory_policy?->value, + 'max_units_per_user' => $this->max_units_per_user, 'has_tickets' => $this->has_tickets, 'validity_time_id' => $this->validity_time_id, 'validity_time' => $this->whenLoaded( diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index 3da31e6..7f0a2bb 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -195,6 +195,19 @@ class CheckoutService try { if ($difference > 0) { + $otherItemQuantity = (int) $purchase->items() + ->where('source_catalog_item_id', $purchaseItem->source_catalog_item_id) + ->whereKeyNot($purchaseItem->getKey()) + ->sum('cantidad'); + $catalogItem = $selection instanceof Variant + ? $selection->catalogItem + : $selection; + $this->assertUserPurchaseLimit( + $catalogItem, + (int) $purchase->user_id, + $otherItemQuantity + $quantity, + $purchase->getKey(), + ); $this->catalogInventoryService->reserve($selection, $difference); } else { $this->catalogInventoryService->release($selection, abs($difference)); @@ -410,6 +423,15 @@ class CheckoutService $variantId = isset($directItem['variant_id']) ? (int) $directItem['variant_id'] : null; $quantity = (int) $directItem['cantidad']; $selection = $this->resolveSelection($tenant, $catalogItemId, $variantId); + $catalogItem = $selection instanceof Variant + ? $selection->catalogItem + : $selection; + $this->assertUserPurchaseLimit( + $catalogItem, + $userId, + $quantity, + field: 'direct_item.cantidad', + ); $availableQuantity = $this->catalogInventoryService->availableQuantity($selection); if ($availableQuantity !== null && $availableQuantity < $quantity) { @@ -461,6 +483,7 @@ class CheckoutService $this->loadCartItems($cartItems); $this->verifyTenantItems($tenant, $cartItems); + $this->assertCartUserPurchaseLimits($tenant, $userId, $cartItems); $cart->setRelation('items', $cartItems); $purchase = $this->createPurchase( @@ -648,6 +671,74 @@ class CheckoutService } } + /** @param Collection $cartItems */ + private function assertCartUserPurchaseLimits( + Tenant $tenant, + int $userId, + Collection $cartItems, + ): void { + $quantities = $cartItems + ->groupBy('catalog_item_id') + ->map(fn (Collection $items): int => (int) $items->sum('cantidad')) + ->sortKeys(); + + $catalogItems = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->whereKey($quantities->keys()) + ->orderBy('id') + ->lockForUpdate() + ->get() + ->keyBy('id'); + + foreach ($quantities as $catalogItemId => $quantity) { + /** @var CatalogItem $catalogItem */ + $catalogItem = $catalogItems->get($catalogItemId); + $this->assertUserPurchaseLimit( + $catalogItem, + $userId, + $quantity, + field: 'cart_id', + ); + } + } + + private function assertUserPurchaseLimit( + CatalogItem $catalogItem, + int $userId, + int $requestedQuantity, + ?int $excludedPurchaseId = null, + string $field = 'quantity', + ): void { + $limit = $catalogItem->max_units_per_user; + + if ($limit === null) { + return; + } + + $purchasedQuantity = (int) PurchaseItem::query() + ->where('source_catalog_item_id', $catalogItem->getKey()) + ->whereHas('purchase', function ($query) use ($userId, $excludedPurchaseId): void { + $query + ->where('user_id', $userId) + ->whereIn('status', [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + Purchase::STATUS_PAID, + ]) + ->when( + $excludedPurchaseId !== null, + fn ($query) => $query->whereKeyNot($excludedPurchaseId), + ); + }) + ->sum('cantidad'); + + if ($purchasedQuantity + $requestedQuantity > $limit) { + throw ValidationException::withMessages([ + $field => __('api.purchase.max_units_per_user', ['max' => $limit]), + ]); + } + } + protected function resolveCheckoutCart(Tenant $tenant, int $userId, int $cartId): Cart { /** @var Cart|null $cart */ diff --git a/database/migrations/2026_08_06_000500_add_max_units_per_user_to_catalog_items.php b/database/migrations/2026_08_06_000500_add_max_units_per_user_to_catalog_items.php new file mode 100644 index 0000000..119964a --- /dev/null +++ b/database/migrations/2026_08_06_000500_add_max_units_per_user_to_catalog_items.php @@ -0,0 +1,22 @@ +unsignedInteger('max_units_per_user')->nullable(); + }); + } + + public function down(): void + { + Schema::table('catalog_items', function (Blueprint $table): void { + $table->dropColumn('max_units_per_user'); + }); + } +}; diff --git a/database/seeders/AttributeSeeder.php b/database/seeders/AttributeSeeder.php index f4b4cc0..c11a04a 100644 --- a/database/seeders/AttributeSeeder.php +++ b/database/seeders/AttributeSeeder.php @@ -5,6 +5,8 @@ namespace Database\Seeders; use App\Domains\Catalog\Models\Attribute; use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Enums\ValidityTimeType; +use App\Domains\Ticket\Models\ValidityTime; use Illuminate\Database\Seeder; class AttributeSeeder extends Seeder @@ -17,12 +19,8 @@ class AttributeSeeder extends Seeder $tenants = Tenant::all(); foreach ($tenants as $tenant) { - // Event dates replace catalog attributes for Fiesta Futbol Infantil. if ($tenant->codigo === 'fiesta_futbol_infantil') { - Attribute::query() - ->where('tenant_codigo', $tenant->codigo) - ->whereIn('codigo', ['color', 'talle', 'talle_numerico', 'fecha']) - ->delete(); + $this->seedFiestaFutbolInfantilAttributes($tenant); continue; } @@ -108,6 +106,94 @@ class AttributeSeeder extends Seeder } } + private function seedFiestaFutbolInfantilAttributes(Tenant $tenant): void + { + Attribute::query() + ->where('tenant_codigo', $tenant->codigo) + ->whereIn('codigo', ['talle_numerico', 'fecha']) + ->delete(); + + $breakfastValidityTime = $this->timeWindow('07:00:00', '12:00:00'); + $lunchValidityTime = $this->timeWindow('12:00:00', '15:00:00'); + $dinnerValidityTime = $this->timeWindow('20:00:00', '24:00:00'); + + $this->seedAttribute($tenant, [ + 'codigo' => 'servicio', + 'nombre' => 'Servicio', + 'type' => FieldType::Select->value, + 'is_required' => true, + 'options' => [ + ['value' => 'Comedor', 'label' => 'Comedor', 'sort_order' => 1], + ['value' => 'Vianda', 'label' => 'Vianda', 'sort_order' => 2], + ], + ]); + + $this->seedAttribute($tenant, [ + 'codigo' => 'color', + 'nombre' => 'Color', + 'type' => FieldType::Select->value, + 'is_required' => true, + 'metadata_schema' => [ + 'hex' => ['type' => 'string'], + ], + 'options' => [ + ['value' => 'Verde', 'label' => 'Verde', 'sort_order' => 1, 'metadata' => ['hex' => '#00973F']], + ['value' => 'Blanco', 'label' => 'Blanco', 'sort_order' => 2, 'metadata' => ['hex' => '#FFFFFF']], + ], + ]); + + $this->seedAttribute($tenant, [ + 'codigo' => 'horario', + 'nombre' => 'Horario', + 'type' => FieldType::Select->value, + 'is_required' => true, + 'options' => [ + [ + 'value' => 'Desayuno', + 'label' => 'Desayuno', + 'sort_order' => 1, + 'validity_time_id' => $breakfastValidityTime->id, + ], + [ + 'value' => 'Almuerzo', + 'label' => 'Almuerzo', + 'sort_order' => 2, + 'validity_time_id' => $lunchValidityTime->id, + ], + [ + 'value' => 'Cena', + 'label' => 'Cena', + 'sort_order' => 3, + 'validity_time_id' => $dinnerValidityTime->id, + ], + ], + ]); + + $this->seedAttribute($tenant, [ + 'codigo' => 'talle', + 'nombre' => 'Talle', + 'type' => FieldType::Select->value, + 'is_required' => true, + 'options' => [ + ['value' => '14', 'label' => '14', 'sort_order' => 1], + ['value' => 'S', 'label' => 'S', 'sort_order' => 2], + ['value' => 'M', 'label' => 'M', 'sort_order' => 3], + ['value' => 'L', 'label' => 'L', 'sort_order' => 4], + ['value' => 'XL', 'label' => 'XL', 'sort_order' => 5], + ['value' => 'XXL', 'label' => 'XXL', 'sort_order' => 6], + ], + ]); + } + + private function timeWindow(string $startTime, string $endTime): ValidityTime + { + return ValidityTime::query()->firstOrCreate([ + 'type' => ValidityTimeType::TimeWindow, + 'start_time' => $startTime, + 'end_time' => $endTime, + ]); + } + /** * @param array $data */ diff --git a/lang/en/api.php b/lang/en/api.php index 9cdf268..b56d727 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -42,6 +42,7 @@ return [ 'inconsistent_reservation' => 'The purchase has an inconsistent stock reservation.', 'paid_cannot_cancel' => 'A paid purchase cannot be cancelled.', 'direct_item_max_stock' => 'There is not enough stock. Maximum available: :max.', + 'max_units_per_user' => 'You can purchase up to :max units of this product.', 'empty_cart' => 'The selected cart does not contain items.', 'catalog_item_missing' => 'One or more catalog items could not be loaded.', 'catalog_item_wrong_tenant' => 'One or more catalog items do not belong to the tenant.', diff --git a/lang/es/api.php b/lang/es/api.php index cbcfaef..bd0d990 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -42,6 +42,7 @@ return [ 'inconsistent_reservation' => 'La compra tiene una reserva de stock inconsistente.', 'paid_cannot_cancel' => 'Una compra pagada no se puede cancelar.', 'direct_item_max_stock' => 'Stock insuficiente. Máximo disponible: :max.', + 'max_units_per_user' => 'Podés comprar hasta :max unidades de este producto.', 'empty_cart' => 'El carrito seleccionado no contiene productos.', 'catalog_item_missing' => 'No se pudieron cargar uno o más productos del catálogo.', 'catalog_item_wrong_tenant' => 'Uno o más productos no pertenecen al tenant.', diff --git a/tests/Feature/Catalog/CatalogItemControllerTest.php b/tests/Feature/Catalog/CatalogItemControllerTest.php index 48eb7d1..1b86f43 100644 --- a/tests/Feature/Catalog/CatalogItemControllerTest.php +++ b/tests/Feature/Catalog/CatalogItemControllerTest.php @@ -32,6 +32,7 @@ class CatalogItemControllerTest extends TestCase 'slug' => 'shirt', 'nombre' => 'Shirt', 'precio' => 100, + 'max_units_per_user' => 4, 'attribute_codes' => [$attribute->codigo], 'images' => [$image, $image], 'variants' => [ @@ -46,6 +47,7 @@ class CatalogItemControllerTest extends TestCase $response ->assertCreated() ->assertJsonPath('data.nombre', 'Shirt') + ->assertJsonPath('data.max_units_per_user', 4) ->assertJsonCount(2, 'data.images') ->assertJsonCount(1, 'data.variants') ->assertJsonCount(1, 'data.variants.0.images'); @@ -54,6 +56,7 @@ class CatalogItemControllerTest extends TestCase $variant = $item->variants()->firstOrFail(); $this->assertSame([0, 1], $item->attachments()->get()->pluck('pivot.orden')->all()); + $this->assertSame(4, $item->max_units_per_user); $this->assertSame([0], $variant->attachments()->get()->pluck('pivot.orden')->all()); $this->assertDatabaseHas('catalog_items_attachments', [ 'catalog_item_id' => $item->id, @@ -77,6 +80,21 @@ class CatalogItemControllerTest extends TestCase $this->assertDatabaseMissing('catalog_items', ['slug' => 'invalid-image']); } + public function test_it_rejects_a_non_positive_user_purchase_limit(): void + { + $tenant = $this->createTenant('purchase-limit-validation'); + + $this->postJson("/api/tenants/{$tenant->codigo}/catalog-items", [ + 'slug' => 'invalid-purchase-limit', + 'nombre' => 'Invalid purchase limit', + 'precio' => 100, + 'real_stock' => 10, + 'max_units_per_user' => 0, + ])->assertUnprocessable()->assertJsonValidationErrors('max_units_per_user'); + + $this->assertDatabaseMissing('catalog_items', ['slug' => 'invalid-purchase-limit']); + } + private function createTenant(string $code = 'catalog-controller'): Tenant { $headerLogo = $this->createAttachment("{$code}-header"); diff --git a/tests/Feature/Catalog/CatalogSchemaTest.php b/tests/Feature/Catalog/CatalogSchemaTest.php index c888f6d..643ae38 100644 --- a/tests/Feature/Catalog/CatalogSchemaTest.php +++ b/tests/Feature/Catalog/CatalogSchemaTest.php @@ -43,6 +43,7 @@ class CatalogSchemaTest extends TestCase 'descripcion', 'precio', 'inventory_policy', + 'max_units_per_user', 'has_tickets', 'validity_time_id', ], Schema::getColumnListing('catalog_items')); diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index ab003c7..e8e3b6f 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -190,6 +190,98 @@ class StorePurchaseTest extends TestCase ]); } + public function test_it_enforces_the_user_purchase_limit_and_releases_it_after_cancellation(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $otherUser = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 20, '50.00'); + $variant->catalogItem->update(['max_units_per_user' => 3]); + + $firstPurchaseId = $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/sonder/compras/start-checkout', [ + 'direct_item' => [ + 'catalog_item_id' => $variant->catalog_item_id, + 'variant_id' => $variant->id, + 'cantidad' => 2, + ], + ]) + ->assertCreated() + ->json('data.id'); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/sonder/compras/start-checkout', [ + 'direct_item' => [ + 'catalog_item_id' => $variant->catalog_item_id, + 'variant_id' => $variant->id, + 'cantidad' => 2, + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('direct_item.cantidad'); + + $this->actingAs($otherUser, 'sanctum') + ->postJson('/api/tenants/sonder/compras/start-checkout', [ + 'direct_item' => [ + 'catalog_item_id' => $variant->catalog_item_id, + 'variant_id' => $variant->id, + 'cantidad' => 3, + ], + ]) + ->assertCreated(); + + $this->actingAs($user, 'sanctum') + ->postJson("/api/tenants/sonder/compras/{$firstPurchaseId}/cancel") + ->assertOk(); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/sonder/compras/start-checkout', [ + 'direct_item' => [ + 'catalog_item_id' => $variant->catalog_item_id, + 'variant_id' => $variant->id, + 'cantidad' => 3, + ], + ]) + ->assertCreated(); + } + + public function test_the_user_purchase_limit_is_shared_by_all_item_variants(): void + { + $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $firstVariant = $this->createVariantForTenant('sonder', 20, '50.00'); + $firstVariant->catalogItem->update(['max_units_per_user' => 3]); + $secondInventory = Inventory::query()->create(['real_stock' => 20]); + $secondVariant = Variant::query()->create([ + 'catalog_item_id' => $firstVariant->catalog_item_id, + 'inventory_id' => $secondInventory->id, + ]); + $cart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $user->id, + 'status' => 'active', + ]); + $cart->addItem($firstVariant->catalog_item_id, $firstVariant->id, 2); + $cart->addItem($secondVariant->catalog_item_id, $secondVariant->id, 2); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/sonder/compras/start-checkout', [ + 'cart_id' => $cart->id, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('cart_id'); + + $this->assertDatabaseCount('compras', 0); + $this->assertDatabaseHas('inventories', [ + 'id' => $firstVariant->inventory_id, + 'reserved_stock' => 2, + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $secondInventory->id, + 'reserved_stock' => 2, + ]); + } + public function test_it_restores_the_source_cart_when_checkout_is_cancelled(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); @@ -382,6 +474,32 @@ class StorePurchaseTest extends TestCase ]); } + public function test_it_rejects_a_quantity_update_above_the_user_purchase_limit(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + $variant->catalogItem->update(['max_units_per_user' => 3]); + $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); + $itemId = $purchase->items->firstOrFail()->id; + + $this->actingAs($user, 'sanctum') + ->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [ + 'quantity' => 4, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('quantity'); + + $this->assertDatabaseHas('compra_items', [ + 'id' => $itemId, + 'cantidad' => 2, + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $variant->inventory_id, + 'reserved_stock' => 2, + ]); + } + public function test_it_reopens_a_pending_purchase_before_editing_items(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); diff --git a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php index 57e71c9..6661cb0 100644 --- a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php +++ b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php @@ -17,6 +17,7 @@ use App\Domains\Catalog\Models\Inventory; use App\Domains\Event\Models\Event; use App\Domains\Event\Models\EventDate; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Enums\ValidityTimeType; use Database\Seeders\AttributeSeeder; use Database\Seeders\FiestaFutbolInfantilProductSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -64,9 +65,36 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase FiestaFutbolInfantilProductSeeder::class, ]); - $this->assertFalse(Attribute::query() + $attributes = Attribute::query() ->where('tenant_codigo', $tenant->codigo) - ->exists()); + ->with('options.validityTime') + ->orderBy('id') + ->get(); + + $this->assertSame( + ['servicio', 'color', 'horario', 'talle'], + $attributes->pluck('codigo')->all(), + ); + $this->assertSame(['Comedor', 'Vianda'], $attributes[0]->options->pluck('value')->all()); + $this->assertSame(['Verde', 'Blanco'], $attributes[1]->options->pluck('value')->all()); + $this->assertSame(['Desayuno', 'Almuerzo', 'Cena'], $attributes[2]->options->pluck('value')->all()); + $this->assertSame(['14', 'S', 'M', 'L', 'XL', 'XXL'], $attributes[3]->options->pluck('value')->all()); + + $this->assertSame( + [ + ['Desayuno', ValidityTimeType::TimeWindow, '07:00:00', '12:00:00'], + ['Almuerzo', ValidityTimeType::TimeWindow, '12:00:00', '15:00:00'], + ['Cena', ValidityTimeType::TimeWindow, '20:00:00', '24:00:00'], + ], + $attributes[2]->options + ->map(fn ($option): array => [ + $option->value, + $option->validityTime->type, + $option->validityTime->start_time, + $option->validityTime->end_time, + ]) + ->all(), + ); $event = Event::query() ->where('tenant_code', $tenant->codigo)