php-admin/tests/Feature/Modal/ServiceManagerModalTest.php
Claude d1afc5592a
test: add tests for admin modal components (Settings, PlatformUser, ActivityLog, ServiceManager)
Adds Pest test suites for the four critical admin modals using test double
components, following the existing LivewireModalTest.php pattern. Covers
tab navigation, form validation, CRUD flows, filter combinations, and
state management. Fixes #7.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:51:57 +00:00

707 lines
24 KiB
PHP

<?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');
});
});