Refactor Catalog API: Remove Create Catalog Item Endpoint and Update Related Logic

- Removed the "Create Catalog" endpoint from the Postman collection.
- Updated the CatalogController to handle tenant-specific catalog items based on active events.
- Refactored CatalogItem model to automatically set event_id based on tenant's active_event_id during creation.
- Deleted StoreCatalogItemRequest and CatalogItemResource as they are no longer needed.
- Updated various services and controllers to utilize the new tenant-based catalog item retrieval logic.
- Added tests to ensure that catalog reads are limited to items associated with the active event.
This commit is contained in:
2026-09-18 13:51:13 -03:00
parent cdc17a13e2
commit 22ef5619f3
21 changed files with 107 additions and 482 deletions

View File

@@ -2,7 +2,7 @@
"info": {
"_postman_id": "76fd6fd2-53b9-4d92-8e02-1ddcc6207fa2",
"name": "ShopIt API — Complete",
"description": "Colección canónica generada desde las rutas reales de Laravel. Incluye 137 operaciones HTTP, presets de configuración para cada integración, ejemplos de payload, filtros, archivos y tokens separados para Storefront, Admin App y Scanner.\n\nUso rápido:\n1. Ajustá `base_url` y las credenciales.\n2. Ejecutá el Login de la aplicación correspondiente; el token se guarda automáticamente.\n3. Ajustá los IDs y códigos de las variables de colección.\n\nRegeneración: `php postman/generate-shopit-collection.php`.",
"description": "Colección canónica generada desde las rutas reales de Laravel. Incluye 136 operaciones HTTP, presets de configuración para cada integración, ejemplos de payload, filtros, archivos y tokens separados para Storefront, Admin App y Scanner.\n\nUso rápido:\n1. Ajustá `base_url` y las credenciales.\n2. Ejecutá el Login de la aplicación correspondiente; el token se guarda automáticamente.\n3. Ajustá los IDs y códigos de las variables de colección.\n\nRegeneración: `php postman/generate-shopit-collection.php`.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
@@ -678,57 +678,6 @@
},
"response": []
},
{
"name": "Create Catalog",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"description": "Ruta Laravel: `POST /api/tenants/{tenant:codigo}/catalog-items`\n\nControlador: `App\\Domains\\Catalog\\Controllers\\CatalogController@store`",
"url": {
"raw": "{{base_url}}/api/tenants/{{tenant_code}}/catalog-items?q=demo&page=1",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"{{tenant_code}}",
"catalog-items"
],
"query": [
{
"key": "q",
"value": "demo"
},
{
"key": "page",
"value": "1"
}
]
},
"body": {
"mode": "raw",
"raw": "{\n \"type\": \"standard\",\n \"category_id\": {{category_id}},\n \"slug\": \"producto-demo\",\n \"nombre\": \"Producto Demo\",\n \"descripcion\": \"Producto creado desde Postman\",\n \"precio\": 10000,\n \"inventory_policy\": \"tracked\",\n \"inventory_subject\": \"product\",\n \"max_units_per_user\": 5,\n \"has_tickets\": false,\n \"real_stock\": 100,\n \"images\": [\n \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\"\n ]\n}",
"options": {
"raw": {
"language": "json"
}
}
}
},
"response": []
},
{
"name": "Get Catalog",
"request": {

View File

@@ -375,7 +375,7 @@ class Cart extends Model
): CatalogItem|Variant {
$catalogItemQuery = CatalogItem::query()
->whereKey($catalogItemId)
->where('tenant_code', $this->tenant_codigo);
->forTenantCatalog($this->tenant()->firstOrFail());
if ($lockForUpdate) {
$catalogItemQuery->lockForUpdate();

View File

@@ -11,10 +11,8 @@ use App\Domains\Commerce\Catalog\Requests\CatalogVariantOptionsRequest;
use App\Domains\Commerce\Catalog\Requests\CategoryPageRequest;
use App\Domains\Commerce\Catalog\Requests\FeaturedGroupPageRequest;
use App\Domains\Commerce\Catalog\Requests\SearchCatalogItemsRequest;
use App\Domains\Commerce\Catalog\Requests\StoreCatalogItemRequest;
use App\Domains\Commerce\Catalog\Resources\CatalogFeaturedGroupResource;
use App\Domains\Commerce\Catalog\Resources\CatalogItemDetailResource;
use App\Domains\Commerce\Catalog\Resources\CatalogItemResource;
use App\Domains\Commerce\Catalog\Resources\CatalogSearchItemResource;
use App\Domains\Commerce\Catalog\Resources\CatalogVariantOptionsResource;
use App\Domains\Commerce\Catalog\Services\CatalogItemAllowanceService;
@@ -40,7 +38,7 @@ class CatalogController extends Controller
return response()->json($featuredGroups->map(
fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource(
$featuredGroup,
$featuredGroupService->itemsResponse($featuredGroup, 1, $this->userId($request)),
$featuredGroupService->itemsResponse($featuredGroup, $tenant, 1, $this->userId($request)),
))->resolve()
));
}
@@ -102,6 +100,7 @@ class CatalogController extends Controller
return response()->json($featuredGroupService->itemsResponse(
$featuredGroup,
$tenant,
$page,
$this->userId($request),
));
@@ -114,7 +113,8 @@ class CatalogController extends Controller
CatalogService $catalogService,
CatalogItemAllowanceService $allowances,
): CatalogItemDetailResource {
abort_unless($catalogItem->tenant_code === $tenant->codigo, 404);
abort_unless($catalogItem->tenant_code === $tenant->codigo
&& ($tenant->active_event_id === null || $catalogItem->event_id === $tenant->active_event_id), 404);
$variantId = $request->validated('variant_id');
@@ -134,7 +134,8 @@ class CatalogController extends Controller
VariantSelectionService $variantSelectionService,
CartService $cartService,
): CatalogVariantOptionsResource {
abort_unless($catalogItem->tenant_code === $tenant->codigo, 404);
abort_unless($catalogItem->tenant_code === $tenant->codigo
&& ($tenant->active_event_id === null || $catalogItem->event_id === $tenant->active_event_id), 404);
$includedVariantId = null;
$cartItemId = $request->validated('cart_item_id');
@@ -163,21 +164,6 @@ class CatalogController extends Controller
);
}
public function store(
StoreCatalogItemRequest $request,
Tenant $tenant,
CatalogService $catalogService,
): JsonResponse {
$catalogItem = $catalogService->create([
...$request->validated(),
'tenant_code' => $tenant->codigo,
]);
return CatalogItemResource::make($catalogItem)
->response()
->setStatusCode(201);
}
private function userId(Request $request): ?int
{
$userId = $request->user()?->getAuthIdentifier() ?? Auth::guard('sanctum')->id();

View File

@@ -52,6 +52,19 @@ class CatalogItem extends Model
'group_order' => 0,
];
protected static function booted(): void
{
static::creating(function (CatalogItem $item): void {
if ($item->event_id !== null || $item->tenant_code === null) {
return;
}
$item->event_id = Tenant::query()
->where('codigo', $item->tenant_code)
->value('active_event_id');
});
}
protected function casts(): array
{
return [
@@ -81,6 +94,15 @@ class CatalogItem extends Model
return $this->belongsTo(Event::class);
}
/** @param Builder<CatalogItem> $query */
public function scopeForTenantCatalog(Builder $query, Tenant $tenant): Builder
{
return $query
->where('catalog_items.tenant_code', $tenant->codigo)
->when($tenant->active_event_id !== null, fn (Builder $query): Builder => $query
->where('catalog_items.event_id', $tenant->active_event_id));
}
/** @return BelongsTo<Category, $this> */
public function category(): BelongsTo
{

View File

@@ -1,146 +0,0 @@
<?php
namespace App\Domains\Commerce\Catalog\Requests;
use App\Domains\Commerce\Catalog\Enums\CatalogItemType;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Enums\InventorySubject;
use App\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreCatalogItemRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
$tenantCode = $this->route('tenant')?->codigo;
$type = $this->input('type', CatalogItemType::Standard->value);
$isBundle = $type === CatalogItemType::Bundle->value;
return [
'tenant_code' => ['prohibited'],
'event_id' => ['prohibited'],
'type' => ['sometimes', Rule::enum(CatalogItemType::class)],
'category_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('categorias', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'brand_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('brands', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $tenantCode)
),
],
'slug' => [
'required',
'string',
'max:255',
Rule::unique('catalog_items', 'slug')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'nombre' => ['required', 'string', 'max:255'],
'group_order' => ['sometimes', 'integer', 'min:0'],
'descripcion' => ['sometimes', 'nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],
'inventory_subject' => ['sometimes', Rule::enum(InventorySubject::class)],
'max_units_per_user' => ['sometimes', 'nullable', 'integer', 'min:1'],
'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'],
'real_stock' => [Rule::prohibitedIf($isBundle), 'sometimes', 'integer', 'min:0'],
'inventory_id' => ['prohibited'],
'reserved_stock' => ['prohibited'],
'sold_units' => ['prohibited'],
'attribute_codes' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
'attribute_codes.*' => [
'required',
'string',
'distinct',
Rule::exists('attribute', 'codigo')->where(
fn ($query) => $query->where('tenant_codigo', $tenantCode)
),
],
'multi_select_attribute_codes' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
'multi_select_attribute_codes.*' => [
'required',
'string',
'distinct',
Rule::exists('attribute', 'codigo')->where(
fn ($query) => $query->where('tenant_codigo', $tenantCode)
),
],
'hidden_attribute_codes' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
'hidden_attribute_codes.*' => [
'required',
'string',
'distinct',
Rule::exists('attribute', 'codigo')->where(
fn ($query) => $query->where('tenant_codigo', $tenantCode)
),
],
'images' => ['sometimes', 'array'],
'images.*' => ['required', new ImageOrBase64Rule],
'variants' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
'variants.*.real_stock' => ['sometimes', 'integer', 'min:0'],
'variants.*.descripcion' => ['sometimes', 'nullable', 'string'],
'variants.*.precio' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:99999999.99'],
'variants.*.event_date_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('event_dates', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'variants.*.event_date_ids' => ['sometimes', 'array', 'min:1'],
'variants.*.event_date_ids.*' => [
'required',
'integer',
'distinct',
Rule::exists('event_dates', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'variants.*.inventory_id' => ['prohibited'],
'variants.*.reserved_stock' => ['prohibited'],
'variants.*.sold_units' => ['prohibited'],
'variants.*.values' => ['sometimes', 'array'],
'variants.*.values.*' => ['nullable'],
'variants.*.values.*.*' => ['required', 'string'],
'variants.*.images' => ['sometimes', 'array'],
'variants.*.images.*' => ['required', new ImageOrBase64Rule],
'components' => [
Rule::requiredIf($isBundle),
Rule::prohibitedIf(! $isBundle),
'array',
'min:1',
],
'components.*.catalog_item_id' => [
'required',
'integer',
Rule::exists('catalog_items', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'components.*.variant_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('variantes', 'id'),
],
'components.*.quantity' => ['required', 'integer', 'min:1'],
];
}
}

View File

@@ -1,50 +0,0 @@
<?php
namespace App\Domains\Commerce\Catalog\Resources;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin CatalogItem */
class CatalogItemResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'type' => $this->type->value,
'category_id' => $this->category_id,
'brand_id' => $this->brand_id,
'slug' => $this->slug,
'nombre' => $this->nombre,
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'inventory_policy' => $this->inventory_policy?->value,
'inventory_subject' => $this->inventory_subject->value,
'max_units_per_user' => $this->max_units_per_user,
'has_tickets' => $this->has_tickets,
'real_stock' => $this->whenLoaded('inventory', fn () => $this->inventory?->real_stock),
'images' => $this->whenLoaded('attachments', fn () => $this->attachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
->values()),
'variants' => $this->whenLoaded('variants', fn () => $this->variants
->map(fn ($variant) => [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'real_stock' => $variant->inventory?->real_stock,
'values' => $variant->selectorOptions($this->itemAttributes),
'images' => $variant->attachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
->values(),
])
->values()),
];
}
}

View File

@@ -238,7 +238,7 @@ class CatalogService
$startsWithPattern = "{$normalizedTerm}%";
$paginator = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->forTenantCatalog($tenant)
->whereAvailable()
->where(function (Builder $query) use ($containsPattern): void {
$query
@@ -287,7 +287,7 @@ class CatalogService
int $page,
): LengthAwarePaginator {
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->forTenantCatalog($tenant)
->where('category_id', $category->id)
->whereAvailable()
->with([
@@ -369,6 +369,8 @@ class CatalogService
$componentItem = CatalogItem::query()
->whereKey($catalogItemId)
->where('tenant_code', $bundle->tenant_code)
->when($bundle->event_id !== null, fn (Builder $query): Builder => $query
->where('event_id', $bundle->event_id))
->first();
if ($componentItem === null) {

View File

@@ -7,6 +7,7 @@ use App\Domains\Commerce\Catalog\Enums\GroupLayout;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
use App\Domains\Commerce\Catalog\Resources\CatalogFeaturedItemResource;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
@@ -19,10 +20,10 @@ class FeaturedGroupService
private readonly CatalogItemAllowanceService $allowances,
) {}
public function itemsResponse(FeaturedGroup $featuredGroup, int $page, ?int $userId = null): array
public function itemsResponse(FeaturedGroup $featuredGroup, Tenant $tenant, int $page, ?int $userId = null): array
{
if ($featuredGroup->group_layout !== GroupLayout::Paginated) {
$query = $this->itemsQuery($featuredGroup);
$query = $this->itemsQuery($featuredGroup, $tenant);
if ($featuredGroup->group_layout === GroupLayout::Single) {
$query->limit(1);
@@ -35,7 +36,7 @@ class FeaturedGroupService
return CatalogFeaturedItemResource::collection($items)->resolve();
}
$paginator = $this->paginateItems($featuredGroup, $page);
$paginator = $this->paginateItems($featuredGroup, $tenant, $page);
$this->attachGroup($paginator->getCollection(), $featuredGroup);
$this->allowances->attach($paginator->getCollection(), $userId);
@@ -45,10 +46,10 @@ class FeaturedGroupService
}
/** @return Builder<CatalogItem> */
private function itemsQuery(FeaturedGroup $featuredGroup): Builder
private function itemsQuery(FeaturedGroup $featuredGroup, Tenant $tenant): Builder
{
$query = CatalogItem::query()
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
->forTenantCatalog($tenant)
->whereAvailable()
->where(function (Builder $query): void {
$query
@@ -90,9 +91,10 @@ class FeaturedGroupService
private function paginateItems(
FeaturedGroup $featuredGroup,
Tenant $tenant,
int $page,
): LengthAwarePaginator {
$paginator = $this->itemsQuery($featuredGroup)->paginate(
$paginator = $this->itemsQuery($featuredGroup, $tenant)->paginate(
perPage: self::ITEMS_PER_PAGE,
pageName: 'page',
page: $page,

View File

@@ -13,7 +13,6 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
->name('categories.show');
Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']);
Route::post('catalog-items/{catalogItem}/variant-options', [CatalogController::class, 'variantOptions']);
Route::post('catalog-items', [CatalogController::class, 'store']);
});
require __DIR__.'/adminapp.php';

View File

@@ -20,7 +20,7 @@ class CatalogSelectionResolver
/** @var CatalogItem|null $catalogItem */
$catalogItem = CatalogItem::query()
->whereKey($catalogItemId)
->where('tenant_code', $tenant->codigo)
->forTenantCatalog($tenant)
->lockForUpdate()
->first();

View File

@@ -359,6 +359,13 @@ class StartCheckoutService
'cart_id' => __('api.purchase.catalog_item_wrong_tenant'),
]);
}
if ($tenant->active_event_id !== null
&& $item->catalogItem->event_id !== $tenant->active_event_id) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.catalog_item_missing'),
]);
}
}
}

View File

@@ -152,6 +152,7 @@ class Tenant extends Model
protected function casts(): array
{
return [
'active_event_id' => 'integer',
'search_product_layout' => ProductLayout::class,
'search_group_layout' => GroupLayout::class,
'search_items_per_page' => 'integer',

View File

@@ -278,7 +278,7 @@ class EntryService
private function entryQuery(Tenant $tenant): Builder
{
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->forTenantCatalog($tenant)
->where('slug', 'entrada');
}

View File

@@ -25,7 +25,7 @@ class AccommodationService
public function current(Tenant $tenant): ?CatalogItem
{
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->forTenantCatalog($tenant)
->where('slug', 'alojamiento')
->with([
'itemAttributes.attribute.options',
@@ -126,7 +126,7 @@ class AccommodationService
'nombre' => 'Alojamientos',
]);
$accommodation = CatalogItem::withTrashed()
->where('tenant_code', $tenant->codigo)
->forTenantCatalog($tenant)
->where('slug', 'alojamiento')
->lockForUpdate()
->first();

View File

@@ -22,7 +22,7 @@ class EntryService
public function all(Tenant $tenant): Collection
{
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->forTenantCatalog($tenant)
->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas'))
->whereHas('variants', fn ($query) => $query
->whereNull('replaced_by_variant_id')
@@ -84,7 +84,7 @@ class EntryService
{
$entry = CatalogItem::query()
->whereKey($entryId)
->where('tenant_code', $tenant->codigo)
->forTenantCatalog($tenant)
->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas'))
->firstOrFail();
@@ -96,7 +96,7 @@ class EntryService
{
$catalogItem = CatalogItem::query()
->whereKey($entry['id'])
->where('tenant_code', $tenant->codigo)
->forTenantCatalog($tenant)
->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas'))
->lockForUpdate()
->firstOrFail();

View File

@@ -33,7 +33,7 @@ class FoodService
public function current(Tenant $tenant): ?CatalogItem
{
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->forTenantCatalog($tenant)
->where('slug', 'comida')
->with([
'variants.catalogItem',
@@ -119,7 +119,7 @@ class FoodService
{
return DB::transaction(function () use ($tenant, $variants): CatalogItem {
$food = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->forTenantCatalog($tenant)
->where('slug', 'comida')
->lockForUpdate()
->firstOrFail();
@@ -233,7 +233,7 @@ class FoodService
'nombre' => 'Comidas',
]);
$food = CatalogItem::withTrashed()
->where('tenant_code', $tenant->codigo)
->forTenantCatalog($tenant)
->where('slug', 'comida')
->lockForUpdate()
->first();

View File

@@ -27,7 +27,7 @@ class MerchandiseService
public function all(Tenant $tenant): Collection
{
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->forTenantCatalog($tenant)
->whereHas('category', fn ($query) => $query->where('nombre', 'Merchandising'))
->with([
'itemAttributes.attribute.options',
@@ -164,7 +164,7 @@ class MerchandiseService
): CatalogItem {
$item = CatalogItem::query()
->whereKey($itemId)
->where('tenant_code', $tenant->codigo)
->forTenantCatalog($tenant)
->where('category_id', $category->id)
->lockForUpdate()
->first();

View File

@@ -150,23 +150,6 @@ function bodyFor(string $method, string $uri): ?array
return jsonBody(['nombre' => 'Tenant Demo Actualizado', 'site_title' => 'ShopIt Demo', 'primary_color' => '#111827', 'cart_editing_policy' => 'full']);
}
if ($key === 'POST api/tenants/{tenant:codigo}/catalog-items') {
return jsonBody([
'type' => 'standard',
'category_id' => '{{category_id}}',
'slug' => 'producto-demo',
'nombre' => 'Producto Demo',
'descripcion' => 'Producto creado desde Postman',
'precio' => 10000,
'inventory_policy' => 'tracked',
'inventory_subject' => 'product',
'max_units_per_user' => 5,
'has_tickets' => false,
'real_stock' => 100,
'images' => [TINY_PNG],
]);
}
if ($key === 'POST api/storage-test/s3/upload') {
return formDataBody(['path' => 'postman/test-file.png', 'expires_in_minutes' => '60'], ['file']);
}

View File

@@ -247,24 +247,14 @@ class BundleCatalogItemTest extends TestCase
]);
}
public function test_catalog_api_creates_and_returns_bundle_details(): void
public function test_catalog_api_returns_bundle_details_created_by_the_service(): void
{
$component = $this->createStandardItem('api-component', 8);
$bundle = $this->createBundle('api-bundle', [
['catalog_item_id' => $component->id, 'quantity' => 2],
], '75.00');
$bundleId = $this->postJson("/api/tenants/{$this->tenant->codigo}/catalog-items", [
'type' => CatalogItemType::Bundle->value,
'slug' => 'api-bundle',
'nombre' => 'API Bundle',
'precio' => 75,
'components' => [
['catalog_item_id' => $component->id, 'quantity' => 2],
],
])
->assertCreated()
->assertJsonPath('data.type', CatalogItemType::Bundle->value)
->json('data.id');
$this->getJson("/api/tenants/{$this->tenant->codigo}/catalog-items/{$bundleId}")
$this->getJson("/api/tenants/{$this->tenant->codigo}/catalog-items/{$bundle->id}")
->assertOk()
->assertJsonPath('data.type', CatalogItemType::Bundle->value)
->assertJsonPath('data.maximum_addable_quantity', 4)
@@ -274,7 +264,9 @@ class BundleCatalogItemTest extends TestCase
->assertJsonPath('data.components.0.variant_id', null)
->assertJsonPath('data.components.0.quantity', 2);
$this->postJson("/api/tenants/{$this->tenant->codigo}/catalog-items", [
$this->expectException(ValidationException::class);
$this->catalogService->create([
'tenant_code' => $this->tenant->codigo,
'type' => CatalogItemType::Bundle->value,
'slug' => 'bundle-with-stock',
'nombre' => 'Invalid Bundle',
@@ -283,9 +275,7 @@ class BundleCatalogItemTest extends TestCase
'components' => [
['catalog_item_id' => $component->id, 'quantity' => 1],
],
])
->assertUnprocessable()
->assertJsonValidationErrors(['real_stock']);
]);
}
public function test_bundle_components_are_preserved_when_the_bundle_is_soft_deleted(): void

View File

@@ -14,6 +14,7 @@ use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
@@ -22,6 +23,41 @@ class CatalogControllerTest extends TestCase
{
use RefreshDatabase;
public function test_active_event_limits_catalog_reads_to_its_items(): void
{
$tenant = $this->createTenant('active-event-catalog');
$group = $this->createGroup($tenant, ProductLayout::Row, 'Events', groupLayout: GroupLayout::Simple);
$group->update(['source_type' => FeaturedGroupSource::All]);
$activeEvent = Event::query()->create(['tenant_code' => $tenant->codigo, 'title' => 'Active']);
$otherEvent = Event::query()->create(['tenant_code' => $tenant->codigo, 'title' => 'Other']);
$activeItem = $this->createItem($tenant, 'Active item', Inventory::query()->create(['real_stock' => 5]));
$activeItem->event_id = $activeEvent->id;
$activeItem->save();
$otherItem = $this->createItem($tenant, 'Other item', Inventory::query()->create(['real_stock' => 5]));
$otherItem->event_id = $otherEvent->id;
$otherItem->save();
$tenant->update(['active_event_id' => $activeEvent->id]);
$this->getJson("/api/tenants/{$tenant->codigo}/catalog")
->assertOk()
->assertJsonCount(1, '0.items')
->assertJsonPath('0.items.0.id', $activeItem->id)
->assertJsonMissing(['nombre' => 'Other item']);
$this->getJson("/api/tenants/{$tenant->codigo}/catalog-items?q=item")
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $activeItem->id);
$this->getJson("/api/tenants/{$tenant->codigo}/catalog-items/{$otherItem->id}")
->assertNotFound();
$this->getJson("/api/tenants/{$tenant->codigo}/catalog-items/{$activeItem->id}")
->assertOk();
}
public function test_row_and_column_with_cart_return_item_details_variants_and_maximum_quantity(): void
{
$tenant = $this->createTenant('catalog-index');

View File

@@ -1,156 +0,0 @@
<?php
namespace Tests\Feature\Catalog;
use App\Shared\Attachable\Enums\AttachmentType;
use App\Shared\Attachable\Models\Attachment;
use App\Domains\Commerce\Catalog\Models\Attribute;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Shared\Enums\FieldType;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class CatalogItemControllerTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_a_catalog_item_with_item_and_variant_images(): void
{
Storage::fake('s3');
$tenant = $this->createTenant();
$attribute = Attribute::query()->create([
'tenant_codigo' => $tenant->codigo,
'codigo' => 'size',
'nombre' => 'Size',
'type' => FieldType::String,
]);
$image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
$response = $this->postJson("/api/tenants/{$tenant->codigo}/catalog-items", [
'slug' => 'shirt',
'nombre' => 'Shirt',
'group_order' => 7,
'precio' => 100,
'max_units_per_user' => 4,
'attribute_codes' => [$attribute->codigo],
'images' => [$image, $image],
'variants' => [
[
'real_stock' => 5,
'values' => ['size' => 'M'],
'images' => [$image],
],
],
]);
$response
->assertCreated()
->assertJsonPath('data.nombre', 'Shirt')
->assertJsonMissingPath('data.event_id')
->assertJsonMissingPath('data.group_order')
->assertJsonPath('data.max_units_per_user', 4)
->assertJsonCount(2, 'data.images')
->assertJsonCount(1, 'data.variants')
->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(7, $item->group_order);
$this->assertNull($item->event_id);
$this->assertSame([0], $variant->attachments()->get()->pluck('pivot.orden')->all());
$this->assertDatabaseHas('catalog_items_attachments', [
'catalog_item_id' => $item->id,
'variant_id' => $variant->id,
'orden' => 0,
'is_enabled' => true,
]);
}
public function test_event_id_is_an_internal_nullable_catalog_item_link(): void
{
$this->assertTrue(Schema::hasColumn('catalog_items', 'event_id'));
$tenant = $this->createTenant('catalog-event-link');
$event = Event::query()->create([
'tenant_code' => $tenant->codigo,
'title' => 'Internal event',
]);
$this->postJson("/api/tenants/{$tenant->codigo}/catalog-items", [
'slug' => 'client-event-id',
'nombre' => 'Client event ID',
'precio' => 100,
'real_stock' => 1,
'event_id' => $event->id,
])->assertUnprocessable()->assertJsonValidationErrors('event_id');
$this->assertDatabaseMissing('catalog_items', ['slug' => 'client-event-id']);
}
public function test_it_validates_images_before_creating_the_catalog_item(): void
{
$tenant = $this->createTenant('validation');
$this->postJson("/api/tenants/{$tenant->codigo}/catalog-items", [
'slug' => 'invalid-image',
'nombre' => 'Invalid image',
'precio' => 100,
'real_stock' => 1,
'images' => ['not-an-image'],
])->assertUnprocessable()->assertJsonValidationErrors('images.0');
$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");
$footerLogo = $this->createAttachment("{$code}-footer");
return Tenant::query()->create([
'codigo' => $code,
'nombre' => ucfirst($code),
'dominio' => "{$code}.local",
'primary_color' => '#000000',
'secondary_color' => '#000000',
'danger_color' => '#000000',
'success_color' => '#000000',
'header_bg_color' => '#000000',
'footer_bg_color' => '#000000',
'header_logo_id' => $headerLogo->id,
'footer_logo_id' => $footerLogo->id,
]);
}
private function createAttachment(string $name): Attachment
{
return Attachment::query()->create([
'path' => "test/{$name}.png",
'filename' => "{$name}.png",
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
}
}