service = app(CatalogService::class); $this->tenant = $this->createTenant(); } public function test_it_creates_an_item_with_direct_inventory_when_it_has_no_variants(): void { $item = $this->service->create([ 'tenant_code' => $this->tenant->codigo, 'slug' => 'simple-item', 'nombre' => 'Simple item', 'precio' => 100, 'real_stock' => 12, ]); $this->assertNotNull($item->inventory_id); $this->assertSame(CatalogItemType::Standard, $item->type); $this->assertCount(0, $item->variants); $this->assertSame(12, $item->inventory->real_stock); $this->assertSame(0, $item->inventory->reserved_stock); $this->assertSame(0, $item->inventory->sold_units); } public function test_it_creates_variant_inventory_without_direct_item_inventory(): void { $attribute = Attribute::query()->create([ 'tenant_codigo' => $this->tenant->codigo, 'codigo' => 'size', 'nombre' => 'Size', 'type' => FieldType::String, ]); $item = $this->service->create([ 'tenant_code' => $this->tenant->codigo, 'slug' => 'variant-item', 'nombre' => 'Variant item', 'precio' => 100, 'attribute_codes' => [$attribute->codigo], 'variants' => [ [ 'real_stock' => 5, 'values' => [$attribute->codigo => 'S'], ], [ 'real_stock' => 8, 'values' => [$attribute->codigo => 'M'], ], ], ]); $this->assertNull($item->inventory_id); $this->assertCount(2, $item->variants); $this->assertCount(1, $item->itemAttributes); $this->assertTrue($item->itemAttributes->first()->attribute->is($attribute)); $this->assertSame([5, 8], $item->variants->pluck('inventory.real_stock')->all()); $this->assertSame( ['S', 'M'], $item->variants->pluck('definitions')->flatten()->pluck('value')->all(), ); foreach ($item->variants as $variant) { $this->assertNotNull($variant->inventory_id); $this->assertSame(0, $variant->inventory->reserved_stock); $this->assertSame(0, $variant->inventory->sold_units); } } public function test_it_can_hide_an_item_attribute_from_the_product_selector(): void { $attribute = $this->createAttribute('internal_type'); $item = $this->service->create([ 'tenant_code' => $this->tenant->codigo, 'slug' => 'hidden-attribute-item', 'nombre' => 'Hidden attribute item', 'precio' => 100, 'attribute_codes' => [$attribute->codigo], 'hidden_attribute_codes' => [$attribute->codigo], 'variants' => [[ 'real_stock' => 5, 'values' => [$attribute->codigo => 'internal'], ]], ]); $this->assertFalse($item->itemAttributes->sole()->show_in_selector); } public function test_it_allows_the_same_event_date_with_different_attribute_values(): void { $sector = $this->createAttribute('sector'); $eventDateAttribute = $this->createAttribute('event_date', FieldType::EventDate); $eventDate = $this->tenant->eventDates()->create([ 'date' => '2026-10-09', 'time_start' => '09:00', 'time_end' => '18:00', ]); $item = $this->service->create([ 'tenant_code' => $this->tenant->codigo, 'slug' => 'entry-by-sector', 'nombre' => 'Entry by sector', 'precio' => 100, 'attribute_codes' => [$eventDateAttribute->codigo, $sector->codigo], 'variants' => [ [ 'event_date_id' => $eventDate->id, 'values' => ['sector' => 'General'], ], [ 'event_date_id' => $eventDate->id, 'values' => ['sector' => 'VIP'], ], ], ]); $this->assertCount(2, $item->variants); $this->assertSame( [$eventDate->id, $eventDate->id], $item->variants->pluck('event_date_id')->all(), ); $this->assertSame( ['event_date', 'sector'], $item->itemAttributes->pluck('attribute.codigo')->all(), ); $this->assertSame( FieldType::EventDate, $item->itemAttributes->first()->attribute->type, ); } public function test_it_creates_a_variant_with_multiple_event_dates(): void { $eventDateAttribute = $this->createAttribute('event_date', FieldType::EventDate); $firstDate = $this->tenant->eventDates()->create([ 'date' => '2026-10-09', 'time_start' => '00:00:00', 'time_end' => '23:59:59', ]); $secondDate = $this->tenant->eventDates()->create([ 'date' => '2026-10-10', 'time_start' => '00:00:00', 'time_end' => '23:59:59', ]); $item = $this->service->create([ 'tenant_code' => $this->tenant->codigo, 'slug' => 'multi-date-pass', 'nombre' => 'Multi-date pass', 'precio' => 100, 'attribute_codes' => [$eventDateAttribute->codigo], 'multi_select_attribute_codes' => ['event_date'], 'variants' => [[ 'real_stock' => 5, 'event_date_ids' => [$secondDate->id, $firstDate->id], ]], ]); $variant = $item->variants->sole(); $this->assertNull($variant->event_date_id); $this->assertSame([$firstDate->id, $secondDate->id], $variant->eventDates->pluck('id')->all()); $this->assertSame( [(string) $firstDate->id, (string) $secondDate->id], $variant->selectionValues()->get('event_date'), ); } public function test_it_creates_multiple_values_for_any_multi_select_attribute(): void { $color = $this->createAttribute('color', FieldType::Select); $color->options()->createMany([ ['value' => 'Green', 'label' => 'Green', 'sort_order' => 1], ['value' => 'White', 'label' => 'White', 'sort_order' => 2], ]); $item = $this->service->create([ 'tenant_code' => $this->tenant->codigo, 'slug' => 'multi-color-shirt', 'nombre' => 'Multi-color shirt', 'precio' => 100, 'attribute_codes' => ['color'], 'multi_select_attribute_codes' => ['color'], 'variants' => [[ 'real_stock' => 5, 'values' => ['color' => ['White', 'Green']], ]], ]); $variant = $item->variants->sole(); $this->assertSame(['White', 'Green'], $variant->definitions->pluck('value')->all()); $this->assertSame(['White', 'Green'], $variant->selectionValues()->get('color')); } public function test_it_rejects_duplicate_variant_combinations(): void { $sector = $this->createAttribute('sector'); $eventDateAttribute = $this->createAttribute('event_date', FieldType::EventDate); $eventDate = $this->tenant->eventDates()->create([ 'date' => '2026-10-09', 'time_start' => '09:00', 'time_end' => '18:00', ]); try { $this->service->create([ 'tenant_code' => $this->tenant->codigo, 'slug' => 'duplicate-entry', 'nombre' => 'Duplicate entry', 'precio' => 100, 'attribute_codes' => [$eventDateAttribute->codigo, $sector->codigo], 'variants' => [ ['event_date_id' => $eventDate->id, 'values' => ['sector' => 'VIP']], ['event_date_id' => $eventDate->id, 'values' => ['sector' => ' vip ']], ], ]); $this->fail('A validation exception was not thrown.'); } catch (ValidationException $exception) { $this->assertArrayHasKey('variants.1', $exception->errors()); } } public function test_it_rejects_direct_inventory_together_with_variants(): void { $attribute = $this->createAttribute('size'); try { $this->service->create([ 'tenant_code' => $this->tenant->codigo, 'slug' => 'invalid-item', 'nombre' => 'Invalid item', 'precio' => 100, 'real_stock' => 10, 'attribute_codes' => [$attribute->codigo], 'variants' => [ ['real_stock' => 5], ], ]); $this->fail('A validation exception was not thrown.'); } catch (ValidationException $exception) { $this->assertArrayHasKey('real_stock', $exception->errors()); } $this->assertDatabaseMissing('catalog_items', ['slug' => 'invalid-item']); $this->assertSame(0, CatalogItem::query()->count()); $this->assertSame(0, Variant::query()->count()); $this->assertSame(0, Inventory::query()->count()); } public function test_it_rejects_variants_when_attribute_codes_are_empty(): void { $this->expectException(ValidationException::class); $this->service->create([ 'tenant_code' => $this->tenant->codigo, 'slug' => 'variants-without-attributes', 'nombre' => 'Variants without attributes', 'precio' => 100, 'variants' => [ ['real_stock' => 5], ], ]); } public function test_it_requires_variants_when_attribute_codes_are_present(): void { $attribute = $this->createAttribute('color'); $this->expectException(ValidationException::class); $this->service->create([ 'tenant_code' => $this->tenant->codigo, 'slug' => 'attributes-without-variants', 'nombre' => 'Attributes without variants', 'precio' => 100, 'attribute_codes' => [$attribute->codigo], ]); } public function test_it_only_resolves_attribute_codes_from_the_item_tenant(): void { $otherTenant = $this->createTenant('other-tenant'); Attribute::query()->create([ 'tenant_codigo' => $otherTenant->codigo, 'codigo' => 'size', 'nombre' => 'Size', 'type' => FieldType::String, ]); try { $this->service->create([ 'tenant_code' => $this->tenant->codigo, 'slug' => 'foreign-attribute', 'nombre' => 'Foreign attribute', 'precio' => 100, 'attribute_codes' => ['size'], 'variants' => [ [ 'real_stock' => 5, 'values' => ['size' => 'M'], ], ], ]); $this->fail('A validation exception was not thrown.'); } catch (ValidationException $exception) { $this->assertArrayHasKey('attribute_codes', $exception->errors()); } $this->assertDatabaseMissing('catalog_items', ['slug' => 'foreign-attribute']); $this->assertSame(0, Inventory::query()->count()); } private function createTenant(string $code = 'catalog-service'): Tenant { $headerLogo = $this->createAttachment("{$code}-header"); $footerLogo = $this->createAttachment("{$code}-footer"); return Tenant::query()->create([ 'codigo' => $code, 'nombre' => ucfirst($code), 'dominio' => "{$code}.local", 'primary_color' => '#000000', 'secondary_color' => '#000000', 'danger_color' => '#000000', 'success_color' => '#000000', 'header_bg_color' => '#000000', 'footer_bg_color' => '#000000', 'header_logo_id' => $headerLogo->id, 'footer_logo_id' => $footerLogo->id, ]); } private function createAttachment(string $name): Attachment { return Attachment::query()->create([ 'path' => "test/{$name}.png", 'filename' => "{$name}.png", 'type' => AttachmentType::Image, 'mime_type' => 'image/png', ]); } private function createAttribute( string $code, FieldType $type = FieldType::String, ): Attribute { return Attribute::query()->create([ 'tenant_codigo' => $this->tenant->codigo, 'codigo' => $code, 'nombre' => ucfirst($code), 'type' => $type, ]); } }