feat: separate tenant domain and base path, update related models, requests, and tests

This commit is contained in:
2026-08-18 17:23:01 -03:00
parent 8e26611097
commit a8973f6171
14 changed files with 355 additions and 35 deletions

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ class ClientResource extends JsonResource
'codigo' => $tenant->codigo, 'codigo' => $tenant->codigo,
'nombre' => $tenant->nombre, 'nombre' => $tenant->nombre,
'dominio' => $tenant->dominio, 'dominio' => $tenant->dominio,
'base_path' => $tenant->base_path,
])), ])),
]; ];
} }

View File

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

View File

@@ -24,6 +24,7 @@ use Illuminate\Support\Facades\Schema;
'codigo', 'codigo',
'nombre', 'nombre',
'dominio', 'dominio',
'base_path',
'site_title', 'site_title',
'primary_color', 'primary_color',
'secondary_color', 'secondary_color',
@@ -55,6 +56,7 @@ class Tenant extends Model
use HasFactory; use HasFactory;
protected $attributes = [ protected $attributes = [
'base_path' => '/',
'search_product_layout' => ProductLayout::ColumnWithImage->value, 'search_product_layout' => ProductLayout::ColumnWithImage->value,
'search_group_layout' => GroupLayout::Paginated->value, 'search_group_layout' => GroupLayout::Paginated->value,
'search_items_per_page' => 12, 'search_items_per_page' => 12,

View File

@@ -15,6 +15,8 @@ class StoreTenantRequest extends FormRequest
{ {
protected bool $hasInvalidDomain = false; protected bool $hasInvalidDomain = false;
protected bool $hasInvalidBasePath = false;
public function authorize(): bool public function authorize(): bool
{ {
return true; return true;
@@ -23,13 +25,21 @@ class StoreTenantRequest extends FormRequest
protected function prepareForValidation(): void protected function prepareForValidation(): void
{ {
$rawDomain = $this->input('dominio'); $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) $this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
&& $normalizedDomain === null; && $normalizedDomain === null;
$this->hasInvalidBasePath = ($hasExplicitBasePath && ! is_string($rawBasePath))
|| $normalizedBasePath === null;
$this->merge([ $this->merge([
'dominio' => $normalizedDomain, 'dominio' => $normalizedDomain,
'base_path' => $normalizedBasePath,
]); ]);
} }
@@ -54,7 +64,21 @@ class StoreTenantRequest extends FormRequest
'required', 'required',
'string', 'string',
'max:255', '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'], '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})$/'], 'primary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],

View File

@@ -15,6 +15,8 @@ class UpdateTenantRequest extends FormRequest
{ {
protected bool $hasInvalidDomain = false; protected bool $hasInvalidDomain = false;
protected bool $hasInvalidBasePath = false;
public function authorize(): bool public function authorize(): bool
{ {
return true; return true;
@@ -24,14 +26,29 @@ class UpdateTenantRequest extends FormRequest
{ {
if ($this->has('dominio')) { if ($this->has('dominio')) {
$rawDomain = $this->input('dominio'); $rawDomain = $this->input('dominio');
$normalizedDomain = TenantDomainNormalizer::normalizeTenantKey($rawDomain); $normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
$embeddedBasePath = TenantDomainNormalizer::pathFromDomain($rawDomain);
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain) $this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
&& $normalizedDomain === null; && ($normalizedDomain === null || $embeddedBasePath === null);
$this->merge([ $this->merge([
'dominio' => $normalizedDomain, '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 */ /** @var Tenant|null $tenant */
$tenant = $this->route('tenant'); $tenant = $this->route('tenant');
$domain = $this->input('dominio', $tenant?->dominio);
$basePath = $this->input('base_path', $tenant?->base_path ?? '/');
$logoRule = ['nullable', new ImageOrBase64Rule]; $logoRule = ['nullable', new ImageOrBase64Rule];
@@ -64,7 +83,23 @@ class UpdateTenantRequest extends FormRequest
'nullable', 'nullable',
'string', 'string',
'max:255', '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'], '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})$/'], 'primary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],

View File

@@ -28,6 +28,7 @@ class TenantResource extends JsonResource
'codigo' => $this->codigo, 'codigo' => $this->codigo,
'nombre' => $this->nombre, 'nombre' => $this->nombre,
'dominio' => $this->dominio, 'dominio' => $this->dominio,
'base_path' => $this->base_path,
'site_title' => $this->site_title 'site_title' => $this->site_title
?? $this->websiteType?->site_title ?? $this->websiteType?->site_title
?? 'ShopitFront', ?? 'ShopitFront',

View File

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

View File

@@ -6,7 +6,7 @@ Es la raíz del modelo multi-tenant. Gestiona organizaciones/sitios, tipos de we
## Modelo ## 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. - `WebsiteType`: plantilla o tipo de sitio disponible.
- `WebsiteTypeExtra`: definición de un extra y su configuración admitida. - `WebsiteTypeExtra`: definición de un extra y su configuración admitida.
- `WebsiteExtra`: valor resuelto y estado del extra para un tenant. - `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. - `WebsiteExtraService`: construye reglas dinámicas, crea, actualiza y habilita/deshabilita extras.
- `TenantDomainNormalizer`: normaliza dominios antes de resolver el tenant. - `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 ## Endpoints
- Recurso REST público/administrativo `/tenants`. - Recurso REST público/administrativo `/tenants`.

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,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

@@ -142,23 +142,27 @@ class BootstrapTenantControllerTest extends TestCase
$this->createTenant([ $this->createTenant([
'codigo' => 'pura-tendencia', 'codigo' => 'pura-tendencia',
'nombre' => 'Pura Tendencia', 'nombre' => 'Pura Tendencia',
'dominio' => 'qa.onticket.com.ar/puratendencia', 'dominio' => 'qa.onticket.com.ar',
'base_path' => '/puratendencia',
]); ]);
$this->createTenant([ $this->createTenant([
'codigo' => 'pura-tendencia-vip', 'codigo' => 'pura-tendencia-vip',
'nombre' => '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') $this->getJson('/api/tenants/bootstrap?dominio=qa.onticket.com.ar&path=%2Fpuratendencia%2Fproductos%2F123')
->assertOk() ->assertOk()
->assertJsonPath('data.codigo', 'pura-tendencia') ->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') $this->getJson('/api/tenants/bootstrap?dominio=qa.onticket.com.ar&path=%2Fpuratendencia%2Fvip%2Fproductos%2F123')
->assertOk() ->assertOk()
->assertJsonPath('data.codigo', 'pura-tendencia-vip') ->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 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([ $this->createTenant([
'codigo' => 'sonder', 'codigo' => 'sonder',
'nombre' => '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') $this->getJson('/api/tenants/bootstrap?dominio=qa.onticket.com.ar&path=%2Fproducto%2F123')
@@ -574,7 +579,8 @@ class BootstrapTenantControllerTest extends TestCase
$firstResponse $firstResponse
->assertCreated() ->assertCreated()
->assertJsonPath('data.dominio', 'acme.com/puratendencia') ->assertJsonPath('data.dominio', 'acme.com')
->assertJsonPath('data.base_path', '/puratendencia')
->assertJsonPath('data.primary_color', '#111111') ->assertJsonPath('data.primary_color', '#111111')
->assertJsonPath('data.secondary_color', '#222222') ->assertJsonPath('data.secondary_color', '#222222')
->assertJsonPath('data.danger_color', '#333333') ->assertJsonPath('data.danger_color', '#333333')
@@ -613,7 +619,8 @@ class BootstrapTenantControllerTest extends TestCase
$differentPathResponse = $this->postJson('/api/tenants', [ $differentPathResponse = $this->postJson('/api/tenants', [
'codigo' => 'pura-tendencia', 'codigo' => 'pura-tendencia',
'nombre' => 'Pura Tendencia', 'nombre' => 'Pura Tendencia',
'dominio' => 'acme.com/sonder', 'dominio' => 'acme.com',
'base_path' => '/sonder/',
'primary_color' => '#111111', 'primary_color' => '#111111',
'secondary_color' => '#222222', 'secondary_color' => '#222222',
'danger_color' => '#333333', 'danger_color' => '#333333',
@@ -626,12 +633,14 @@ class BootstrapTenantControllerTest extends TestCase
$differentPathResponse $differentPathResponse
->assertCreated() ->assertCreated()
->assertJsonPath('data.dominio', 'acme.com/sonder'); ->assertJsonPath('data.dominio', 'acme.com')
->assertJsonPath('data.base_path', '/sonder');
$secondResponse = $this->postJson('/api/tenants', [ $secondResponse = $this->postJson('/api/tenants', [
'codigo' => 'globex', 'codigo' => 'globex',
'nombre' => 'Globex', 'nombre' => 'Globex',
'dominio' => 'acme.com/puratendencia', 'dominio' => 'acme.com',
'base_path' => '/puratendencia',
'primary_color' => '#111111', 'primary_color' => '#111111',
'secondary_color' => '#222222', 'secondary_color' => '#222222',
'danger_color' => '#333333', 'danger_color' => '#333333',

View File

@@ -73,4 +73,14 @@ class TenantDomainNormalizerTest extends TestCase
'/sonder-shop/productos', '/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'));
}
} }