php-admin/tests/Feature/Modal/ServiceManagerModalTest.php

708 lines
24 KiB
PHP
Raw Normal View History

<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
use Livewire\Attributes\Computed;
use Livewire\Component;
use Livewire\Livewire;
/**
* Tests for ServiceManager modal component patterns.
*
* These tests verify the ServiceManager behaviour using test doubles:
* opening/closing the edit modal, form validation, field population
* from data, form reset on close, service toggling, table rendering,
* and sync operations.
*/
// =============================================================================
// Test Double Components
// =============================================================================
/**
* ServiceManager-style component with CRUD modal and table display.
*/
class ServiceManagerModalDouble extends Component
{
public bool $showModal = false;
public ?int $editingId = null;
// Editable form fields
public string $name = '';
public string $tagline = '';
public string $description = '';
public string $icon = '';
public string $color = '';
public string $marketing_domain = '';
public string $marketing_url = '';
public string $docs_url = '';
public bool $is_enabled = true;
public bool $is_public = true;
public bool $is_featured = false;
public int $sort_order = 50;
// Read-only fields
public string $code = '';
public string $module = '';
public string $entitlement_code = '';
// Simulated service data
public array $serviceData = [];
// Track actions
public array $flashMessages = [];
public function mount(): void
{
$this->serviceData = [
[
'id' => 1,
'code' => 'social',
'module' => 'Core\\Mod\\Social',
'name' => 'Social',
'tagline' => 'Social media management',
'description' => 'Full social media management platform.',
'icon' => 'fa-share-nodes',
'color' => 'blue',
'marketing_domain' => 'social.lthn.sh',
'marketing_url' => 'https://social.lthn.sh',
'docs_url' => 'https://docs.lthn.sh/social',
'is_enabled' => true,
'is_public' => true,
'is_featured' => true,
'entitlement_code' => 'core.srv.social',
'sort_order' => 10,
],
[
'id' => 2,
'code' => 'analytics',
'module' => 'Core\\Mod\\Analytics',
'name' => 'Analytics',
'tagline' => 'Privacy-focused analytics',
'description' => 'Website analytics without cookies.',
'icon' => 'fa-chart-line',
'color' => 'cyan',
'marketing_domain' => '',
'marketing_url' => '',
'docs_url' => '',
'is_enabled' => true,
'is_public' => true,
'is_featured' => false,
'entitlement_code' => 'core.srv.analytics',
'sort_order' => 20,
],
[
'id' => 3,
'code' => 'legacy-api',
'module' => 'Core\\Mod\\Legacy',
'name' => 'Legacy API',
'tagline' => null,
'description' => null,
'icon' => '',
'color' => '',
'marketing_domain' => '',
'marketing_url' => '',
'docs_url' => '',
'is_enabled' => false,
'is_public' => false,
'is_featured' => false,
'entitlement_code' => '',
'sort_order' => 99,
],
];
}
protected function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'tagline' => ['nullable', 'string', 'max:200'],
'description' => ['nullable', 'string', 'max:2000'],
'icon' => ['nullable', 'string', 'max:50'],
'color' => ['nullable', 'string', 'max:20'],
'marketing_domain' => ['nullable', 'string', 'max:100'],
'marketing_url' => ['nullable', 'url', 'max:255'],
'docs_url' => ['nullable', 'url', 'max:255'],
'is_enabled' => ['boolean'],
'is_public' => ['boolean'],
'is_featured' => ['boolean'],
'sort_order' => ['integer', 'min:0', 'max:999'],
];
}
public function openEdit(int $id): void
{
$service = collect($this->serviceData)->firstWhere('id', $id);
if (! $service) {
return;
}
$this->editingId = $id;
// Read-only fields
$this->code = $service['code'];
$this->module = $service['module'];
$this->entitlement_code = $service['entitlement_code'] ?? '';
// Editable fields
$this->name = $service['name'];
$this->tagline = $service['tagline'] ?? '';
$this->description = $service['description'] ?? '';
$this->icon = $service['icon'] ?? '';
$this->color = $service['color'] ?? '';
$this->marketing_domain = $service['marketing_domain'] ?? '';
$this->marketing_url = $service['marketing_url'] ?? '';
$this->docs_url = $service['docs_url'] ?? '';
$this->is_enabled = $service['is_enabled'];
$this->is_public = $service['is_public'];
$this->is_featured = $service['is_featured'];
$this->sort_order = $service['sort_order'];
$this->showModal = true;
}
public function save(): void
{
$this->validate();
// Simulate updating the service in our data array
$this->serviceData = collect($this->serviceData)->map(function ($s) {
if ($s['id'] === $this->editingId) {
$s['name'] = $this->name;
$s['tagline'] = $this->tagline ?: null;
$s['description'] = $this->description ?: null;
$s['icon'] = $this->icon ?: null;
$s['color'] = $this->color ?: null;
$s['marketing_domain'] = $this->marketing_domain ?: null;
$s['marketing_url'] = $this->marketing_url ?: null;
$s['docs_url'] = $this->docs_url ?: null;
$s['is_enabled'] = $this->is_enabled;
$s['is_public'] = $this->is_public;
$s['is_featured'] = $this->is_featured;
$s['sort_order'] = $this->sort_order;
}
return $s;
})->all();
$this->flashMessages[] = 'Service updated successfully.';
$this->closeModal();
}
public function toggleEnabled(int $id): void
{
$this->serviceData = collect($this->serviceData)->map(function ($s) use ($id) {
if ($s['id'] === $id) {
$s['is_enabled'] = ! $s['is_enabled'];
$status = $s['is_enabled'] ? 'enabled' : 'disabled';
$this->flashMessages[] = "{$s['name']} has been {$status}.";
}
return $s;
})->all();
}
public function syncFromModules(): void
{
$this->flashMessages[] = 'Services synced from modules successfully.';
}
public function closeModal(): void
{
$this->showModal = false;
$this->resetForm();
}
protected function resetForm(): void
{
$this->editingId = null;
$this->code = '';
$this->module = '';
$this->entitlement_code = '';
$this->name = '';
$this->tagline = '';
$this->description = '';
$this->icon = '';
$this->color = '';
$this->marketing_domain = '';
$this->marketing_url = '';
$this->docs_url = '';
$this->is_enabled = true;
$this->is_public = true;
$this->is_featured = false;
$this->sort_order = 50;
}
#[Computed]
public function services(): array
{
return $this->serviceData;
}
#[Computed]
public function tableColumns(): array
{
return [
'Service',
'Code',
'Domain',
['label' => 'Entitlement', 'align' => 'center'],
['label' => 'Status', 'align' => 'center'],
['label' => 'Actions', 'align' => 'center'],
];
}
#[Computed]
public function enabledCount(): int
{
return count(array_filter($this->serviceData, fn ($s) => $s['is_enabled']));
}
#[Computed]
public function featuredCount(): int
{
return count(array_filter($this->serviceData, fn ($s) => $s['is_featured']));
}
public function render(): string
{
return <<<'HTML'
<div>
<span>Services: {{ count($this->services) }}</span>
<span>Enabled: {{ $this->enabledCount }}</span>
<span>Featured: {{ $this->featuredCount }}</span>
@if($showModal)
<div class="edit-modal">
<h2>Edit: {{ $name }}</h2>
<span>Code: {{ $code }}</span>
<span>Module: {{ $module }}</span>
</div>
@endif
@foreach($flashMessages as $msg)
<div class="flash">{{ $msg }}</div>
@endforeach
</div>
HTML;
}
}
// =============================================================================
// Initial State Tests
// =============================================================================
describe('ServiceManager initial state', function () {
it('starts with modal closed', function () {
Livewire::test(ServiceManagerModalDouble::class)
->assertSet('showModal', false)
->assertSet('editingId', null);
});
it('loads service data on mount', function () {
Livewire::test(ServiceManagerModalDouble::class)
->assertSet('serviceData', fn ($data) => count($data) === 3);
});
it('shows service count', function () {
Livewire::test(ServiceManagerModalDouble::class)
->assertSee('Services: 3');
});
it('counts enabled services correctly', function () {
Livewire::test(ServiceManagerModalDouble::class)
->assertSee('Enabled: 2');
});
it('counts featured services correctly', function () {
Livewire::test(ServiceManagerModalDouble::class)
->assertSee('Featured: 1');
});
it('has correct default form values', function () {
Livewire::test(ServiceManagerModalDouble::class)
->assertSet('is_enabled', true)
->assertSet('is_public', true)
->assertSet('is_featured', false)
->assertSet('sort_order', 50);
});
});
// =============================================================================
// Open Edit Modal Tests
// =============================================================================
describe('ServiceManager edit modal opening', function () {
it('opens modal and populates all fields', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 1)
->assertSet('showModal', true)
->assertSet('editingId', 1)
->assertSet('name', 'Social')
->assertSet('tagline', 'Social media management')
->assertSet('code', 'social')
->assertSet('module', 'Core\\Mod\\Social')
->assertSet('entitlement_code', 'core.srv.social')
->assertSet('icon', 'fa-share-nodes')
->assertSet('color', 'blue')
->assertSet('marketing_domain', 'social.lthn.sh')
->assertSet('marketing_url', 'https://social.lthn.sh')
->assertSet('docs_url', 'https://docs.lthn.sh/social')
->assertSet('is_enabled', true)
->assertSet('is_public', true)
->assertSet('is_featured', true)
->assertSet('sort_order', 10);
});
it('renders edit modal with service name', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 1)
->assertSee('Edit: Social')
->assertSee('Code: social')
->assertSee('Module: Core\\Mod\\Social');
});
it('populates fields for service with empty optional fields', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 3)
->assertSet('showModal', true)
->assertSet('name', 'Legacy API')
->assertSet('tagline', '')
->assertSet('description', '')
->assertSet('icon', '')
->assertSet('marketing_domain', '')
->assertSet('is_enabled', false)
->assertSet('is_public', false)
->assertSet('is_featured', false)
->assertSet('sort_order', 99);
});
it('does nothing for non-existent service id', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 999)
->assertSet('showModal', false)
->assertSet('editingId', null);
});
it('can open different services sequentially', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 1)
->assertSet('name', 'Social')
->call('closeModal')
->call('openEdit', 2)
->assertSet('name', 'Analytics')
->assertSet('code', 'analytics');
});
});
// =============================================================================
// Close Modal and Form Reset Tests
// =============================================================================
describe('ServiceManager close modal', function () {
it('closes modal and resets all form fields', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 1)
->call('closeModal')
->assertSet('showModal', false)
->assertSet('editingId', null)
->assertSet('code', '')
->assertSet('module', '')
->assertSet('entitlement_code', '')
->assertSet('name', '')
->assertSet('tagline', '')
->assertSet('description', '')
->assertSet('icon', '')
->assertSet('color', '')
->assertSet('marketing_domain', '')
->assertSet('marketing_url', '')
->assertSet('docs_url', '')
->assertSet('is_enabled', true)
->assertSet('is_public', true)
->assertSet('is_featured', false)
->assertSet('sort_order', 50);
});
it('does not show modal content after closing', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 1)
->assertSee('Edit: Social')
->call('closeModal')
->assertDontSee('Edit: Social');
});
});
// =============================================================================
// Form Validation Tests
// =============================================================================
describe('ServiceManager form validation', function () {
it('validates required name', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 1)
->set('name', '')
->call('save')
->assertHasErrors(['name']);
});
it('validates name max length', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 1)
->set('name', str_repeat('A', 101))
->call('save')
->assertHasErrors(['name']);
});
it('validates tagline max length', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 1)
->set('tagline', str_repeat('A', 201))
->call('save')
->assertHasErrors(['tagline']);
});
it('validates description max length', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 1)
->set('description', str_repeat('A', 2001))
->call('save')
->assertHasErrors(['description']);
});
it('validates marketing_url format', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 1)
->set('marketing_url', 'not-a-url')
->call('save')
->assertHasErrors(['marketing_url']);
});
it('validates docs_url format', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 1)
->set('docs_url', 'invalid')
->call('save')
->assertHasErrors(['docs_url']);
});
it('validates sort_order range', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 1)
->set('sort_order', 1000)
->call('save')
->assertHasErrors(['sort_order']);
});
it('validates sort_order minimum', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 1)
->set('sort_order', -1)
->call('save')
->assertHasErrors(['sort_order']);
});
it('accepts valid form data', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 1)
->set('name', 'Updated Social')
->set('tagline', 'New tagline')
->set('marketing_url', 'https://new.example.com')
->set('docs_url', 'https://docs.example.com')
->set('sort_order', 5)
->call('save')
->assertHasNoErrors();
});
it('accepts nullable optional fields', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 1)
->set('tagline', '')
->set('description', '')
->set('icon', '')
->set('color', '')
->set('marketing_domain', '')
->set('marketing_url', '')
->set('docs_url', '')
->call('save')
->assertHasNoErrors(['tagline', 'description', 'icon', 'color', 'marketing_domain', 'marketing_url', 'docs_url']);
});
});
// =============================================================================
// Save and Update Tests
// =============================================================================
describe('ServiceManager save', function () {
it('updates service data on save', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 1)
->set('name', 'Social Pro')
->set('is_featured', false)
->call('save')
->assertHasNoErrors()
->assertSet('showModal', false)
->assertSet('serviceData', fn ($data) => $data[0]['name'] === 'Social Pro'
&& $data[0]['is_featured'] === false);
});
it('records success message on save', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 1)
->call('save')
->assertSee('Service updated successfully.');
});
it('closes modal after successful save', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 1)
->call('save')
->assertSet('showModal', false)
->assertSet('editingId', null);
});
it('preserves other services when updating one', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 1)
->set('name', 'Updated Name')
->call('save')
->assertSet('serviceData', fn ($data) => $data[1]['name'] === 'Analytics'
&& $data[2]['name'] === 'Legacy API');
});
});
// =============================================================================
// Toggle Enabled Tests
// =============================================================================
describe('ServiceManager toggle enabled', function () {
it('disables an enabled service', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('toggleEnabled', 1)
->assertSet('serviceData', fn ($data) => $data[0]['is_enabled'] === false)
->assertSee('Social has been disabled.');
});
it('enables a disabled service', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('toggleEnabled', 3)
->assertSet('serviceData', fn ($data) => $data[2]['is_enabled'] === true)
->assertSee('Legacy API has been enabled.');
});
it('toggles without opening the modal', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('toggleEnabled', 1)
->assertSet('showModal', false);
});
it('can toggle the same service twice', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('toggleEnabled', 1)
->assertSet('serviceData', fn ($data) => $data[0]['is_enabled'] === false)
->call('toggleEnabled', 1)
->assertSet('serviceData', fn ($data) => $data[0]['is_enabled'] === true);
});
it('updates enabled count after toggling', function () {
Livewire::test(ServiceManagerModalDouble::class)
->assertSee('Enabled: 2')
->call('toggleEnabled', 1)
->assertSee('Enabled: 1');
});
});
// =============================================================================
// Sync From Modules Tests
// =============================================================================
describe('ServiceManager sync from modules', function () {
it('records sync success message', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('syncFromModules')
->assertSee('Services synced from modules successfully.');
});
});
// =============================================================================
// Table Structure Tests
// =============================================================================
describe('ServiceManager table structure', function () {
it('has correct table columns', function () {
Livewire::test(ServiceManagerModalDouble::class)
->assertSet(fn ($component) => count($component->tableColumns) === 6
&& $component->tableColumns[0] === 'Service'
&& $component->tableColumns[1] === 'Code'
&& $component->tableColumns[2] === 'Domain');
});
it('has alignment config for status and actions columns', function () {
Livewire::test(ServiceManagerModalDouble::class)
->assertSet(fn ($component) => $component->tableColumns[4]['label'] === 'Status'
&& $component->tableColumns[4]['align'] === 'center'
&& $component->tableColumns[5]['label'] === 'Actions'
&& $component->tableColumns[5]['align'] === 'center');
});
});
// =============================================================================
// Edge Cases
// =============================================================================
describe('ServiceManager edge cases', function () {
it('handles editing fields while modal is open', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 1)
->set('name', 'Changed')
->assertSet('name', 'Changed')
->assertSet('editingId', 1) // Still editing same service
->assertSet('code', 'social'); // Read-only unchanged
});
it('handles rapid open/close without saving', function () {
$component = Livewire::test(ServiceManagerModalDouble::class);
for ($i = 0; $i < 5; $i++) {
$component->call('openEdit', 1)
->assertSet('showModal', true)
->call('closeModal')
->assertSet('showModal', false);
}
// Original data should be unchanged
$component->assertSet('serviceData', fn ($data) => $data[0]['name'] === 'Social');
});
it('preserves service data across multiple edits', function () {
Livewire::test(ServiceManagerModalDouble::class)
->call('openEdit', 1)
->set('name', 'Social v2')
->call('save')
->call('openEdit', 2)
->set('name', 'Analytics v2')
->call('save')
->assertSet('serviceData', fn ($data) => $data[0]['name'] === 'Social v2'
&& $data[1]['name'] === 'Analytics v2'
&& $data[2]['name'] === 'Legacy API');
});
});