Compare commits

...

9 Commits

Author SHA1 Message Date
67e6211857 merge: client-owned integrations into dev 2026-08-18 17:34:31 -03:00
58471cb13d fix(seed): assign Sonder tenant to its client 2026-08-18 17:33:05 -03:00
864bed0113 feat(onticket): group event tenants under shared client 2026-08-18 17:33:05 -03:00
ced7d594e8 refactor(integrations): move configuration ownership to clients 2026-08-18 17:32:44 -03:00
38e87fff6c feat(clients): add client ownership for tenants 2026-08-18 17:32:27 -03:00
a8973f6171 feat: separate tenant domain and base path, update related models, requests, and tests 2026-08-18 17:23:01 -03:00
8e26611097 feat: add display_cart_item_images feature to tenants and update related resources 2026-08-18 16:46:45 -03:00
817d0de6d2 feat(migration): move integrations from tenants to clients and update schema 2026-08-18 16:33:05 -03:00
a2298c9de2 feat: add cart editing feature to tenants
- Added 'cart_editing_enabled' attribute to Tenant model with default value true.
- Updated StoreTenantRequest and UpdateTenantRequest to include validation for 'cart_editing_enabled'.
- Modified TenantResource to expose 'cart_editing_enabled' in API responses.
- Created migration to add 'cart_editing_enabled' column to tenants table, defaulting to true, and set to false for specific tenant.
- Updated DesfilePuraTendenciaSeeder to set 'cart_editing_enabled' to false for the desfile tenant.
- Enhanced Postman collection generation script to include 'cart_editing_enabled' in tenant creation and update requests.
- Added tests to verify the correct behavior of 'cart_editing_enabled' during migrations and tenant provisioning.
2026-08-18 16:32:17 -03:00
26 changed files with 6380 additions and 1482 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -155,16 +155,19 @@ class GoogleAuthService
$parts = parse_url($returnUrl);
if (! is_array($parts)
|| ! isset($parts['scheme'], $parts['host'])
|| isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment'])
|| ($parts['path'] ?? '') !== '') {
|| isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment'])) {
return false;
}
$scheme = strtolower($parts['scheme']);
$host = TenantDomainNormalizer::normalize($parts['host']);
$tenantDomain = TenantDomainNormalizer::normalize($tenant->dominio);
$returnPath = TenantDomainNormalizer::normalizePath($parts['path'] ?? '/');
if ($host === null || $tenantDomain === null || $host !== $tenantDomain) {
if ($host === null
|| $tenantDomain === null
|| $host !== $tenantDomain
|| $returnPath !== $tenant->base_path) {
return false;
}

View File

@@ -14,14 +14,15 @@ class TenantBootstrapService
public function get(string $domain, string $path = '/'): Tenant
{
$candidateKeys = TenantDomainNormalizer::tenantKeyCandidates($domain, $path);
$tenantsByDomain = Tenant::query()
->whereIn('dominio', $candidateKeys)
$candidateBasePaths = TenantDomainNormalizer::basePathCandidates($path);
$tenantsByBasePath = Tenant::query()
->where('dominio', $domain)
->whereIn('base_path', $candidateBasePaths)
->get()
->keyBy('dominio');
->keyBy('base_path');
$tenant = collect($candidateKeys)
->map(fn (string $candidate): ?Tenant => $tenantsByDomain->get($candidate))
$tenant = collect($candidateBasePaths)
->map(fn (string $candidate): ?Tenant => $tenantsByBasePath->get($candidate))
->first(fn (?Tenant $candidate): bool => $candidate !== null);
if (! $tenant instanceof Tenant) {

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Cart\Resources;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -19,12 +20,14 @@ class CartItemResource extends JsonResource
{
$selectedItem = $this->selectedItem();
$imageUrl = null;
$tenant = $request->route('tenant');
$displayImage = ! $tenant instanceof Tenant || $tenant->display_cart_item_images;
if ($selectedItem?->relationLoaded('attachments')) {
if ($displayImage && $selectedItem?->relationLoaded('attachments')) {
$imageUrl = $selectedItem->attachments->first()?->getTemporaryUrl(1440);
}
if ($imageUrl === null && $this->catalogItem?->relationLoaded('attachments')) {
if ($displayImage && $imageUrl === null && $this->catalogItem?->relationLoaded('attachments')) {
$imageUrl = $this->catalogItem->attachments->first()?->getTemporaryUrl(1440);
}

View File

@@ -61,6 +61,10 @@ class NotificationMailService
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
default => $tenant->dominio,
};
$recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT
&& $tenant->base_path !== '/'
? $tenant->base_path
: '';
$recoveryQuery = ['email' => $attempt->user->email];
if (
$channel === PasswordResetRequested::CHANNEL_SCANNER
@@ -70,7 +74,7 @@ class NotificationMailService
}
$recoveryUrl = $recoveryDomain === null
? null
: 'https://'.$recoveryDomain.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
$this->mailService
->forTenant($tenantCode)

View File

@@ -6,6 +6,7 @@ use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -15,8 +16,13 @@ class PurchaseItemResource extends JsonResource
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$tenant = $request->route('tenant');
$displayImage = ! $tenant instanceof Tenant || $tenant->display_cart_item_images;
if ($this->resource instanceof PurchaseItem) {
$imageUrl = $this->imageAttachment?->getTemporaryUrl(1440);
$imageUrl = $displayImage
? $this->imageAttachment?->getTemporaryUrl(1440)
: null;
$attributes = $this->variant_attributes ?? [];
return [
@@ -42,7 +48,9 @@ class PurchaseItemResource extends JsonResource
$quantity = (int) ($this->cantidad ?? 0);
$unitPrice = $this->resolveUnitPrice($selectedItem);
$lineTotal = $unitPrice * $quantity;
$imageUrl = $this->resolveImageUrl($selectedItem, $catalogItem);
$imageUrl = $displayImage
? $this->resolveImageUrl($selectedItem, $catalogItem)
: null;
return [
'id' => $this->id,

View File

@@ -24,6 +24,7 @@ use Illuminate\Support\Facades\Schema;
'codigo',
'nombre',
'dominio',
'base_path',
'site_title',
'primary_color',
'secondary_color',
@@ -43,6 +44,8 @@ use Illuminate\Support\Facades\Schema;
'display_categories',
'display_seach_bar',
'display_cart',
'cart_editing_enabled',
'display_cart_item_images',
'scanner_category_validation_enabled',
'event_title',
'event_location',
@@ -53,12 +56,15 @@ class Tenant extends Model
use HasFactory;
protected $attributes = [
'base_path' => '/',
'search_product_layout' => ProductLayout::ColumnWithImage->value,
'search_group_layout' => GroupLayout::Paginated->value,
'search_items_per_page' => 12,
'display_categories' => true,
'display_seach_bar' => true,
'display_cart' => true,
'cart_editing_enabled' => true,
'display_cart_item_images' => true,
'scanner_category_validation_enabled' => true,
];
@@ -108,6 +114,8 @@ class Tenant extends Model
'display_categories' => 'boolean',
'display_seach_bar' => 'boolean',
'display_cart' => 'boolean',
'cart_editing_enabled' => 'boolean',
'display_cart_item_images' => 'boolean',
'scanner_category_validation_enabled' => 'boolean',
];
}

View File

@@ -15,6 +15,8 @@ class StoreTenantRequest extends FormRequest
{
protected bool $hasInvalidDomain = false;
protected bool $hasInvalidBasePath = false;
public function authorize(): bool
{
return true;
@@ -23,13 +25,21 @@ class StoreTenantRequest extends FormRequest
protected function prepareForValidation(): void
{
$rawDomain = $this->input('dominio');
$normalizedDomain = TenantDomainNormalizer::normalizeTenantKey($rawDomain);
$hasExplicitBasePath = $this->has('base_path');
$rawBasePath = $hasExplicitBasePath
? $this->input('base_path')
: TenantDomainNormalizer::pathFromDomain($rawDomain);
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
$normalizedBasePath = TenantDomainNormalizer::normalizePath($rawBasePath);
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
&& $normalizedDomain === null;
$this->hasInvalidBasePath = ($hasExplicitBasePath && ! is_string($rawBasePath))
|| $normalizedBasePath === null;
$this->merge([
'dominio' => $normalizedDomain,
'base_path' => $normalizedBasePath,
]);
}
@@ -54,7 +64,21 @@ class StoreTenantRequest extends FormRequest
'required',
'string',
'max:255',
Rule::unique('tenants', 'dominio'),
Rule::unique('tenants', 'dominio')
->where('base_path', $this->input('base_path')),
],
'base_path' => [
'bail',
function (string $attribute, mixed $value, Closure $fail): void {
if ($this->hasInvalidBasePath) {
$fail("The {$attribute} field must contain a valid URL path.");
}
},
'required',
'string',
'max:255',
Rule::unique('tenants', 'base_path')
->where('dominio', $this->input('dominio')),
],
'site_title' => ['sometimes', 'nullable', 'string', 'max:255'],
'primary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
@@ -83,6 +107,8 @@ class StoreTenantRequest extends FormRequest
'display_categories' => ['sometimes', 'boolean'],
'display_seach_bar' => ['sometimes', 'boolean'],
'display_cart' => ['sometimes', 'boolean'],
'cart_editing_enabled' => ['sometimes', 'boolean'],
'display_cart_item_images' => ['sometimes', 'boolean'],
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
'website_type_code' => [
'required_with:extras',

View File

@@ -15,6 +15,8 @@ class UpdateTenantRequest extends FormRequest
{
protected bool $hasInvalidDomain = false;
protected bool $hasInvalidBasePath = false;
public function authorize(): bool
{
return true;
@@ -24,14 +26,29 @@ class UpdateTenantRequest extends FormRequest
{
if ($this->has('dominio')) {
$rawDomain = $this->input('dominio');
$normalizedDomain = TenantDomainNormalizer::normalizeTenantKey($rawDomain);
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
$embeddedBasePath = TenantDomainNormalizer::pathFromDomain($rawDomain);
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
&& $normalizedDomain === null;
&& ($normalizedDomain === null || $embeddedBasePath === null);
$this->merge([
'dominio' => $normalizedDomain,
]);
if (! $this->has('base_path') && $embeddedBasePath !== null && $embeddedBasePath !== '/') {
$this->merge(['base_path' => $embeddedBasePath]);
}
}
if ($this->has('base_path')) {
$rawBasePath = $this->input('base_path');
$normalizedBasePath = TenantDomainNormalizer::normalizePath($rawBasePath);
$this->hasInvalidBasePath = ! is_string($rawBasePath)
|| $normalizedBasePath === null;
$this->merge(['base_path' => $normalizedBasePath]);
}
}
@@ -42,6 +59,8 @@ class UpdateTenantRequest extends FormRequest
{
/** @var Tenant|null $tenant */
$tenant = $this->route('tenant');
$domain = $this->input('dominio', $tenant?->dominio);
$basePath = $this->input('base_path', $tenant?->base_path ?? '/');
$logoRule = ['nullable', new ImageOrBase64Rule];
@@ -64,7 +83,23 @@ class UpdateTenantRequest extends FormRequest
'nullable',
'string',
'max:255',
Rule::unique('tenants', 'dominio')->ignore($tenant?->id),
Rule::unique('tenants', 'dominio')
->where('base_path', $basePath)
->ignore($tenant?->id),
],
'base_path' => [
'bail',
function (string $attribute, mixed $value, Closure $fail): void {
if ($this->hasInvalidBasePath) {
$fail("The {$attribute} field must contain a valid URL path.");
}
},
'nullable',
'string',
'max:255',
Rule::unique('tenants', 'base_path')
->where('dominio', $domain)
->ignore($tenant?->id),
],
'site_title' => ['sometimes', 'nullable', 'string', 'max:255'],
'primary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
@@ -93,6 +128,8 @@ class UpdateTenantRequest extends FormRequest
'display_categories' => ['sometimes', 'boolean'],
'display_seach_bar' => ['sometimes', 'boolean'],
'display_cart' => ['sometimes', 'boolean'],
'cart_editing_enabled' => ['sometimes', 'boolean'],
'display_cart_item_images' => ['sometimes', 'boolean'],
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
];
}

View File

@@ -28,6 +28,7 @@ class TenantResource extends JsonResource
'codigo' => $this->codigo,
'nombre' => $this->nombre,
'dominio' => $this->dominio,
'base_path' => $this->base_path,
'site_title' => $this->site_title
?? $this->websiteType?->site_title
?? 'ShopitFront',
@@ -74,6 +75,8 @@ class TenantResource extends JsonResource
'display_categories' => $this->display_categories,
'display_seach_bar' => $this->display_seach_bar,
'display_cart' => $this->display_cart,
'cart_editing_enabled' => $this->cart_editing_enabled,
'display_cart_item_images' => $this->display_cart_item_images,
'scanner_category_validation_enabled' => $this->scanner_category_validation_enabled,
'social_media' => $this->whenLoaded(
'socialMedia',

View File

@@ -42,13 +42,7 @@ class TenantDomainNormalizer
return null;
}
if ($path === null && is_string($domain)) {
$decodedDomain = trim(urldecode($domain));
$candidate = str_contains($decodedDomain, '://')
? $decodedDomain
: "//{$decodedDomain}";
$path = parse_url($candidate, PHP_URL_PATH) ?: '/';
}
$path ??= self::pathFromDomain($domain);
$normalizedPath = self::normalizePath($path);
@@ -59,6 +53,25 @@ class TenantDomainNormalizer
return $host.($normalizedPath === '/' ? '' : $normalizedPath);
}
public static function pathFromDomain(mixed $domain): ?string
{
if (! is_string($domain)) {
return null;
}
$decodedDomain = trim(urldecode($domain));
if ($decodedDomain === '') {
return null;
}
$candidate = str_contains($decodedDomain, '://')
? $decodedDomain
: "//{$decodedDomain}";
return self::normalizePath(parse_url($candidate, PHP_URL_PATH) ?: '/');
}
public static function normalizePath(mixed $path): ?string
{
if (! is_string($path)) {
@@ -98,9 +111,23 @@ class TenantDomainNormalizer
public static function tenantKeyCandidates(mixed $domain, mixed $path): array
{
$host = self::normalize($domain);
if ($host === null) {
return [];
}
return array_map(
static fn (string $basePath): string => $host.($basePath === '/' ? '' : $basePath),
self::basePathCandidates($path),
);
}
/** @return list<string> */
public static function basePathCandidates(mixed $path): array
{
$normalizedPath = self::normalizePath($path);
if ($host === null || $normalizedPath === null) {
if ($normalizedPath === null) {
return [];
}
@@ -111,11 +138,11 @@ class TenantDomainNormalizer
$candidates = [];
while ($segments !== []) {
$candidates[] = $host.'/'.implode('/', $segments);
$candidates[] = '/'.implode('/', $segments);
array_pop($segments);
}
$candidates[] = $host;
$candidates[] = '/';
return $candidates;
}

View File

@@ -6,7 +6,7 @@ Es la raíz del modelo multi-tenant. Gestiona organizaciones/sitios, tipos de we
## Modelo
- `Tenant`: entidad principal, resuelta en rutas por `codigo`; relaciona catálogo, fechas, redes, menús y configuración visual.
- `Tenant`: entidad principal, resuelta en rutas por `codigo`; relaciona catálogo, fechas, redes, menús y configuración visual. Su ubicación pública se representa con `dominio` y `base_path` (`/` para la raíz).
- `WebsiteType`: plantilla o tipo de sitio disponible.
- `WebsiteTypeExtra`: definición de un extra y su configuración admitida.
- `WebsiteExtra`: valor resuelto y estado del extra para un tenant.
@@ -20,6 +20,8 @@ Es la raíz del modelo multi-tenant. Gestiona organizaciones/sitios, tipos de we
- `WebsiteExtraService`: construye reglas dinámicas, crea, actualiza y habilita/deshabilita extras.
- `TenantDomainNormalizer`: normaliza dominios antes de resolver el tenant.
El par `(dominio, base_path)` es único. Un mismo dominio puede alojar el tenant raíz y otros tenants en prefijos diferentes. El bootstrap compara segmentos completos del path y selecciona el prefijo más específico.
## Endpoints
- Recurso REST público/administrativo `/tenants`.

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private const DESFILE_TENANT_CODE = 'desfile_pura_tendencia';
public function up(): void
{
Schema::table('tenants', function (Blueprint $table): void {
$table->boolean('cart_editing_enabled')->default(true)->after('display_cart');
});
DB::table('tenants')
->where('codigo', self::DESFILE_TENANT_CODE)
->update(['cart_editing_enabled' => false]);
}
public function down(): void
{
Schema::table('tenants', function (Blueprint $table): void {
$table->dropColumn('cart_editing_enabled');
});
}
};

View File

@@ -0,0 +1,109 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$locations = DB::table('tenants')
->select(['id', 'dominio'])
->orderBy('id')
->get()
->mapWithKeys(function (object $tenant): array {
[$domain, $basePath] = $this->splitTenantLocation($tenant->dominio, $tenant->id);
return [$tenant->id => compact('domain', 'basePath')];
});
$duplicates = $locations
->groupBy(fn (array $location): string => $location['domain'].'|'.$location['basePath'])
->filter(fn ($matches): bool => $matches->count() > 1);
if ($duplicates->isNotEmpty()) {
throw new RuntimeException(
'Cannot create the tenant domain/base-path unique index; duplicates exist: '
.$duplicates->keys()->implode(', ')
);
}
Schema::table('tenants', function (Blueprint $table) {
$table->string('base_path')->default('/')->after('dominio');
$table->dropUnique('tenants_dominio_unique');
});
foreach ($locations as $tenantId => $location) {
DB::table('tenants')
->where('id', $tenantId)
->update([
'dominio' => $location['domain'],
'base_path' => $location['basePath'],
]);
}
Schema::table('tenants', function (Blueprint $table) {
$table->unique(
['dominio', 'base_path'],
'tenants_dominio_base_path_unique',
);
});
}
public function down(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->dropUnique('tenants_dominio_base_path_unique');
});
DB::table('tenants')
->select(['id', 'dominio', 'base_path'])
->orderBy('id')
->each(function (object $tenant): void {
$tenantKey = $tenant->dominio.($tenant->base_path === '/' ? '' : $tenant->base_path);
DB::table('tenants')
->where('id', $tenant->id)
->update(['dominio' => $tenantKey]);
});
Schema::table('tenants', function (Blueprint $table) {
$table->dropColumn('base_path');
$table->unique('dominio', 'tenants_dominio_unique');
});
}
/** @return array{string, string} */
private function splitTenantLocation(mixed $value, int $tenantId): array
{
if (! is_string($value) || trim(urldecode($value)) === '') {
throw new RuntimeException("Cannot migrate tenant location for tenant {$tenantId}.");
}
$decodedValue = trim(urldecode($value));
$candidate = str_contains($decodedValue, '://')
? $decodedValue
: "//{$decodedValue}";
$host = parse_url($candidate, PHP_URL_HOST);
$path = parse_url($candidate, PHP_URL_PATH) ?: '/';
if (! is_string($host) || $host === '') {
throw new RuntimeException("Cannot migrate tenant location '{$value}' for tenant {$tenantId}.");
}
$segments = array_values(array_filter(
explode('/', preg_replace('#/+#', '/', $path) ?? ''),
static fn (string $segment): bool => $segment !== '',
));
if (array_intersect($segments, ['.', '..']) !== []) {
throw new RuntimeException("Cannot migrate tenant base path '{$path}' for tenant {$tenantId}.");
}
return [
strtolower($host),
$segments === [] ? '/' : '/'.implode('/', $segments),
];
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private const DESFILE_TENANT_CODE = 'desfile_pura_tendencia';
public function up(): void
{
Schema::table('tenants', function (Blueprint $table): void {
$table->boolean('display_cart_item_images')->default(true)->after('cart_editing_enabled');
});
DB::table('tenants')
->where('codigo', self::DESFILE_TENANT_CODE)
->update(['display_cart_item_images' => false]);
}
public function down(): void
{
Schema::table('tenants', function (Blueprint $table): void {
$table->dropColumn('display_cart_item_images');
});
}
};

View File

@@ -72,6 +72,8 @@ class DesfilePuraTendenciaSeeder extends Seeder
'display_categories' => false,
'display_seach_bar' => false,
'display_cart' => true,
'cart_editing_enabled' => false,
'display_cart_item_images' => false,
'scanner_category_validation_enabled' => false,
'website_type_code' => 'onticket',
'header_logo' => $this->uploadedImage(

View File

@@ -16,6 +16,8 @@ class TenantSeeder extends Seeder
{
private const ONTICKET_CLIENT_CODE = 'onticket';
private const SONDER_CLIENT_CODE = 'sonder';
private const SOCIAL_MEDIA = [
[
'code' => 'instagram',
@@ -43,6 +45,11 @@ class TenantSeeder extends Seeder
public function run(): void
{
$sonderClient = Client::query()->firstOrCreate(
['code' => self::SONDER_CLIENT_CODE],
['name' => 'Sonder'],
);
$onTicketClient = Client::query()->firstOrCreate(
['code' => self::ONTICKET_CLIENT_CODE],
['name' => 'OnTicket'],
@@ -55,6 +62,7 @@ class TenantSeeder extends Seeder
->delete();
$this->tenantService->create([
'client_id' => $sonderClient->id,
'codigo' => 'sonder',
'nombre' => 'Sonder',
'dominio' => 'localhost',

View File

@@ -0,0 +1,451 @@
<?php
declare(strict_types=1);
/**
* Regenerates the canonical Postman collection from Laravel's registered routes.
*
* Run from the backend root with:
* php postman/generate-shopit-collection.php
*/
$root = dirname(__DIR__);
chdir($root);
$command = escapeshellarg(PHP_BINARY).' artisan route:list --path=api --json';
$routeJson = shell_exec($command);
if (! is_string($routeJson) || trim($routeJson) === '') {
fwrite(STDERR, "Unable to read Laravel routes.\n");
exit(1);
}
$routes = json_decode($routeJson, true, flags: JSON_THROW_ON_ERROR);
const TINY_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
/** @return array<string, mixed> */
function jsonBody(array $payload): array
{
$raw = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$raw = preg_replace('/"\{\{([a-z_]+_id)\}\}"/', '{{$1}}', $raw);
return [
'mode' => 'raw',
'raw' => $raw,
'options' => ['raw' => ['language' => 'json']],
];
}
/** @param array<string, string> $fields
* @return array<string, mixed>
*/
function formDataBody(array $fields, array $fileFields = []): array
{
$formData = [];
foreach ($fields as $key => $value) {
$formData[] = ['key' => $key, 'value' => $value, 'type' => 'text'];
}
foreach ($fileFields as $key) {
$formData[] = ['key' => $key, 'type' => 'file', 'src' => []];
}
return ['mode' => 'formdata', 'formdata' => $formData];
}
/** @return array<string, mixed>|null */
function bodyFor(string $method, string $uri): ?array
{
$key = $method.' '.$uri;
$exact = [
'POST api/auth/google/exchange' => ['oauth_code' => '{{oauth_code}}', 'tenant_codigo' => '{{tenant_code}}'],
'POST api/register' => ['tenant_codigo' => '{{tenant_code}}', 'nombre_apellido' => 'Usuario Demo', 'email' => '{{user_email}}', 'password' => '{{user_password}}', 'password_confirmation' => '{{user_password}}', 'dni' => '30123456', 'telefono' => '+5491112345678'],
'POST api/login' => ['email' => '{{user_email}}', 'password' => '{{user_password}}', 'tenant_codigo' => '{{tenant_code}}'],
'PUT api/me' => ['nombre_apellido' => 'Usuario Demo Actualizado', 'email' => '{{user_email}}', 'dni' => '30123456', 'telefono' => '+5491112345678'],
'POST api/password/reset-attempts' => ['tenant_codigo' => '{{tenant_code}}', 'email' => '{{user_email}}'],
'POST api/password/reset-attempts/validate' => ['email' => '{{user_email}}', 'codigo' => '{{reset_code}}'],
'POST api/password/reset' => ['email' => '{{user_email}}', 'codigo' => '{{reset_code}}', 'password' => '{{user_password}}', 'password_confirmation' => '{{user_password}}'],
'POST api/clients' => ['code' => 'cliente-demo', 'name' => 'Cliente Demo'],
'PUT api/clients/{client}' => ['code' => 'cliente-demo', 'name' => 'Cliente Demo Actualizado'],
'PATCH api/clients/{client}' => ['name' => 'Cliente Demo Actualizado'],
'POST api/integrations' => ['integration_code' => 'telepagos', 'name' => 'Telepagos', 'url' => 'https://api.example.com', 'integration_data_schema' => ['api_key' => ['required', 'string']], 'requires_client_configuration' => true],
'PUT api/integrations/{integration}' => ['name' => 'Telepagos', 'url' => 'https://api.example.com', 'requires_client_configuration' => true],
'PUT api/clients/{client}/integrations/{integration_code}' => ['integration_data' => ['api_key' => 'replace-me']],
'POST api/menues' => ['code' => 'demo', 'label' => 'Demo', 'parent_menu_code' => null, 'content_type' => 'static', 'static_content_schema' => ['title' => ['required', 'string']], 'route' => '/demo'],
'PUT api/menues/{menue}' => ['label' => 'Demo actualizado', 'route' => '/demo'],
'PATCH api/menues/{menue}' => ['label' => 'Demo actualizado'],
'POST api/{tenant_code}/menues/{menu_code}' => ['static_content' => ['title' => 'Contenido demo']],
'POST api/webhooks/telepagos/{client}' => ['id' => 'payment-id-demo'],
'POST api/{tenant_code}/mail-test/send' => ['to' => 'destinatario@example.com', 'subject' => 'Prueba ShopIt', 'message' => 'Correo de prueba enviado desde Postman.'],
'POST api/tenants/{tenant:codigo}/cart/items' => ['catalog_item_id' => '{{catalog_item_id}}', 'variant_id' => '{{variant_id}}', 'cantidad' => 1],
'PATCH api/tenants/{tenant:codigo}/cart/items/{cartItem}' => ['cantidad' => 2, 'variant_id' => '{{variant_id}}'],
'POST api/tenants/{tenant:codigo}/catalog-items/{catalogItem}/variant-options' => ['selected_values' => ['color' => 'azul'], 'cart_item_id' => '{{cart_item_id}}'],
'POST api/tenants/{tenant:codigo}/compras/start-checkout' => ['cart_id' => '{{cart_id}}'],
'PATCH api/tenants/{tenant:codigo}/compras/{compra}/customer-data' => ['dni' => '30123456', 'telefono' => '+5491112345678', 'nombre_apellido' => 'Usuario Demo', 'email' => '{{user_email}}'],
'PATCH api/tenants/{tenant:codigo}/compras/{compra}/items/{item}' => ['quantity' => 2],
'POST api/tenants/{tenant:codigo}/compras/{compra}/payment-intent' => ['method' => 'transfer', 'transfer_payer_dni' => '30123456'],
'POST api/tenants/{tenant:codigo}/tickets/pdf' => ['ticket_ids' => [1]],
'POST api/v1/adminapp/login' => ['email' => '{{admin_email}}', 'password' => '{{admin_password}}'],
'POST api/v1/adminapp/password/reset-attempts' => ['email' => '{{admin_email}}'],
'POST api/v1/adminapp/password/reset-attempts/validate' => ['email' => '{{admin_email}}', 'codigo' => '{{reset_code}}'],
'POST api/v1/adminapp/password/reset' => ['email' => '{{admin_email}}', 'codigo' => '{{reset_code}}', 'password' => '{{admin_password}}', 'password_confirmation' => '{{admin_password}}'],
'PUT api/v1/adminapp/tenant/event' => ['title' => 'Evento Demo', 'location' => 'Buenos Aires', 'dates' => [['date' => '2026-12-01', 'start_time' => '18:00', 'end_time' => '23:00']], 'social_media' => [['code' => 'instagram', 'url' => 'https://instagram.com/example', 'orden' => 0]]],
'POST api/v1/adminapp/tenant/featured-groups' => ['category_name' => 'Destacados', 'is_featured' => true],
'PUT api/v1/adminapp/tenant/featured-groups/{featuredGroup}' => ['category_name' => 'Destacados', 'is_featured' => true],
'POST api/v1/adminapp/tenant/staff' => ['nombre_apellido' => 'Operador Demo', 'dni' => '30123456', 'email' => 'operador@example.com', 'category_ids' => [1]],
'PUT api/v1/adminapp/tenant/staff/{staff}' => ['nombre_apellido' => 'Operador Demo', 'dni' => '30123456', 'email' => 'operador@example.com', 'category_ids' => [1]],
'PATCH api/v1/adminapp/tenant/staff/{staff}' => ['nombre_apellido' => 'Operador Demo', 'dni' => '30123456', 'email' => 'operador@example.com', 'category_ids' => [1]],
'PUT api/v1/adminapp/tenant/website-extras/{websiteExtraCode}' => ['enabled' => true, 'content' => ['title' => 'Contenido demo']],
'PATCH api/v1/adminapp/tenant/website-extras/{websiteExtraCode}/toggle' => ['enabled' => true],
'POST api/v1/adminapp/tenant/accommodations' => ['variants' => [['title' => 'Habitación doble', 'description' => 'Dos personas', 'stock' => 10, 'price' => 100000]]],
'POST api/v1/adminapp/tenant/entries' => ['entries' => [['title' => 'Entrada general', 'description' => 'Acceso general', 'event_date_ids' => [1], 'stock' => 100, 'price' => 25000]]],
'POST api/v1/adminapp/tenant/foods' => ['variants' => [['event_date_id' => 1, 'schedule' => '21:00', 'service' => 'Cena', 'description' => 'Menú completo', 'stock' => 100, 'price' => 15000]]],
'POST api/v1/adminapp/tenant/merchandise' => ['items' => [['title' => 'Camiseta', 'description' => 'Camiseta oficial', 'max_units_per_user' => 2, 'variants' => [['color' => 'Azul', 'size' => 'M', 'stock' => 20, 'price' => 30000]]]]],
'PUT api/v1/adminapp/tenant/desfile/entries' => ['rows' => [['type' => 'General', 'sector' => 'A', 'row' => 1, 'max_seat' => 20, 'price' => 25000]]],
'PATCH api/v1/adminapp/tenant/desfile/entries/image' => ['is_enabled' => true],
'POST api/v1/scanner/login' => ['email' => '{{scanner_email}}', 'password' => '{{scanner_password}}'],
'POST api/v1/scanner/password/reset-attempts' => ['email' => '{{scanner_email}}'],
'POST api/v1/scanner/password/reset-attempts/validate' => ['email' => '{{scanner_email}}', 'codigo' => '{{reset_code}}'],
'POST api/v1/scanner/password/reset' => ['email' => '{{scanner_email}}', 'codigo' => '{{reset_code}}', 'password' => '{{scanner_password}}', 'password_confirmation' => '{{scanner_password}}'],
];
if (isset($exact[$key])) {
return jsonBody($exact[$key]);
}
if ($key === 'POST api/tenants') {
return jsonBody([
'client_id' => '{{client_id}}',
'codigo' => '{{tenant_code}}',
'nombre' => 'Tenant Demo',
'dominio' => '{{tenant_domain}}',
'site_title' => 'ShopIt Demo',
'primary_color' => '#111827',
'secondary_color' => '#2563EB',
'danger_color' => '#DC2626',
'success_color' => '#16A34A',
'header_bg_color' => '#FFFFFF',
'footer_bg_color' => '#111827',
'header_logo' => TINY_PNG,
'footer_logo' => TINY_PNG,
'search_product_layout' => 'column_with_image',
'search_group_layout' => 'paginated',
'search_items_per_page' => 12,
'display_categories' => true,
'display_seach_bar' => true,
'display_cart' => true,
'cart_editing_enabled' => true,
'website_type_code' => 'shopit',
]);
}
if (in_array($key, ['PUT api/tenants/{tenant}', 'PATCH api/tenants/{tenant}'], true)) {
return jsonBody(['nombre' => 'Tenant Demo Actualizado', 'site_title' => 'ShopIt Demo', 'primary_color' => '#111827', 'cart_editing_enabled' => true]);
}
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']);
}
if ($key === 'POST api/v1/adminapp/tenant/desfile/entries/image') {
return formDataBody(['is_enabled' => '1'], ['image']);
}
return null;
}
/** @return array<int, array{key: string, value: string, disabled?: bool}> */
function queryFor(string $uri): array
{
return match ($uri) {
'api/tenants/bootstrap' => [['key' => 'dominio', 'value' => '{{tenant_domain}}'], ['key' => 'path', 'value' => '/']],
'api/v1/adminapp/bootstrap/{dominio}', 'api/v1/scanner/bootstrap/{dominio}' => [['key' => 'path', 'value' => '/']],
'api/tenants/{tenant:codigo}/catalog-items' => [['key' => 'q', 'value' => 'demo'], ['key' => 'page', 'value' => '1']],
'api/tenants/{tenant:codigo}/catalog-items/{catalogItem}' => [['key' => 'variant_id', 'value' => '{{variant_id}}', 'disabled' => true]],
'api/tenants/{tenant:codigo}/catalog/featured-groups/{featuredGroup}/items',
'api/tenants/{tenant:codigo}/categories/{category}' => [['key' => 'page', 'value' => '1']],
'api/tenants/{tenant:codigo}/compras' => [['key' => 'status', 'value' => 'pending_payment', 'disabled' => true], ['key' => 'page', 'value' => '1']],
'api/v1/adminapp/tenant/sales', 'api/v1/adminapp/tenant/sales/pdf' => [
['key' => 'q', 'value' => '', 'disabled' => true],
['key' => 'status', 'value' => 'confirmed', 'disabled' => true],
['key' => 'sale_date', 'value' => '2026-12-01', 'disabled' => true],
['key' => 'sort_by', 'value' => 'date'],
['key' => 'sort_direction', 'value' => 'desc'],
['key' => 'page', 'value' => '1'],
['key' => 'per_page', 'value' => '20'],
],
'api/v1/scanner/tickets' => [['key' => 'q', 'value' => '', 'disabled' => true], ['key' => 'page', 'value' => '1'], ['key' => 'per_page', 'value' => '20']],
'api/storage-test/s3/temporary-url' => [['key' => 'path', 'value' => '{{s3_path}}'], ['key' => 'expires_in_minutes', 'value' => '60']],
default => [],
};
}
/** @return array{0: string, 1: string} */
function folderFor(string $uri, string $action): array
{
preg_match('/App\\\\Domains\\\\([^\\\\]+)/', $action, $matches);
$domain = $matches[1] ?? 'Other';
if (str_starts_with($uri, 'api/v1/adminapp/')) {
return ['Admin App API', $domain];
}
if (str_starts_with($uri, 'api/v1/scanner/')) {
return ['Scanner API', $domain];
}
if (str_starts_with($uri, 'api/webhooks/')) {
return ['Webhooks', $domain];
}
if (in_array($domain, ['StorageTest', 'MailTest'], true)) {
return ['Developer Utilities', $domain];
}
if (in_array($domain, ['Client', 'Integration', 'Menu', 'Tenant'], true) && ! str_contains($uri, '{tenant:codigo}')) {
return ['Platform Management', $domain];
}
return ['Storefront API', $domain];
}
function humanize(string $value): string
{
$value = preg_replace('/Controller$/', '', $value);
$value = preg_replace('/(?<!^)[A-Z]/', ' $0', (string) $value);
return trim(str_replace(['_', '-'], ' ', (string) $value));
}
function requestName(string $method, string $action, bool $multiMethod): string
{
$parts = explode('\\', explode('@', $action)[0]);
$controller = humanize(end($parts));
$handler = str_contains($action, '@') ? explode('@', $action)[1] : '__invoke';
$verbs = [
'index' => 'List', 'store' => 'Create', 'show' => 'Get', 'update' => 'Update',
'destroy' => 'Delete', 'addItem' => 'Add Item', 'updateItemQuantity' => 'Update Item Quantity',
'removeItem' => 'Remove Item', 'search' => 'Search', 'category' => 'Get Category',
'featuredGroupItems' => 'List Featured Group Items', 'variantOptions' => 'Get Variant Options',
'startCheckout' => 'Start Checkout', 'updateCustomerData' => 'Update Customer Data',
'prepareItemEditing' => 'Prepare Item Editing', 'paymentIntent' => 'Create Payment Intent',
'submitForReview' => 'Submit for Review', 'cancel' => 'Cancel', 'complete' => 'Complete',
'downloadPdf' => 'Download PDF', 'downloadModificationsPdf' => 'Download Modifications PDF',
'modifications' => 'List Modifications', 'tickets' => 'List Tickets', 'confirm' => 'Confirm',
'temporaryUrl' => 'Generate Temporary URL', 'replaceImage' => 'Replace Image',
'updateImage' => 'Update Image', 'destroyImage' => 'Delete Image', 'showExtra' => 'Get Extra',
'toggle' => 'Toggle', 'scan' => 'Scan', 'handle' => 'Receive',
];
$name = $handler === '__invoke'
? $controller
: (($verbs[$handler] ?? humanize($handler)).' '.$controller);
return $multiMethod ? $name.' ('.$method.')' : $name;
}
function pathFor(string $uri): string
{
$variables = [
'tenant:codigo' => 'tenant_code', 'tenant' => 'tenant_id', 'client' => 'client_id',
'integration_code' => 'integration_code', 'integration' => 'integration_id', 'menue' => 'menu_id',
'menu_code' => 'menu_code', 'tenant_code' => 'tenant_code', 'catalogItem' => 'catalog_item_id',
'featuredGroup' => 'featured_group_id', 'category' => 'category_id', 'cartItem' => 'cart_item_id',
'compra' => 'purchase_id', 'item' => 'purchase_item_id', 'dominio' => 'tenant_domain',
'sale' => 'sale_id', 'staff' => 'staff_id', 'websiteExtraCode' => 'website_extra_code',
'accommodation' => 'accommodation_id', 'entry' => 'entry_id', 'food' => 'food_id',
'merchandise' => 'merchandise_id', 'ticketUuid' => 'ticket_uuid',
];
return preg_replace_callback('/\{([^}]+)\}/', function (array $matches) use ($variables): string {
$variable = $variables[$matches[1]] ?? strtolower((string) preg_replace('/(?<!^)[A-Z]/', '_$0', $matches[1]));
return '{{'.$variable.'}}';
}, $uri);
}
/** @return array<string, mixed> */
function authFor(string $uri, array $middleware): ?array
{
$protected = count(array_filter(
$middleware,
fn (string $item): bool => str_contains($item, 'Authenticate:sanctum'),
)) > 0;
if (! $protected) {
return null;
}
$variable = str_starts_with($uri, 'api/v1/adminapp/')
? 'admin_token'
: (str_starts_with($uri, 'api/v1/scanner/') ? 'scanner_token' : 'token');
return ['type' => 'bearer', 'bearer' => [['key' => 'token', 'value' => '{{'.$variable.'}}', 'type' => 'string']]];
}
/** @return array<string, mixed> */
function tokenCaptureEvent(string $uri): array
{
$variable = $uri === 'api/v1/adminapp/login' ? 'admin_token' : ($uri === 'api/v1/scanner/login' ? 'scanner_token' : 'token');
return [
'listen' => 'test',
'script' => [
'type' => 'text/javascript',
'exec' => [
'if (pm.response.code >= 200 && pm.response.code < 300) {',
' const payload = pm.response.json();',
" if (payload.token) pm.collectionVariables.set('{$variable}', payload.token);",
'}',
],
],
];
}
$tree = [];
$registeredOperations = 0;
foreach ($routes as $route) {
$methods = array_values(array_filter(explode('|', $route['method']), fn (string $method): bool => $method !== 'HEAD'));
$multiMethod = count($methods) > 1;
foreach ($methods as $method) {
[$section, $folder] = folderFor($route['uri'], $route['action']);
$path = pathFor($route['uri']);
$query = queryFor($route['uri']);
$enabledQuery = array_values(array_filter($query, fn (array $item): bool => ! ($item['disabled'] ?? false)));
$queryString = $enabledQuery === [] ? '' : '?'.implode('&', array_map(
fn (array $item): string => rawurlencode($item['key']).'='.$item['value'],
$enabledQuery,
));
$body = bodyFor($method, $route['uri']);
$auth = authFor($route['uri'], $route['middleware']);
$headers = [['key' => 'Accept', 'value' => 'application/json', 'type' => 'text']];
if (($body['mode'] ?? null) === 'raw') {
$headers[] = ['key' => 'Content-Type', 'value' => 'application/json', 'type' => 'text'];
}
$request = [
'method' => $method,
'header' => $headers,
'description' => sprintf(
"Ruta Laravel: `%s /%s`\n\nControlador: `%s`%s",
$method,
$route['uri'],
$route['action'],
$auth === null ? '' : "\n\nRequiere autenticación Sanctum.",
),
'url' => [
'raw' => '{{base_url}}/'.$path.$queryString,
'host' => ['{{base_url}}'],
'path' => explode('/', $path),
],
];
if ($query !== []) {
$request['url']['query'] = $query;
}
if ($body !== null) {
$request['body'] = $body;
}
if ($auth !== null) {
$request['auth'] = $auth;
}
$item = [
'name' => requestName($method, $route['action'], $multiMethod),
'request' => $request,
'response' => [],
];
if (in_array($route['uri'], ['api/login', 'api/v1/adminapp/login', 'api/v1/scanner/login'], true)) {
$item['event'] = [tokenCaptureEvent($route['uri'])];
}
$tree[$section][$folder][] = $item;
$registeredOperations++;
}
}
$sectionDescriptions = [
'Storefront API' => 'API pública y autenticada consumida por el storefront: autenticación, catálogo, carrito, compras y tickets.',
'Admin App API' => 'Operaciones de administración del tenant. Ejecutá Login primero para completar `admin_token`.',
'Scanner API' => 'Operaciones de la aplicación de escaneo. Ejecutá Login primero para completar `scanner_token`.',
'Platform Management' => 'CRUD y configuración de tenants, clientes, menús e integraciones.',
'Webhooks' => 'Entradas públicas para notificaciones de proveedores externos.',
'Developer Utilities' => 'Endpoints de diagnóstico de correo y almacenamiento. No deberían exponerse en producción.',
];
$items = [];
foreach ($tree as $section => $folders) {
$children = [];
foreach ($folders as $folder => $requests) {
$children[] = ['name' => humanize($folder), 'item' => $requests];
}
$items[] = [
'name' => $section,
'description' => $sectionDescriptions[$section] ?? '',
'item' => $children,
];
}
$variables = [
'base_url' => 'http://localhost:8000',
'token' => '', 'admin_token' => '', 'scanner_token' => '',
'user_email' => 'usuario@example.com', 'user_password' => 'Password!123',
'admin_email' => 'admin@example.com', 'admin_password' => 'Password!123',
'scanner_email' => 'scanner@example.com', 'scanner_password' => 'Password!123',
'tenant_code' => 'demo', 'tenant_id' => '1', 'tenant_domain' => 'demo.test',
'client_id' => '1', 'integration_id' => '1', 'integration_code' => 'telepagos',
'menu_id' => '1', 'menu_code' => 'demo', 'catalog_item_id' => '1', 'variant_id' => '1',
'category_id' => '1', 'featured_group_id' => '1', 'cart_id' => '1', 'cart_item_id' => '1',
'purchase_id' => '1', 'purchase_item_id' => '1', 'sale_id' => '1', 'staff_id' => '1',
'website_extra_code' => 'hero', 'accommodation_id' => '1', 'entry_id' => '1', 'food_id' => '1',
'merchandise_id' => '1', 'ticket_uuid' => '00000000-0000-0000-0000-000000000000',
'oauth_code' => '00000000-0000-0000-0000-000000000000', 'reset_code' => '1234',
's3_path' => 'postman/test-file.png',
];
$collection = [
'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 {$registeredOperations} operaciones HTTP, 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' => $items,
'variable' => array_map(
fn (string $key, string $value): array => ['key' => $key, 'value' => $value, 'type' => 'string'],
array_keys($variables),
array_values($variables),
),
];
$output = $root.DIRECTORY_SEPARATOR.'ShopIt_API_Postman_Collection.json';
file_put_contents($output, json_encode($collection, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE).PHP_EOL);
fwrite(STDOUT, "Generated {$registeredOperations} operations in {$output}\n");

View File

@@ -75,6 +75,21 @@ class CartControllerTest extends TestCase
]);
}
public function test_it_filters_item_images_when_the_tenant_disables_them(): void
{
$tenant = $this->createTenant('acme');
$tenant->update(['display_cart_item_images' => false]);
$item = $this->createDirectItem($tenant, 10, '49.90');
$item->attachments()->attach($this->createAttachment('cart-item'), ['orden' => 0]);
$this->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'cantidad' => 1,
])
->assertOk()
->assertJsonPath('data.items.0.imagen', null);
}
public function test_it_adds_a_specific_variant_and_merges_repeated_additions(): void
{
$tenant = $this->createTenant('acme');

View File

@@ -0,0 +1,38 @@
<?php
namespace Tests\Feature\Migrations;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class AddCartEditingEnabledToTenantsTest extends TestCase
{
use RefreshDatabase;
public function test_it_disables_cart_editing_only_for_desfile(): void
{
$migration = require database_path(
'migrations/2026_08_18_060000_add_cart_editing_enabled_to_tenants_table.php'
);
$migration->down();
$migration->up();
$this->assertDatabaseHas('tenants', [
'codigo' => 'desfile_pura_tendencia',
'cart_editing_enabled' => false,
]);
DB::table('tenants')->insert([
'codigo' => 'cart-editing-default',
'nombre' => 'Cart editing default',
'dominio' => 'cart-editing-default.test',
]);
$this->assertDatabaseHas('tenants', [
'codigo' => 'cart-editing-default',
'cart_editing_enabled' => true,
]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Tests\Feature\Migrations;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class AddDisplayCartItemImagesToTenantsTest extends TestCase
{
use RefreshDatabase;
public function test_it_hides_cart_item_images_only_for_desfile(): void
{
$migration = require database_path(
'migrations/2026_08_18_061000_add_display_cart_item_images_to_tenants_table.php'
);
$migration->down();
$migration->up();
$this->assertDatabaseHas('tenants', [
'codigo' => 'desfile_pura_tendencia',
'display_cart_item_images' => false,
]);
DB::table('tenants')->insert([
'codigo' => 'cart-images-default',
'nombre' => 'Cart images default',
'dominio' => 'cart-images-default.test',
]);
$this->assertDatabaseHas('tenants', [
'codigo' => 'cart-images-default',
'display_cart_item_images' => true,
]);
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace Tests\Feature\Migrations;
use Illuminate\Database\QueryException;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class SeparateTenantDomainAndBasePathTest extends TestCase
{
private string $originalConnection;
protected function setUp(): void
{
parent::setUp();
$this->originalConnection = DB::getDefaultConnection();
config()->set('database.connections.tenant_path_test', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
'foreign_key_constraints' => true,
]);
DB::setDefaultConnection('tenant_path_test');
Schema::create('tenants', function (Blueprint $table): void {
$table->id();
$table->string('dominio');
$table->unique('dominio', 'tenants_dominio_unique');
});
}
protected function tearDown(): void
{
DB::purge('tenant_path_test');
DB::setDefaultConnection($this->originalConnection);
parent::tearDown();
}
public function test_it_splits_existing_tenant_keys_and_enforces_composite_uniqueness(): void
{
DB::table('tenants')->insert([
['dominio' => 'onticket.com.ar'],
['dominio' => 'onticket.com.ar/desfile/'],
['dominio' => 'https://ONTICKET.COM.AR/sonder'],
]);
$migration = require database_path(
'migrations/2026_08_18_060000_separate_tenant_domain_and_base_path.php'
);
$migration->up();
$this->assertDatabaseHas('tenants', [
'dominio' => 'onticket.com.ar',
'base_path' => '/',
]);
$this->assertDatabaseHas('tenants', [
'dominio' => 'onticket.com.ar',
'base_path' => '/desfile',
]);
$this->assertDatabaseHas('tenants', [
'dominio' => 'onticket.com.ar',
'base_path' => '/sonder',
]);
$this->expectException(QueryException::class);
DB::table('tenants')->insert([
'dominio' => 'onticket.com.ar',
'base_path' => '/desfile',
]);
}
public function test_it_recombines_tenant_locations_when_rolled_back(): void
{
DB::table('tenants')->insert(['dominio' => 'onticket.com.ar/desfile']);
$migration = require database_path(
'migrations/2026_08_18_060000_separate_tenant_domain_and_base_path.php'
);
$migration->up();
$migration->down();
$this->assertFalse(Schema::hasColumn('tenants', 'base_path'));
$this->assertDatabaseHas('tenants', [
'dominio' => 'onticket.com.ar/desfile',
]);
}
}

View File

@@ -847,6 +847,30 @@ class StorePurchaseTest extends TestCase
->assertJsonPath('data.total', '100.00');
}
public function test_purchase_detail_filters_item_images_when_the_tenant_disables_them(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$tenant->update(['display_cart_item_images' => false]);
$user = User::factory()->create([
'email' => 'buyer@example.com',
]);
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
$image = Attachment::create([
'key' => (string) Str::uuid(),
'path' => 'catalog/purchase-item.png',
'filename' => 'purchase-item.png',
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
$purchase->items()->firstOrFail()->update(['image_attachment_id' => $image->id]);
$this->actingAs($user, 'sanctum')
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
->assertOk()
->assertJsonPath('data.items.0.item_details.imagen', null);
}
public function test_purchase_detail_uses_purchase_items_for_pending_payment_purchase(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');

View File

@@ -44,6 +44,8 @@ class DesfilePuraTendenciaSeederTest extends TestCase
'footer_bg_color' => '#D4441C',
'display_categories' => false,
'display_seach_bar' => false,
'cart_editing_enabled' => false,
'display_cart_item_images' => false,
'site_title' => 'Desfile Pura Tendencia',
]);

View File

@@ -73,6 +73,8 @@ class BootstrapTenantControllerTest extends TestCase
'display_categories' => false,
'display_seach_bar' => false,
'display_cart' => false,
'cart_editing_enabled' => false,
'display_cart_item_images' => false,
]);
$response = $this->getJson('/api/tenants/bootstrap?dominio=acme.com&path=%2F');
@@ -89,6 +91,8 @@ class BootstrapTenantControllerTest extends TestCase
->assertJsonPath('data.display_categories', false)
->assertJsonPath('data.display_seach_bar', false)
->assertJsonPath('data.display_cart', false)
->assertJsonPath('data.cart_editing_enabled', false)
->assertJsonPath('data.display_cart_item_images', false)
->assertJsonPath('data.header_bg_color', '#ffffff')->assertJsonPath('data.footer_bg_color', '#ffffff');
$headerUrl = $response->json('data.header_logo');
@@ -138,23 +142,27 @@ class BootstrapTenantControllerTest extends TestCase
$this->createTenant([
'codigo' => 'pura-tendencia',
'nombre' => 'Pura Tendencia',
'dominio' => 'qa.onticket.com.ar/puratendencia',
'dominio' => 'qa.onticket.com.ar',
'base_path' => '/puratendencia',
]);
$this->createTenant([
'codigo' => 'pura-tendencia-vip',
'nombre' => 'Pura Tendencia VIP',
'dominio' => 'qa.onticket.com.ar/puratendencia/vip',
'dominio' => 'qa.onticket.com.ar',
'base_path' => '/puratendencia/vip',
]);
$this->getJson('/api/tenants/bootstrap?dominio=qa.onticket.com.ar&path=%2Fpuratendencia%2Fproductos%2F123')
->assertOk()
->assertJsonPath('data.codigo', 'pura-tendencia')
->assertJsonPath('data.dominio', 'qa.onticket.com.ar/puratendencia');
->assertJsonPath('data.dominio', 'qa.onticket.com.ar')
->assertJsonPath('data.base_path', '/puratendencia');
$this->getJson('/api/tenants/bootstrap?dominio=qa.onticket.com.ar&path=%2Fpuratendencia%2Fvip%2Fproductos%2F123')
->assertOk()
->assertJsonPath('data.codigo', 'pura-tendencia-vip')
->assertJsonPath('data.dominio', 'qa.onticket.com.ar/puratendencia/vip');
->assertJsonPath('data.dominio', 'qa.onticket.com.ar')
->assertJsonPath('data.base_path', '/puratendencia/vip');
}
public function test_it_uses_the_root_tenant_for_spa_paths_without_a_tenant_prefix(): void
@@ -167,7 +175,8 @@ class BootstrapTenantControllerTest extends TestCase
$this->createTenant([
'codigo' => 'sonder',
'nombre' => 'Sonder',
'dominio' => 'qa.onticket.com.ar/sonder',
'dominio' => 'qa.onticket.com.ar',
'base_path' => '/sonder',
]);
$this->getJson('/api/tenants/bootstrap?dominio=qa.onticket.com.ar&path=%2Fproducto%2F123')
@@ -570,7 +579,8 @@ class BootstrapTenantControllerTest extends TestCase
$firstResponse
->assertCreated()
->assertJsonPath('data.dominio', 'acme.com/puratendencia')
->assertJsonPath('data.dominio', 'acme.com')
->assertJsonPath('data.base_path', '/puratendencia')
->assertJsonPath('data.primary_color', '#111111')
->assertJsonPath('data.secondary_color', '#222222')
->assertJsonPath('data.danger_color', '#333333')
@@ -609,7 +619,8 @@ class BootstrapTenantControllerTest extends TestCase
$differentPathResponse = $this->postJson('/api/tenants', [
'codigo' => 'pura-tendencia',
'nombre' => 'Pura Tendencia',
'dominio' => 'acme.com/sonder',
'dominio' => 'acme.com',
'base_path' => '/sonder/',
'primary_color' => '#111111',
'secondary_color' => '#222222',
'danger_color' => '#333333',
@@ -622,12 +633,14 @@ class BootstrapTenantControllerTest extends TestCase
$differentPathResponse
->assertCreated()
->assertJsonPath('data.dominio', 'acme.com/sonder');
->assertJsonPath('data.dominio', 'acme.com')
->assertJsonPath('data.base_path', '/sonder');
$secondResponse = $this->postJson('/api/tenants', [
'codigo' => 'globex',
'nombre' => 'Globex',
'dominio' => 'acme.com/puratendencia',
'dominio' => 'acme.com',
'base_path' => '/puratendencia',
'primary_color' => '#111111',
'secondary_color' => '#222222',
'danger_color' => '#333333',
@@ -745,19 +758,25 @@ class BootstrapTenantControllerTest extends TestCase
$response = $this->putJson("/api/tenants/{$tenant->codigo}", [
'primary_color' => '#000000',
'cart_editing_enabled' => false,
'display_cart_item_images' => false,
]);
$response
->assertOk()
->assertJsonPath('data.codigo', 'acme')
->assertJsonPath('data.nombre', 'Acme')
->assertJsonPath('data.primary_color', '#000000');
->assertJsonPath('data.primary_color', '#000000')
->assertJsonPath('data.cart_editing_enabled', false)
->assertJsonPath('data.display_cart_item_images', false);
$this->assertDatabaseHas('tenants', [
'id' => $tenant->id,
'codigo' => 'acme',
'nombre' => 'Acme',
'primary_color' => '#000000',
'cart_editing_enabled' => false,
'display_cart_item_images' => false,
]);
}

View File

@@ -73,4 +73,14 @@ class TenantDomainNormalizerTest extends TestCase
'/sonder-shop/productos',
));
}
public function test_it_builds_base_path_candidates_from_the_longest_path_to_root(): void
{
$this->assertSame([
'/desfile/productos/123',
'/desfile/productos',
'/desfile',
'/',
], TenantDomainNormalizer::basePathCandidates('/desfile/productos/123?ref=home'));
}
}