diff --git a/app/Domains/Auth/Services/GoogleAuthService.php b/app/Domains/Auth/Services/GoogleAuthService.php index 364c09f..6f9bb3f 100644 --- a/app/Domains/Auth/Services/GoogleAuthService.php +++ b/app/Domains/Auth/Services/GoogleAuthService.php @@ -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; } diff --git a/app/Domains/Bootstrap/Services/TenantBootstrapService.php b/app/Domains/Bootstrap/Services/TenantBootstrapService.php index d4a9653..0327c4e 100644 --- a/app/Domains/Bootstrap/Services/TenantBootstrapService.php +++ b/app/Domains/Bootstrap/Services/TenantBootstrapService.php @@ -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) { diff --git a/app/Domains/Client/Resources/ClientResource.php b/app/Domains/Client/Resources/ClientResource.php index caf724e..f2e13bc 100644 --- a/app/Domains/Client/Resources/ClientResource.php +++ b/app/Domains/Client/Resources/ClientResource.php @@ -20,6 +20,7 @@ class ClientResource extends JsonResource 'codigo' => $tenant->codigo, 'nombre' => $tenant->nombre, 'dominio' => $tenant->dominio, + 'base_path' => $tenant->base_path, ])), ]; } diff --git a/app/Domains/Notification/Services/NotificationMailService.php b/app/Domains/Notification/Services/NotificationMailService.php index 4217366..6912028 100644 --- a/app/Domains/Notification/Services/NotificationMailService.php +++ b/app/Domains/Notification/Services/NotificationMailService.php @@ -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) diff --git a/app/Domains/Tenant/Models/Tenant.php b/app/Domains/Tenant/Models/Tenant.php index ef50043..7016029 100644 --- a/app/Domains/Tenant/Models/Tenant.php +++ b/app/Domains/Tenant/Models/Tenant.php @@ -24,6 +24,7 @@ use Illuminate\Support\Facades\Schema; 'codigo', 'nombre', 'dominio', + 'base_path', 'site_title', 'primary_color', 'secondary_color', @@ -55,6 +56,7 @@ 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, diff --git a/app/Domains/Tenant/Requests/StoreTenantRequest.php b/app/Domains/Tenant/Requests/StoreTenantRequest.php index cac31c8..4743178 100644 --- a/app/Domains/Tenant/Requests/StoreTenantRequest.php +++ b/app/Domains/Tenant/Requests/StoreTenantRequest.php @@ -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})$/'], diff --git a/app/Domains/Tenant/Requests/UpdateTenantRequest.php b/app/Domains/Tenant/Requests/UpdateTenantRequest.php index adfb7b8..6328743 100644 --- a/app/Domains/Tenant/Requests/UpdateTenantRequest.php +++ b/app/Domains/Tenant/Requests/UpdateTenantRequest.php @@ -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})$/'], diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index 631f3ab..5eab5cc 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -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', diff --git a/app/Domains/Tenant/Support/TenantDomainNormalizer.php b/app/Domains/Tenant/Support/TenantDomainNormalizer.php index de8ea62..4e43a2e 100644 --- a/app/Domains/Tenant/Support/TenantDomainNormalizer.php +++ b/app/Domains/Tenant/Support/TenantDomainNormalizer.php @@ -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 */ + 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; } diff --git a/app/Domains/Tenant/documentacion/README.md b/app/Domains/Tenant/documentacion/README.md index aca3c97..30735c0 100644 --- a/app/Domains/Tenant/documentacion/README.md +++ b/app/Domains/Tenant/documentacion/README.md @@ -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`. diff --git a/database/migrations/2026_08_18_060000_separate_tenant_domain_and_base_path.php b/database/migrations/2026_08_18_060000_separate_tenant_domain_and_base_path.php new file mode 100644 index 0000000..ba2435b --- /dev/null +++ b/database/migrations/2026_08_18_060000_separate_tenant_domain_and_base_path.php @@ -0,0 +1,109 @@ +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), + ]; + } +}; diff --git a/tests/Feature/Migrations/SeparateTenantDomainAndBasePathTest.php b/tests/Feature/Migrations/SeparateTenantDomainAndBasePathTest.php new file mode 100644 index 0000000..5a52bf3 --- /dev/null +++ b/tests/Feature/Migrations/SeparateTenantDomainAndBasePathTest.php @@ -0,0 +1,92 @@ +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', + ]); + } +} diff --git a/tests/Feature/Tenant/BootstrapTenantControllerTest.php b/tests/Feature/Tenant/BootstrapTenantControllerTest.php index 7abdfe7..9526d32 100644 --- a/tests/Feature/Tenant/BootstrapTenantControllerTest.php +++ b/tests/Feature/Tenant/BootstrapTenantControllerTest.php @@ -142,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 @@ -171,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') @@ -574,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') @@ -613,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', @@ -626,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', diff --git a/tests/Unit/Tenant/TenantDomainNormalizerTest.php b/tests/Unit/Tenant/TenantDomainNormalizerTest.php index ce0037c..b736424 100644 --- a/tests/Unit/Tenant/TenantDomainNormalizerTest.php +++ b/tests/Unit/Tenant/TenantDomainNormalizerTest.php @@ -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')); + } }