feat(menu): implement menu hierarchy and static content for help section in MenuSeeder and related tests

This commit is contained in:
2026-07-23 17:07:55 -03:00
parent 2b1ef5f8a9
commit 460eec3c78
4 changed files with 391 additions and 3 deletions

View File

@@ -2,9 +2,11 @@
namespace App\Domains\Tenant\Resources; namespace App\Domains\Tenant\Resources;
use App\Domains\Menu\Models\Menu;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
/** /**
* @mixin Tenant * @mixin Tenant
@@ -55,7 +57,49 @@ class TenantResource extends JsonResource
]) ])
->values() ->values()
), ),
'menues' => $this->whenLoaded('menues'), 'menues' => $this->whenLoaded(
'menues',
fn () => $this->menuTree($this->menues)
),
]; ];
} }
/**
* @param Collection<int, Menu> $menus
* @return Collection<int, array<string, mixed>>
*/
private function menuTree(Collection $menus): Collection
{
$menuCodes = $menus->pluck('code')->flip();
$childrenByParent = $menus
->filter(fn (Menu $menu) => $menu->parent_menu_code !== null)
->groupBy('parent_menu_code');
$formatMenu = function (Menu $menu) use (&$formatMenu, $childrenByParent): array {
$formatted = [
'id' => $menu->id,
'code' => $menu->code,
'parent_menu_code' => $menu->parent_menu_code,
'content_type' => $menu->content_type,
'route' => $menu->route,
];
if ($menu->pivot?->static_content !== null) {
$formatted['static_content'] = $menu->pivot->static_content;
}
$formatted['submenues'] = $childrenByParent
->get($menu->code, collect())
->map($formatMenu)
->values();
return $formatted;
};
return $menus
->filter(fn (Menu $menu) => $menu->parent_menu_code === null
|| ! $menuCodes->has($menu->parent_menu_code))
->map($formatMenu)
->values();
}
} }

View File

@@ -2,9 +2,9 @@
namespace Database\Seeders; namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Domains\Menu\Models\Menu; use App\Domains\Menu\Models\Menu;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Seeder;
class MenuSeeder extends Seeder class MenuSeeder extends Seeder
{ {
@@ -20,15 +20,123 @@ class MenuSeeder extends Seeder
['code' => 'profile', 'route' => '/profile'], ['code' => 'profile', 'route' => '/profile'],
['code' => 'purchases', 'route' => '/purchases'], ['code' => 'purchases', 'route' => '/purchases'],
['code' => 'tickets', 'route' => '/tickets'], ['code' => 'tickets', 'route' => '/tickets'],
[
'code' => 'help',
'content_type' => Menu::CONTENT_TYPE_DYNAMIC,
'route' => '/ayuda',
],
[
'code' => 'help.faq',
'parent_menu_code' => 'help',
'content_type' => Menu::CONTENT_TYPE_STATIC,
'static_content_schema' => [
'*.pregunta' => 'required|string',
'*.respuesta' => 'required|string',
'*.is_active' => 'sometimes|boolean',
],
'route' => '/ayuda/preguntas-frecuentes',
],
[
'code' => 'help.contact',
'parent_menu_code' => 'help',
'content_type' => Menu::CONTENT_TYPE_STATIC,
'static_content_schema' => [
'whatsapp' => 'required|array',
'whatsapp.whatsapp_url' => 'required|url',
'whatsapp.whatsapp_label' => 'required|string',
'phone' => 'required|string',
'locations' => 'required|array|min:1',
'locations.*.label' => 'required|string',
'locations.*.addresses' => 'required|array|min:1',
'locations.*.addresses.*' => 'required|string',
'map_locations' => 'required|array',
'map_locations.*' => 'required|array|size:2',
'map_locations.*.*' => 'required|numeric',
],
'route' => '/ayuda/contacto',
],
[
'code' => 'help.payment-methods',
'parent_menu_code' => 'help',
'content_type' => Menu::CONTENT_TYPE_DYNAMIC,
'route' => '/ayuda/medios-de-pago',
],
[
'code' => 'help.shipping',
'parent_menu_code' => 'help',
'content_type' => Menu::CONTENT_TYPE_DYNAMIC,
'route' => '/ayuda/envios',
],
[
'code' => 'help.terms-and-conditions',
'parent_menu_code' => 'help',
'content_type' => Menu::CONTENT_TYPE_DYNAMIC,
'route' => '/ayuda/terminos-y-condiciones',
],
]; ];
foreach ($menus as $menuData) { foreach ($menus as $menuData) {
Menu::firstOrCreate(['code' => $menuData['code']], $menuData); $menuData = array_merge([
'parent_menu_code' => null,
'content_type' => Menu::CONTENT_TYPE_DYNAMIC,
'static_content_schema' => null,
], $menuData);
Menu::updateOrCreate(['code' => $menuData['code']], $menuData);
} }
$tenants = Tenant::all(); $tenants = Tenant::all();
$allMenus = Menu::pluck('code')->toArray(); $allMenus = Menu::pluck('code')->toArray();
$helpMenuCodes = [
'help',
'help.faq',
'help.contact',
'help.payment-methods',
'help.shipping',
'help.terms-and-conditions',
];
$helpTenantCodes = [
'sonder',
'fiesta_futbol_infantil',
];
$frequentlyAskedQuestions = [
[
'pregunta' => '¿Hay algún límite de compra?',
'respuesta' => 'No hay un límite general de compra. La cantidad disponible depende del stock de cada producto.',
'is_active' => true,
],
[
'pregunta' => '¿Cuáles son los medios de pago disponibles?',
'respuesta' => 'Podés consultar y seleccionar los medios de pago habilitados al momento de finalizar tu compra.',
'is_active' => false,
],
[
'pregunta' => 'Necesito asesoramiento antes de comprar el producto, ¿cómo puedo hacer?',
'respuesta' => 'Nuestro equipo comercial está preparado para asesorarte, no dudes en escribirnos por WhatsApp al (0341) 6658247 de lunes a viernes de 8.30 a 20 horas. Los sábados, domingos y feriados te contestaremos lo más rápido posible.',
'is_active' => false,
],
];
$contactContent = [
'whatsapp' => [
'whatsapp_url' => 'https://wa.me/543412602222',
'whatsapp_label' => 'Chatea con nosotros',
],
'phone' => '+54 9 (0341) 6658247',
'locations' => [
'rosario' => [
'label' => 'Rosario',
'addresses' => [
'Av. San Lorenzo 1542',
],
],
],
'map_locations' => [
'gigante_de_arroyito' => [-32.913997, -60.674567],
'punto_centro' => [-32.946820, -60.639320],
'punto_sur' => [-32.971510, -60.650640],
],
];
foreach ($tenants as $tenant) { foreach ($tenants as $tenant) {
$menuCodes = $allMenus; $menuCodes = $allMenus;
@@ -41,8 +149,21 @@ class MenuSeeder extends Seeder
$menuCodes = array_diff($menuCodes, ['product.detail']); $menuCodes = array_diff($menuCodes, ['product.detail']);
} }
if (! in_array($tenant->codigo, $helpTenantCodes, true)) {
$menuCodes = array_diff($menuCodes, $helpMenuCodes);
}
// Usar sync para asociar los menues al tenant // Usar sync para asociar los menues al tenant
$tenant->menues()->sync($menuCodes); $tenant->menues()->sync($menuCodes);
if (in_array($tenant->codigo, $helpTenantCodes, true)) {
$tenant->menues()->updateExistingPivot('help.faq', [
'static_content' => $frequentlyAskedQuestions,
]);
$tenant->menues()->updateExistingPivot('help.contact', [
'static_content' => $contactContent,
]);
}
} }
} }
} }

View File

@@ -0,0 +1,155 @@
<?php
namespace Tests\Feature\Seeders;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Menu\Models\Menu;
use App\Domains\Tenant\Models\Tenant;
use Database\Seeders\MenuSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class MenuSeederTest extends TestCase
{
use RefreshDatabase;
public function test_it_seeds_the_help_menu_hierarchy_and_content_schemas(): void
{
$this->seed(MenuSeeder::class);
$help = Menu::query()
->with('children')
->where('code', 'help')
->firstOrFail();
$this->assertSame(Menu::CONTENT_TYPE_DYNAMIC, $help->content_type);
$this->assertSame([
'help.contact',
'help.faq',
'help.payment-methods',
'help.shipping',
'help.terms-and-conditions',
], $help->children->pluck('code')->sort()->values()->all());
$faq = Menu::query()->where('code', 'help.faq')->firstOrFail();
$this->assertSame(Menu::CONTENT_TYPE_STATIC, $faq->content_type);
$this->assertEquals([
'*.pregunta' => 'required|string',
'*.respuesta' => 'required|string',
'*.is_active' => 'sometimes|boolean',
], $faq->static_content_schema);
$contact = Menu::query()->where('code', 'help.contact')->firstOrFail();
$this->assertSame(Menu::CONTENT_TYPE_STATIC, $contact->content_type);
$this->assertSame('required|url', $contact->static_content_schema['whatsapp.whatsapp_url']);
$this->assertSame('required|string', $contact->static_content_schema['phone']);
$this->assertSame(
'required|array|min:1',
$contact->static_content_schema['locations.*.addresses']
);
$this->assertSame(
'required|array|size:2',
$contact->static_content_schema['map_locations.*']
);
}
public function test_it_assigns_help_to_the_supported_tenants_with_faq_content(): void
{
$sonder = $this->createTenant('sonder');
$fiesta = $this->createTenant('fiesta_futbol_infantil');
$otherTenant = $this->createTenant('other');
$this->seed(MenuSeeder::class);
$expectedQuestions = [
[
'pregunta' => '¿Hay algún límite de compra?',
'respuesta' => 'No hay un límite general de compra. La cantidad disponible depende del stock de cada producto.',
'is_active' => true,
],
[
'pregunta' => '¿Cuáles son los medios de pago disponibles?',
'respuesta' => 'Podés consultar y seleccionar los medios de pago habilitados al momento de finalizar tu compra.',
'is_active' => false,
],
[
'pregunta' => 'Necesito asesoramiento antes de comprar el producto, ¿cómo puedo hacer?',
'respuesta' => 'Nuestro equipo comercial está preparado para asesorarte, no dudes en escribirnos por WhatsApp al (0341) 6658247 de lunes a viernes de 8.30 a 20 horas. Los sábados, domingos y feriados te contestaremos lo más rápido posible.',
'is_active' => false,
],
];
$expectedContact = [
'whatsapp' => [
'whatsapp_url' => 'https://wa.me/543412602222',
'whatsapp_label' => 'Chatea con nosotros',
],
'phone' => '+54 9 (0341) 6658247',
'locations' => [
'rosario' => [
'label' => 'Rosario',
'addresses' => [
'Av. San Lorenzo 1542',
],
],
],
'map_locations' => [
'gigante_de_arroyito' => [-32.913997, -60.674567],
'punto_centro' => [-32.946820, -60.639320],
'punto_sur' => [-32.971510, -60.650640],
],
];
foreach ([$sonder, $fiesta] as $tenant) {
$helpMenus = $tenant->menues()
->where(fn ($query) => $query
->where('menues.code', 'help')
->orWhere('menues.parent_menu_code', 'help'))
->get();
$this->assertCount(6, $helpMenus);
$this->assertEquals(
$expectedQuestions,
$helpMenus->firstWhere('code', 'help.faq')->pivot->static_content
);
$this->assertEquals(
$expectedContact,
$helpMenus->firstWhere('code', 'help.contact')->pivot->static_content
);
}
$this->assertFalse(
$otherTenant->menues()->where('menues.code', 'help')->exists()
);
}
private function createTenant(string $code): Tenant
{
$headerLogo = $this->createAttachment("{$code}-header.png");
$footerLogo = $this->createAttachment("{$code}-footer.png");
return Tenant::query()->create([
'codigo' => $code,
'nombre' => $code,
'dominio' => "{$code}.example.com",
'primary_color' => '#111111',
'secondary_color' => '#222222',
'danger_color' => '#333333',
'success_color' => '#444444',
'header_bg_color' => '#ffffff',
'footer_bg_color' => '#ffffff',
'header_logo_id' => $headerLogo->id,
'footer_logo_id' => $footerLogo->id,
]);
}
private function createAttachment(string $filename): Attachment
{
return Attachment::query()->create([
'path' => "test/{$filename}",
'filename' => $filename,
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
}
}

View File

@@ -2,6 +2,7 @@
namespace Tests\Feature\Tenant; namespace Tests\Feature\Tenant;
use App\Domains\Menu\Models\Menu;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
@@ -99,6 +100,73 @@ class BootstrapTenantControllerTest extends TestCase
$response->assertNotFound(); $response->assertNotFound();
} }
public function test_it_returns_tenant_menus_as_a_clean_hierarchy(): void
{
$tenant = $this->createTenant();
$parent = Menu::query()->create([
'code' => 'help',
'content_type' => Menu::CONTENT_TYPE_DYNAMIC,
'route' => '/ayuda',
]);
$staticChild = Menu::query()->create([
'code' => 'help.faq',
'parent_menu_code' => $parent->code,
'content_type' => Menu::CONTENT_TYPE_STATIC,
'static_content_schema' => [
'*.question' => 'required|string',
'*.answer' => 'required|string',
],
'route' => '/ayuda/preguntas-frecuentes',
]);
$dynamicChild = Menu::query()->create([
'code' => 'help.shipping',
'parent_menu_code' => $parent->code,
'content_type' => Menu::CONTENT_TYPE_DYNAMIC,
'route' => '/ayuda/envios',
]);
$staticContent = [
[
'question' => '¿Cómo compro?',
'answer' => 'Desde el catálogo.',
],
];
$tenant->menues()->sync([
$parent->code,
$staticChild->code => ['static_content' => $staticContent],
$dynamicChild->code,
]);
$response = $this->getJson('/api/tenants/bootstrap/acme.com');
$response
->assertOk()
->assertJsonCount(1, 'data.menues')
->assertJsonCount(2, 'data.menues.0.submenues')
->assertJsonPath('data.menues.0.code', 'help')
->assertJsonPath('data.menues.0.parent_menu_code', null)
->assertJsonPath('data.menues.0.submenues.0.code', 'help.faq')
->assertJsonPath('data.menues.0.submenues.0.parent_menu_code', 'help')
->assertJsonPath('data.menues.0.submenues.1.code', 'help.shipping');
$assertCleanMenu = function (array $menu) use (&$assertCleanMenu): void {
$this->assertArrayNotHasKey('created_at', $menu);
$this->assertArrayNotHasKey('updated_at', $menu);
$this->assertArrayNotHasKey('pivot', $menu);
$this->assertArrayNotHasKey('static_content_schema', $menu);
foreach ($menu['submenues'] as $submenu) {
$assertCleanMenu($submenu);
}
};
$menus = $response->json('data.menues');
$this->assertEquals($staticContent, $menus[0]['submenues'][0]['static_content']);
$assertCleanMenu($menus[0]);
$this->assertArrayNotHasKey('static_content', $menus[0]);
$this->assertArrayNotHasKey('static_content', $menus[0]['submenues'][1]);
}
public function test_it_rejects_duplicate_domains_after_normalization_when_storing(): void public function test_it_rejects_duplicate_domains_after_normalization_when_storing(): void
{ {
$base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; $base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';