Add tests for ticket validity and event date formatting

- Create TicketValiditySchemaTest to verify database schema for ticket validity.
- Update CatalogModelsTest to include tests for event date attributes and selection options.
- Introduce EventDateTextFormatterTest for formatting event dates in Spanish.
- Refactor EventModelsTest to include validity time relationships.
- Add SaleDetailResourceTest to ensure correct serialization of purchase items.
- Enhance TicketTest with validity time checks and status management.
- Implement ValidityTimeResourceTest to validate resource output for different validity types.
- Add ValidityTimeTest to verify casting and validity checks for validity time types.
This commit is contained in:
2026-08-11 12:41:35 -03:00
parent b294e5c46e
commit 02cf3f3773
166 changed files with 10203 additions and 1849 deletions

View File

@@ -4,10 +4,13 @@ namespace Tests\Feature\Cart;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
@@ -99,6 +102,114 @@ class CartControllerTest extends TestCase
]);
}
public function test_authenticated_cart_respects_previous_purchases_and_repeated_additions(): void
{
$tenant = $this->createTenant('acme');
$user = User::factory()->create();
$item = $this->createDirectItem($tenant, 20, '10.00');
$item->update(['max_units_per_user' => 4]);
$this->createPurchaseItem($tenant, $user, $item, 1);
$this->actingAs($user, 'sanctum')
->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'cantidad' => 2,
])
->assertOk()
->assertJsonPath('data.items.0.cantidad', 2);
$this->actingAs($user, 'sanctum')
->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'cantidad' => 2,
])
->assertUnprocessable()
->assertJsonValidationErrors('cantidad');
$this->assertDatabaseHas('carrito_items', [
'catalog_item_id' => $item->id,
'cantidad' => 2,
]);
$this->assertDatabaseHas('inventories', [
'id' => $item->inventory_id,
'reserved_stock' => 2,
]);
}
public function test_authenticated_cart_shares_the_purchase_limit_between_variants(): void
{
$tenant = $this->createTenant('acme');
$user = User::factory()->create();
[$item, $firstVariant] = $this->createVariantItem($tenant, 20, '10.00');
$item->update(['max_units_per_user' => 3]);
$secondInventory = Inventory::query()->create(['real_stock' => 20]);
$secondVariant = $item->variants()->create(['inventory_id' => $secondInventory->id]);
$this->actingAs($user, 'sanctum')
->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'variant_id' => $firstVariant->id,
'cantidad' => 2,
])
->assertOk();
$this->actingAs($user, 'sanctum')
->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'variant_id' => $secondVariant->id,
'cantidad' => 2,
])
->assertUnprocessable()
->assertJsonValidationErrors('cantidad');
$this->assertDatabaseCount('carrito_items', 1);
$this->assertDatabaseHas('inventories', [
'id' => $secondInventory->id,
'reserved_stock' => 0,
]);
}
public function test_authenticated_cart_rejects_quantity_updates_above_the_purchase_limit(): void
{
$tenant = $this->createTenant('acme');
$user = User::factory()->create();
$item = $this->createDirectItem($tenant, 20, '10.00');
$item->update(['max_units_per_user' => 3]);
$response = $this->actingAs($user, 'sanctum')
->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'cantidad' => 2,
])
->assertOk();
$this->actingAs($user, 'sanctum')
->patchJson('/api/tenants/acme/cart/items/'.$response->json('data.items.0.id'), [
'cantidad' => 4,
])
->assertUnprocessable()
->assertJsonValidationErrors('cantidad');
$this->assertDatabaseHas('carrito_items', [
'id' => $response->json('data.items.0.id'),
'cantidad' => 2,
]);
}
public function test_guest_cart_does_not_apply_a_user_purchase_limit(): void
{
$tenant = $this->createTenant('acme');
$item = $this->createDirectItem($tenant, 20, '10.00');
$item->update(['max_units_per_user' => 1]);
$this->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'cantidad' => 2,
])
->assertOk()
->assertJsonPath('data.items.0.cantidad', 2);
}
public function test_it_updates_and_removes_an_item_using_its_selected_inventory(): void
{
$tenant = $this->createTenant('acme');
@@ -145,6 +256,110 @@ class CartControllerTest extends TestCase
]);
}
public function test_it_changes_an_item_variant_and_moves_the_stock_reservation(): void
{
$tenant = $this->createTenant('acme');
[$item, $firstVariant] = $this->createVariantItem($tenant, 10, '15.00');
$secondInventory = Inventory::query()->create(['real_stock' => 2]);
$secondVariant = $item->variants()->create([
'inventory_id' => $secondInventory->id,
'precio' => '20.00',
]);
$unavailableInventory = Inventory::query()->create(['real_stock' => 0]);
$unavailableVariant = $item->variants()->create([
'inventory_id' => $unavailableInventory->id,
'precio' => '25.00',
]);
$createResponse = $this->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'variant_id' => $firstVariant->id,
'cantidad' => 2,
]);
$guestToken = $createResponse->getCookie('guest_token', false)?->getValue();
$cartItemId = $createResponse->json('data.items.0.id');
$this->call(
'PATCH',
"/api/tenants/acme/cart/items/{$cartItemId}",
[],
['guest_token' => $guestToken],
[],
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
json_encode(['cantidad' => 2, 'variant_id' => $secondVariant->id]),
)
->assertOk()
->assertJsonPath('data.items.0.variant_id', $secondVariant->id)
->assertJsonPath('data.items.0.precio_unitario', '20.00')
->assertJsonCount(2, 'data.items.0.product.variants')
->assertJsonPath('data.items.0.product.variants.0.id', $firstVariant->id)
->assertJsonPath('data.items.0.product.variants.1.id', $secondVariant->id)
->assertJsonPath('data.items.0.product.variants.1.stock_tecnico', 0)
->assertJsonMissing(['id' => $unavailableVariant->id, 'stock_tecnico' => 0]);
$this->assertDatabaseHas('inventories', [
'id' => $firstVariant->inventory_id,
'reserved_stock' => 0,
]);
$this->assertDatabaseHas('inventories', [
'id' => $secondInventory->id,
'reserved_stock' => 2,
]);
}
public function test_changing_to_a_variant_already_in_the_cart_merges_both_rows(): void
{
$tenant = $this->createTenant('acme');
[$item, $firstVariant] = $this->createVariantItem($tenant, 10, '15.00');
$secondInventory = Inventory::query()->create(['real_stock' => 10]);
$secondVariant = $item->variants()->create(['inventory_id' => $secondInventory->id]);
$firstResponse = $this->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'variant_id' => $firstVariant->id,
'cantidad' => 2,
]);
$guestToken = $firstResponse->getCookie('guest_token', false)?->getValue();
$firstCartItemId = $firstResponse->json('data.items.0.id');
$this->call(
'POST',
'/api/tenants/acme/cart/items',
[],
['guest_token' => $guestToken],
[],
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
json_encode([
'catalog_item_id' => $item->id,
'variant_id' => $secondVariant->id,
'cantidad' => 3,
]),
)->assertOk();
$this->call(
'PATCH',
"/api/tenants/acme/cart/items/{$firstCartItemId}",
[],
['guest_token' => $guestToken],
[],
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
json_encode(['cantidad' => 2, 'variant_id' => $secondVariant->id]),
)
->assertOk()
->assertJsonCount(1, 'data.items')
->assertJsonPath('data.items.0.variant_id', $secondVariant->id)
->assertJsonPath('data.items.0.cantidad', 5);
$this->assertDatabaseCount('carrito_items', 1);
$this->assertDatabaseHas('inventories', [
'id' => $firstVariant->inventory_id,
'reserved_stock' => 0,
]);
$this->assertDatabaseHas('inventories', [
'id' => $secondInventory->id,
'reserved_stock' => 5,
]);
}
public function test_it_requires_a_variant_when_the_item_has_variant_inventory(): void
{
$tenant = $this->createTenant('acme');
@@ -245,6 +460,33 @@ class CartControllerTest extends TestCase
return [$item, $variant];
}
private function createPurchaseItem(
Tenant $tenant,
User $user,
CatalogItem $item,
int $quantity,
): PurchaseItem {
$purchase = Purchase::query()->create([
'tenant_codigo' => $tenant->codigo,
'user_id' => $user->id,
'status' => Purchase::STATUS_PAID,
'total' => (float) $item->precio * $quantity,
]);
return $purchase->items()->create([
'source_catalog_item_id' => $item->id,
'source_variant_id' => null,
'nombre' => $item->nombre,
'slug' => $item->slug,
'item_nombre' => $item->nombre,
'variant_attributes' => [],
'cantidad' => $quantity,
'precio_unitario' => $item->precio,
'total' => (float) $item->precio * $quantity,
'reservation_status' => PurchaseItem::RESERVATION_COMMITTED,
]);
}
private function createTenant(string $code): Tenant
{
$headerLogo = $this->createAttachment("{$code}-header");

View File

@@ -48,8 +48,15 @@ class CatalogControllerTest extends TestCase
'real_stock' => 4,
'reserved_stock' => 1,
]);
$unavailableInventory = Inventory::query()->create([
'real_stock' => 2,
'reserved_stock' => 2,
]);
$variantItem->variants()->create(['inventory_id' => $firstInventory->id]);
$variantItem->variants()->create(['inventory_id' => $secondInventory->id]);
$unavailableVariant = $variantItem->variants()->create([
'inventory_id' => $unavailableInventory->id,
]);
$cart->featuredItems()->create(['catalog_item_id' => $variantItem->id]);
$response = $this->getJson("/api/tenants/{$tenant->codigo}/catalog");
@@ -67,11 +74,45 @@ class CatalogControllerTest extends TestCase
->assertJsonCount(2, '0.items.0.variants')
->assertJsonPath('0.items.0.variants.0.stock_tecnico', 4)
->assertJsonPath('0.items.0.variants.1.stock_tecnico', 3)
->assertJsonMissing(['id' => $unavailableVariant->id, 'stock_tecnico' => 0])
->assertJsonPath('1.title', 'Row')
->assertJsonPath('1.items.data.0.stock_tecnico', 8)
->assertJsonCount(0, '1.items.data.0.variants');
}
public function test_it_excludes_items_when_all_of_their_variants_are_out_of_stock(): void
{
$tenant = $this->createTenant('catalog-available-variants');
$group = $this->createGroup(
$tenant,
ProductLayout::ColumnWithCart,
'Available variants',
groupLayout: GroupLayout::SimpleVertical,
);
$unavailableItem = $this->createItem($tenant, 'Unavailable');
$unavailableInventory = Inventory::query()->create([
'real_stock' => 4,
'reserved_stock' => 4,
]);
$unavailableItem->variants()->create(['inventory_id' => $unavailableInventory->id]);
$group->featuredItems()->create(['catalog_item_id' => $unavailableItem->id]);
$availableItem = $this->createItem($tenant, 'Available');
$availableInventory = Inventory::query()->create([
'real_stock' => 4,
'reserved_stock' => 3,
]);
$availableItem->variants()->create(['inventory_id' => $availableInventory->id]);
$group->featuredItems()->create(['catalog_item_id' => $availableItem->id]);
$this->getJson("/api/tenants/{$tenant->codigo}/catalog")
->assertOk()
->assertJsonCount(1, '0.items')
->assertJsonPath('0.items.0.nombre', 'Available')
->assertJsonMissing(['nombre' => 'Unavailable']);
}
public function test_column_with_image_uses_item_image_then_variant_image_then_null(): void
{
Storage::fake('s3');

View File

@@ -32,14 +32,12 @@ class CatalogItemControllerTest extends TestCase
'slug' => 'shirt',
'nombre' => 'Shirt',
'precio' => 100,
'minimum_use_date' => '2026-08-01 09:00:00',
'maximum_use_date' => '2026-08-31 18:00:00',
'max_units_per_user' => 4,
'attribute_codes' => [$attribute->codigo],
'images' => [$image, $image],
'variants' => [
[
'real_stock' => 5,
'maximum_use_date' => '2026-08-15 18:00:00',
'values' => ['size' => 'M'],
'images' => [$image],
],
@@ -49,27 +47,16 @@ 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')
->assertJsonPath('data.variants.0.minimum_use_date', null)
->assertJsonPath(
'data.variants.0.maximum_use_date',
fn (string $value): bool => str_starts_with($value, '2026-08-15T18:00:00'),
)
->assertJsonPath(
'data.variants.0.effective_minimum_use_date',
fn (string $value): bool => str_starts_with($value, '2026-08-01T09:00:00'),
)
->assertJsonPath(
'data.variants.0.effective_maximum_use_date',
fn (string $value): bool => str_starts_with($value, '2026-08-15T18:00:00'),
);
->assertJsonCount(1, 'data.variants.0.images');
$item = CatalogItem::query()->where('slug', 'shirt')->firstOrFail();
$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,
@@ -93,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");

View File

@@ -45,7 +45,7 @@ class CatalogItemDetailControllerTest extends TestCase
$this->assertStringContainsString($itemImage->path, $response->json('data.images.0'));
}
public function test_it_selects_the_first_variant_and_returns_its_images_by_default(): void
public function test_it_filters_unavailable_variants_and_selects_the_first_available_one(): void
{
Storage::fake('s3');
$tenant = $this->createTenant('detail-default');
@@ -66,14 +66,21 @@ class CatalogItemDetailControllerTest extends TestCase
$response
->assertOk()
->assertJsonPath('data.selected_variant.id', $firstVariant->id)
->assertJsonPath('data.selected_variant.stock_tecnico', 0)
->assertJsonCount(1, 'data.variants')
->assertJsonPath('data.variants.0.id', $secondVariant->id)
->assertJsonPath('data.selected_variant.id', $secondVariant->id)
->assertJsonPath('data.selected_variant.stock_tecnico', 6)
->assertJsonCount(1, 'data.selected_variant.images');
$response
->assertJsonMissingPath('data.stock_tecnico')
->assertJsonMissingPath('data.images');
$this->assertStringContainsString($firstImage->path, $response->json('data.selected_variant.images.0'));
$this->assertStringContainsString($secondImage->path, $response->json('data.selected_variant.images.0'));
$this->assertStringNotContainsString($firstImage->path, $response->json('data.selected_variant.images.0'));
$this->assertStringNotContainsString($itemImage->path, $response->json('data.selected_variant.images.0'));
$this->getJson(
"/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}?variant_id={$firstVariant->id}"
)->assertNotFound();
}
public function test_it_selects_the_requested_variant_and_lists_variant_values_and_stock(): void
@@ -117,17 +124,20 @@ class CatalogItemDetailControllerTest extends TestCase
->assertOk()
->assertJsonPath('data.variants.0.id', $firstVariant->id)
->assertJsonPath('data.variants.0.stock_tecnico', 4)
->assertJsonPath('data.variants.0.values.size', 'S')
->assertJsonPath('data.variants.0.values.size.value', 'S')
->assertJsonPath('data.variants.0.values.size.label', 'Small')
->assertJsonPath('data.variants.1.id', $secondVariant->id)
->assertJsonPath('data.variants.1.stock_tecnico', 7)
->assertJsonPath('data.variants.1.values.size', 'M')
->assertJsonPath('data.variants.1.values.size.value', 'M')
->assertJsonPath('data.variants.1.values.size.label', 'Medium')
->assertJsonPath('data.attributes.0.codigo', 'size')
->assertJsonPath('data.attributes.0.options.0.value', 'S')
->assertJsonPath('data.attributes.0.options.1.value', 'M')
->assertJsonCount(2, 'data.attributes.0.options')
->assertJsonPath('data.selected_variant.id', $secondVariant->id)
->assertJsonPath('data.selected_variant.stock_tecnico', 7)
->assertJsonPath('data.selected_variant.values.size', 'M')
->assertJsonPath('data.selected_variant.values.size.value', 'M')
->assertJsonPath('data.selected_variant.values.size.label', 'Medium')
->assertJsonCount(1, 'data.selected_variant.images');
$response
->assertJsonMissingPath('data.stock_tecnico')
@@ -170,6 +180,102 @@ class CatalogItemDetailControllerTest extends TestCase
->assertJsonPath('data.variants.0.stock_tecnico', null);
}
public function test_it_exposes_event_dates_as_a_dynamic_variant_attribute(): void
{
$tenant = $this->createTenant('detail-event-date');
$tenant->update([
'event_title' => 'Festival',
'event_location' => 'Rosario',
]);
$eventDate = $tenant->eventDates()->create([
'date' => '2026-10-09',
'time_start' => '09:00',
'time_end' => '18:00',
]);
$unusedEventDate = $tenant->eventDates()->create([
'date' => '2026-10-10',
'time_start' => '10:00',
'time_end' => '19:00',
]);
$item = $this->createItem($tenant, 'Entry');
$attribute = Attribute::query()->create([
'tenant_codigo' => $tenant->codigo,
'codigo' => 'event_date',
'nombre' => 'Fecha',
'is_required' => true,
'type' => FieldType::EventDate,
]);
$item->itemAttributes()->create(['attribute_id' => $attribute->id]);
$variant = $this->createVariant($item, 10, 0);
$variant->update(['event_date_id' => $eventDate->id]);
$this->getJson("/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}")
->assertOk()
->assertJsonPath('data.attributes.0.id', $attribute->id)
->assertJsonPath('data.attributes.0.codigo', 'event_date')
->assertJsonPath('data.attributes.0.type', 'event_date')
->assertJsonCount(2, 'data.attributes.0.options')
->assertJsonPath('data.attributes.0.options.0.id', $eventDate->id)
->assertJsonPath('data.attributes.0.options.0.value', (string) $eventDate->id)
->assertJsonPath('data.attributes.0.options.0.label', '09/10/2026')
->assertJsonPath('data.attributes.0.options.0.validity_time_id', $eventDate->validity_time_id)
->assertJsonPath('data.attributes.0.options.0.validity_time.type', 'fixed_window')
->assertJsonPath('data.attributes.0.options.1.id', $unusedEventDate->id)
->assertJsonPath('data.attributes.0.options.1.value', (string) $unusedEventDate->id)
->assertJsonPath('data.variants.0.values.event_date.value', (string) $eventDate->id)
->assertJsonPath(
'data.variants.0.values.event_date.label',
'09/10/2026',
);
$this->assertDatabaseCount('attribute_options', 0);
}
public function test_it_orders_item_attributes_by_sort_order_and_then_attribute_label(): void
{
$tenant = $this->createTenant('detail-attribute-order');
$item = $this->createItem($tenant, 'Ordered attributes');
$variant = $this->createVariant($item, 10, 0);
$attributes = collect([
['code' => 'zeta', 'label' => 'Zeta', 'sort_order' => 2],
['code' => 'priority', 'label' => 'Priority', 'sort_order' => 1],
['code' => 'alpha', 'label' => 'Alpha', 'sort_order' => 2],
])->mapWithKeys(function (array $data) use ($tenant, $item): array {
$attribute = Attribute::query()->create([
'tenant_codigo' => $tenant->codigo,
'codigo' => $data['code'],
'nombre' => $data['label'],
'type' => FieldType::String,
]);
$itemAttribute = $item->itemAttributes()->create([
'attribute_id' => $attribute->id,
'sort_order' => $data['sort_order'],
]);
return [$data['code'] => $itemAttribute];
});
foreach (['zeta', 'priority', 'alpha'] as $code) {
$variant->definitions()->create([
'item_attribute_id' => $attributes[$code]->id,
'value' => $code,
]);
}
$response = $this->getJson("/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}")
->assertOk()
->assertJsonPath('data.attributes.0.codigo', 'priority')
->assertJsonPath('data.attributes.0.sort_order', 1)
->assertJsonPath('data.attributes.1.codigo', 'alpha')
->assertJsonPath('data.attributes.2.codigo', 'zeta');
$this->assertSame(
['priority', 'alpha', 'zeta'],
array_keys($response->json('data.variants.0.values')),
);
}
private function createItem(
Tenant $tenant,
string $name,

View File

@@ -24,6 +24,7 @@ class CatalogSchemaTest extends TestCase
$this->assertFalse(Schema::hasTable('bundle_items'));
$this->assertTrue(Schema::hasTable('variantes'));
$this->assertTrue(Schema::hasTable('item_attributes'));
$this->assertTrue(Schema::hasColumn('item_attributes', 'sort_order'));
$this->assertTrue(Schema::hasTable('variant_values'));
}
@@ -31,9 +32,8 @@ class CatalogSchemaTest extends TestCase
{
$this->assertEqualsCanonicalizing([
'id',
'validity_time_id',
'tenant_code',
'event_id',
'event_product_type',
'category_id',
'brand_id',
'inventory_id',
@@ -43,9 +43,10 @@ class CatalogSchemaTest extends TestCase
'descripcion',
'precio',
'inventory_policy',
'max_units_per_user',
'has_tickets',
'maximum_use_date',
'minimum_use_date',
'ticket_generation_policy',
'validity_time_id',
], Schema::getColumnListing('catalog_items'));
}
@@ -183,26 +184,25 @@ class CatalogSchemaTest extends TestCase
{
$this->assertTrue(Schema::hasColumns('variantes', [
'event_date_id',
'minimum_use_date',
'maximum_use_date',
]));
}
public function test_events_and_event_dates_are_linked_to_the_catalog(): void
public function test_event_configuration_and_dates_belong_to_the_tenant(): void
{
$this->assertFalse(Schema::hasTable('events'));
$this->assertTrue(Schema::hasColumns('tenants', [
'event_title',
'event_location',
'event_date_text',
]));
$this->assertEqualsCanonicalizing([
'id',
'tenant_code',
'name',
'address',
], Schema::getColumnListing('events'));
$this->assertEqualsCanonicalizing([
'id',
'event_id',
'date',
'time_start',
'time_end',
], Schema::getColumnListing('event_dates'));
$this->assertTrue(Schema::hasColumn('tenants', 'active_event_id'));
$this->assertFalse(Schema::hasColumn('catalog_items', 'event_id'));
$this->assertFalse(Schema::hasColumn('compras', 'event_id'));
}
}

View File

@@ -13,7 +13,6 @@ use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
@@ -94,71 +93,138 @@ class CatalogServiceTest extends TestCase
}
}
public function test_variant_use_dates_override_or_inherit_catalog_item_dates(): void
public function test_it_allows_the_same_event_date_with_different_attribute_values(): void
{
$attribute = $this->createAttribute('day');
$itemMinimum = Carbon::parse('2026-08-01 09:00:00');
$itemMaximum = Carbon::parse('2026-08-31 18:00:00');
$variantMaximum = Carbon::parse('2026-08-15 18:00:00');
$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' => 'dated-variants',
'nombre' => 'Dated variants',
'slug' => 'entry-by-sector',
'nombre' => 'Entry by sector',
'precio' => 100,
'minimum_use_date' => $itemMinimum,
'maximum_use_date' => $itemMaximum,
'attribute_codes' => [$attribute->codigo],
'attribute_codes' => [$eventDateAttribute->codigo, $sector->codigo],
'variants' => [
[
'real_stock' => 5,
'maximum_use_date' => $variantMaximum,
'values' => [$attribute->codigo => 'Saturday'],
'event_date_id' => $eventDate->id,
'values' => ['sector' => 'General'],
],
[
'event_date_id' => $eventDate->id,
'values' => ['sector' => 'VIP'],
],
],
]);
$variant = $item->variants->firstOrFail();
$this->assertNull($variant->minimum_use_date);
$this->assertTrue($variant->maximum_use_date->equalTo($variantMaximum));
$this->assertTrue($variant->getMinimumUseDate()->equalTo($itemMinimum));
$this->assertTrue($variant->getMaximumUseDate()->equalTo($variantMaximum));
$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_rejects_an_invalid_effective_variant_use_date_range(): void
public function test_it_creates_a_variant_with_multiple_event_dates(): void
{
$attribute = $this->createAttribute('day');
$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' => 'invalid-dated-variant',
'nombre' => 'Invalid dated variant',
'slug' => 'duplicate-entry',
'nombre' => 'Duplicate entry',
'precio' => 100,
'minimum_use_date' => '2026-08-10 09:00:00',
'maximum_use_date' => '2026-08-31 18:00:00',
'attribute_codes' => [$attribute->codigo],
'attribute_codes' => [$eventDateAttribute->codigo, $sector->codigo],
'variants' => [
[
'real_stock' => 5,
'maximum_use_date' => '2026-08-09 18:00:00',
'values' => [$attribute->codigo => 'Saturday'],
],
['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.0.maximum_use_date',
$exception->errors(),
);
$this->assertArrayHasKey('variants.1', $exception->errors());
}
$this->assertDatabaseMissing('catalog_items', [
'slug' => 'invalid-dated-variant',
]);
}
public function test_it_rejects_direct_inventory_together_with_variants(): void
@@ -283,13 +349,15 @@ class CatalogServiceTest extends TestCase
]);
}
private function createAttribute(string $code): Attribute
{
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' => FieldType::String,
'type' => $type,
]);
}
}

View File

@@ -2,11 +2,13 @@
namespace Tests\Feature\Event;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Event\Models\Event;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Ticket\Enums\ValidityTimeType;
use Database\Seeders\AuthorizationSeeder;
use Database\Seeders\SocialMediaSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -44,27 +46,37 @@ class AdminAppEventControllerTest extends TestCase
->assertJsonPath('data.title', 'Festival Acme')
->assertJsonPath('data.location', 'Predio Ferial, Rosario')
->assertJsonPath('data.dates.0.date', '2026-10-09')
->assertJsonPath('data.dates.0.validity_time.type', 'fixed_window')
->assertJsonPath('data.dates.0.start_time', '09:00')
->assertJsonPath('data.dates.0.end_time', '18:30')
->assertJsonPath('data.contact.whatsapp_url', 'https://wa.me/5493415550101')
->assertJsonPath('data.contact.instagram_url', 'https://instagram.com/acme')
->assertJsonPath('data.contact.facebook_url', null);
$eventId = $response->json('data.id');
$this->assertDatabaseHas('events', [
'id' => $eventId,
'tenant_code' => $tenant->codigo,
'name' => 'Festival Acme',
'address' => 'Predio Ferial, Rosario',
$this->assertSame($tenant->id, $response->json('data.id'));
$this->assertDatabaseHas('tenants', [
'id' => $tenant->id,
'event_title' => 'Festival Acme',
'event_location' => 'Predio Ferial, Rosario',
'event_date_text' => '9 de Octubre 2026',
]);
$this->assertSame($eventId, $tenant->fresh()->active_event_id);
$this->assertDatabaseHas('event_dates', [
'event_id' => $eventId,
'tenant_code' => $tenant->codigo,
'date' => '2026-10-09',
'time_start' => '09:00:00',
'time_end' => '18:30:00',
]);
$eventDate = $tenant->eventDates()->with('validityTime')->sole();
$this->assertSame($eventDate->validity_time_id, $response->json('data.dates.0.validity_time_id'));
$this->assertSame(ValidityTimeType::FixedWindow, $eventDate->validityTime->type);
$this->assertSame(
'2026-10-09 09:00:00',
$eventDate->validityTime->fixed_starts_at->format('Y-m-d H:i:s'),
);
$this->assertSame(
'2026-10-09 18:30:00',
$eventDate->validityTime->fixed_expires_at->format('Y-m-d H:i:s'),
);
$this->assertDatabaseHas('tenant_social_media', [
'tenant_code' => $tenant->codigo,
'social_media_code' => 'whatsapp',
@@ -86,28 +98,33 @@ class AdminAppEventControllerTest extends TestCase
->assertJsonMissing(['title' => 'Other Event']);
}
public function test_reading_a_tenant_without_an_active_event_returns_not_found(): void
public function test_reading_a_tenant_without_event_configuration_returns_empty_values(): void
{
$tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->getJson('/api/v1/adminapp/tenant/event')->assertNotFound();
$this->getJson('/api/v1/adminapp/tenant/event')
->assertOk()
->assertJsonPath('data.title', null)
->assertJsonCount(0, 'data.dates');
}
public function test_updating_reuses_the_active_event_and_synchronizes_dates_and_contact(): void
{
$tenant = $this->createTenant('acme');
$event = $this->createActiveEvent($tenant, 'Old Event');
$firstDate = $event->dates()->create([
$eventTenant = $this->createActiveEvent($tenant, 'Old Event');
$firstDate = $eventTenant->eventDates()->create([
'date' => '2026-10-01',
'time_start' => '08:00',
'time_end' => '12:00',
]);
$removedDate = $event->dates()->create([
$removedDate = $eventTenant->eventDates()->create([
'date' => '2026-10-02',
'time_start' => '08:00',
'time_end' => '12:00',
]);
$firstValidityTimeId = $firstDate->validity_time_id;
$removedValidityTimeId = $removedDate->validity_time_id;
$tenant->socialMedia()->attach('facebook', [
'url' => 'https://facebook.com/old',
'orden' => 2,
@@ -127,15 +144,24 @@ class AdminAppEventControllerTest extends TestCase
$this->putJson('/api/v1/adminapp/tenant/event', $payload)
->assertOk()
->assertJsonPath('data.id', $event->id)
->assertJsonPath('data.id', $tenant->id)
->assertJsonPath('data.dates.0.id', $firstDate->id)
->assertJsonPath('data.contact.facebook_url', null);
$this->assertDatabaseHas('event_dates', [
'id' => $firstDate->id,
'validity_time_id' => $firstValidityTimeId,
'date' => '2026-11-15',
]);
$this->assertDatabaseHas('validity_times', [
'id' => $firstValidityTimeId,
'type' => ValidityTimeType::FixedWindow->value,
'fixed_starts_at' => '2026-11-15 10:00:00',
'fixed_expires_at' => '2026-11-15 20:00:00',
]);
$this->assertDatabaseMissing('event_dates', ['id' => $removedDate->id]);
$this->assertDatabaseMissing('validity_times', ['id' => $removedValidityTimeId]);
$this->assertSame('15 de Noviembre 2026', $tenant->fresh()->event_date_text);
$this->assertDatabaseMissing('tenant_social_media', [
'tenant_code' => $tenant->codigo,
'social_media_code' => 'facebook',
@@ -147,6 +173,27 @@ class AdminAppEventControllerTest extends TestCase
]);
}
public function test_updating_event_dates_recalculates_the_tenant_date_text(): void
{
$tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$payload = $this->eventPayload();
$payload['dates'] = collect([9, 10, 11, 12])
->map(fn (int $day): array => [
'date' => sprintf('2026-10-%02d', $day),
'start_time' => '09:00',
'end_time' => '18:30',
])
->all();
$this->putJson('/api/v1/adminapp/tenant/event', $payload)->assertOk();
$this->assertSame(
'9, 10, 11 y 12 de Octubre 2026',
$tenant->fresh()->event_date_text
);
}
public function test_update_validates_event_dates_and_contact_urls(): void
{
$tenant = $this->createTenant('acme');
@@ -175,7 +222,7 @@ class AdminAppEventControllerTest extends TestCase
'contact.whatsapp_url',
]);
$this->assertDatabaseCount('events', 0);
$this->assertNull($tenant->fresh()->event_title);
}
public function test_social_media_accepts_any_registered_code_and_rejects_unknown_codes(): void
@@ -241,11 +288,32 @@ class AdminAppEventControllerTest extends TestCase
private function createTenant(string $code): Tenant
{
$headerLogo = $this->createAttachment("{$code}-header");
$footerLogo = $this->createAttachment("{$code}-footer");
return Tenant::query()->create([
'codigo' => $code,
'nombre' => ucfirst($code),
'dominio' => "{$code}.test",
'website_type_code' => 'onticket',
'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',
]);
}
@@ -257,14 +325,13 @@ class AdminAppEventControllerTest extends TestCase
]);
}
private function createActiveEvent(Tenant $tenant, string $name): Event
private function createActiveEvent(Tenant $tenant, string $name): Tenant
{
$event = $tenant->events()->create([
'name' => $name,
'address' => 'Rosario',
$tenant->update([
'event_title' => $name,
'event_location' => 'Rosario',
]);
$tenant->update(['active_event_id' => $event->id]);
return $event;
return $tenant->fresh();
}
}

View File

@@ -0,0 +1,266 @@
<?php
namespace Tests\Feature\FiestaFutbolInfantil;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Menu\Models\Menu;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AccommodationControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(AuthorizationSeeder::class);
WebsiteType::query()->create([
'codigo' => 'onticket',
'nombre' => 'OnTicket',
]);
}
public function test_authentication_is_required(): void
{
$this->postJson('/api/v1/adminapp/tenant/accommodations', ['variants' => []])
->assertUnauthorized();
}
public function test_it_creates_accommodation_variants_and_adds_type_options(): void
{
[$tenant, $attribute] = $this->configuredTenant();
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->postJson('/api/v1/adminapp/tenant/accommodations', [
'variants' => [
$this->variantPayload('Casa Rodante Familiar', 'Parcela grande', 25, 45000),
$this->variantPayload('Carpa', null, 100, 35000),
],
])
->assertOk()
->assertJsonPath('data.name', 'Alojamiento')
->assertJsonCount(2, 'data.variants')
->assertJsonPath('data.variants.0.title', 'Casa Rodante Familiar')
->assertJsonPath('data.variants.0.value', 'casa_rodante_familiar')
->assertJsonPath('data.variants.0.description', 'Parcela grande')
->assertJsonPath('data.variants.0.stock', 25)
->assertJsonPath('data.variants.0.price', '45000.00');
$this->getJson('/api/v1/adminapp/tenant/accommodations')
->assertOk()
->assertJsonPath('data.name', 'Alojamiento')
->assertJsonCount(2, 'data.variants')
->assertJsonPath('data.variants.0.title', 'Casa Rodante Familiar')
->assertJsonPath('data.variants.1.title', 'Carpa');
$this->assertDatabaseHas('attribute_options', [
'attribute_id' => $attribute->id,
'value' => 'casa_rodante_familiar',
'label' => 'Casa Rodante Familiar',
]);
$this->assertDatabaseHas('attribute_options', [
'attribute_id' => $attribute->id,
'value' => 'carpa',
'label' => 'Carpa',
]);
$this->assertDatabaseCount('catalog_items', 1);
$this->assertTrue(CatalogItem::query()->sole()->has_tickets);
$this->assertDatabaseCount('variantes', 2);
$this->assertDatabaseCount('inventories', 2);
$this->assertDatabaseCount('variant_values', 2);
}
public function test_it_updates_variants_with_an_id_and_creates_variants_without_one(): void
{
[$tenant, $attribute] = $this->configuredTenant();
Sanctum::actingAs($this->createAdminAppUser($tenant));
$created = $this->postJson('/api/v1/adminapp/tenant/accommodations', [
'variants' => [
$this->variantPayload('Casa Rodante', 'Descripción inicial', 20, 40000),
],
])->assertOk();
$variantId = $created->json('data.variants.0.id');
$updated = $this->variantPayload('Casa Rodante Premium', 'Con electricidad', 15, 50000);
$updated['id'] = $variantId;
$this->postJson('/api/v1/adminapp/tenant/accommodations', [
'variants' => [
$updated,
$this->variantPayload('Motor Home', null, 10, 60000),
],
])
->assertOk()
->assertJsonCount(2, 'data.variants')
->assertJsonPath('data.variants.0.id', $variantId)
->assertJsonPath('data.variants.0.title', 'Casa Rodante Premium')
->assertJsonPath('data.variants.0.value', 'casa_rodante_premium')
->assertJsonPath('data.variants.0.stock', 15)
->assertJsonPath('data.variants.1.value', 'motor_home');
$this->assertDatabaseMissing('attribute_options', [
'attribute_id' => $attribute->id,
'value' => 'casa_rodante',
]);
$this->assertDatabaseHas('attribute_options', [
'attribute_id' => $attribute->id,
'value' => 'casa_rodante_premium',
'label' => 'Casa Rodante Premium',
]);
$this->assertDatabaseHas('variant_values', [
'variant_id' => $variantId,
'value' => 'casa_rodante_premium',
]);
$this->assertDatabaseCount('variantes', 2);
}
public function test_it_deletes_an_accommodation_and_its_empty_product(): void
{
[$tenant] = $this->configuredTenant();
Sanctum::actingAs($this->createAdminAppUser($tenant));
$accommodationId = $this->postJson('/api/v1/adminapp/tenant/accommodations', [
'variants' => [
$this->variantPayload('Carpa', null, 100, 35000),
],
])->assertOk()->json('data.variants.0.id');
$this->deleteJson("/api/v1/adminapp/tenant/accommodations/{$accommodationId}")
->assertNoContent();
$this->assertDatabaseCount('catalog_items', 0);
$this->assertDatabaseCount('variantes', 0);
$this->assertDatabaseCount('inventories', 0);
}
public function test_it_rejects_duplicate_normalized_titles(): void
{
[$tenant] = $this->configuredTenant();
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->postJson('/api/v1/adminapp/tenant/accommodations', [
'variants' => [
$this->variantPayload('Casa Rodante', null, 10, 40000),
$this->variantPayload(' CASA RODANTE ', null, 10, 40000),
],
])
->assertUnprocessable()
->assertJsonValidationErrors('variants.1.title');
$this->assertDatabaseCount('catalog_items', 0);
$this->assertDatabaseCount('attribute_options', 0);
}
public function test_the_tenant_must_have_the_accommodations_menu(): void
{
[$tenant] = $this->configuredTenant(withMenu: false);
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->postJson('/api/v1/adminapp/tenant/accommodations', [
'variants' => [
$this->variantPayload('Carpa', null, 10, 35000),
],
])->assertNotFound();
$this->assertDatabaseCount('catalog_items', 0);
}
public function test_a_different_tenant_can_use_the_endpoint_when_it_has_the_menu(): void
{
[$tenant] = $this->configuredTenant('another_tenant');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->postJson('/api/v1/adminapp/tenant/accommodations', [
'variants' => [
$this->variantPayload('Dormitorio Compartido', null, 30, 25000),
],
])
->assertOk()
->assertJsonPath('data.variants.0.value', 'dormitorio_compartido');
}
/** @return array{Tenant, Attribute} */
private function configuredTenant(
string $tenantCode = 'fiesta_futbol_infantil',
bool $withMenu = true,
): array {
$headerLogo = Attachment::query()->create([
'path' => 'test/header.png',
'filename' => 'header.png',
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
$footerLogo = Attachment::query()->create([
'path' => 'test/footer.png',
'filename' => 'footer.png',
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
$tenant = Tenant::query()->create([
'codigo' => $tenantCode,
'nombre' => 'Fiesta Fútbol Infantil',
'dominio' => 'fiesta.test',
'primary_color' => '#112233',
'secondary_color' => '#445566',
'danger_color' => '#cc0000',
'header_bg_color' => '#ffffff',
'footer_bg_color' => '#111111',
'header_logo_id' => $headerLogo->id,
'footer_logo_id' => $footerLogo->id,
'website_type_code' => 'onticket',
]);
$attribute = Attribute::query()->create([
'tenant_codigo' => $tenant->codigo,
'codigo' => 'tipo_alojamiento',
'nombre' => 'TipoAlojamiento',
'type' => FieldType::Select,
'is_required' => true,
]);
if ($withMenu) {
$menu = Menu::query()->firstOrCreate(
['code' => 'adminapp.fiesta-futbol-infantil.alojamientos'],
['label' => 'Alojamientos', 'route' => '/admin/alojamientos'],
);
$tenant->menues()->syncWithoutDetaching([$menu->code]);
}
return [$tenant, $attribute];
}
/** @return array<string, mixed> */
private function variantPayload(
string $title,
?string $description,
int $stock,
float $price,
): array {
return [
'title' => $title,
'description' => $description,
'stock' => $stock,
'price' => $price,
];
}
private function createAdminAppUser(Tenant $tenant): User
{
return User::factory()->create([
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $tenant->codigo,
]);
}
}

View File

@@ -0,0 +1,329 @@
<?php
namespace Tests\Feature\FiestaFutbolInfantil;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Menu\Models\Menu;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class EntryControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(AuthorizationSeeder::class);
WebsiteType::query()->create([
'codigo' => 'onticket',
'nombre' => 'OnTicket',
]);
}
public function test_authentication_is_required(): void
{
$this->postJson('/api/v1/adminapp/tenant/entries', ['entries' => []])
->assertUnauthorized();
}
public function test_it_creates_multiple_entries_with_dates_and_tracked_inventory(): void
{
$tenant = $this->createFiestaTenant();
$this->createEventDateAttribute($tenant);
$firstDate = $tenant->eventDates()->create([
'date' => '2026-10-09',
'time_start' => '00:00',
'time_end' => '23:59',
]);
$secondDate = $tenant->eventDates()->create([
'date' => '2026-10-10',
'time_start' => '00:00',
'time_end' => '23:59',
]);
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->postJson('/api/v1/adminapp/tenant/entries', [
'entries' => [
[
'title' => 'Abono',
'description' => 'Acceso para ambas jornadas',
'event_date_ids' => [$firstDate->id, $secondDate->id],
'stock' => 1000,
'price' => 10000,
],
[
'title' => 'Entrada diaria',
'description' => null,
'event_date_ids' => [$firstDate->id],
'stock' => 250,
'price' => 5000.50,
],
],
])
->assertOk()
->assertJsonCount(2, 'data')
->assertJsonPath('data.0.title', 'Abono')
->assertJsonPath('data.0.event_date_ids', [$firstDate->id, $secondDate->id])
->assertJsonPath('data.0.stock', 1000)
->assertJsonPath('data.0.price', '10000.00')
->assertJsonPath('data.1.title', 'Entrada diaria')
->assertJsonPath('data.1.price', '5000.50');
$this->getJson('/api/v1/adminapp/tenant/entries')
->assertOk()
->assertJsonCount(2, 'data')
->assertJsonPath('data.0.title', 'Abono')
->assertJsonPath('data.0.event_date_ids', [$firstDate->id, $secondDate->id])
->assertJsonPath('data.1.title', 'Entrada diaria');
$this->assertDatabaseCount('catalog_items', 2);
$this->assertDatabaseCount('variantes', 2);
$this->assertDatabaseCount('inventories', 2);
$this->assertDatabaseCount('variant_event_dates', 3);
$entryCategory = Category::query()
->where('tenant_code', $tenant->codigo)
->where('nombre', 'Entradas')
->sole();
CatalogItem::query()->each(function (CatalogItem $entry) use ($tenant, $entryCategory): void {
$this->assertSame($tenant->codigo, $entry->tenant_code);
$this->assertSame($entryCategory->id, $entry->category_id);
$this->assertSame('tracked', $entry->inventory_policy->value);
$this->assertTrue($entry->has_tickets);
$this->assertNull($entry->inventory_id);
});
}
public function test_it_updates_entries_with_an_id_and_creates_entries_without_one(): void
{
$tenant = $this->createFiestaTenant();
$this->createEventDateAttribute($tenant);
$firstDate = $tenant->eventDates()->create([
'date' => '2026-10-09',
'time_start' => '00:00',
'time_end' => '23:59',
]);
$secondDate = $tenant->eventDates()->create([
'date' => '2026-10-10',
'time_start' => '00:00',
'time_end' => '23:59',
]);
Sanctum::actingAs($this->createAdminAppUser($tenant));
$createdId = $this->postJson('/api/v1/adminapp/tenant/entries', [
'entries' => [[
'title' => 'Abono original',
'description' => null,
'event_date_ids' => [$firstDate->id],
'stock' => 10,
'price' => 100,
]],
])->assertOk()->json('data.0.id');
$this->postJson('/api/v1/adminapp/tenant/entries', [
'entries' => [
[
'id' => $createdId,
'title' => 'Abono actualizado',
'description' => 'Ahora incluye ambas fechas',
'event_date_ids' => [$firstDate->id, $secondDate->id],
'stock' => 20,
'price' => 250,
],
[
'title' => 'Entrada nueva',
'description' => null,
'event_date_ids' => [$secondDate->id],
'stock' => 30,
'price' => 300,
],
],
])
->assertOk()
->assertJsonCount(2, 'data')
->assertJsonPath('data.0.id', $createdId)
->assertJsonPath('data.0.title', 'Abono actualizado')
->assertJsonPath('data.0.event_date_ids', [$firstDate->id, $secondDate->id])
->assertJsonPath('data.0.stock', 20)
->assertJsonPath('data.1.title', 'Entrada nueva');
$this->assertDatabaseCount('catalog_items', 2);
$this->assertDatabaseHas('catalog_items', [
'id' => $createdId,
'nombre' => 'Abono actualizado',
'precio' => 250,
]);
}
public function test_it_deletes_an_entry_and_its_inventory(): void
{
$tenant = $this->createFiestaTenant();
$this->createEventDateAttribute($tenant);
$eventDate = $tenant->eventDates()->create([
'date' => '2026-10-09',
'time_start' => '00:00',
'time_end' => '23:59',
]);
Sanctum::actingAs($this->createAdminAppUser($tenant));
$entryId = $this->postJson('/api/v1/adminapp/tenant/entries', [
'entries' => [[
'title' => 'Entrada diaria',
'description' => null,
'event_date_ids' => [$eventDate->id],
'stock' => 100,
'price' => 5000,
]],
])->assertOk()->json('data.0.id');
$this->deleteJson("/api/v1/adminapp/tenant/entries/{$entryId}")
->assertNoContent();
$this->assertDatabaseCount('catalog_items', 0);
$this->assertDatabaseCount('variantes', 0);
$this->assertDatabaseCount('inventories', 0);
}
public function test_dates_must_belong_to_the_authenticated_tenant(): void
{
$tenant = $this->createFiestaTenant();
$otherTenant = $this->createTenant('other');
$this->createEventDateAttribute($tenant);
$foreignDate = $otherTenant->eventDates()->create([
'date' => '2026-10-09',
'time_start' => '00:00',
'time_end' => '23:59',
]);
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->postJson('/api/v1/adminapp/tenant/entries', [
'entries' => [[
'title' => 'Entrada inválida',
'description' => null,
'event_date_ids' => [$foreignDate->id],
'stock' => 10,
'price' => 100,
]],
])
->assertUnprocessable()
->assertJsonValidationErrors(['entries.0.event_date_ids.0']);
$this->assertDatabaseCount('catalog_items', 0);
}
public function test_entries_are_identified_by_the_entries_category(): void
{
$tenant = $this->createFiestaTenant();
$this->createEventDateAttribute($tenant);
$eventDate = $tenant->eventDates()->create([
'date' => '2026-10-09',
'time_start' => '00:00',
'time_end' => '23:59',
]);
$merchandisingCategory = Category::query()->create([
'tenant_code' => $tenant->codigo,
'nombre' => 'Merchandising',
]);
$merchandise = CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'category_id' => $merchandisingCategory->id,
'slug' => 'camiseta',
'nombre' => 'Camiseta',
'precio' => 100,
]);
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->getJson('/api/v1/adminapp/tenant/entries')
->assertOk()
->assertJsonCount(0, 'data');
$this->postJson('/api/v1/adminapp/tenant/entries', [
'entries' => [[
'id' => $merchandise->id,
'title' => 'Entrada inválida',
'description' => null,
'event_date_ids' => [$eventDate->id],
'stock' => 10,
'price' => 100,
]],
])
->assertUnprocessable()
->assertJsonValidationErrors(['entries.0.id']);
}
public function test_the_endpoint_is_only_available_for_fiesta_futbol_infantil(): void
{
$tenant = $this->createTenant('other');
$this->createEventDateAttribute($tenant);
$eventDate = $tenant->eventDates()->create([
'date' => '2026-10-09',
'time_start' => '00:00',
'time_end' => '23:59',
]);
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->postJson('/api/v1/adminapp/tenant/entries', [
'entries' => [[
'title' => 'Entrada',
'description' => null,
'event_date_ids' => [$eventDate->id],
'stock' => 10,
'price' => 100,
]],
])->assertNotFound();
}
private function createFiestaTenant(): Tenant
{
$tenant = $this->createTenant('fiesta_futbol_infantil');
$menu = Menu::query()->create([
'code' => 'adminapp.fiesta-futbol-infantil.entradas',
'label' => 'Entradas',
'route' => '/admin/entradas',
]);
$tenant->menues()->attach($menu->code);
return $tenant;
}
private function createTenant(string $code): Tenant
{
return Tenant::query()->create([
'codigo' => $code,
'nombre' => ucfirst($code),
'dominio' => "{$code}.test",
'website_type_code' => 'onticket',
]);
}
private function createEventDateAttribute(Tenant $tenant): void
{
Attribute::query()->create([
'tenant_codigo' => $tenant->codigo,
'codigo' => 'event_date',
'nombre' => 'Fecha',
'type' => FieldType::EventDate,
'is_required' => true,
]);
}
private function createAdminAppUser(Tenant $tenant): User
{
return User::factory()->create([
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $tenant->codigo,
]);
}
}

View File

@@ -0,0 +1,279 @@
<?php
namespace Tests\Feature\FiestaFutbolInfantil;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Menu\Models\Menu;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class FoodControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(AuthorizationSeeder::class);
WebsiteType::query()->create([
'codigo' => 'onticket',
'nombre' => 'OnTicket',
]);
}
public function test_authentication_is_required(): void
{
$this->postJson('/api/v1/adminapp/tenant/foods', ['variants' => []])
->assertUnauthorized();
}
public function test_it_creates_one_food_item_with_multiple_variants(): void
{
[$tenant, $firstDate, $secondDate] = $this->configuredTenant();
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->postJson('/api/v1/adminapp/tenant/foods', [
'variants' => [
$this->variantPayload($firstDate->id, 'Almuerzo', 'Comedor', 1700, 10000),
$this->variantPayload($secondDate->id, 'Cena', 'Vianda', 900, 8000),
],
])
->assertOk()
->assertJsonPath('data.name', 'Comida')
->assertJsonCount(2, 'data.variants')
->assertJsonPath('data.variants.0.schedule', 'Almuerzo')
->assertJsonPath('data.variants.0.service', 'Comedor')
->assertJsonPath('data.variants.0.stock', 1700)
->assertJsonPath('data.variants.0.price', '10000.00')
->assertJsonPath('data.variants.1.price', '8000.00');
$this->getJson('/api/v1/adminapp/tenant/foods')
->assertOk()
->assertJsonPath('data.name', 'Comida')
->assertJsonCount(2, 'data.variants')
->assertJsonPath('data.variants.0.schedule', 'Almuerzo')
->assertJsonPath('data.variants.1.service', 'Vianda');
$this->assertDatabaseCount('catalog_items', 1);
$this->assertDatabaseCount('variantes', 2);
$this->assertDatabaseCount('inventories', 2);
$this->assertDatabaseCount('variant_event_dates', 2);
$this->assertDatabaseCount('variant_values', 4);
$food = CatalogItem::query()->where('slug', 'comida')->sole();
$this->assertDatabaseHas('item_attributes', [
'catalog_item_id' => $food->id,
'attribute_id' => Attribute::query()->where('codigo', 'event_date')->sole()->id,
'sort_order' => 1,
]);
$this->assertDatabaseHas('item_attributes', [
'catalog_item_id' => $food->id,
'attribute_id' => Attribute::query()->where('codigo', 'horario')->sole()->id,
'sort_order' => 2,
]);
$this->assertDatabaseHas('item_attributes', [
'catalog_item_id' => $food->id,
'attribute_id' => Attribute::query()->where('codigo', 'servicio')->sole()->id,
'sort_order' => 3,
]);
$this->assertDatabaseHas('catalog_items', [
'tenant_code' => $tenant->codigo,
'slug' => 'comida',
'nombre' => 'Comida',
'has_tickets' => true,
'category_id' => Category::query()
->where('tenant_code', $tenant->codigo)
->where('nombre', 'Comidas')
->sole()
->id,
'precio' => 8000,
]);
}
public function test_it_updates_variants_with_an_id_and_creates_variants_without_one(): void
{
[$tenant, $firstDate, $secondDate] = $this->configuredTenant();
Sanctum::actingAs($this->createAdminAppUser($tenant));
$created = $this->postJson('/api/v1/adminapp/tenant/foods', [
'variants' => [
$this->variantPayload($firstDate->id, 'Almuerzo', 'Comedor', 100, 10000),
],
])->assertOk();
$variantId = $created->json('data.variants.0.id');
$updated = $this->variantPayload($secondDate->id, 'Cena', 'Vianda', 80, 8500);
$updated['id'] = $variantId;
$updated['description'] = 'Cena para llevar';
$this->postJson('/api/v1/adminapp/tenant/foods', [
'variants' => [
$updated,
$this->variantPayload($firstDate->id, 'Almuerzo', 'Vianda', 50, 9000),
],
])
->assertOk()
->assertJsonCount(2, 'data.variants')
->assertJsonPath('data.variants.0.id', $variantId)
->assertJsonPath('data.variants.0.event_date_id', $secondDate->id)
->assertJsonPath('data.variants.0.schedule', 'Cena')
->assertJsonPath('data.variants.0.service', 'Vianda')
->assertJsonPath('data.variants.0.description', 'Cena para llevar')
->assertJsonPath('data.variants.0.stock', 80)
->assertJsonPath('data.variants.0.price', '8500.00');
$this->assertDatabaseCount('catalog_items', 1);
$this->assertDatabaseCount('variantes', 2);
$this->assertDatabaseHas('variantes', [
'id' => $variantId,
'event_date_id' => $secondDate->id,
'descripcion' => 'Cena para llevar',
'precio' => 8500,
]);
}
public function test_it_deletes_food_records_and_removes_the_empty_product(): void
{
[$tenant, $firstDate, $secondDate] = $this->configuredTenant();
Sanctum::actingAs($this->createAdminAppUser($tenant));
$created = $this->postJson('/api/v1/adminapp/tenant/foods', [
'variants' => [
$this->variantPayload($firstDate->id, 'Almuerzo', 'Comedor', 100, 10000),
$this->variantPayload($secondDate->id, 'Cena', 'Vianda', 50, 8000),
],
])->assertOk();
$firstId = $created->json('data.variants.0.id');
$secondId = $created->json('data.variants.1.id');
$this->deleteJson("/api/v1/adminapp/tenant/foods/{$secondId}")
->assertNoContent();
$this->assertDatabaseHas('catalog_items', [
'tenant_code' => $tenant->codigo,
'slug' => 'comida',
'precio' => 10000,
]);
$this->assertDatabaseCount('variantes', 1);
$this->assertDatabaseCount('inventories', 1);
$this->deleteJson("/api/v1/adminapp/tenant/foods/{$firstId}")
->assertNoContent();
$this->assertDatabaseCount('catalog_items', 0);
$this->assertDatabaseCount('variantes', 0);
$this->assertDatabaseCount('inventories', 0);
}
public function test_it_rejects_duplicate_combinations(): void
{
[$tenant, $firstDate] = $this->configuredTenant();
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->postJson('/api/v1/adminapp/tenant/foods', [
'variants' => [
$this->variantPayload($firstDate->id, 'Almuerzo', 'Comedor', 100, 10000),
$this->variantPayload($firstDate->id, ' almuerzo ', ' comedor ', 100, 10000),
],
])
->assertUnprocessable()
->assertJsonValidationErrors(['variants.1']);
$this->assertDatabaseCount('catalog_items', 0);
}
/** @return array{Tenant, mixed, mixed} */
private function configuredTenant(): array
{
$tenant = Tenant::query()->create([
'codigo' => 'fiesta_futbol_infantil',
'nombre' => 'Fiesta Fútbol Infantil',
'dominio' => 'fiesta.test',
'website_type_code' => 'onticket',
]);
$eventDateAttribute = Attribute::query()->create([
'tenant_codigo' => $tenant->codigo,
'codigo' => 'event_date',
'nombre' => 'Fecha',
'type' => FieldType::EventDate,
'is_required' => true,
]);
$scheduleAttribute = Attribute::query()->create([
'tenant_codigo' => $tenant->codigo,
'codigo' => 'horario',
'nombre' => 'Horario',
'type' => FieldType::Select,
'is_required' => true,
]);
$scheduleAttribute->options()->createMany([
['value' => 'Almuerzo', 'label' => 'Almuerzo', 'sort_order' => 1],
['value' => 'Cena', 'label' => 'Cena', 'sort_order' => 2],
]);
$serviceAttribute = Attribute::query()->create([
'tenant_codigo' => $tenant->codigo,
'codigo' => 'servicio',
'nombre' => 'Servicio',
'type' => FieldType::Select,
'is_required' => true,
]);
$serviceAttribute->options()->createMany([
['value' => 'Comedor', 'label' => 'Comedor', 'sort_order' => 1],
['value' => 'Vianda', 'label' => 'Vianda', 'sort_order' => 2],
]);
$firstDate = $tenant->eventDates()->create([
'date' => '2026-10-09',
'time_start' => '00:00',
'time_end' => '23:59',
]);
$secondDate = $tenant->eventDates()->create([
'date' => '2026-10-10',
'time_start' => '00:00',
'time_end' => '23:59',
]);
$menu = Menu::query()->create([
'code' => 'adminapp.fiesta-futbol-infantil.comida',
'label' => 'Comida',
'route' => '/admin/comidas',
]);
$tenant->menues()->attach($menu->code);
$this->assertNotNull($eventDateAttribute);
return [$tenant, $firstDate, $secondDate];
}
/** @return array<string, mixed> */
private function variantPayload(
int $eventDateId,
string $schedule,
string $service,
int $stock,
float $price,
): array {
return [
'event_date_id' => $eventDateId,
'schedule' => $schedule,
'service' => $service,
'description' => null,
'stock' => $stock,
'price' => $price,
];
}
private function createAdminAppUser(Tenant $tenant): User
{
return User::factory()->create([
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $tenant->codigo,
]);
}
}

View File

@@ -0,0 +1,344 @@
<?php
namespace Tests\Feature\FiestaFutbolInfantil;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Menu\Models\Menu;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class MerchandiseControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(AuthorizationSeeder::class);
WebsiteType::query()->create([
'codigo' => 'onticket',
'nombre' => 'OnTicket',
]);
}
public function test_authentication_is_required(): void
{
$this->postJson('/api/v1/adminapp/tenant/merchandise', ['items' => []])
->assertUnauthorized();
}
public function test_it_creates_multiple_items_with_color_and_size_variants(): void
{
[$tenant, $colorAttribute] = $this->configuredTenant();
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->postJson('/api/v1/adminapp/tenant/merchandise', [
'items' => [
$this->itemPayload('Camiseta', 3, [
$this->variantPayload('Verde', 'S', 1700, 100000),
$this->variantPayload('Verde', 'M', 1600, 95000),
$this->variantPayload('Azul Marino', 'L', 500, 110000),
]),
$this->itemPayload('Buzo', 2, [
$this->variantPayload('Blanco', 'XL', 300, 120000),
]),
],
])
->assertOk()
->assertJsonCount(2, 'data')
->assertJsonPath('data.0.title', 'Camiseta')
->assertJsonPath('data.0.max_units_per_user', 3)
->assertJsonCount(3, 'data.0.variants')
->assertJsonPath('data.0.variants.0.color', 'Verde')
->assertJsonPath('data.0.variants.0.color_value', 'Verde')
->assertJsonPath('data.0.variants.0.size', 'S')
->assertJsonPath('data.0.variants.0.stock', 1700)
->assertJsonPath('data.0.variants.1.price', '95000.00')
->assertJsonPath('data.0.variants.2.color', 'Azul Marino')
->assertJsonPath('data.0.variants.2.color_value', 'azul_marino');
$this->getJson('/api/v1/adminapp/tenant/merchandise')
->assertOk()
->assertJsonCount(2, 'data')
->assertJsonPath('data.0.title', 'Camiseta')
->assertJsonCount(3, 'data.0.variants')
->assertJsonPath('data.1.title', 'Buzo')
->assertJsonPath('data.1.variants.0.size', 'XL');
$this->assertDatabaseCount('catalog_items', 2);
$this->assertDatabaseCount('variantes', 4);
$this->assertDatabaseCount('inventories', 4);
$this->assertDatabaseCount('item_attributes', 4);
$this->assertDatabaseCount('variant_values', 8);
$this->assertDatabaseHas('attribute_options', [
'attribute_id' => $colorAttribute->id,
'value' => 'azul_marino',
'label' => 'Azul Marino',
]);
$this->assertDatabaseHas('catalog_items', [
'nombre' => 'Camiseta',
'precio' => 95000,
'max_units_per_user' => 3,
'inventory_policy' => 'tracked',
'has_tickets' => true,
]);
}
public function test_it_updates_items_and_variants_with_ids_and_creates_those_without_ids(): void
{
[$tenant] = $this->configuredTenant();
Sanctum::actingAs($this->createAdminAppUser($tenant));
$created = $this->postJson('/api/v1/adminapp/tenant/merchandise', [
'items' => [
$this->itemPayload('Camiseta', 3, [
$this->variantPayload('Verde', 'S', 100, 10000),
]),
],
])->assertOk();
$itemId = $created->json('data.0.id');
$variantId = $created->json('data.0.variants.0.id');
$updatedVariant = $this->variantPayload('Blanco', 'M', 80, 12500);
$updatedVariant['id'] = $variantId;
$updatedItem = $this->itemPayload('Camiseta oficial', 2, [
$updatedVariant,
$this->variantPayload('Verde', 'L', 60, 15000),
]);
$updatedItem['id'] = $itemId;
$this->postJson('/api/v1/adminapp/tenant/merchandise', [
'items' => [$updatedItem],
])
->assertOk()
->assertJsonPath('data.0.id', $itemId)
->assertJsonPath('data.0.title', 'Camiseta oficial')
->assertJsonPath('data.0.max_units_per_user', 2)
->assertJsonPath('data.0.variants.0.id', $variantId)
->assertJsonPath('data.0.variants.0.color', 'Blanco')
->assertJsonPath('data.0.variants.0.size', 'M')
->assertJsonPath('data.0.variants.0.stock', 80)
->assertJsonCount(2, 'data.0.variants');
$this->assertDatabaseCount('catalog_items', 1);
$this->assertDatabaseCount('variantes', 2);
$this->assertDatabaseHas('catalog_items', [
'id' => $itemId,
'nombre' => 'Camiseta oficial',
'precio' => 12500,
'max_units_per_user' => 2,
]);
}
public function test_it_deletes_merchandise_records_and_removes_the_empty_item(): void
{
[$tenant] = $this->configuredTenant();
Sanctum::actingAs($this->createAdminAppUser($tenant));
$created = $this->postJson('/api/v1/adminapp/tenant/merchandise', [
'items' => [
$this->itemPayload('Camiseta', 3, [
$this->variantPayload('Verde', 'S', 100, 10000),
$this->variantPayload('Blanco', 'M', 50, 15000),
]),
],
])->assertOk();
$itemId = $created->json('data.0.id');
$firstId = $created->json('data.0.variants.0.id');
$secondId = $created->json('data.0.variants.1.id');
$this->deleteJson("/api/v1/adminapp/tenant/merchandise/{$firstId}")
->assertNoContent();
$this->assertDatabaseHas('catalog_items', [
'id' => $itemId,
'precio' => 15000,
]);
$this->assertDatabaseCount('variantes', 1);
$this->deleteJson("/api/v1/adminapp/tenant/merchandise/{$secondId}")
->assertNoContent();
$this->assertDatabaseMissing('catalog_items', ['id' => $itemId]);
$this->assertDatabaseCount('variantes', 0);
$this->assertDatabaseCount('inventories', 0);
}
public function test_it_rejects_duplicate_color_and_size_combinations(): void
{
[$tenant] = $this->configuredTenant();
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->postJson('/api/v1/adminapp/tenant/merchandise', [
'items' => [
$this->itemPayload('Camiseta', 3, [
$this->variantPayload('Verde', 'S', 100, 10000),
$this->variantPayload(' verde ', 's', 50, 12000),
]),
],
])
->assertUnprocessable()
->assertJsonValidationErrors('items.0.variants.1');
$this->assertDatabaseCount('catalog_items', 0);
}
public function test_it_rejects_sizes_that_are_not_attribute_options(): void
{
[$tenant] = $this->configuredTenant();
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->postJson('/api/v1/adminapp/tenant/merchandise', [
'items' => [
$this->itemPayload('Gorra', 2, [
$this->variantPayload('Verde', 'Único', 100, 10000),
]),
],
])
->assertUnprocessable()
->assertJsonValidationErrors('items.0.variants.0.size');
$this->assertDatabaseCount('catalog_items', 0);
}
public function test_a_variant_id_must_belong_to_its_merchandise_item(): void
{
[$tenant] = $this->configuredTenant();
Sanctum::actingAs($this->createAdminAppUser($tenant));
$created = $this->postJson('/api/v1/adminapp/tenant/merchandise', [
'items' => [
$this->itemPayload('Camiseta', 3, [
$this->variantPayload('Verde', 'S', 100, 10000),
]),
$this->itemPayload('Buzo', 2, [
$this->variantPayload('Blanco', 'M', 50, 20000),
]),
],
])->assertOk();
$firstItemId = $created->json('data.0.id');
$secondItemVariantId = $created->json('data.1.variants.0.id');
$foreignVariant = $this->variantPayload('Azul Marino', 'XL', 20, 30000);
$foreignVariant['id'] = $secondItemVariantId;
$firstItem = $this->itemPayload('Camiseta', 3, [$foreignVariant]);
$firstItem['id'] = $firstItemId;
$this->postJson('/api/v1/adminapp/tenant/merchandise', [
'items' => [$firstItem],
])
->assertUnprocessable()
->assertJsonValidationErrors('items.0.variants.0.id');
$this->assertDatabaseMissing('attribute_options', ['value' => 'azul_marino']);
}
/** @return array{Tenant, Attribute, Attribute} */
private function configuredTenant(): array
{
$headerLogo = $this->attachment('header.png');
$footerLogo = $this->attachment('footer.png');
$tenant = Tenant::query()->create([
'codigo' => 'fiesta_futbol_infantil',
'nombre' => 'Fiesta Fútbol Infantil',
'dominio' => 'fiesta.test',
'primary_color' => '#112233',
'secondary_color' => '#445566',
'danger_color' => '#cc0000',
'header_bg_color' => '#ffffff',
'footer_bg_color' => '#111111',
'header_logo_id' => $headerLogo->id,
'footer_logo_id' => $footerLogo->id,
'website_type_code' => 'onticket',
]);
$colorAttribute = Attribute::query()->create([
'tenant_codigo' => $tenant->codigo,
'codigo' => 'color',
'nombre' => 'Color',
'type' => FieldType::Select,
'is_required' => true,
]);
$colorAttribute->options()->createMany([
['value' => 'Verde', 'label' => 'Verde', 'sort_order' => 1],
['value' => 'Blanco', 'label' => 'Blanco', 'sort_order' => 2],
]);
$sizeAttribute = Attribute::query()->create([
'tenant_codigo' => $tenant->codigo,
'codigo' => 'talle',
'nombre' => 'Talle',
'type' => FieldType::Select,
'is_required' => true,
]);
$sizeAttribute->options()->createMany([
['value' => 'S', 'label' => 'S', 'sort_order' => 1],
['value' => 'M', 'label' => 'M', 'sort_order' => 2],
['value' => 'L', 'label' => 'L', 'sort_order' => 3],
['value' => 'XL', 'label' => 'XL', 'sort_order' => 4],
]);
$menu = Menu::query()->create([
'code' => 'adminapp.fiesta-futbol-infantil.merchandising',
'label' => 'Merchandising',
'route' => '/admin/merchandising',
]);
$tenant->menues()->attach($menu->code);
return [$tenant, $colorAttribute, $sizeAttribute];
}
private function attachment(string $filename): Attachment
{
return Attachment::query()->create([
'path' => "test/{$filename}",
'filename' => $filename,
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
}
/**
* @param array<int, array<string, mixed>> $variants
* @return array<string, mixed>
*/
private function itemPayload(string $title, int $limit, array $variants): array
{
return [
'title' => $title,
'description' => null,
'max_units_per_user' => $limit,
'variants' => $variants,
];
}
/** @return array<string, mixed> */
private function variantPayload(
string $color,
string $size,
int $stock,
float $price,
): array {
return [
'color' => $color,
'size' => $size,
'stock' => $stock,
'price' => $price,
];
}
private function createAdminAppUser(Tenant $tenant): User
{
return User::factory()->create([
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $tenant->codigo,
]);
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace Tests\Feature\Forms;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdminAppFoodFormControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(AuthorizationSeeder::class);
WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']);
}
public function test_authentication_is_required(): void
{
$this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/food')
->assertUnauthorized();
}
public function test_it_returns_event_dates_schedules_and_services_for_the_tenant(): void
{
$tenant = Tenant::query()->create([
'codigo' => 'fiesta',
'nombre' => 'Fiesta',
'dominio' => 'fiesta.test',
'website_type_code' => 'onticket',
]);
$eventDate = $tenant->eventDates()->create([
'date' => '2026-10-09',
'time_start' => '00:00',
'time_end' => '23:59',
]);
$this->createAttribute($tenant, 'horario', [
['value' => 'Cena', 'label' => 'Cena', 'sort_order' => 2],
['value' => 'Almuerzo', 'label' => 'Almuerzo', 'sort_order' => 1],
]);
$this->createAttribute($tenant, 'servicio', [
['value' => 'Comedor', 'label' => 'Comedor', 'sort_order' => 1],
]);
Sanctum::actingAs(User::factory()->create([
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $tenant->codigo,
]));
$this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/food')
->assertOk()
->assertJsonCount(1, 'data.event_dates')
->assertJsonPath('data.event_dates.0.date', '2026-10-09')
->assertJsonPath('data.event_dates.0.validity_time_id', $eventDate->validity_time_id)
->assertJsonPath('data.event_dates.0.validity_time.type', 'fixed_window')
->assertJsonPath('data.schedules.0.value', 'Almuerzo')
->assertJsonPath('data.schedules.1.value', 'Cena')
->assertJsonPath('data.services.0.value', 'Comedor');
}
/** @param array<int, array{value: string, label: string, sort_order: int}> $options */
private function createAttribute(Tenant $tenant, string $code, array $options): void
{
$attribute = Attribute::query()->create([
'tenant_codigo' => $tenant->codigo,
'codigo' => $code,
'nombre' => ucfirst($code),
'type' => FieldType::Select,
'is_required' => true,
]);
$attribute->options()->createMany($options);
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace Tests\Feature\Forms;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdminAppMerchandiseFormControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(AuthorizationSeeder::class);
WebsiteType::query()->create([
'codigo' => 'onticket',
'nombre' => 'OnTicket',
]);
}
public function test_authentication_is_required(): void
{
$this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/merchandise')
->assertUnauthorized();
}
public function test_it_returns_color_and_size_options_for_the_authenticated_tenant(): void
{
$tenant = $this->createTenant('fiesta');
$otherTenant = $this->createTenant('other');
$this->createAttribute($tenant, 'color', [
['value' => 'verde', 'label' => 'Verde', 'sort_order' => 2],
['value' => 'blanco', 'label' => 'Blanco', 'sort_order' => 1],
]);
$this->createAttribute($tenant, 'talle', [
['value' => 'S', 'label' => 'S', 'sort_order' => 1],
['value' => 'M', 'label' => 'M', 'sort_order' => 2],
]);
$this->createAttribute($otherTenant, 'color', [
['value' => 'negro', 'label' => 'Negro', 'sort_order' => 1],
]);
Sanctum::actingAs(User::factory()->create([
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $tenant->codigo,
]));
$this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/merchandise')
->assertOk()
->assertJsonCount(2, 'data.colors')
->assertJsonCount(2, 'data.sizes')
->assertJsonPath('data.colors.0.value', 'blanco')
->assertJsonPath('data.colors.0.label', 'Blanco')
->assertJsonPath('data.colors.1.value', 'verde')
->assertJsonPath('data.sizes.0.value', 'S')
->assertJsonPath('data.sizes.1.value', 'M')
->assertJsonMissing(['value' => 'negro']);
}
public function test_a_customer_cannot_get_the_merchandise_form(): void
{
Sanctum::actingAs(User::factory()->create([
'rol_codigo' => RoleCode::User->value,
]));
$this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/merchandise')
->assertForbidden();
}
private function createTenant(string $code): Tenant
{
return Tenant::query()->create([
'codigo' => $code,
'nombre' => ucfirst($code),
'dominio' => "{$code}.test",
'website_type_code' => 'onticket',
]);
}
/** @param array<int, array{value: string, label: string, sort_order: int}> $options */
private function createAttribute(Tenant $tenant, string $code, array $options): void
{
$attribute = Attribute::query()->create([
'tenant_codigo' => $tenant->codigo,
'codigo' => $code,
'nombre' => ucfirst($code),
'type' => FieldType::Select,
'is_required' => true,
]);
$attribute->options()->createMany($options);
}
}

View File

@@ -259,7 +259,7 @@ class TelepagosWebhookTest extends TestCase
]);
}
public function test_webhook_confirms_a_purchase_when_its_ticket_use_date_has_ended(): void
public function test_webhook_confirms_a_purchase_with_tickets_enabled(): void
{
$tenant = $this->createTenant('expired-ticket', 'Expired Ticket', 'expired-ticket.com.ar');
$this->configureTelepagosIntegration($tenant);
@@ -267,7 +267,6 @@ class TelepagosWebhookTest extends TestCase
$variant = $this->createVariantForTenant('expired-ticket', 1, '50.00');
$variant->catalogItem->update([
'has_tickets' => true,
'maximum_use_date' => now()->subMinute(),
]);
$purchase = $this->createPendingTransferPurchase(
$tenant,

View File

@@ -11,7 +11,6 @@ use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\Event;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant;
@@ -34,12 +33,10 @@ class StorePurchaseTest extends TestCase
public function test_it_creates_an_independent_purchase_snapshot_from_cart(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$event = Event::query()->create([
'tenant_code' => $tenant->codigo,
'name' => 'Sonder Fest',
'address' => 'Test address',
$tenant->update([
'event_title' => 'Sonder Fest',
'event_location' => 'Test address',
]);
$tenant->update(['active_event_id' => $event->id]);
$user = User::factory()->create([
'email' => 'buyer@example.com',
]);
@@ -92,7 +89,6 @@ class StorePurchaseTest extends TestCase
$response->assertJsonPath('data.nombre_apellido', null);
$response->assertJsonPath('data.email', null);
$response->assertJsonPath('data.tenant_codigo', 'sonder');
$response->assertJsonPath('data.event_id', $event->id);
$response->assertJsonPath('data.status', Purchase::STATUS_CREATED);
$response->assertJsonPath('data.items_source', 'purchase');
$response->assertJsonCount(1, 'data.items');
@@ -105,7 +101,6 @@ class StorePurchaseTest extends TestCase
'id' => $purchaseId,
'cart_id' => $cartId,
'tenant_codigo' => 'sonder',
'event_id' => $event->id,
'user_id' => $user->id,
'dni' => null,
'telefono' => null,
@@ -190,6 +185,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 +469,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');

View File

@@ -0,0 +1,265 @@
<?php
namespace Tests\Feature\Sale;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Cart\Models\Cart;
use App\Domains\Purchase\Events\PurchasePaid;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Ticket\Models\Ticket;
use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdminAppSaleControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(AuthorizationSeeder::class);
WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']);
}
public function test_authentication_is_required_to_read_a_sale_detail(): void
{
$this->getJson('/api/v1/adminapp/tenant/sales/1')->assertUnauthorized();
}
public function test_an_adminapp_user_can_read_a_sale_detail_from_its_tenant(): void
{
$tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$purchase = Purchase::query()->create([
'tenant_codigo' => $tenant->codigo,
'status' => Purchase::STATUS_PAID,
'total' => '40000.00',
'nombre_apellido' => 'Ada Lovelace',
]);
PurchaseItem::query()->create([
'compra_id' => $purchase->id,
'source_catalog_item_id' => 10,
'nombre' => 'Comida',
'item_nombre' => 'Comida',
'variant_attributes' => [
['name' => 'Fecha', 'value' => ['2026-10-12']],
['name' => 'Servicio', 'value' => 'Almuerzo'],
],
'cantidad' => 2,
'precio_unitario' => '10000.00',
'total' => '20000.00',
]);
$this->getJson("/api/v1/adminapp/tenant/sales/{$purchase->id}")
->assertOk()
->assertJsonPath('data.id', $purchase->id)
->assertJsonPath('data.items.0.product', 'Comida')
->assertJsonPath('data.items.0.event_dates.0', '2026-10-12')
->assertJsonPath('data.items.0.quantity', 2)
->assertJsonPath('data.items.0.unit_price', '10000.00')
->assertJsonPath('data.items.0.total', '20000.00')
->assertJsonPath('data.total', '40000.00');
}
public function test_an_adminapp_user_cannot_read_a_sale_from_another_tenant(): void
{
$tenant = $this->createTenant('acme');
$otherTenant = $this->createTenant('other');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$foreignPurchase = Purchase::query()->create([
'tenant_codigo' => $otherTenant->codigo,
'status' => Purchase::STATUS_PAID,
'total' => '10000.00',
]);
$this->getJson("/api/v1/adminapp/tenant/sales/{$foreignPurchase->id}")
->assertNotFound();
}
public function test_an_adminapp_user_can_read_all_tickets_from_a_sale_in_its_tenant(): void
{
$tenant = $this->createTenant('acme');
$admin = $this->createAdminAppUser($tenant);
Sanctum::actingAs($admin);
$purchase = Purchase::query()->create([
'tenant_codigo' => $tenant->codigo,
'status' => Purchase::STATUS_PAID,
'total' => '20000.00',
]);
$firstTicket = Ticket::query()->create([
'tenant_code' => $tenant->codigo,
'ticket' => '11111111-1111-4111-8111-111111111111',
'name' => 'Abono general',
'description' => 'Acceso general',
'source_purchase_id' => $purchase->id,
'user_id' => $admin->id,
]);
$usedTicket = Ticket::query()->create([
'tenant_code' => $tenant->codigo,
'ticket' => '22222222-2222-4222-8222-222222222222',
'name' => 'Abono general',
'description' => 'Acceso general',
'source_purchase_id' => $purchase->id,
'used_at' => now()->subMinute(),
'user_id' => $admin->id,
]);
$this->getJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/tickets")
->assertOk()
->assertExactJson([
'data' => [
[
'product' => 'Abono general',
'id' => $firstTicket->id,
'expires_at' => null,
'status' => Ticket::STATUS_ACTIVE,
],
[
'product' => 'Abono general',
'id' => $usedTicket->id,
'expires_at' => null,
'status' => Ticket::STATUS_USED,
],
],
]);
}
public function test_an_adminapp_user_cannot_read_tickets_from_another_tenant_sale(): void
{
$tenant = $this->createTenant('acme');
$otherTenant = $this->createTenant('other');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$foreignPurchase = Purchase::query()->create([
'tenant_codigo' => $otherTenant->codigo,
'status' => Purchase::STATUS_PAID,
'total' => '10000.00',
]);
$this->getJson("/api/v1/adminapp/tenant/sales/{$foreignPurchase->id}/tickets")
->assertNotFound();
}
public function test_an_adminapp_user_can_confirm_a_pending_sale(): void
{
Event::fake([PurchasePaid::class]);
$tenant = $this->createTenant('acme');
$admin = $this->createAdminAppUser($tenant);
Sanctum::actingAs($admin);
$purchase = Purchase::query()->create([
'tenant_codigo' => $tenant->codigo,
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => '10000.00',
]);
$this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/confirm")
->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_PAID);
$this->assertSame(Purchase::STATUS_PAID, $purchase->fresh()->status);
$this->assertDatabaseHas('value_changes', [
'trackable_id' => $purchase->id,
'attribute' => 'status',
'old_value' => Purchase::STATUS_PENDING_PAYMENT,
'new_value' => Purchase::STATUS_PAID,
'user_id' => $admin->id,
]);
Event::assertDispatchedTimes(PurchasePaid::class, 1);
$this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/confirm")
->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_PAID);
Event::assertDispatchedTimes(PurchasePaid::class, 1);
}
public function test_adminapp_cancellation_does_not_restore_the_source_cart(): void
{
$tenant = $this->createTenant('acme');
$admin = $this->createAdminAppUser($tenant);
Sanctum::actingAs($admin);
$cart = Cart::query()->create([
'tenant_codigo' => $tenant->codigo,
'user_id' => $admin->id,
'status' => 'converted',
]);
$cart->delete();
$purchase = Purchase::query()->create([
'cart_id' => $cart->id,
'tenant_codigo' => $tenant->codigo,
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => '10000.00',
]);
$this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/cancel")
->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_CANCELLED);
$this->assertSame(Purchase::STATUS_CANCELLED, $purchase->fresh()->status);
$this->assertSoftDeleted('carritos', ['id' => $cart->id]);
$this->assertSame('converted', Cart::withTrashed()->findOrFail($cart->id)->status);
}
public function test_an_adminapp_user_cannot_change_a_sale_from_another_tenant(): void
{
$tenant = $this->createTenant('acme');
$otherTenant = $this->createTenant('other');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$foreignPurchase = Purchase::query()->create([
'tenant_codigo' => $otherTenant->codigo,
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => '10000.00',
]);
$this->postJson("/api/v1/adminapp/tenant/sales/{$foreignPurchase->id}/confirm")
->assertNotFound();
$this->postJson("/api/v1/adminapp/tenant/sales/{$foreignPurchase->id}/cancel")
->assertNotFound();
}
public function test_a_paid_sale_cannot_be_cancelled_from_adminapp(): void
{
$tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$purchase = Purchase::query()->create([
'tenant_codigo' => $tenant->codigo,
'status' => Purchase::STATUS_PAID,
'total' => '10000.00',
]);
$this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/cancel")
->assertUnprocessable()
->assertJsonValidationErrors('purchase');
}
private function createTenant(string $code): Tenant
{
return Tenant::query()->create([
'codigo' => $code,
'nombre' => ucfirst($code),
'dominio' => "{$code}.test",
'website_type_code' => 'onticket',
]);
}
private function createAdminAppUser(Tenant $tenant): User
{
return User::factory()->create([
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $tenant->codigo,
]);
}
}

View File

@@ -4,8 +4,6 @@ namespace Tests\Feature\Seeders;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
@@ -13,10 +11,8 @@ use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
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\TicketGenerationPolicy;
use Database\Seeders\AttributeSeeder;
use Database\Seeders\FiestaFutbolInfantilProductSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -26,21 +22,10 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
{
use RefreshDatabase;
public function test_it_seeds_event_items_for_the_new_catalog(): void
public function test_it_seeds_the_configurable_catalog_with_its_categories(): void
{
$headerLogo = Attachment::query()->create([
'path' => 'tests/header.png',
'filename' => 'header.png',
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
$footerLogo = Attachment::query()->create([
'path' => 'tests/footer.png',
'filename' => 'footer.png',
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
$headerLogo = $this->attachment('header');
$footerLogo = $this->attachment('footer');
$tenant = Tenant::query()->create([
'codigo' => 'fiesta_futbol_infantil',
'nombre' => 'Fiesta Fútbol Infantil',
@@ -55,127 +40,17 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
'footer_logo_id' => $footerLogo->id,
]);
$this->seed([
AttributeSeeder::class,
FiestaFutbolInfantilProductSeeder::class,
]);
$this->seed([
AttributeSeeder::class,
FiestaFutbolInfantilProductSeeder::class,
]);
$this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]);
$this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]);
$this->assertFalse(Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->exists());
$this->assertSame('9, 10, 11 y 12 de Octubre 2026', $tenant->fresh()->event_date_text);
$event = Event::query()
->where('tenant_code', $tenant->codigo)
->with('dates')
->sole();
$this->assertSame($event->id, $tenant->fresh()->active_event_id);
$this->assertCount(4, $event->dates);
$this->assertSame(1, Event::query()->where('tenant_code', $tenant->codigo)->count());
$this->assertSame(4, EventDate::query()->where('event_id', $event->id)->count());
$generalAdmission = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('slug', 'entrada-general')
->with('variants.eventDate', 'itemAttributes')
->sole();
$this->assertNull($generalAdmission->inventory_id);
$this->assertTrue($generalAdmission->event->is($event));
$this->assertSame(EventProductType::Entry, $generalAdmission->event_product_type);
$this->assertCount(0, $generalAdmission->itemAttributes);
$this->assertCount(4, $generalAdmission->variants);
$this->assertEqualsCanonicalizing(
['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'],
$generalAdmission->variants
->map(fn ($variant) => $variant->eventDate->date->format('Y-m-d'))
->all()
$this->assertSame(
['color', 'event_date', 'horario', 'servicio', 'talle', 'tipo_alojamiento'],
Attribute::query()->where('tenant_codigo', $tenant->codigo)->orderBy('codigo')->pluck('codigo')->all(),
);
$this->assertSame(
[
['2026-10-09 00:00:00', '2026-10-09 23:59:59'],
['2026-10-10 00:00:00', '2026-10-10 23:59:59'],
['2026-10-11 00:00:00', '2026-10-11 23:59:59'],
['2026-10-12 00:00:00', '2026-10-12 23:59:59'],
],
$generalAdmission->variants
->sortBy(fn ($variant) => $variant->eventDate->date)
->map(fn ($variant): array => [
$variant->getMinimumUseDate()->format('Y-m-d H:i:s'),
$variant->getMaximumUseDate()->format('Y-m-d H:i:s'),
])
->values()
->all()
);
$standardItems = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('type', CatalogItemType::Standard->value)
->get();
$this->assertCount(7, $standardItems);
foreach ($standardItems as $standardItem) {
$this->assertSame($event->id, $standardItem->event_id);
$this->assertSame(
'2026-10-09 00:00:00',
$standardItem->minimum_use_date->format('Y-m-d H:i:s'),
);
$this->assertSame(
'2026-10-12 23:59:59',
$standardItem->maximum_use_date->format('Y-m-d H:i:s'),
);
}
$allDaysItem = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('nombre', 'Entrada General - Todos los días')
->with('bundleComponents.variant.eventDate')
->sole();
$this->assertSame(CatalogItemType::Bundle, $allDaysItem->type);
$this->assertSame('40000.00', $allDaysItem->precio);
$this->assertNull($allDaysItem->inventory_id);
$this->assertFalse($allDaysItem->has_tickets);
$this->assertSame($event->id, $allDaysItem->event_id);
$this->assertSame(EventProductType::Entry, $allDaysItem->event_product_type);
$this->assertCount(4, $allDaysItem->bundleComponents);
$this->assertEqualsCanonicalizing(
['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'],
$allDaysItem->bundleComponents
->map(fn ($component) => $component->variant->eventDate->date->format('Y-m-d'))
->all()
);
$foodCombo = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('nombre', 'Combo 2 Panchos + 2 Hamburguesas')
->with('bundleComponents.catalogItem')
->sole();
$this->assertSame(CatalogItemType::Bundle, $foodCombo->type);
$this->assertSame('24000.00', $foodCombo->precio);
$this->assertNull($foodCombo->inventory_id);
$this->assertSame(EventProductType::Product, $foodCombo->event_product_type);
$this->assertSame(
[
'hamburguesa-papa-frita' => 2,
'pancho' => 2,
],
$foodCombo->bundleComponents
->mapWithKeys(fn ($component): array => [
$component->catalogItem->slug => $component->quantity,
])
->sortKeys()
->all()
);
$this->assertSame(9, CatalogItem::query()->where('tenant_code', $tenant->codigo)->count());
$this->assertSame(10, Inventory::query()->count());
$this->assertSame(
['Bebidas', 'Comidas', 'Entradas', 'Estacionamiento', 'Gastronomía'],
['Alojamientos', 'Comidas', 'Entradas', 'Merchandising'],
Category::query()
->where('tenant_code', $tenant->codigo)
->orderBy('nombre')
@@ -183,53 +58,100 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
->all(),
);
$this->assertSame(
'Estacionamiento',
['abono', 'alojamiento', 'camiseta', 'comida'],
CatalogItem::query()->where('tenant_code', $tenant->codigo)->orderBy('slug')->pluck('slug')->all(),
);
$this->assertTrue(
CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('slug', 'estacionamiento-auto')
->with('category')
->sole()
->category
->nombre,
->get()
->every(fn (CatalogItem $item): bool => $item->has_tickets),
);
$this->assertTrue(
CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->get()
->every(fn (CatalogItem $item): bool => $item->ticket_generation_policy === TicketGenerationPolicy::OnePerUnit),
);
$featuredGroups = FeaturedGroup::query()
$expectedVariantCounts = [
'camiseta' => 12,
'alojamiento' => 2,
'comida' => 24,
'abono' => 1,
];
foreach ($expectedVariantCounts as $slug => $count) {
$item = CatalogItem::query()->where('tenant_code', $tenant->codigo)->where('slug', $slug)->sole();
$this->assertCount($count, $item->variants);
}
$variantStocks = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->with('category.catalogItems')
->orderBy('group_order')
->get();
->with('variants.inventory')
->get()
->flatMap(fn (CatalogItem $item) => $item->variants)
->map(fn ($variant): int => $variant->inventory->real_stock);
$this->assertGreaterThan($variantStocks->count() / 2, $variantStocks->filter(fn (int $stock): bool => $stock > 5)->count());
$this->assertNotEmpty($variantStocks->filter(fn (int $stock): bool => $stock === 0));
$this->assertNotEmpty($variantStocks->filter(fn (int $stock): bool => $stock >= 1 && $stock <= 5));
$this->assertSame(
[
'Entradas' => ['entrada-general', 'entrada-general-todos-los-dias'],
'Estacionamiento' => ['estacionamiento-auto', 'estacionamiento-moto'],
'Comidas' => ['hamburguesa-papa-frita', 'pancho', 'combo-2-panchos-2-hamburguesas'],
'Bebidas' => ['coca-cola-500ml', 'agua-mineral-1l'],
'abono' => 'Entradas',
'alojamiento' => 'Alojamientos',
'camiseta' => 'Merchandising',
'comida' => 'Comidas',
],
$featuredGroups
->mapWithKeys(fn (FeaturedGroup $group): array => [
$group->group_name => $group->category->catalogItems->pluck('slug')->all(),
])
->all()
CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->with('category')
->orderBy('slug')
->get()
->mapWithKeys(fn (CatalogItem $item): array => [$item->slug => $item->category?->nombre])
->all(),
);
$this->assertSame(
[
['Entradas', FeaturedGroupSource::Category, 'Entradas', ProductLayout::Row, GroupLayout::SimpleVertical, 0],
['Estacionamiento', FeaturedGroupSource::Category, 'Estacionamiento', ProductLayout::ColumnWithCart, GroupLayout::Simple, 1],
['Comidas', FeaturedGroupSource::Category, 'Comidas', ProductLayout::ColumnWithCart, GroupLayout::Simple, 2],
['Bebidas', FeaturedGroupSource::Category, 'Bebidas', ProductLayout::ColumnWithCart, GroupLayout::Simple, 3],
],
$featuredGroups
->map(fn (FeaturedGroup $group): array => [
$group->group_name,
$group->source_type,
$group->category->nombre,
$group->product_layout,
$group->group_layout,
$group->group_order,
])
->all()
$abono = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('slug', 'abono')
->with('itemAttributes.attribute', 'variants.eventDates')
->sole();
$dateAttribute = $abono->itemAttributes->firstWhere('attribute.codigo', 'event_date');
$this->assertTrue($dateAttribute->allow_multi_select);
$this->assertEqualsCanonicalizing(
[4],
$abono->variants->map(fn ($variant): int => $variant->eventDates->count())->all(),
);
$food = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('slug', 'comida')
->with('variants')
->sole();
$this->assertSame('4000.00', $food->precio);
$this->assertCount(24, $food->variants->pluck('descripcion')->unique());
$this->assertEqualsCanonicalizing(
['4000.00', '8000.00', '10000.00'],
$food->variants->pluck('precio')->unique()->values()->all(),
);
$this->assertTrue($food->variants->every(
fn ($variant): bool => $variant->descripcion !== null && $variant->precio !== null
));
$featuredGroup = FeaturedGroup::query()->where('tenant_code', $tenant->codigo)->sole();
$this->assertSame(FeaturedGroupSource::All, $featuredGroup->source_type);
$this->assertSame(ProductLayout::Row, $featuredGroup->product_layout);
$this->assertSame(GroupLayout::SimpleVertical, $featuredGroup->group_layout);
$this->assertNull($featuredGroup->category_id);
}
private function attachment(string $name): Attachment
{
return Attachment::query()->create([
'path' => "tests/{$name}.png",
'filename' => "{$name}.png",
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
}
}

View File

@@ -20,10 +20,12 @@ class MenuSeederTest extends TestCase
public function test_it_seeds_the_admin_menus(): void
{
$tenant = $this->createTenant('admin_tenant');
$fiestaTenant = $this->createTenant('fiesta_futbol_infantil');
$this->seed(MenuSeeder::class);
$expectedMenus = [
'adminapp.inicio' => ['Inicio', '/admin/inicio'],
'adminapp.catalog' => ['Catálogo', '/admin/catalog'],
'adminapp.categories' => ['Categorías', '/admin/categories'],
'adminapp.combos' => ['Combos', '/admin/combos'],
@@ -31,6 +33,12 @@ class MenuSeederTest extends TestCase
'adminapp.staff' => ['Staff', '/admin/staff'],
'adminapp.ventas' => ['Ventas', '/admin/ventas'],
];
$fiestaCategoryMenus = [
'adminapp.fiesta-futbol-infantil.entradas' => ['Entradas', '/admin/entradas'],
'adminapp.fiesta-futbol-infantil.alojamientos' => ['Alojamientos', '/admin/alojamientos'],
'adminapp.fiesta-futbol-infantil.merchandising' => ['Merchandising', '/admin/merchandising'],
'adminapp.fiesta-futbol-infantil.comida' => ['Comida', '/admin/comidas'],
];
$adminApp = Menu::query()
->with('children')
@@ -41,7 +49,7 @@ class MenuSeederTest extends TestCase
$this->assertSame(Menu::CONTENT_TYPE_DYNAMIC, $adminApp->content_type);
$this->assertSame('/', $adminApp->route);
$this->assertSame(
array_keys($expectedMenus),
array_keys([...$expectedMenus, ...$fiestaCategoryMenus]),
$adminApp->children->pluck('code')->sort()->values()->all()
);
$this->assertTrue(
@@ -64,6 +72,27 @@ class MenuSeederTest extends TestCase
);
}
foreach ($fiestaCategoryMenus as $code => [$label, $route]) {
$menu = Menu::query()->where('code', $code)->firstOrFail();
$this->assertSame($label, $menu->label);
$this->assertSame($route, $menu->route);
$this->assertSame('main.adminapp', $menu->parent_menu_code);
$this->assertFalse($tenant->menues()->where('menues.code', $code)->exists());
$this->assertTrue($fiestaTenant->menues()->where('menues.code', $code)->exists());
}
foreach ([
'adminapp.inicio',
'adminapp.catalog',
'adminapp.categories',
'adminapp.combos',
] as $code) {
$this->assertFalse(
$fiestaTenant->menues()->where('menues.code', $code)->exists()
);
}
$this->assertFalse(
Menu::query()->whereIn('code', [
'admin.event',

View File

@@ -52,6 +52,9 @@ class BootstrapTenantControllerTest extends TestCase
'footer_bg_color' => '#ffffff',
'header_logo_id' => $headerAttachment->id,
'footer_logo_id' => $footerAttachment->id,
'event_date_text' => '9, 10, 11 y 12 de Octubre 2026',
'display_categories' => false,
'display_seach_bar' => false,
]);
$response = $this->getJson('/api/tenants/bootstrap/acme.com');
@@ -64,6 +67,9 @@ class BootstrapTenantControllerTest extends TestCase
->assertJsonPath('data.secondary_color', '#00ff00')
->assertJsonPath('data.danger_color', '#0000ff')
->assertJsonPath('data.success_color', '#00ff00')
->assertJsonPath('data.event_date_text', '9, 10, 11 y 12 de Octubre 2026')
->assertJsonPath('data.display_categories', false)
->assertJsonPath('data.display_seach_bar', false)
->assertJsonPath('data.header_bg_color', '#ffffff')->assertJsonPath('data.footer_bg_color', '#ffffff');
$headerUrl = $response->json('data.header_logo');
@@ -242,10 +248,10 @@ class BootstrapTenantControllerTest extends TestCase
->assertJsonPath('data.menues.0.code', 'help')
->assertJsonPath('data.menues.0.label', 'Ayuda')
->assertJsonPath('data.menues.0.parent_menu_code', null)
->assertJsonPath('data.menues.0.submenues.0.code', 'help.faq')
->assertJsonPath('data.menues.0.submenues.0.label', 'Preguntas frecuentes')
->assertJsonPath('data.menues.0.submenues.0.code', 'help.shipping')
->assertJsonPath('data.menues.0.submenues.0.label', 'Envíos')
->assertJsonPath('data.menues.0.submenues.0.parent_menu_code', 'help')
->assertJsonPath('data.menues.0.submenues.1.code', 'help.shipping');
->assertJsonPath('data.menues.0.submenues.1.code', 'help.faq');
$assertCleanMenu = function (array $menu) use (&$assertCleanMenu): void {
$this->assertArrayNotHasKey('created_at', $menu);
@@ -259,10 +265,10 @@ class BootstrapTenantControllerTest extends TestCase
};
$menus = $response->json('data.menues');
$this->assertEquals($staticContent, $menus[0]['submenues'][0]['static_content']);
$this->assertEquals($staticContent, $menus[0]['submenues'][1]['static_content']);
$assertCleanMenu($menus[0]);
$this->assertArrayNotHasKey('static_content', $menus[0]);
$this->assertArrayNotHasKey('static_content', $menus[0]['submenues'][1]);
$this->assertArrayNotHasKey('static_content', $menus[0]['submenues'][0]);
}
public function test_it_returns_only_menus_assigned_to_the_user_role(): void
@@ -297,6 +303,49 @@ class BootstrapTenantControllerTest extends TestCase
->assertJsonMissing(['code' => 'admin.catalog']);
}
public function test_it_orders_parent_menus_and_their_submenus_by_label(): void
{
$tenant = $this->createTenant();
$userRole = Role::query()->create([
'codigo' => RoleCode::User->value,
'nombre' => 'Usuario',
]);
$zeta = Menu::query()->create([
'code' => 'zeta',
'label' => 'Zeta',
'route' => '/zeta',
]);
$tree = Menu::query()->create([
'code' => 'tree',
'label' => 'Árbol',
'route' => '/arbol',
]);
$fox = Menu::query()->create([
'code' => 'tree.fox',
'label' => 'Zorro',
'parent_menu_code' => $tree->code,
'route' => '/arbol/zorro',
]);
$eagle = Menu::query()->create([
'code' => 'tree.eagle',
'label' => 'Águila',
'parent_menu_code' => $tree->code,
'route' => '/arbol/aguila',
]);
$menuCodes = [$zeta->code, $tree->code, $fox->code, $eagle->code];
$tenant->menues()->sync($menuCodes);
$userRole->menus()->sync($menuCodes);
$this->getJson('/api/tenants/bootstrap/acme.com')
->assertOk()
->assertJsonPath('data.menues.0.code', 'tree')
->assertJsonPath('data.menues.1.code', 'zeta')
->assertJsonPath('data.menues.0.submenues.0.code', 'tree.eagle')
->assertJsonPath('data.menues.0.submenues.1.code', 'tree.fox');
}
public function test_it_rejects_duplicate_domains_after_normalization_when_storing(): void
{
$base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';

View File

@@ -6,7 +6,9 @@ use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
@@ -21,6 +23,22 @@ class TicketControllerTest extends TestCase
$user = User::factory()->create();
$olderTicket = $this->createTicket($tenant, $user, 'Older ticket');
$newerTicket = $this->createTicket($tenant, $user, 'Newer ticket');
$validityTimes = collect([
ValidityTime::query()->create([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => now()->subHour(),
'fixed_expires_at' => now()->addHour(),
]),
ValidityTime::query()->create([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => now()->addDay(),
'fixed_expires_at' => now()->addDays(2),
]),
]);
$validityTimes->each(function (ValidityTime $validityTime) use ($newerTicket): void {
$group = $newerTicket->validityGroups()->create();
$group->validityTimes()->attach($validityTime);
});
$this->actingAs($user, 'sanctum')
->getJson("/api/tenants/{$tenant->codigo}/tickets")
@@ -31,6 +49,10 @@ class TicketControllerTest extends TestCase
->assertJsonPath('data.0.is_valid', true)
->assertJsonPath('data.0.is_expired', false)
->assertJsonPath('data.0.is_used', false)
->assertJsonCount(2, 'data.0.validity_times')
->assertJsonCount(2, 'data.0.validity_groups')
->assertJsonCount(1, 'data.0.validity_groups.0.validity_times')
->assertJsonMissingPath('data.0.validity_time')
->assertJsonPath('data.1.id', $olderTicket->id)
->assertJsonMissingPath('meta')
->assertJsonMissingPath('links');

View File

@@ -6,12 +6,18 @@ use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Event\Models\EventDate;
use App\Domains\Notification\Events\TicketsAvailable;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Exceptions\TicketGenerationException;
use App\Domains\Ticket\Models\ValidityTime;
use App\Domains\Ticket\Services\TicketGeneratorService;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -51,11 +57,7 @@ class TicketGeneratorServiceTest extends TestCase
public function test_it_generates_tickets_from_a_standard_catalog_item(): void
{
$item = $this->createTicketableItem(
'single-day',
now()->subHour(),
now()->addDay(),
);
$item = $this->createTicketableItem('single-day');
$tickets = $this->service->generate($item, $this->user, 2);
@@ -68,8 +70,7 @@ class TicketGeneratorServiceTest extends TestCase
$this->assertSame($item->descripcion, $ticket->description);
$this->assertSame($item->id, $ticket->source_catalog_item_id);
$this->assertNull($ticket->source_variant_id);
$this->assertTrue($ticket->starts_at->equalTo($item->minimum_use_date));
$this->assertTrue($ticket->expires_at->equalTo($item->maximum_use_date));
$this->assertTrue($ticket->validityGroups->isEmpty());
}
}
@@ -84,41 +85,56 @@ class TicketGeneratorServiceTest extends TestCase
$this->service->generate($item->fresh(), $this->user);
}
public function test_it_generates_a_ticket_when_its_maximum_use_date_was_reached(): void
public function test_variant_properties_are_appended_to_the_ticket_name(): void
{
$item = $this->createTicketableItem('expired', maximumUseDate: now());
$tickets = $this->service->generate($item, $this->user);
$this->assertCount(1, $tickets);
$this->assertTrue($tickets->first()->expires_at->equalTo($item->maximum_use_date));
}
public function test_variant_dates_override_and_inherit_catalog_item_dates(): void
{
$item = $this->createTicketableItem(
'variant-dates',
now()->subDay(),
now()->addMonth(),
);
$inventory = Inventory::query()->create();
$item = $this->createTicketableItem('shirt');
$color = Attribute::query()->create([
'tenant_codigo' => $this->tenant->codigo,
'codigo' => 'color',
'nombre' => 'Color',
'type' => FieldType::Select,
]);
$color->options()->create([
'value' => 'black',
'label' => 'Negro',
]);
$size = Attribute::query()->create([
'tenant_codigo' => $this->tenant->codigo,
'codigo' => 'size',
'nombre' => 'Talle',
'type' => FieldType::Select,
]);
$size->options()->create([
'value' => 'xl',
'label' => 'XL',
]);
$itemColor = $item->itemAttributes()->create([
'attribute_id' => $color->id,
'sort_order' => 1,
]);
$itemSize = $item->itemAttributes()->create([
'attribute_id' => $size->id,
'sort_order' => 2,
]);
$variant = $item->variants()->create([
'inventory_id' => $inventory->id,
'maximum_use_date' => now()->addWeek(),
'inventory_id' => Inventory::query()->create()->id,
]);
$variant->definitions()->createMany([
['item_attribute_id' => $itemColor->id, 'value' => 'black'],
['item_attribute_id' => $itemSize->id, 'value' => 'xl'],
]);
$ticket = $this->service
->generate($item, $this->user, sourceVariantId: $variant->id)
->firstOrFail();
->generate($item, $this->user, 1, $variant->id)
->sole();
$this->assertTrue($ticket->starts_at->equalTo($item->minimum_use_date));
$this->assertTrue($ticket->expires_at->equalTo($variant->maximum_use_date));
$this->assertSame('Shirt (Negro, XL)', $ticket->name);
}
public function test_it_generates_tickets_for_every_bundle_component_and_quantity(): void
{
$first = $this->createTicketableItem('first', maximumUseDate: now()->addDay());
$second = $this->createTicketableItem('second', maximumUseDate: now()->addDays(2));
$first = $this->createTicketableItem('first');
$second = $this->createTicketableItem('second');
$bundle = $this->createBundle('bundle');
$bundle->bundleComponents()->createMany([
['component_catalog_item_id' => $first->id, 'quantity' => 2],
@@ -134,18 +150,12 @@ class TicketGeneratorServiceTest extends TestCase
$this->assertCount(2, $tickets->where('name', $second->nombre));
}
public function test_bundle_component_uses_its_variant_dates(): void
public function test_bundle_component_preserves_its_source_variant(): void
{
$component = $this->createTicketableItem(
'variant-component',
now()->subDay(),
now()->addMonth(),
);
$component = $this->createTicketableItem('variant-component');
$inventory = Inventory::query()->create();
$variant = $component->variants()->create([
'inventory_id' => $inventory->id,
'minimum_use_date' => now()->addDay(),
'maximum_use_date' => now()->addWeek(),
]);
$bundle = $this->createBundle('variant-bundle');
$bundle->bundleComponents()->create([
@@ -160,8 +170,6 @@ class TicketGeneratorServiceTest extends TestCase
$this->assertSame($component->id, $ticket->source_catalog_item_id);
$this->assertSame($variant->id, $ticket->source_variant_id);
$this->assertTrue($ticket->starts_at->equalTo($variant->minimum_use_date));
$this->assertTrue($ticket->expires_at->equalTo($variant->maximum_use_date));
}
public function test_bundle_generation_is_rolled_back_when_a_component_is_invalid(): void
@@ -224,6 +232,208 @@ class TicketGeneratorServiceTest extends TestCase
]);
}
public function test_event_date_and_time_window_are_combined_in_the_same_and_group(): void
{
$item = $this->createTicketableItem('scheduled-meal');
$eventDate = EventDate::query()->create([
'tenant_code' => $this->tenant->codigo,
'date' => '2026-08-20',
'time_start' => '09:00:00',
'time_end' => '23:59:59',
]);
$timeWindow = ValidityTime::query()->create([
'type' => ValidityTimeType::TimeWindow,
'start_time' => '22:00:00',
'end_time' => '02:00:00',
]);
$schedule = Attribute::query()->create([
'tenant_codigo' => $this->tenant->codigo,
'codigo' => 'schedule',
'nombre' => 'Schedule',
'type' => FieldType::Select,
]);
$schedule->options()->create([
'value' => 'night',
'label' => '22:00 - 02:00',
'validity_time_id' => $timeWindow->id,
]);
$itemSchedule = $item->itemAttributes()->create([
'attribute_id' => $schedule->id,
]);
$inventory = Inventory::query()->create();
$variant = $item->variants()->create([
'event_date_id' => $eventDate->id,
'inventory_id' => $inventory->id,
]);
$variant->definitions()->create([
'item_attribute_id' => $itemSchedule->id,
'value' => 'night',
]);
$tickets = $this->service->generate($item, $this->user, 2, $variant->id);
$this->assertCount(2, $tickets);
$this->assertTrue($tickets->every(fn ($ticket): bool => $ticket->validityGroups->count() === 1));
$this->assertTrue($tickets->every(
fn ($ticket): bool => $ticket->validityGroups->sole()->validityTimes
->pluck('id')
->sort()
->values()
->all() === collect([$eventDate->validity_time_id, $timeWindow->id])->sort()->values()->all()
));
$this->assertSame(
'2026-08-20 22:00:00',
$tickets->first()->getEffectiveStartsAt()->format('Y-m-d H:i:s'),
);
$this->assertSame(
'2026-08-20 23:59:59',
$tickets->first()->getEffectiveExpiresAt()->format('Y-m-d H:i:s'),
);
}
public function test_a_multi_date_variant_generates_one_ticket_for_each_selected_date(): void
{
$item = $this->createTicketableItem('multi-date-pass');
$dates = collect(['2026-08-20', '2026-08-21'])->map(fn (string $date) => EventDate::query()->create([
'tenant_code' => $this->tenant->codigo,
'date' => $date,
'time_start' => '00:00:00',
'time_end' => '23:59:59',
]));
$variant = $item->variants()->create([
'inventory_id' => Inventory::query()->create()->id,
]);
$variant->eventDates()->sync($dates->pluck('id'));
$tickets = $this->service->generate($item, $this->user, 1, $variant->id);
$tickets->each->loadMissing('validityGroups.validityTimes');
$this->assertCount(2, $tickets);
$this->assertSame(
$dates->pluck('validity_time_id')->all(),
$tickets->map(fn ($ticket): int => $ticket->validityGroups->sole()->validityTimes->sole()->id)->all(),
);
$this->assertSame(
['2026-08-20 00:00:00', '2026-08-21 00:00:00'],
$tickets->map(fn ($ticket): string => $ticket->validityGroups->sole()->validityTimes->sole()->fixed_starts_at->format('Y-m-d H:i:s'))->all(),
);
$this->assertSame(
['Multi-date-pass (20/08/2026)', 'Multi-date-pass (21/08/2026)'],
$tickets->pluck('name')->all(),
);
$this->assertSame([$variant->id], $tickets->pluck('source_variant_id')->unique()->values()->all());
}
public function test_one_per_unit_policy_generates_one_ticket_covering_all_selected_dates(): void
{
$item = $this->createTicketableItem('multi-date-pass');
$item->update([
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit,
]);
$dates = collect(['2026-08-20', '2026-08-21'])->map(fn (string $date) => EventDate::query()->create([
'tenant_code' => $this->tenant->codigo,
'date' => $date,
'time_start' => '00:00:00',
'time_end' => '23:59:59',
]));
$variant = $item->variants()->create([
'inventory_id' => Inventory::query()->create()->id,
]);
$variant->eventDates()->sync($dates->pluck('id'));
$tickets = $this->service->generate($item, $this->user, 2, $variant->id);
$tickets->each->loadMissing('validityGroups.validityTimes');
$this->assertCount(2, $tickets);
$this->assertTrue($tickets->every(fn ($ticket): bool => $ticket->validityGroups->count() === 2));
$this->assertTrue($tickets->every(
fn ($ticket): bool => $ticket->validityGroups->every(
fn ($group): bool => $group->validityTimes->count() === 1
)
));
$this->assertEqualsCanonicalizing(
$dates->pluck('validity_time_id')->all(),
$tickets->flatMap(fn ($ticket) => $ticket->allValidityTimes())->pluck('id')->unique()->all(),
);
$this->assertTrue($tickets->every(
fn ($ticket): bool => $ticket->name === 'Multi-date-pass (20/08/2026, 21/08/2026)'
));
$this->assertTrue($tickets->every(
fn ($ticket): bool => $ticket->allValidityTimes()
->map(fn (ValidityTime $validityTime): array => [
$validityTime->fixed_starts_at->format('Y-m-d H:i:s'),
$validityTime->fixed_expires_at->format('Y-m-d H:i:s'),
])
->all() === [
['2026-08-20 00:00:00', '2026-08-20 23:59:59'],
['2026-08-21 00:00:00', '2026-08-21 23:59:59'],
]
));
}
public function test_common_schedule_is_anded_into_each_alternative_event_date_group(): void
{
$item = $this->createTicketableItem('multi-date-lunch');
$item->update(['ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit]);
$dates = collect(['2026-08-20', '2026-08-21'])->map(fn (string $date) => EventDate::query()->create([
'tenant_code' => $this->tenant->codigo,
'date' => $date,
'time_start' => '09:00:00',
'time_end' => '18:00:00',
]));
$lunch = ValidityTime::query()->create([
'type' => ValidityTimeType::TimeWindow,
'start_time' => '12:00:00',
'end_time' => '15:00:00',
]);
$schedule = Attribute::query()->create([
'tenant_codigo' => $this->tenant->codigo,
'codigo' => 'schedule',
'nombre' => 'Schedule',
'type' => FieldType::Select,
]);
$schedule->options()->create([
'value' => 'lunch',
'label' => 'Lunch',
'validity_time_id' => $lunch->id,
]);
$itemSchedule = $item->itemAttributes()->create(['attribute_id' => $schedule->id]);
$variant = $item->variants()->create([
'inventory_id' => Inventory::query()->create()->id,
]);
$variant->eventDates()->sync($dates->pluck('id'));
$variant->definitions()->create([
'item_attribute_id' => $itemSchedule->id,
'value' => 'lunch',
]);
$ticket = $this->service->generate($item, $this->user, 1, $variant->id)->sole();
$ticket->loadMissing('validityGroups.validityTimes');
$this->assertCount(2, $ticket->validityGroups);
$this->assertTrue($ticket->validityGroups->every(
fn ($group): bool => $group->validityTimes->count() === 2
&& $group->validityTimes->contains($lunch)
));
$this->assertEqualsCanonicalizing(
$dates->pluck('validity_time_id')->all(),
$ticket->validityGroups
->flatMap->validityTimes
->reject(fn (ValidityTime $validityTime): bool => $validityTime->is($lunch))
->pluck('id')
->all(),
);
$this->assertSame('2026-08-20 12:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s'));
$this->assertSame('2026-08-21 15:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s'));
Carbon::setTestNow('2026-08-20 13:00:00');
$this->assertTrue($ticket->isValid());
Carbon::setTestNow('2026-08-20 16:00:00');
$this->assertFalse($ticket->isValid());
$this->assertFalse($ticket->is_expired);
}
public function test_marking_a_purchase_as_paid_ignores_items_without_tickets(): void
{
Event::fake([TicketsAvailable::class]);
@@ -244,9 +454,9 @@ class TicketGeneratorServiceTest extends TestCase
->assertJsonPath('data.has_generated_tickets', false);
}
public function test_paid_status_is_confirmed_when_ticket_maximum_use_date_was_reached(): void
public function test_paid_status_is_confirmed_when_ticket_is_generated(): void
{
$item = $this->createTicketableItem('expired-paid-ticket', maximumUseDate: now());
$item = $this->createTicketableItem('paid-ticket');
$purchase = $this->createPurchase($item, 1);
$purchase->markAsPaid();
@@ -258,11 +468,8 @@ class TicketGeneratorServiceTest extends TestCase
]);
}
private function createTicketableItem(
string $slug,
mixed $minimumUseDate = null,
mixed $maximumUseDate = null,
): CatalogItem {
private function createTicketableItem(string $slug): CatalogItem
{
return CatalogItem::query()->create([
'tenant_code' => $this->tenant->codigo,
'slug' => $slug,
@@ -270,8 +477,6 @@ class TicketGeneratorServiceTest extends TestCase
'descripcion' => "Descripción de {$slug}",
'precio' => 10,
'has_tickets' => true,
'minimum_use_date' => $minimumUseDate,
'maximum_use_date' => $maximumUseDate,
]);
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Tests\Feature\Ticket;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class TicketValiditySchemaTest extends TestCase
{
use RefreshDatabase;
public function test_ticket_validity_time_schema_is_available(): void
{
$this->assertEqualsCanonicalizing([
'id',
'type',
'start_time',
'end_time',
'fixed_starts_at',
'fixed_expires_at',
'created_at',
'updated_at',
], Schema::getColumnListing('validity_times'));
$this->assertFalse(Schema::hasColumn('tickets', 'validity_time_id'));
$this->assertFalse(Schema::hasTable('ticket_validity_times'));
$this->assertEqualsCanonicalizing([
'id',
'ticket_id',
], Schema::getColumnListing('ticket_validity_groups'));
$this->assertEqualsCanonicalizing([
'ticket_validity_group_id',
'validity_time_id',
], Schema::getColumnListing('ticket_validity_group_times'));
$this->assertFalse(Schema::hasColumn('tickets', 'service_date'));
$this->assertFalse(Schema::hasColumn('tickets', 'starts_at'));
$this->assertFalse(Schema::hasColumn('tickets', 'expires_at'));
$this->assertTrue(Schema::hasColumn('catalog_items', 'validity_time_id'));
$this->assertFalse(Schema::hasColumn('catalog_items', 'minimum_use_date'));
$this->assertFalse(Schema::hasColumn('catalog_items', 'maximum_use_date'));
$this->assertTrue(Schema::hasColumn('attribute_options', 'validity_time_id'));
$this->assertTrue(Schema::hasColumn('event_dates', 'validity_time_id'));
$this->assertFalse(Schema::hasColumn('variantes', 'minimum_use_date'));
$this->assertFalse(Schema::hasColumn('variantes', 'maximum_use_date'));
}
}

View File

@@ -4,7 +4,6 @@ namespace Tests\Unit\Catalog;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\InventoryPolicy;
@@ -21,13 +20,11 @@ use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Models\VariantDefinition;
use App\Domains\Event\Models\Event;
use App\Domains\Event\Models\EventDate;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class CatalogModelsTest extends TestCase
@@ -59,6 +56,18 @@ class CatalogModelsTest extends TestCase
$this->assertInstanceOf(AttributeOption::class, $attribute->options()->getRelated());
}
public function test_event_date_is_an_attribute_type_with_dynamic_options(): void
{
$attribute = new Attribute;
$attribute->type = FieldType::EventDate->value;
$this->assertSame(FieldType::EventDate, $attribute->type);
$this->assertTrue($attribute->type->supportsOptions());
$this->assertTrue($attribute->type->usesDynamicOptions());
$this->assertContains('event_date', FieldType::values());
$this->assertInstanceOf(EventDate::class, $attribute->eventDates()->getRelated());
}
public function test_catalog_item_is_the_catalog_root(): void
{
$item = new CatalogItem;
@@ -66,8 +75,6 @@ class CatalogModelsTest extends TestCase
'category_id' => '10',
'brand_id' => '20',
'inventory_id' => '30',
'event_id' => '40',
'event_product_type' => EventProductType::Entry->value,
'type' => CatalogItemType::Standard->value,
'precio' => '12.50',
'inventory_policy' => InventoryPolicy::Tracked->value,
@@ -79,14 +86,11 @@ class CatalogModelsTest extends TestCase
$this->assertSame(10, $item->category_id);
$this->assertSame(20, $item->brand_id);
$this->assertSame(30, $item->inventory_id);
$this->assertSame(40, $item->event_id);
$this->assertSame(EventProductType::Entry, $item->event_product_type);
$this->assertSame(CatalogItemType::Standard, $item->type);
$this->assertSame('12.50', $item->precio);
$this->assertSame(InventoryPolicy::Tracked, $item->inventory_policy);
$this->assertTrue($item->has_tickets);
$this->assertInstanceOf(Tenant::class, $item->tenant()->getRelated());
$this->assertInstanceOf(Event::class, $item->event()->getRelated());
$this->assertInstanceOf(Category::class, $item->category()->getRelated());
$this->assertInstanceOf(Brand::class, $item->brand()->getRelated());
$this->assertInstanceOf(Inventory::class, $item->inventory()->getRelated());
@@ -160,24 +164,6 @@ class CatalogModelsTest extends TestCase
$this->assertSame('catalog_items_attachments', $variant->attachments()->getTable());
}
public function test_variant_use_dates_override_or_inherit_catalog_item_dates(): void
{
$item = new CatalogItem;
$item->minimum_use_date = Carbon::parse('2026-08-01 09:00:00');
$item->maximum_use_date = Carbon::parse('2026-08-31 18:00:00');
$variant = new Variant;
$variant->maximum_use_date = Carbon::parse('2026-08-15 18:00:00');
$variant->setRelation('catalogItem', $item);
$this->assertTrue(
$variant->getMinimumUseDate()->equalTo($item->minimum_use_date),
);
$this->assertTrue(
$variant->getMaximumUseDate()->equalTo($variant->maximum_use_date),
);
}
public function test_event_date_identifies_a_variant_without_catalog_attributes(): void
{
$item = new CatalogItem;
@@ -189,15 +175,86 @@ class CatalogModelsTest extends TestCase
$eventDate->time_end = '18:00:00';
$variant = new Variant;
$variant->minimum_use_date = Carbon::parse('2026-10-09 08:00:00');
$variant->maximum_use_date = Carbon::parse('2026-10-09 20:00:00');
$variant->setRelation('catalogItem', $item);
$variant->setRelation('eventDate', $eventDate);
$variant->setRelation('definitions', new EloquentCollection);
$this->assertSame('Entrada General', $variant->getName());
$this->assertSame('2026-10-09 09:00:00', $variant->getMinimumUseDate()->format('Y-m-d H:i:s'));
$this->assertSame('2026-10-09 18:00:00', $variant->getMaximumUseDate()->format('Y-m-d H:i:s'));
$this->assertSame('2026-10-09', $variant->eventDate->date->format('Y-m-d'));
}
public function test_variant_exposes_selection_values_with_api_labels(): void
{
$attribute = new Attribute;
$attribute->codigo = 'size';
$attribute->setRelation('options', new EloquentCollection([
new AttributeOption(['value' => 'M', 'label' => 'Medium']),
]));
$itemAttribute = new ItemAttribute;
$itemAttribute->allow_multi_select = false;
$itemAttribute->setRelation('attribute', $attribute);
$definition = new VariantDefinition(['value' => 'M']);
$definition->item_attribute_id = 1;
$definition->setRelation('itemAttribute', $itemAttribute);
$eventDate = new EventDate([
'date' => '2026-10-09',
'time_start' => '09:00:00',
'time_end' => '18:00:00',
]);
$eventDate->id = 20;
$variant = new Variant;
$variant->setRelation('definitions', new EloquentCollection([$definition]));
$variant->setRelation('eventDates', new EloquentCollection([$eventDate]));
$this->assertSame(
['value' => 'M', 'label' => 'Medium'],
$variant->selectionOptions()->get('size'),
);
$this->assertSame(
['value' => '20', 'label' => '09/10/2026'],
$variant->selectionOptions()->get('event_date'),
);
}
public function test_variant_orders_selection_options_by_item_order_and_attribute_label(): void
{
$definitions = collect([
['id' => 1, 'code' => 'zeta', 'label' => 'Zeta', 'sort_order' => 2],
['id' => 2, 'code' => 'priority', 'label' => 'Priority', 'sort_order' => 1],
['id' => 3, 'code' => 'alpha', 'label' => 'Alpha', 'sort_order' => 2],
])->map(function (array $data): VariantDefinition {
$attribute = new Attribute([
'codigo' => $data['code'],
'nombre' => $data['label'],
]);
$attribute->setRelation('options', new EloquentCollection);
$itemAttribute = new ItemAttribute([
'sort_order' => $data['sort_order'],
'allow_multi_select' => false,
]);
$itemAttribute->id = $data['id'];
$itemAttribute->setRelation('attribute', $attribute);
$definition = new VariantDefinition(['value' => $data['code']]);
$definition->item_attribute_id = $itemAttribute->id;
$definition->setRelation('itemAttribute', $itemAttribute);
return $definition;
});
$variant = new Variant;
$variant->setRelation('definitions', new EloquentCollection($definitions));
$variant->setRelation('eventDates', new EloquentCollection);
$this->assertSame(
['priority', 'alpha', 'zeta'],
$variant->selectionOptions()->keys()->all(),
);
}
public function test_inventory_maps_stock_without_a_polymorphic_owner(): void
@@ -226,6 +283,27 @@ class CatalogModelsTest extends TestCase
$this->assertTrue($item->isAvailable());
}
public function test_catalog_item_only_exposes_available_tracked_variants(): void
{
$unavailable = (new Variant)->setRelation('inventory', $this->trackedInventory(3, 3));
$available = (new Variant)->setRelation('inventory', $this->trackedInventory(5, 2));
$unavailable->id = 10;
$available->id = 20;
$item = new CatalogItem;
$item->inventory_policy = InventoryPolicy::Tracked;
$item->setRelation('variants', new EloquentCollection([$unavailable, $available]));
$this->assertSame([$available], $item->visibleVariants()->all());
$this->assertSame(
[$unavailable, $available],
$item->visibleVariants($unavailable->id)->all(),
);
$item->inventory_policy = InventoryPolicy::Unlimited;
$this->assertSame([$unavailable, $available], $item->visibleVariants()->all());
}
public function test_catalog_item_prioritizes_its_inventory_over_variants(): void
{
$item = new CatalogItem;

View File

@@ -0,0 +1,38 @@
<?php
namespace Tests\Unit\Event;
use App\Domains\Event\Services\EventDateTextFormatter;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class EventDateTextFormatterTest extends TestCase
{
/** @param array<int, string> $dates */
#[DataProvider('dateCases')]
public function test_it_formats_event_dates_in_spanish(array $dates, ?string $expected): void
{
$this->assertSame($expected, (new EventDateTextFormatter)->format($dates));
}
/** @return array<string, array{array<int, string>, string|null}> */
public static function dateCases(): array
{
return [
'no dates' => [[], null],
'one date' => [['2026-10-09'], '9 de Octubre 2026'],
'same month sorted' => [
['2026-10-12', '2026-10-09', '2026-10-11', '2026-10-10'],
'9, 10, 11 y 12 de Octubre 2026',
],
'different months' => [
['2026-11-01', '2026-10-31'],
'31 de Octubre y 1 de Noviembre 2026',
],
'different years' => [
['2027-01-01', '2026-12-31'],
'31 de Diciembre 2026 y 1 de Enero 2027',
],
];
}
}

View File

@@ -2,48 +2,40 @@
namespace Tests\Unit\Event;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\Event;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\ValidityTime;
use Tests\TestCase;
class EventModelsTest extends TestCase
{
public function test_event_maps_its_tenant_dates_and_catalog_items(): void
{
$event = new Event;
$this->assertFalse($event->usesTimestamps());
$this->assertInstanceOf(Tenant::class, $event->tenant()->getRelated());
$this->assertInstanceOf(EventDate::class, $event->dates()->getRelated());
$this->assertInstanceOf(CatalogItem::class, $event->catalogItems()->getRelated());
}
public function test_event_date_maps_schedule_and_variants(): void
{
$eventDate = new EventDate;
$eventDate->setRawAttributes([
'event_id' => '10',
'tenant_code' => 'acme',
'validity_time_id' => '12',
'date' => '2026-10-09',
'time_start' => '09:00:00',
'time_end' => '18:30:00',
]);
$this->assertFalse($eventDate->usesTimestamps());
$this->assertSame(10, $eventDate->event_id);
$this->assertSame('acme', $eventDate->tenant_code);
$this->assertSame(12, $eventDate->validity_time_id);
$this->assertSame('2026-10-09 09:00:00', $eventDate->startsAt()->format('Y-m-d H:i:s'));
$this->assertSame('2026-10-09 18:30:00', $eventDate->endsAt()->format('Y-m-d H:i:s'));
$this->assertInstanceOf(Event::class, $eventDate->event()->getRelated());
$this->assertInstanceOf(Tenant::class, $eventDate->tenant()->getRelated());
$this->assertInstanceOf(ValidityTime::class, $eventDate->validityTime()->getRelated());
$this->assertInstanceOf(EventDate::class, (new ValidityTime)->eventDate()->getRelated());
$this->assertInstanceOf(Variant::class, $eventDate->variants()->getRelated());
}
public function test_tenant_has_many_events_and_one_active_event(): void
public function test_tenant_has_many_event_dates(): void
{
$tenant = new Tenant;
$this->assertInstanceOf(Event::class, $tenant->events()->getRelated());
$this->assertInstanceOf(Event::class, $tenant->activeEvent()->getRelated());
$this->assertInstanceOf(EventDate::class, $tenant->eventDates()->getRelated());
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Tests\Unit\Sale;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
use Illuminate\Http\Request;
use Tests\TestCase;
class SaleDetailResourceTest extends TestCase
{
public function test_it_serializes_the_purchased_item_snapshot(): void
{
$purchase = (new Purchase)->forceFill([
'id' => 15,
'total' => '30000.00',
]);
$item = (new PurchaseItem)->forceFill([
'id' => 8,
'item_nombre' => 'Comida',
'variant_attributes' => [
['name' => 'Fecha', 'value' => ['2026-10-12']],
],
'cantidad' => 3,
'precio_unitario' => '10000.00',
'total' => '30000.00',
]);
$purchase->setRelation('items', collect([$item]));
$data = (new SaleDetailResource($purchase))->resolve(Request::create('/'));
$this->assertSame(15, $data['id']);
$this->assertSame('Comida', $data['items'][0]['product']);
$this->assertSame(['2026-10-12'], $data['items'][0]['event_dates']);
$this->assertSame(3, $data['items'][0]['quantity']);
$this->assertSame('10000.00', $data['items'][0]['unit_price']);
$this->assertSame('30000.00', $data['items'][0]['total']);
$this->assertSame('30000.00', $data['total']);
}
}

View File

@@ -5,9 +5,12 @@ namespace Tests\Unit\Ticket;
use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\TicketValidityGroup;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Carbon;
use Tests\TestCase;
@@ -20,14 +23,12 @@ class TicketTest extends TestCase
parent::tearDown();
}
public function test_it_maps_its_dates_and_relations(): void
public function test_it_maps_its_fields_and_relations(): void
{
$ticket = new Ticket;
$ticket->setRawAttributes([
'source_catalog_item_id' => '20',
'source_variant_id' => '30',
'starts_at' => '2026-07-21 10:00:00',
'expires_at' => '2026-07-22 10:00:00',
'used_at' => null,
'scanner_user_id' => '15',
'user_id' => '10',
@@ -37,8 +38,6 @@ class TicketTest extends TestCase
$this->assertFalse($ticket->usesTimestamps());
$this->assertSame(20, $ticket->source_catalog_item_id);
$this->assertSame(30, $ticket->source_variant_id);
$this->assertInstanceOf(Carbon::class, $ticket->starts_at);
$this->assertInstanceOf(Carbon::class, $ticket->expires_at);
$this->assertNull($ticket->used_at);
$this->assertSame(15, $ticket->scanner_user_id);
$this->assertSame(10, $ticket->user_id);
@@ -47,105 +46,158 @@ class TicketTest extends TestCase
$this->assertInstanceOf(User::class, $ticket->scannerUser()->getRelated());
$this->assertInstanceOf(CatalogItem::class, $ticket->sourceCatalogItem()->getRelated());
$this->assertInstanceOf(Variant::class, $ticket->sourceVariant()->getRelated());
$this->assertInstanceOf(TicketValidityGroup::class, $ticket->validityGroups()->getRelated());
$this->assertInstanceOf(ValidityTime::class, (new TicketValidityGroup)->validityTimes()->getRelated());
}
public function test_unused_ticket_without_date_restrictions_is_valid(): void
public function test_unused_ticket_without_validity_time_is_valid(): void
{
$this->assertTrue((new Ticket)->isValid());
$ticket = new Ticket;
$this->assertTrue($ticket->isValid());
$this->assertSame(Ticket::STATUS_ACTIVE, $ticket->status);
}
public function test_ticket_is_invalid_before_its_start_date(): void
public function test_fixed_window_controls_ticket_validity(): void
{
Carbon::setTestNow('2026-07-21 10:00:00');
$ticket = new Ticket(['starts_at' => now()->addSecond()]);
$ticket = $this->ticketWithValidityTime(new ValidityTime([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => now()->subHour(),
'fixed_expires_at' => now()->addHour(),
]));
$this->assertFalse($ticket->isValid());
$this->assertTrue($ticket->isValid());
$this->assertFalse($ticket->is_expired);
}
public function test_ticket_is_valid_when_its_start_date_is_reached(): void
public function test_time_window_is_resolved_for_current_date(): void
{
Carbon::setTestNow('2026-07-21 10:00:00');
$ticket = new Ticket(['starts_at' => now()]);
Carbon::setTestNow('2026-07-21 12:00:00');
$ticket = $this->ticketWithValidityGroups([[new ValidityTime([
'type' => ValidityTimeType::TimeWindow,
'start_time' => '10:00:00',
'end_time' => '14:00:00',
])]]);
$this->assertSame('2026-07-21 10:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s'));
$this->assertSame('2026-07-21 14:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s'));
$this->assertTrue($ticket->isValid());
}
public function test_ticket_is_invalid_when_it_expires(): void
public function test_ticket_is_invalid_when_validity_time_expires(): void
{
Carbon::setTestNow('2026-07-21 10:00:00');
$ticket = new Ticket(['expires_at' => now()]);
$ticket = $this->ticketWithValidityTime(new ValidityTime([
'type' => ValidityTimeType::FixedWindow,
'fixed_expires_at' => now(),
]));
$this->assertFalse($ticket->isValid());
}
public function test_used_ticket_is_invalid(): void
{
$ticket = new Ticket(['used_at' => now()->subSecond()]);
$this->assertFalse($ticket->isValid());
}
public function test_it_appends_computed_status_fields(): void
{
Carbon::setTestNow('2026-07-21 10:00:00');
$ticket = new Ticket([
'starts_at' => now()->subHour(),
'expires_at' => now()->addHour(),
]);
$attributes = $ticket->toArray();
$this->assertTrue($attributes['is_valid']);
$this->assertFalse($attributes['is_expired']);
$this->assertFalse($attributes['is_used']);
}
public function test_unused_ticket_is_expired_when_its_expiration_date_is_reached(): void
{
Carbon::setTestNow('2026-07-21 10:00:00');
$ticket = new Ticket(['expires_at' => now()]);
$this->assertFalse($ticket->is_valid);
$this->assertTrue($ticket->is_expired);
$this->assertFalse($ticket->is_used);
$this->assertSame(Ticket::STATUS_EXPIRED, $ticket->status);
}
public function test_used_ticket_is_not_reported_as_expired(): void
public function test_ticket_is_valid_when_any_validity_time_is_active(): void
{
Carbon::setTestNow('2026-07-21 10:00:00');
$ticket = new Ticket([
'expires_at' => now()->subHour(),
'used_at' => now()->subDay(),
$ticket = $this->ticketWithValidityGroups([
[new ValidityTime([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => now()->subDays(2),
'fixed_expires_at' => now()->subDay(),
])],
[new ValidityTime([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => now()->subHour(),
'fixed_expires_at' => now()->addHour(),
])],
]);
$this->assertTrue($ticket->isValid());
$this->assertFalse($ticket->is_expired);
$this->assertSame('2026-07-19 10:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s'));
$this->assertSame('2026-07-21 11:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s'));
}
public function test_ticket_is_invalid_but_not_expired_between_validity_times(): void
{
Carbon::setTestNow('2026-07-21 10:00:00');
$ticket = $this->ticketWithValidityGroups([
[new ValidityTime([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => now()->subDays(2),
'fixed_expires_at' => now()->subDay(),
])],
[new ValidityTime([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => now()->addDay(),
'fixed_expires_at' => now()->addDays(2),
])],
]);
$this->assertFalse($ticket->isValid());
$this->assertFalse($ticket->is_expired);
$this->assertSame(Ticket::STATUS_ACTIVE, $ticket->status);
}
public function test_used_ticket_is_invalid_and_not_reported_as_expired(): void
{
$ticket = $this->ticketWithValidityTime(new ValidityTime([
'type' => ValidityTimeType::FixedWindow,
'fixed_expires_at' => now()->subHour(),
]));
$ticket->used_at = now()->subMinute();
$this->assertFalse($ticket->is_valid);
$this->assertFalse($ticket->is_expired);
$this->assertTrue($ticket->is_used);
$this->assertSame(Ticket::STATUS_USED, $ticket->status);
}
public function test_event_date_has_priority_over_ticket_and_variant_dates(): void
public function test_all_validity_times_in_the_same_group_must_be_active(): void
{
Carbon::setTestNow('2026-10-09 19:00:00');
Carbon::setTestNow('2026-08-20 13:00:00');
$ticket = $this->ticketWithValidityGroups([[
new ValidityTime([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => '2026-08-20 09:00:00',
'fixed_expires_at' => '2026-08-20 18:00:00',
]),
new ValidityTime([
'type' => ValidityTimeType::TimeWindow,
'start_time' => '12:00:00',
'end_time' => '15:00:00',
]),
]]);
$eventDate = new EventDate;
$eventDate->date = '2026-10-09';
$eventDate->time_start = '09:00:00';
$eventDate->time_end = '18:00:00';
$this->assertTrue($ticket->isValid());
$this->assertSame('2026-08-20 12:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s'));
$this->assertSame('2026-08-20 15:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s'));
$variant = new Variant;
$variant->minimum_use_date = Carbon::parse('2026-10-09 08:00:00');
$variant->maximum_use_date = Carbon::parse('2026-10-09 22:00:00');
$variant->setRelation('eventDate', $eventDate);
Carbon::setTestNow('2026-08-20 16:00:00');
$ticket = new Ticket([
'starts_at' => Carbon::parse('2026-10-09 07:00:00'),
'expires_at' => Carbon::parse('2026-10-10 23:59:59'),
]);
$ticket->setRelation('sourceVariant', $variant);
$this->assertSame('2026-10-09 09:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s'));
$this->assertSame('2026-10-09 18:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s'));
$this->assertFalse($ticket->isValid());
$this->assertTrue($ticket->is_expired);
}
private function ticketWithValidityTime(ValidityTime $validityTime): Ticket
{
return $this->ticketWithValidityGroups([[$validityTime]]);
}
/** @param array<int, array<int, ValidityTime>> $validityGroups */
private function ticketWithValidityGroups(array $validityGroups): Ticket
{
$ticket = new Ticket;
$groups = collect($validityGroups)->map(function (array $validityTimes): TicketValidityGroup {
$group = new TicketValidityGroup;
$group->setRelation('validityTimes', new EloquentCollection($validityTimes));
return $group;
});
$ticket->setRelation('validityGroups', new EloquentCollection($groups->all()));
return $ticket;
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Tests\Unit\Ticket;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\ValidityTime;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class ValidityTimeResourceTest extends TestCase
{
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_time_window_only_contains_non_null_time_fields(): void
{
Carbon::setTestNow('2026-08-20 12:00:00');
$resource = ValidityTimeResource::make(new ValidityTime([
'type' => ValidityTimeType::TimeWindow,
'start_time' => '11:00:00',
'end_time' => null,
'fixed_starts_at' => '2026-08-20 10:00:00',
]))->resolve();
$this->assertSame('time_window', $resource['type']);
$this->assertTrue($resource['is_valid']);
$this->assertSame('11:00:00', $resource['start_time']);
$this->assertArrayNotHasKey('end_time', $resource);
$this->assertArrayNotHasKey('fixed_starts_at', $resource);
$this->assertArrayNotHasKey('fixed_expires_at', $resource);
}
public function test_fixed_window_only_contains_non_null_datetime_fields(): void
{
Carbon::setTestNow('2026-08-20 11:00:00');
$resource = ValidityTimeResource::make(new ValidityTime([
'type' => ValidityTimeType::FixedWindow,
'start_time' => '11:00:00',
'fixed_starts_at' => '2026-08-20 10:00:00',
'fixed_expires_at' => null,
]))->resolve();
$this->assertSame('fixed_window', $resource['type']);
$this->assertTrue($resource['is_valid']);
$this->assertArrayHasKey('fixed_starts_at', $resource);
$this->assertArrayNotHasKey('fixed_expires_at', $resource);
$this->assertArrayNotHasKey('start_time', $resource);
$this->assertArrayNotHasKey('end_time', $resource);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Tests\Unit\Ticket;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\TicketValidityGroup;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class ValidityTimeTest extends TestCase
{
public function test_it_casts_its_type_dates_and_flags(): void
{
$validityTime = new ValidityTime;
$validityTime->setRawAttributes([
'type' => ValidityTimeType::FixedWindow->value,
'fixed_starts_at' => '2026-08-20 10:00:00',
'fixed_expires_at' => '2026-08-21 02:00:00',
]);
$this->assertSame(ValidityTimeType::FixedWindow, $validityTime->type);
$this->assertInstanceOf(Carbon::class, $validityTime->fixed_starts_at);
$this->assertInstanceOf(Carbon::class, $validityTime->fixed_expires_at);
$this->assertInstanceOf(CatalogItem::class, $validityTime->catalogItems()->getRelated());
$this->assertInstanceOf(AttributeOption::class, $validityTime->attributeOptions()->getRelated());
$this->assertInstanceOf(
TicketValidityGroup::class,
$validityTime->ticketValidityGroups()->getRelated(),
);
}
public function test_it_exposes_supported_type_values(): void
{
$this->assertSame([
'time_window',
'fixed_window',
], ValidityTimeType::values());
}
public function test_fixed_window_is_valid_between_its_limits(): void
{
$validityTime = new ValidityTime([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => '2026-08-20 10:00:00',
'fixed_expires_at' => '2026-08-20 12:00:00',
]);
$this->assertTrue($validityTime->isValid(at: Carbon::parse('2026-08-20 10:00:00')));
$this->assertTrue($validityTime->isValid(at: Carbon::parse('2026-08-20 11:00:00')));
$this->assertFalse($validityTime->isValid(at: Carbon::parse('2026-08-20 12:00:00')));
}
public function test_time_window_is_valid_between_its_limits(): void
{
$validityTime = new ValidityTime([
'type' => ValidityTimeType::TimeWindow,
'start_time' => '11:00:00',
'end_time' => '14:00:00',
]);
$this->assertTrue($validityTime->isValid(
Carbon::parse('2026-08-20 12:00:00'),
));
$this->assertFalse($validityTime->isValid(
Carbon::parse('2026-08-20 14:00:00'),
));
}
}