6 Commits

32 changed files with 755 additions and 106 deletions

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\User;
use App\Domains\Auth\Resources\AdminAppMeResource;
use App\Domains\Auth\Services\AdminAppContextService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class AdminAppMeController extends Controller
{
public function __construct(
private readonly AdminAppContextService $adminAppContextService,
) {}
public function __invoke(Request $request): AdminAppMeResource
{
/** @var User $user */
$user = $request->user();
return AdminAppMeResource::make(
$this->adminAppContextService->load($user)
);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Domains\Auth\Resources;
use App\Domains\Auth\Models\User;
use App\Domains\Tenant\Resources\TenantResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin User
*/
class AdminAppMeResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'user' => UserResource::make($this->resource),
'tenant' => TenantResource::make($this->tenant),
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
class AdminAppContextService
{
public function load(User $user): User
{
$tenant = $user->tenant()
->with([
'menues' => fn ($query) => $query
->whereHas(
'roles',
fn ($query) => $query->where('codigo', RoleCode::AdminApp->value)
),
])
->firstOrFail();
$user->setRelation('tenant', $tenant);
return $user;
}
}

View File

@@ -1,8 +1,11 @@
<?php
use App\Domains\Auth\Controllers\AdminAppLoginController;
use App\Domains\Auth\Controllers\AdminAppMeController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp')->group(function (): void {
Route::post('login', AdminAppLoginController::class)->middleware('throttle:login');
Route::middleware(['auth:sanctum', 'adminapp.tenant'])
->get('me', AdminAppMeController::class);
});

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Domains\Tenant\Controllers\AdminApp;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Tenant\Requests\BootstrapAdminAppRequest;
use App\Domains\Tenant\Resources\AdminApp\BootstrapAdminAppResource;
use App\Http\Controllers\Controller;
class BootstrapAdminAppController extends Controller
{
public function __invoke(
BootstrapAdminAppRequest $request
): BootstrapAdminAppResource {
/** @var string $domain */
$domain = $request->validated('dominio');
return BootstrapAdminAppResource::make(
WebsiteType::query()
->with(['siteLogo', 'footerLogo'])
->where('dominio', $domain)
->firstOrFail()
);
}
}

View File

@@ -1,34 +0,0 @@
<?php
namespace App\Domains\Tenant\Controllers\AdminApp;
use App\Domains\Auth\Models\User;
use App\Domains\Tenant\Resources\TenantResource;
use App\Domains\Tenant\Services\TenantInformationService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class BootstrapTenantController extends Controller
{
public function __construct(
protected TenantInformationService $tenantInformationService
) {}
public function __invoke(Request $request): TenantResource
{
/** @var User $user */
$user = $request->user();
$tenant = $user->tenant()->firstOrFail();
return TenantResource::make(
$this->tenantInformationService->load($tenant, [
'menues' => fn ($query) => $query
->where('code', 'like', 'admin.%')
->whereHas(
'roles',
fn ($query) => $query->where('codigo', $user->rol_codigo)
),
])
);
}
}

View File

@@ -65,7 +65,10 @@ class WebsiteExtraController extends Controller
return WebsiteExtrasResource::make(
$this->loadTenant($request->user())
);
)->additional([
'code' => 'tenant.website_extra_toggled',
'message' => __('api.tenant.website_extra_toggled'),
]);
}
private function loadTenant(User $user): Tenant

View File

@@ -17,8 +17,15 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'secondary_color',
'danger_color',
'success_color',
'warning_color',
'body_color',
'darker_body_color',
'surface_color',
'background_color',
'border_color',
'login_header_footer_color',
'site_logo',
'footer_logo',
])]
class WebsiteType extends Model
{
@@ -34,6 +41,14 @@ class WebsiteType extends Model
return $this->belongsTo(Attachment::class, 'site_logo');
}
/**
* @return BelongsTo<Attachment, $this>
*/
public function footerLogo(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'footer_logo');
}
/**
* @return HasMany<WebsiteTypeExtra, $this>
*/

View File

@@ -0,0 +1,5 @@
<?php
namespace App\Domains\Tenant\Requests;
class BootstrapAdminAppRequest extends BootstrapTenantRequest {}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domains\Tenant\Resources\AdminApp;
use App\Domains\Tenant\Models\WebsiteType;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin WebsiteType
*/
class BootstrapAdminAppResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'website_type_code' => $this->codigo,
'primary_color' => $this->primary_color,
'secondary_color' => $this->secondary_color,
'danger_color' => $this->danger_color,
'success_color' => $this->success_color,
'warning_color' => $this->warning_color,
'body_color' => $this->body_color,
'darker_body_color' => $this->darker_body_color,
'surface_color' => $this->surface_color,
'background_color' => $this->background_color,
'border_color' => $this->border_color,
'login_header_footer_color' => $this->login_header_footer_color,
'site_logo' => $this->siteLogo?->getTemporaryUrl(1440),
'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440),
];
}
}

View File

@@ -174,7 +174,15 @@ class WebsiteExtraService
$websiteExtra = $tenant->websiteExtras()
->where('website_type_extra_id', $definition->id)
->lockForUpdate()
->firstOrFail();
->first();
if (! $websiteExtra) {
return $tenant->websiteExtras()->create([
'website_type_extra_id' => $definition->id,
'config' => [],
'is_enabled' => true,
]);
}
$websiteExtra->update([
'is_enabled' => ! $websiteExtra->is_enabled,

View File

@@ -15,33 +15,71 @@ class WebsiteTypeService
) {}
/**
* Create a website type and store its site logo.
* Create a website type and store its logos.
*
* @param array<string, mixed> $data
*/
public function create(array $data): WebsiteType
{
return DB::transaction(function () use ($data): WebsiteType {
$siteLogo = $data['site_logo'] ?? null;
return $this->save(new WebsiteType, $data);
}
unset($data['site_logo']);
/**
* Create or update a website type and replace its logos when provided.
*
* @param array<string, mixed> $attributes
* @param array<string, mixed> $values
*/
public function updateOrCreate(array $attributes, array $values = []): WebsiteType
{
/** @var WebsiteType $websiteType */
$websiteType = WebsiteType::query()->firstOrNew($attributes);
$siteLogoAttachmentId = null;
return $this->save($websiteType, [...$attributes, ...$values]);
}
if ($siteLogo) {
$attachment = Str::isUuid($siteLogo)
? Attachment::query()->where('key', $siteLogo)->first()
: $this->attachmentService->store($siteLogo, 'website-types');
/**
* @param array<string, mixed> $data
*/
private function save(WebsiteType $websiteType, array $data): WebsiteType
{
return DB::transaction(function () use ($websiteType, $data): WebsiteType {
$previousLogos = [];
if ($attachment) {
$siteLogoAttachmentId = $attachment->id;
foreach (['site_logo' => 'siteLogo', 'footer_logo' => 'footerLogo'] as $field => $relation) {
if (! array_key_exists($field, $data)) {
continue;
}
$logo = $data[$field];
$previousLogos[$field] = $websiteType->exists
? $websiteType->{$relation}()->first()
: null;
unset($data[$field]);
$attachment = null;
if ($logo) {
$attachment = is_string($logo) && Str::isUuid($logo)
? Attachment::query()->where('key', $logo)->first()
: $this->attachmentService->store($logo, 'website-types');
}
$data[$field] = $attachment?->id;
}
$data['site_logo'] = $siteLogoAttachmentId;
$websiteType->fill($data)->save();
/** @var WebsiteType $websiteType */
$websiteType = WebsiteType::query()->create($data);
foreach ($previousLogos as $field => $previousLogo) {
if (
$previousLogo instanceof Attachment
&& $previousLogo->id !== $websiteType->{$field}
&& $previousLogo->id !== $websiteType->site_logo
&& $previousLogo->id !== $websiteType->footer_logo
) {
$this->attachmentService->delete($previousLogo);
}
}
return $websiteType;
});

View File

@@ -1,13 +1,17 @@
<?php
use App\Domains\Tenant\Controllers\AdminApp\BootstrapTenantController;
use App\Domains\Tenant\Controllers\AdminApp\BootstrapAdminAppController;
use App\Domains\Tenant\Controllers\AdminApp\WebsiteExtraController;
use Illuminate\Support\Facades\Route;
Route::get(
'v1/adminapp/bootstrap/{dominio}',
BootstrapAdminAppController::class
);
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::get('bootstrap', BootstrapTenantController::class);
Route::get('website-extras', [WebsiteExtraController::class, 'show']);
Route::get('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'showExtra']);
Route::put('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'update']);

View File

@@ -14,7 +14,13 @@ return new class extends Migration
$table->string('secondary_color')->nullable()->after('primary_color');
$table->string('danger_color')->nullable()->after('secondary_color');
$table->string('success_color')->nullable()->after('danger_color');
$table->string('login_header_footer_color')->nullable()->after('success_color');
$table->string('warning_color')->nullable()->after('success_color');
$table->string('body_color')->nullable()->after('warning_color');
$table->string('darker_body_color')->nullable()->after('body_color');
$table->string('surface_color')->nullable()->after('darker_body_color');
$table->string('background_color')->nullable()->after('surface_color');
$table->string('border_color')->nullable()->after('background_color');
$table->string('login_header_footer_color')->nullable()->after('border_color');
$table->unsignedBigInteger('site_logo')->nullable()->after('login_header_footer_color');
$table->foreign('site_logo')
@@ -37,6 +43,12 @@ return new class extends Migration
'secondary_color',
'danger_color',
'success_color',
'warning_color',
'body_color',
'darker_body_color',
'surface_color',
'background_color',
'border_color',
'login_header_footer_color',
'site_logo',
]);

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('website_type', function (Blueprint $table): void {
$table->unsignedBigInteger('footer_logo')->nullable()->after('site_logo');
$table->foreign('footer_logo')
->references('id')
->on('attachments')
->nullOnDelete();
});
}
public function down(): void
{
Schema::table('website_type', function (Blueprint $table): void {
if (Schema::getConnection()->getDriverName() !== 'sqlite') {
$table->dropForeign(['footer_logo']);
}
$table->dropColumn('footer_logo');
});
}
};

View File

@@ -23,16 +23,54 @@ class MenuSeeder extends Seeder
'route' => '/product/:id',
],
['code' => 'checkout', 'label' => 'Finalizar compra', 'route' => '/checkout'],
['code' => 'admin.event', 'label' => 'Eventos', 'route' => '/admin/event'],
['code' => 'admin.catalog', 'label' => 'Catálogo', 'route' => '/admin/catalog'],
['code' => 'admin.combos', 'label' => 'Combos', 'route' => '/admin/combos'],
[
'code' => 'admin.categories',
'code' => 'main.adminapp',
'label' => 'Administración',
'content_type' => Menu::CONTENT_TYPE_DYNAMIC,
'route' => '/',
],
[
'code' => 'adminapp.event',
'label' => 'Eventos',
'parent_menu_code' => 'main.adminapp',
'route' => '/admin/event',
],
[
'code' => 'adminapp.inicio',
'label' => 'Inicio',
'parent_menu_code' => 'main.adminapp',
'route' => '/admin/inicio',
],
[
'code' => 'adminapp.catalog',
'label' => 'Catálogo',
'parent_menu_code' => 'main.adminapp',
'route' => '/admin/catalog',
],
[
'code' => 'adminapp.combos',
'label' => 'Combos',
'parent_menu_code' => 'main.adminapp',
'route' => '/admin/combos',
],
[
'code' => 'adminapp.categories',
'label' => 'Categorías',
'parent_menu_code' => 'main.adminapp',
'route' => '/admin/categories',
],
['code' => 'admin.ventas', 'label' => 'Ventas', 'route' => '/admin/ventas'],
['code' => 'admin.staff', 'label' => 'Staff', 'route' => '/admin/staff'],
[
'code' => 'adminapp.ventas',
'label' => 'Ventas',
'parent_menu_code' => 'main.adminapp',
'route' => '/admin/ventas',
],
[
'code' => 'adminapp.staff',
'label' => 'Staff',
'parent_menu_code' => 'main.adminapp',
'route' => '/admin/staff',
],
[
'code' => 'account',
'label' => 'Mi cuenta',
@@ -135,9 +173,30 @@ class MenuSeeder extends Seeder
->whereIn('code', ['profile', 'purchases', 'tickets'])
->delete();
Menu::query()
->whereIn('code', [
'admin.event',
'admin.catalog',
'admin.combos',
'admin.categories',
'admin.ventas',
'admin.staff',
'event',
'catalog',
'combos',
'categories',
'ventas',
'staff',
])
->delete();
$allRoleMenuCodes = Menu::query()->pluck('code');
$adminAppMenuCodes = Menu::query()
->where('code', 'main.adminapp')
->orWhere('parent_menu_code', 'main.adminapp')
->pluck('code');
$userMenuCodes = Menu::query()
->where('code', 'not like', 'admin.%')
->whereNotIn('code', $adminAppMenuCodes)
->pluck('code');
Role::query()

View File

@@ -120,7 +120,6 @@ class TenantSeeder extends Seeder
'extras' => [
'heroConfig' => [
'title_html' => '<h1>Fiesta Fútbol Infantil</h1>',
'description_html' => '<p>Viví una jornada inolvidable de fútbol infantil.</p>',
'button_text' => 'Comprar entradas',
'button_href' => '/tickets',
'background_image_id' => $this->uploadedImage(
@@ -133,10 +132,21 @@ class TenantSeeder extends Seeder
'location' => 'Rosario, Santa Fe',
'dates_text' => '9, 10, 11 y 12 de Octubre 2026',
'dates' => [
'2026-12-05',
'2026-12-06',
[
'date' => '2026-12-05',
'start_time' => '09:00',
'end_time' => '18:00',
],
[
'date' => '2026-12-06',
'start_time' => '09:00',
'end_time' => '18:00',
],
],
],
'additionalInfoConfig' => [
'description' => '<p>Viví una jornada inolvidable de fútbol infantil.</p>',
],
],
]);
}

View File

@@ -2,16 +2,39 @@
namespace Database\Seeders;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Tenant\Services\WebsiteTypeService;
use Illuminate\Database\Seeder;
use Illuminate\Http\UploadedFile;
use RuntimeException;
class WebsiteTypeSeeder extends Seeder
{
private const PRESENTATION = [
'primary_color' => '#FF7006',
'secondary_color' => '#777777',
'danger_color' => '#E04A4A',
'success_color' => '#81BC73',
'warning_color' => '#81BC73',
'body_color' => '#666666',
'darker_body_color' => '#333333',
'surface_color' => '#ffffff',
'background_color' => '#f8f8f8',
'border_color' => '#EAEAEA',
];
public function __construct(private readonly WebsiteTypeService $websiteTypeService) {}
public function run(): void
{
$shopIt = WebsiteType::query()->updateOrCreate(
$shopIt = $this->websiteTypeService->updateOrCreate(
['codigo' => 'shopit'],
['nombre' => 'ShopIt'],
[
'nombre' => 'ShopIt',
'dominio' => 'localhost',
...self::PRESENTATION,
'site_logo' => $this->onTicketLogo(),
'footer_logo' => $this->onTicketFooterLogo(),
],
);
$shopIt->extras()->updateOrCreate(
@@ -35,9 +58,15 @@ class WebsiteTypeSeeder extends Seeder
],
);
$onTicket = WebsiteType::query()->updateOrCreate(
$onTicket = $this->websiteTypeService->updateOrCreate(
['codigo' => 'onticket'],
['nombre' => 'OnTicket'],
[
'nombre' => 'OnTicket',
'dominio' => 'onticket.localhost',
...self::PRESENTATION,
'site_logo' => $this->onTicketLogo(),
'footer_logo' => $this->onTicketFooterLogo(),
],
);
$onTicket->extras()->updateOrCreate(
@@ -78,11 +107,68 @@ class WebsiteTypeSeeder extends Seeder
'location' => 'nullable|string',
'dates_text' => 'nullable|string|max:255',
'dates' => 'nullable|array',
'dates.*' => 'required|date_format:Y-m-d|distinct',
'dates.*' => 'required|array:date,start_time,end_time',
'dates.*.date' => 'required|date_format:Y-m-d|distinct',
'dates.*.start_time' => 'required|date_format:H:i',
'dates.*.end_time' => 'required|date_format:H:i',
'contact' => 'nullable|array:whatsapp_url,instagram_url,facebook_url',
'contact.whatsapp_url' => 'nullable|url|max:2048',
'contact.instagram_url' => 'nullable|url|max:2048',
'contact.facebook_url' => 'nullable|url|max:2048',
],
'transforms' => [],
],
],
);
$onTicket->extras()->updateOrCreate(
['codigo' => 'additionalInfoConfig'],
[
'nombre' => 'Información adicional',
'descripcion' => 'Información adicional visible en la página del evento.',
'is_required' => false,
'config_schema' => [
'request_rules' => [
'$' => 'required|array',
'description' => 'nullable|string',
],
'transforms' => [],
],
],
);
}
private function onTicketLogo(): UploadedFile
{
$path = public_path('images/website_types/onticket_logo.png');
if (! file_exists($path)) {
throw new RuntimeException("OnTicket logo not found at path: {$path}");
}
return new UploadedFile(
$path,
'onticket_logo.png',
'image/png',
null,
true,
);
}
private function onTicketFooterLogo(): UploadedFile
{
$path = public_path('images/website_types/onticket_footer_logo.png');
if (! file_exists($path)) {
throw new RuntimeException("OnTicket footer logo not found at path: {$path}");
}
return new UploadedFile(
$path,
'onticket_footer_logo.png',
'image/png',
null,
true,
);
}
}

View File

@@ -94,6 +94,9 @@ return [
'schema_required' => 'The schema is required for static menus.',
'not_found' => 'The specified menu does not exist.',
],
'tenant' => [
'website_extra_toggled' => 'The section status was updated successfully.',
],
'errors' => [
'forbidden' => 'You do not have permission to perform this action.',
'not_found' => 'The requested resource was not found.',

View File

@@ -94,6 +94,9 @@ return [
'schema_required' => 'El schema es obligatorio para los menús estáticos.',
'not_found' => 'El menú indicado no existe.',
],
'tenant' => [
'website_extra_toggled' => 'El estado de la sección se actualizó correctamente.',
],
'errors' => [
'forbidden' => 'No tienes permiso para realizar esta acción.',
'not_found' => 'El recurso solicitado no fue encontrado.',

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -1,6 +1,6 @@
<?php
namespace Tests\Feature\Tenant;
namespace Tests\Feature\Auth;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
@@ -11,31 +11,28 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdminAppBootstrapTenantControllerTest extends TestCase
class AdminAppMeControllerTest extends TestCase
{
use RefreshDatabase;
public function test_authentication_is_required(): void
{
$this->getJson('/api/v1/adminapp/tenant/bootstrap')
->assertUnauthorized();
$this->getJson('/api/v1/adminapp/me')->assertUnauthorized();
}
public function test_a_customer_cannot_bootstrap_adminapp(): void
public function test_a_customer_cannot_access_the_adminapp_context(): void
{
$userRole = $this->createRole(RoleCode::User);
$customer = User::factory()->create([
'rol_codigo' => $userRole->codigo,
'rol_codigo' => $this->createRole(RoleCode::User)->codigo,
'tenant_codigo' => null,
]);
Sanctum::actingAs($customer);
$this->getJson('/api/v1/adminapp/tenant/bootstrap')
->assertForbidden();
$this->getJson('/api/v1/adminapp/me')->assertForbidden();
}
public function test_it_returns_the_authenticated_users_tenant_and_admin_menus(): void
public function test_it_returns_the_user_tenant_and_authorized_admin_menus(): void
{
$adminAppRole = $this->createRole(RoleCode::AdminApp);
$tenant = $this->createTenant('acme');
@@ -49,18 +46,23 @@ class AdminAppBootstrapTenantControllerTest extends TestCase
$tenant->menues()->sync([$catalog->code, $staff->code, $storefront->code]);
$otherTenant->menues()->sync([$staff->code]);
Sanctum::actingAs(User::factory()->create([
$user = User::factory()->create([
'nombre_apellido' => 'Admin Acme',
'email' => 'admin@acme.test',
'rol_codigo' => $adminAppRole->codigo,
'tenant_codigo' => $tenant->codigo,
]));
]);
Sanctum::actingAs($user);
$this->getJson('/api/v1/adminapp/tenant/bootstrap')
$this->getJson('/api/v1/adminapp/me')
->assertOk()
->assertJsonPath('data.codigo', 'acme')
->assertJsonCount(1, 'data.menues')
->assertJsonPath('data.menues.0.code', 'admin.catalog')
->assertJsonPath('data.menues.0.label', 'Catálogo')
->assertJsonPath('data.menues.0.route', '/admin/catalog')
->assertJsonPath('data.user.id', $user->id)
->assertJsonPath('data.user.email', 'admin@acme.test')
->assertJsonPath('data.tenant.codigo', 'acme')
->assertJsonCount(1, 'data.tenant.menues')
->assertJsonPath('data.tenant.menues.0.code', 'admin.catalog')
->assertJsonPath('data.tenant.menues.0.label', 'Catálogo')
->assertJsonPath('data.tenant.menues.0.route', '/admin/catalog')
->assertJsonMissing(['code' => 'admin.staff'])
->assertJsonMissing(['code' => 'index']);
}

View File

@@ -24,14 +24,30 @@ class MenuSeederTest extends TestCase
$this->seed(MenuSeeder::class);
$expectedMenus = [
'admin.catalog' => ['Catálogo', '/admin/catalog'],
'admin.categories' => ['Categorías', '/admin/categories'],
'admin.combos' => ['Combos', '/admin/combos'],
'admin.event' => ['Eventos', '/admin/event'],
'admin.staff' => ['Staff', '/admin/staff'],
'admin.ventas' => ['Ventas', '/admin/ventas'],
'adminapp.catalog' => ['Catálogo', '/admin/catalog'],
'adminapp.categories' => ['Categorías', '/admin/categories'],
'adminapp.combos' => ['Combos', '/admin/combos'],
'adminapp.event' => ['Eventos', '/admin/event'],
'adminapp.staff' => ['Staff', '/admin/staff'],
'adminapp.ventas' => ['Ventas', '/admin/ventas'],
];
$adminApp = Menu::query()
->with('children')
->where('code', 'main.adminapp')
->firstOrFail();
$this->assertSame('Administración', $adminApp->label);
$this->assertSame(Menu::CONTENT_TYPE_DYNAMIC, $adminApp->content_type);
$this->assertSame('/', $adminApp->route);
$this->assertSame(
array_keys($expectedMenus),
$adminApp->children->pluck('code')->sort()->values()->all()
);
$this->assertTrue(
$tenant->menues()->where('menues.code', 'main.adminapp')->exists()
);
$adminMenus = Menu::query()
->whereIn('code', array_keys($expectedMenus))
->get()
@@ -42,10 +58,28 @@ class MenuSeederTest extends TestCase
foreach ($expectedMenus as $code => [$label, $route]) {
$this->assertSame($label, $adminMenus->get($code)?->label);
$this->assertSame($route, $adminMenus->get($code)?->route);
$this->assertSame('main.adminapp', $adminMenus->get($code)?->parent_menu_code);
$this->assertTrue(
$tenant->menues()->where('menues.code', $code)->exists()
);
}
$this->assertFalse(
Menu::query()->whereIn('code', [
'admin.event',
'admin.catalog',
'admin.combos',
'admin.categories',
'admin.ventas',
'admin.staff',
'event',
'catalog',
'combos',
'categories',
'ventas',
'staff',
])->exists()
);
}
public function test_it_assigns_menus_to_the_expected_roles(): void
@@ -57,8 +91,12 @@ class MenuSeederTest extends TestCase
->orderBy('code')
->pluck('code')
->all();
$adminAppMenuCodes = Menu::query()
->where('code', 'main.adminapp')
->orWhere('parent_menu_code', 'main.adminapp')
->pluck('code');
$userMenuCodes = Menu::query()
->where('code', 'not like', 'admin.%')
->whereNotIn('code', $adminAppMenuCodes)
->orderBy('code')
->pluck('code')
->all();
@@ -84,6 +122,8 @@ class MenuSeederTest extends TestCase
->all();
$this->assertSame($userMenuCodes, $userRoleMenuCodes);
$this->assertNotContains('main.adminapp', $userRoleMenuCodes);
$this->assertNotContains('adminapp.catalog', $userRoleMenuCodes);
$this->assertDatabaseCount(
'roles_menues',
(count($allMenuCodes) * 2) + count($userMenuCodes)

View File

@@ -84,12 +84,13 @@ class TenantSeederTest extends TestCase
$extras = $fiesta->websiteExtras->keyBy('websiteTypeExtra.codigo');
$this->assertEqualsCanonicalizing(
['heroConfig', 'eventConfig'],
['heroConfig', 'eventConfig', 'additionalInfoConfig'],
$extras->keys()->all(),
);
$heroConfig = $extras->get('heroConfig')->config;
$this->assertSame('<h1>Fiesta Fútbol Infantil</h1>', $heroConfig['title_html']);
$this->assertArrayNotHasKey('description_html', $heroConfig);
$this->assertIsInt($heroConfig['background_image_id']);
$this->assertDatabaseHas('attachments', [
'id' => $heroConfig['background_image_id'],
@@ -101,9 +102,21 @@ class TenantSeederTest extends TestCase
'location' => 'Rosario, Santa Fe',
'dates_text' => '9, 10, 11 y 12 de Octubre 2026',
'dates' => [
'2026-12-05',
'2026-12-06',
[
'date' => '2026-12-05',
'start_time' => '09:00',
'end_time' => '18:00',
],
[
'date' => '2026-12-06',
'start_time' => '09:00',
'end_time' => '18:00',
],
],
], $extras->get('eventConfig')->config);
$this->assertSame([
'description' => '<p>Viví una jornada inolvidable de fútbol infantil.</p>',
], $extras->get('additionalInfoConfig')->config);
}
}

View File

@@ -2,9 +2,11 @@
namespace Tests\Feature\Seeders;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Tenant\Models\WebsiteType;
use Database\Seeders\WebsiteTypeSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class WebsiteTypeSeederTest extends TestCase
@@ -13,10 +15,26 @@ class WebsiteTypeSeederTest extends TestCase
public function test_it_seeds_website_types_and_their_config_schemas_idempotently(): void
{
Storage::fake('s3');
$this->seed(WebsiteTypeSeeder::class);
$this->seed(WebsiteTypeSeeder::class);
$this->assertSame(2, WebsiteType::query()->count());
$this->assertSame(4, Attachment::query()->count());
$expectedPresentation = [
'primary_color' => '#FF7006',
'secondary_color' => '#777777',
'danger_color' => '#E04A4A',
'success_color' => '#81BC73',
'warning_color' => '#81BC73',
'body_color' => '#666666',
'darker_body_color' => '#333333',
'surface_color' => '#ffffff',
'background_color' => '#f8f8f8',
'border_color' => '#EAEAEA',
];
$shopIt = WebsiteType::query()
->where('codigo', 'shopit')
@@ -24,6 +42,12 @@ class WebsiteTypeSeederTest extends TestCase
->sole();
$this->assertSame('ShopIt', $shopIt->nombre);
$this->assertSame('localhost', $shopIt->dominio);
$this->assertSame($expectedPresentation, $shopIt->only(array_keys($expectedPresentation)));
$this->assertSame('onticket_logo.png', $shopIt->siteLogo->filename);
Storage::disk('s3')->assertExists($shopIt->siteLogo->path);
$this->assertSame('onticket_footer_logo.png', $shopIt->footerLogo->filename);
Storage::disk('s3')->assertExists($shopIt->footerLogo->path);
$this->assertSame(['carousel'], $shopIt->extras->pluck('codigo')->all());
$this->assertSame('Carrusel principal', $shopIt->extras->sole()->nombre);
$this->assertSame([
@@ -45,8 +69,16 @@ class WebsiteTypeSeederTest extends TestCase
->sole();
$this->assertSame('OnTicket', $onTicket->nombre);
$this->assertSame('onticket.localhost', $onTicket->dominio);
$this->assertSame($expectedPresentation, $onTicket->only(array_keys($expectedPresentation)));
$this->assertSame('onticket_logo.png', $onTicket->siteLogo->filename);
Storage::disk('s3')->assertExists($onTicket->siteLogo->path);
$this->assertSame('onticket_footer_logo.png', $onTicket->footerLogo->filename);
Storage::disk('s3')->assertExists($onTicket->footerLogo->path);
$this->assertNotSame($shopIt->site_logo, $onTicket->site_logo);
$this->assertNotSame($shopIt->footer_logo, $onTicket->footer_logo);
$this->assertEqualsCanonicalizing(
['heroConfig', 'eventConfig'],
['heroConfig', 'eventConfig', 'additionalInfoConfig'],
$onTicket->extras->pluck('codigo')->all(),
);
@@ -76,9 +108,27 @@ class WebsiteTypeSeederTest extends TestCase
'location' => 'nullable|string',
'dates_text' => 'nullable|string|max:255',
'dates' => 'nullable|array',
'dates.*' => 'required|date_format:Y-m-d|distinct',
'dates.*' => 'required|array:date,start_time,end_time',
'dates.*.date' => 'required|date_format:Y-m-d|distinct',
'dates.*.start_time' => 'required|date_format:H:i',
'dates.*.end_time' => 'required|date_format:H:i',
'contact' => 'nullable|array:whatsapp_url,instagram_url,facebook_url',
'contact.whatsapp_url' => 'nullable|url|max:2048',
'contact.instagram_url' => 'nullable|url|max:2048',
'contact.facebook_url' => 'nullable|url|max:2048',
],
'transforms' => [],
], $eventSchema);
$additionalInfoSchema = $onTicket->extras
->firstWhere('codigo', 'additionalInfoConfig')
->config_schema;
$this->assertSame([
'request_rules' => [
'$' => 'required|array',
'description' => 'nullable|string',
],
'transforms' => [],
], $additionalInfoSchema);
}
}

View File

@@ -195,6 +195,8 @@ class AdminAppWebsiteExtraControllerTest extends TestCase
$this->patchJson('/api/v1/adminapp/tenant/website-extras/contactConfig/toggle')
->assertOk()
->assertJsonPath('code', 'tenant.website_extra_toggled')
->assertJsonPath('message', 'El estado de la sección se actualizó correctamente.')
->assertJsonPath('data.definitions.contactConfig.is_enabled', true);
$this->assertTrue($websiteExtra->refresh()->is_enabled);
@@ -206,13 +208,21 @@ class AdminAppWebsiteExtraControllerTest extends TestCase
$this->assertFalse($websiteExtra->refresh()->is_enabled);
}
public function test_toggle_returns_not_found_for_an_extra_without_a_tenant_value(): void
public function test_toggle_enables_an_extra_without_a_tenant_value(): void
{
$tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$this->patchJson('/api/v1/adminapp/tenant/website-extras/contactConfig/toggle')
->assertNotFound();
->assertOk()
->assertJsonPath('data.definitions.contactConfig.is_enabled', true)
->assertJsonPath('data.extras.contactConfig', []);
$this->assertDatabaseHas('websites_extras', [
'website_code' => $tenant->codigo,
'website_type_extra_id' => $this->websiteType->extras()->firstOrFail()->id,
'is_enabled' => true,
]);
}
private function createTenant(string $code): Tenant

View File

@@ -0,0 +1,63 @@
<?php
namespace Tests\Feature\Tenant;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Tenant\Models\WebsiteType;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class BootstrapAdminAppControllerTest extends TestCase
{
use RefreshDatabase;
public function test_it_publicly_bootstraps_the_admin_app_by_domain(): void
{
$footerLogo = Attachment::factory()->create();
WebsiteType::query()->create([
'codigo' => 'shopit',
'nombre' => 'ShopIt',
'dominio' => 'admin.shopit.test',
'primary_color' => '#112233',
'secondary_color' => '#445566',
'danger_color' => '#aa0000',
'success_color' => '#00aa00',
'warning_color' => '#ffaa00',
'body_color' => '#666666',
'darker_body_color' => '#333333',
'surface_color' => '#ffffff',
'background_color' => '#f8f8f8',
'border_color' => '#eaeaea',
'login_header_footer_color' => '#313131',
'footer_logo' => $footerLogo->id,
]);
$this->getJson('/api/v1/adminapp/bootstrap/ADMIN.SHOPIT.TEST')
->assertOk()
->assertJsonPath('data.website_type_code', 'shopit')
->assertJsonPath('data.primary_color', '#112233')
->assertJsonPath('data.warning_color', '#ffaa00')
->assertJsonPath('data.login_header_footer_color', '#313131')
->assertJsonPath('data.site_logo', null)
->assertJsonPath('data.footer_logo', $footerLogo->getTemporaryUrl(1440))
->assertJsonMissingPath('data.codigo')
->assertJsonMissingPath('data.nombre')
->assertJsonMissingPath('data.dominio');
}
public function test_it_returns_not_found_for_an_unknown_domain(): void
{
$this->getJson('/api/v1/adminapp/bootstrap/unknown.test')
->assertNotFound();
}
public function test_it_rejects_an_invalid_domain(): void
{
$invalidDomain = str_repeat('a', 256);
$this->getJson("/api/v1/adminapp/bootstrap/{$invalidDomain}")
->assertUnprocessable()
->assertJsonValidationErrors(['dominio']);
}
}

View File

@@ -31,7 +31,18 @@ class StoreTenantWithExtrasTest extends TestCase
'title' => 'Festival',
'location' => 'Buenos Aires',
'dates_text' => '10 y 11 de octubre de 2026',
'dates' => ['2026-10-10', '2026-10-11'],
'dates' => [
[
'date' => '2026-10-10',
'start_time' => '09:00',
'end_time' => '18:00',
],
[
'date' => '2026-10-11',
'start_time' => '10:00',
'end_time' => '17:00',
],
],
],
],
]));
@@ -52,7 +63,18 @@ class StoreTenantWithExtrasTest extends TestCase
'title' => 'Festival',
'location' => 'Buenos Aires',
'dates_text' => '10 y 11 de octubre de 2026',
'dates' => ['2026-10-10', '2026-10-11'],
'dates' => [
[
'date' => '2026-10-10',
'start_time' => '09:00',
'end_time' => '18:00',
],
[
'date' => '2026-10-11',
'start_time' => '10:00',
'end_time' => '17:00',
],
],
],
$tenant->websiteExtras()->sole()->config
);
@@ -80,12 +102,16 @@ class StoreTenantWithExtrasTest extends TestCase
'website_type_code' => 'onticket',
'extras' => [
'eventConfig' => [
'dates' => ['not-a-date'],
'dates' => [[
'date' => 'not-a-date',
'start_time' => '09:00',
'end_time' => '18:00',
]],
],
],
]))
->assertUnprocessable()
->assertJsonValidationErrors(['extras.eventConfig.dates.0']);
->assertJsonValidationErrors(['extras.eventConfig.dates.0.date']);
$this->assertDatabaseMissing('tenants', ['codigo' => 'festival']);
}

View File

@@ -27,6 +27,12 @@ class WebsiteExtrasTest extends TestCase
'secondary_color',
'danger_color',
'success_color',
'warning_color',
'body_color',
'darker_body_color',
'surface_color',
'background_color',
'border_color',
'login_header_footer_color',
'site_logo',
'created_at',
@@ -73,6 +79,12 @@ class WebsiteExtrasTest extends TestCase
'secondary_color' => '#222222',
'danger_color' => '#ff0000',
'success_color' => '#00aa55',
'warning_color' => '#ffaa00',
'body_color' => '#666666',
'darker_body_color' => '#333333',
'surface_color' => '#ffffff',
'background_color' => '#f8f8f8',
'border_color' => '#dddddd',
'login_header_footer_color' => '#333333',
'site_logo' => $siteLogo->id,
]);
@@ -100,6 +112,12 @@ class WebsiteExtrasTest extends TestCase
$this->assertSame('#222222', $type->secondary_color);
$this->assertSame('#ff0000', $type->danger_color);
$this->assertSame('#00aa55', $type->success_color);
$this->assertSame('#ffaa00', $type->warning_color);
$this->assertSame('#666666', $type->body_color);
$this->assertSame('#333333', $type->darker_body_color);
$this->assertSame('#ffffff', $type->surface_color);
$this->assertSame('#f8f8f8', $type->background_color);
$this->assertSame('#dddddd', $type->border_color);
$this->assertSame('#333333', $type->login_header_footer_color);
$this->assertTrue($type->siteLogo()->firstOrFail()->is($siteLogo));
$this->assertSame($type->codigo, $typeExtra->website_type_code);

View File

@@ -12,7 +12,7 @@ class WebsiteTypeServiceTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_a_website_type_and_uploads_its_logo_to_s3(): void
public function test_it_creates_a_website_type_and_uploads_its_logos_to_s3(): void
{
Storage::fake('s3');
@@ -24,21 +24,39 @@ class WebsiteTypeServiceTest extends TestCase
'secondary_color' => '#222222',
'danger_color' => '#ff0000',
'success_color' => '#00aa55',
'warning_color' => '#ffaa00',
'body_color' => '#666666',
'darker_body_color' => '#333333',
'surface_color' => '#ffffff',
'background_color' => '#f8f8f8',
'border_color' => '#dddddd',
'login_header_footer_color' => '#333333',
'site_logo' => UploadedFile::fake()->image('site-logo.png'),
'footer_logo' => UploadedFile::fake()->image('footer-logo.png'),
]);
$siteLogo = $websiteType->siteLogo()->firstOrFail();
$footerLogo = $websiteType->footerLogo()->firstOrFail();
$this->assertSame('marketplace', $websiteType->codigo);
$this->assertSame($siteLogo->id, $websiteType->site_logo);
$this->assertSame($footerLogo->id, $websiteType->footer_logo);
$this->assertStringStartsWith('website-types/', $siteLogo->path);
$this->assertStringStartsWith('website-types/', $footerLogo->path);
Storage::disk('s3')->assertExists($siteLogo->path);
Storage::disk('s3')->assertExists($footerLogo->path);
$this->assertDatabaseHas('website_type', [
'codigo' => 'marketplace',
'dominio' => 'marketplace.test',
'warning_color' => '#ffaa00',
'body_color' => '#666666',
'darker_body_color' => '#333333',
'surface_color' => '#ffffff',
'background_color' => '#f8f8f8',
'border_color' => '#dddddd',
'login_header_footer_color' => '#333333',
'site_logo' => $siteLogo->id,
'footer_logo' => $footerLogo->id,
]);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Tests\Unit\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Tenant\Resources\AdminApp\BootstrapAdminAppResource;
use Tests\TestCase;
class BootstrapAdminAppResourceTest extends TestCase
{
public function test_it_exposes_the_website_type_code(): void
{
$websiteType = new WebsiteType([
'codigo' => 'shopit',
'nombre' => 'ShopIt',
'dominio' => 'admin.shopit.test',
]);
$websiteType->setRelation('siteLogo', null);
$data = BootstrapAdminAppResource::make($websiteType)->resolve(request());
$this->assertSame('shopit', $data['codigo']);
$this->assertSame('shopit', $data['website_type_code']);
}
}