test: add tests for admin modal components #36

Open
Charon wants to merge 1 commit from feat/test-admin-modals into dev
4 changed files with 2242 additions and 0 deletions

View file

@ -0,0 +1,483 @@
<?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\Attributes\Url;
use Livewire\Component;
use Livewire\Livewire;
/**
* Tests for ActivityLog modal component patterns.
*
* These tests verify the ActivityLog behaviour using test doubles:
* search filtering, log name filtering, event filtering, pagination
* reset on filter changes, clearing filters, and activity item mapping.
*/
// =============================================================================
// Test Double Components
// =============================================================================
/**
* ActivityLog-style component with search, filters, and computed data.
*/
class ActivityLogModalDouble extends Component
{
#[Url]
public string $search = '';
#[Url]
public string $logName = '';
#[Url]
public string $event = '';
public int $page = 1;
// Simulated activity data
public array $activityData = [];
public function mount(): void
{
// Seed with sample activity data
$this->activityData = [
[
'id' => 1,
'description' => 'Created new workspace',
'log_name' => 'workspace',
'event' => 'created',
'causer_name' => 'John Doe',
'subject_type' => 'Workspace',
'subject_name' => 'My Project',
'changes' => null,
'timestamp' => '2026-03-20 10:00:00',
],
[
'id' => 2,
'description' => 'Updated user profile',
'log_name' => 'user',
'event' => 'updated',
'causer_name' => 'Jane Smith',
'subject_type' => 'User',
'subject_name' => 'Jane Smith',
'changes' => ['old' => ['name' => 'Jane'], 'new' => ['name' => 'Jane Smith']],
'timestamp' => '2026-03-21 14:30:00',
],
[
'id' => 3,
'description' => 'Deleted old service',
'log_name' => 'service',
'event' => 'deleted',
'causer_name' => 'Admin',
'subject_type' => 'Service',
'subject_name' => 'Legacy API',
'changes' => null,
'timestamp' => '2026-03-22 09:15:00',
],
[
'id' => 4,
'description' => 'Updated workspace settings',
'log_name' => 'workspace',
'event' => 'updated',
'causer_name' => 'John Doe',
'subject_type' => 'Workspace',
'subject_name' => 'My Project',
'changes' => ['old' => ['public' => false], 'new' => ['public' => true]],
'timestamp' => '2026-03-23 11:45:00',
],
[
'id' => 5,
'description' => 'Created new user account',
'log_name' => 'user',
'event' => 'created',
'causer_name' => 'System',
'subject_type' => 'User',
'subject_name' => 'New User',
'changes' => null,
'timestamp' => '2026-03-24 08:00:00',
],
];
}
public function clearFilters(): void
{
$this->search = '';
$this->logName = '';
$this->event = '';
$this->page = 1;
}
#[Computed]
public function logNames(): array
{
return array_values(array_unique(array_column($this->activityData, 'log_name')));
}
#[Computed]
public function events(): array
{
return array_values(array_unique(array_column($this->activityData, 'event')));
}
#[Computed]
public function logNameOptions(): array
{
$options = ['' => 'All logs'];
foreach ($this->logNames as $name) {
$options[$name] = ucfirst($name);
}
return $options;
}
#[Computed]
public function eventOptions(): array
{
$options = ['' => 'All events'];
foreach ($this->events as $eventName) {
$options[$eventName] = ucfirst($eventName);
}
return $options;
}
#[Computed]
public function filteredActivities(): array
{
$result = $this->activityData;
if ($this->logName) {
$result = array_filter($result, fn ($a) => $a['log_name'] === $this->logName);
}
if ($this->event) {
$result = array_filter($result, fn ($a) => $a['event'] === $this->event);
}
if ($this->search) {
$search = strtolower($this->search);
$result = array_filter($result, fn ($a) => str_contains(strtolower($a['description']), $search));
}
return array_values($result);
}
#[Computed]
public function activityItems(): array
{
return array_map(function ($activity) {
$item = [
'description' => $activity['description'],
'event' => $activity['event'],
'timestamp' => $activity['timestamp'],
];
if ($activity['causer_name']) {
$item['actor'] = [
'name' => $activity['causer_name'],
'initials' => substr($activity['causer_name'], 0, 1),
];
}
if ($activity['subject_type']) {
$item['subject'] = [
'type' => $activity['subject_type'],
'name' => $activity['subject_name'],
];
}
if ($activity['changes']) {
$item['changes'] = $activity['changes'];
}
return $item;
}, $this->filteredActivities);
}
public function render(): string
{
return <<<'HTML'
<div>
<input wire:model="search" placeholder="Search..." />
<span>Results: {{ count($this->filteredActivities) }}</span>
<span>Log: {{ $logName ?: 'all' }}</span>
<span>Event: {{ $event ?: 'all' }}</span>
@foreach($this->activityItems as $item)
<div class="activity-item">{{ $item['description'] }}</div>
@endforeach
</div>
HTML;
}
}
// =============================================================================
// Initial State Tests
// =============================================================================
describe('ActivityLog initial state', function () {
it('starts with empty filters', function () {
Livewire::test(ActivityLogModalDouble::class)
->assertSet('search', '')
->assertSet('logName', '')
->assertSet('event', '');
});
it('loads activity data on mount', function () {
Livewire::test(ActivityLogModalDouble::class)
->assertSet('activityData', fn ($data) => count($data) === 5);
});
it('shows all activities initially', function () {
Livewire::test(ActivityLogModalDouble::class)
->assertSee('Results: 5');
});
});
// =============================================================================
// Search Filter Tests
// =============================================================================
describe('ActivityLog search filtering', function () {
it('filters by search term in description', function () {
Livewire::test(ActivityLogModalDouble::class)
->set('search', 'workspace')
->assertSee('Results: 2')
->assertSee('Created new workspace')
->assertSee('Updated workspace settings');
});
it('search is case-insensitive', function () {
Livewire::test(ActivityLogModalDouble::class)
->set('search', 'WORKSPACE')
->assertSee('Results: 2');
});
it('returns no results for non-matching search', function () {
Livewire::test(ActivityLogModalDouble::class)
->set('search', 'nonexistent-term')
->assertSee('Results: 0');
});
it('shows all results when search is cleared', function () {
Livewire::test(ActivityLogModalDouble::class)
->set('search', 'workspace')
->assertSee('Results: 2')
->set('search', '')
->assertSee('Results: 5');
});
it('filters by partial description match', function () {
Livewire::test(ActivityLogModalDouble::class)
->set('search', 'user')
->assertSee('Results: 2');
});
});
// =============================================================================
// Log Name Filter Tests
// =============================================================================
describe('ActivityLog log name filtering', function () {
it('filters by workspace log', function () {
Livewire::test(ActivityLogModalDouble::class)
->set('logName', 'workspace')
->assertSee('Results: 2')
->assertSee('Log: workspace');
});
it('filters by user log', function () {
Livewire::test(ActivityLogModalDouble::class)
->set('logName', 'user')
->assertSee('Results: 2');
});
it('filters by service log', function () {
Livewire::test(ActivityLogModalDouble::class)
->set('logName', 'service')
->assertSee('Results: 1');
});
it('shows all logs when filter is empty', function () {
Livewire::test(ActivityLogModalDouble::class)
->set('logName', '')
->assertSee('Results: 5')
->assertSee('Log: all');
});
it('provides log name options with All logs default', function () {
Livewire::test(ActivityLogModalDouble::class)
->assertSet(fn ($component) => $component->logNameOptions[''] === 'All logs'
&& array_key_exists('workspace', $component->logNameOptions)
&& array_key_exists('user', $component->logNameOptions)
&& array_key_exists('service', $component->logNameOptions));
});
it('returns distinct log names', function () {
Livewire::test(ActivityLogModalDouble::class)
->assertSet(fn ($component) => count($component->logNames) === 3);
});
});
// =============================================================================
// Event Filter Tests
// =============================================================================
describe('ActivityLog event filtering', function () {
it('filters by created events', function () {
Livewire::test(ActivityLogModalDouble::class)
->set('event', 'created')
->assertSee('Results: 2')
->assertSee('Event: created');
});
it('filters by updated events', function () {
Livewire::test(ActivityLogModalDouble::class)
->set('event', 'updated')
->assertSee('Results: 2');
});
it('filters by deleted events', function () {
Livewire::test(ActivityLogModalDouble::class)
->set('event', 'deleted')
->assertSee('Results: 1');
});
it('shows all events when filter is empty', function () {
Livewire::test(ActivityLogModalDouble::class)
->set('event', '')
->assertSee('Results: 5')
->assertSee('Event: all');
});
it('provides event options with All events default', function () {
Livewire::test(ActivityLogModalDouble::class)
->assertSet(fn ($component) => $component->eventOptions[''] === 'All events'
&& array_key_exists('created', $component->eventOptions)
&& array_key_exists('updated', $component->eventOptions)
&& array_key_exists('deleted', $component->eventOptions));
});
it('returns distinct event types', function () {
Livewire::test(ActivityLogModalDouble::class)
->assertSet(fn ($component) => count($component->events) === 3);
});
});
// =============================================================================
// Combined Filter Tests
// =============================================================================
describe('ActivityLog combined filters', function () {
it('combines log name and event filters', function () {
Livewire::test(ActivityLogModalDouble::class)
->set('logName', 'workspace')
->set('event', 'created')
->assertSee('Results: 1')
->assertSee('Created new workspace');
});
it('combines search with log name filter', function () {
Livewire::test(ActivityLogModalDouble::class)
->set('search', 'settings')
->set('logName', 'workspace')
->assertSee('Results: 1')
->assertSee('Updated workspace settings');
});
it('combines all three filters', function () {
Livewire::test(ActivityLogModalDouble::class)
->set('search', 'workspace')
->set('logName', 'workspace')
->set('event', 'updated')
->assertSee('Results: 1');
});
it('returns empty when combined filters exclude everything', function () {
Livewire::test(ActivityLogModalDouble::class)
->set('logName', 'service')
->set('event', 'created')
->assertSee('Results: 0');
});
});
// =============================================================================
// Clear Filters Tests
// =============================================================================
describe('ActivityLog clear filters', function () {
it('clears all filters at once', function () {
Livewire::test(ActivityLogModalDouble::class)
->set('search', 'workspace')
->set('logName', 'workspace')
->set('event', 'created')
->call('clearFilters')
->assertSet('search', '')
->assertSet('logName', '')
->assertSet('event', '')
->assertSee('Results: 5');
});
it('resets page number when clearing filters', function () {
Livewire::test(ActivityLogModalDouble::class)
->set('page', 3)
->call('clearFilters')
->assertSet('page', 1);
});
});
// =============================================================================
// Activity Item Mapping Tests
// =============================================================================
describe('ActivityLog item mapping', function () {
it('maps activity items with actor information', function () {
Livewire::test(ActivityLogModalDouble::class)
->assertSet(fn ($component) => $component->activityItems[0]['actor']['name'] === 'John Doe'
&& $component->activityItems[0]['actor']['initials'] === 'J');
});
it('maps activity items with subject information', function () {
Livewire::test(ActivityLogModalDouble::class)
->assertSet(fn ($component) => $component->activityItems[0]['subject']['type'] === 'Workspace'
&& $component->activityItems[0]['subject']['name'] === 'My Project');
});
it('includes changes diff when present', function () {
Livewire::test(ActivityLogModalDouble::class)
->assertSet(fn ($component) => isset($component->activityItems[1]['changes'])
&& $component->activityItems[1]['changes']['old']['name'] === 'Jane'
&& $component->activityItems[1]['changes']['new']['name'] === 'Jane Smith');
});
it('omits changes when not present', function () {
Livewire::test(ActivityLogModalDouble::class)
->assertSet(fn ($component) => ! isset($component->activityItems[0]['changes']));
});
it('includes event type in each item', function () {
Livewire::test(ActivityLogModalDouble::class)
->assertSet(fn ($component) => $component->activityItems[0]['event'] === 'created'
&& $component->activityItems[1]['event'] === 'updated'
&& $component->activityItems[2]['event'] === 'deleted');
});
it('includes timestamp in each item', function () {
Livewire::test(ActivityLogModalDouble::class)
->assertSet(fn ($component) => $component->activityItems[0]['timestamp'] === '2026-03-20 10:00:00');
});
it('renders activity descriptions in view', function () {
Livewire::test(ActivityLogModalDouble::class)
->assertSee('Created new workspace')
->assertSee('Updated user profile')
->assertSee('Deleted old service');
});
});

View file

@ -0,0 +1,602 @@
<?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 PlatformUser modal component patterns.
*
* These tests verify the PlatformUser admin panel behaviour using test doubles:
* tab navigation, tier management, verification toggling, delete confirmation
* flows, package/entitlement modals, and GDPR data export.
*/
// =============================================================================
// Test Double Components
// =============================================================================
/**
* PlatformUser-style component for admin user management.
*/
class PlatformUserModalDouble extends Component
{
// User data
public int $userId = 0;
public string $userName = '';
public string $userEmail = '';
// Editable fields
public string $editingTier = 'free';
public bool $editingVerified = false;
// Action state
public string $actionMessage = '';
public string $actionType = '';
// Tab navigation
public string $activeTab = 'overview';
// Delete confirmation flow
public bool $showDeleteConfirm = false;
public bool $immediateDelete = false;
public string $deleteReason = '';
// Package provisioning modal
public bool $showPackageModal = false;
public ?int $selectedWorkspaceId = null;
public string $selectedPackageCode = '';
// Entitlement provisioning modal
public bool $showEntitlementModal = false;
public ?int $entitlementWorkspaceId = null;
public string $entitlementFeatureCode = '';
public string $entitlementType = 'enable';
public ?int $entitlementLimit = null;
public string $entitlementDuration = 'permanent';
public ?string $entitlementExpiresAt = null;
// Export tracking
public bool $dataExported = false;
public function mount(int $id = 1): void
{
$this->userId = $id;
$this->userName = 'Test User';
$this->userEmail = 'test@example.com';
$this->editingTier = 'free';
$this->editingVerified = false;
}
public function setTab(string $tab): void
{
if (in_array($tab, ['overview', 'workspaces', 'entitlements', 'data', 'danger'])) {
$this->activeTab = $tab;
}
}
public function saveTier(): void
{
$this->actionMessage = "Tier updated to {$this->editingTier}.";
$this->actionType = 'success';
}
public function saveVerification(): void
{
$this->actionMessage = $this->editingVerified
? 'Email marked as verified.'
: 'Email verification removed.';
$this->actionType = 'success';
}
public function resendVerification(): void
{
if ($this->editingVerified) {
$this->actionMessage = 'User email is already verified.';
$this->actionType = 'warning';
return;
}
$this->actionMessage = 'Verification email sent.';
$this->actionType = 'success';
}
public function confirmDelete(bool $immediate = false): void
{
$this->immediateDelete = $immediate;
$this->showDeleteConfirm = true;
$this->deleteReason = '';
}
public function cancelDelete(): void
{
$this->showDeleteConfirm = false;
$this->immediateDelete = false;
$this->deleteReason = '';
}
public function scheduleDelete(): void
{
if ($this->immediateDelete) {
$this->actionMessage = 'User has been permanently deleted.';
$this->actionType = 'success';
} else {
$this->actionMessage = 'Account deletion scheduled. Will be deleted in 7 days unless cancelled.';
$this->actionType = 'warning';
}
$this->showDeleteConfirm = false;
}
public function anonymizeUser(): void
{
$this->userName = 'Anonymized User';
$this->userEmail = "anon_{$this->userId}@anonymized.local";
$this->editingTier = 'free';
$this->editingVerified = false;
$this->actionMessage = 'User data has been anonymized.';
$this->actionType = 'success';
}
public function openPackageModal(int $workspaceId): void
{
$this->selectedWorkspaceId = $workspaceId;
$this->selectedPackageCode = '';
$this->showPackageModal = true;
}
public function closePackageModal(): void
{
$this->showPackageModal = false;
$this->selectedWorkspaceId = null;
$this->selectedPackageCode = '';
}
public function provisionPackage(): void
{
if (! $this->selectedWorkspaceId || ! $this->selectedPackageCode) {
$this->actionMessage = 'Please select a workspace and package.';
$this->actionType = 'warning';
return;
}
$this->actionMessage = "Package provisioned to workspace.";
$this->actionType = 'success';
$this->closePackageModal();
}
public function openEntitlementModal(int $workspaceId): void
{
$this->entitlementWorkspaceId = $workspaceId;
$this->entitlementFeatureCode = '';
$this->entitlementType = 'enable';
$this->entitlementLimit = null;
$this->entitlementDuration = 'permanent';
$this->entitlementExpiresAt = null;
$this->showEntitlementModal = true;
}
public function closeEntitlementModal(): void
{
$this->showEntitlementModal = false;
$this->entitlementWorkspaceId = null;
$this->entitlementFeatureCode = '';
}
public function provisionEntitlement(): void
{
if (! $this->entitlementWorkspaceId || ! $this->entitlementFeatureCode) {
$this->actionMessage = 'Please select a workspace and feature.';
$this->actionType = 'warning';
return;
}
$this->actionMessage = "Entitlement added to workspace.";
$this->actionType = 'success';
$this->closeEntitlementModal();
}
public function exportUserData(): void
{
$this->dataExported = true;
}
#[Computed]
public function collectUserData(): array
{
return [
'export_info' => [
'exported_at' => now()->toIso8601String(),
'exported_by' => 'Platform Administrator',
],
'account' => [
'id' => $this->userId,
'name' => $this->userName,
'email' => $this->userEmail,
'tier' => $this->editingTier,
],
];
}
public function render(): string
{
return <<<'HTML'
<div>
<h1>User: {{ $userName }}</h1>
<span>Tab: {{ $activeTab }}</span>
<span>Tier: {{ $editingTier }}</span>
@if($showDeleteConfirm)
<div class="delete-confirm">Delete Confirm</div>
@endif
@if($showPackageModal)
<div class="package-modal">Package Modal</div>
@endif
@if($showEntitlementModal)
<div class="entitlement-modal">Entitlement Modal</div>
@endif
@if($actionMessage)
<div class="action-message {{ $actionType }}">{{ $actionMessage }}</div>
@endif
</div>
HTML;
}
}
// =============================================================================
// Tab Navigation Tests
// =============================================================================
describe('PlatformUser tab navigation', function () {
it('defaults to overview tab', function () {
Livewire::test(PlatformUserModalDouble::class)
->assertSet('activeTab', 'overview');
});
it('switches to valid tabs', function () {
$validTabs = ['overview', 'workspaces', 'entitlements', 'data', 'danger'];
foreach ($validTabs as $tab) {
Livewire::test(PlatformUserModalDouble::class)
->call('setTab', $tab)
->assertSet('activeTab', $tab);
}
});
it('ignores invalid tab names', function () {
Livewire::test(PlatformUserModalDouble::class)
->call('setTab', 'nonexistent')
->assertSet('activeTab', 'overview');
});
it('renders current tab label', function () {
Livewire::test(PlatformUserModalDouble::class)
->call('setTab', 'workspaces')
->assertSee('Tab: workspaces');
});
});
// =============================================================================
// Tier Management Tests
// =============================================================================
describe('PlatformUser tier management', function () {
it('loads user tier on mount', function () {
Livewire::test(PlatformUserModalDouble::class)
->assertSet('editingTier', 'free');
});
it('can change tier to apollo', function () {
Livewire::test(PlatformUserModalDouble::class)
->set('editingTier', 'apollo')
->call('saveTier')
->assertSet('actionMessage', 'Tier updated to apollo.')
->assertSet('actionType', 'success');
});
it('can change tier to hades', function () {
Livewire::test(PlatformUserModalDouble::class)
->set('editingTier', 'hades')
->call('saveTier')
->assertSet('actionMessage', 'Tier updated to hades.')
->assertSet('actionType', 'success');
});
it('renders tier value', function () {
Livewire::test(PlatformUserModalDouble::class)
->set('editingTier', 'apollo')
->assertSee('Tier: apollo');
});
});
// =============================================================================
// Verification Management Tests
// =============================================================================
describe('PlatformUser verification management', function () {
it('can mark email as verified', function () {
Livewire::test(PlatformUserModalDouble::class)
->set('editingVerified', true)
->call('saveVerification')
->assertSet('actionMessage', 'Email marked as verified.')
->assertSet('actionType', 'success');
});
it('can remove email verification', function () {
Livewire::test(PlatformUserModalDouble::class)
->set('editingVerified', false)
->call('saveVerification')
->assertSet('actionMessage', 'Email verification removed.')
->assertSet('actionType', 'success');
});
it('warns when resending to already verified user', function () {
Livewire::test(PlatformUserModalDouble::class)
->set('editingVerified', true)
->call('resendVerification')
->assertSet('actionMessage', 'User email is already verified.')
->assertSet('actionType', 'warning');
});
it('sends verification email for unverified user', function () {
Livewire::test(PlatformUserModalDouble::class)
->set('editingVerified', false)
->call('resendVerification')
->assertSet('actionMessage', 'Verification email sent.')
->assertSet('actionType', 'success');
});
});
// =============================================================================
// Delete Confirmation Flow Tests
// =============================================================================
describe('PlatformUser delete confirmation flow', function () {
it('opens delete confirmation with scheduled mode', function () {
Livewire::test(PlatformUserModalDouble::class)
->call('confirmDelete', false)
->assertSet('showDeleteConfirm', true)
->assertSet('immediateDelete', false)
->assertSet('deleteReason', '')
->assertSee('Delete Confirm');
});
it('opens delete confirmation with immediate mode', function () {
Livewire::test(PlatformUserModalDouble::class)
->call('confirmDelete', true)
->assertSet('showDeleteConfirm', true)
->assertSet('immediateDelete', true);
});
it('cancels delete confirmation', function () {
Livewire::test(PlatformUserModalDouble::class)
->call('confirmDelete', true)
->set('deleteReason', 'GDPR request')
->call('cancelDelete')
->assertSet('showDeleteConfirm', false)
->assertSet('immediateDelete', false)
->assertSet('deleteReason', '')
->assertDontSee('Delete Confirm');
});
it('schedules deletion (non-immediate)', function () {
Livewire::test(PlatformUserModalDouble::class)
->call('confirmDelete', false)
->set('deleteReason', 'User request')
->call('scheduleDelete')
->assertSet('showDeleteConfirm', false)
->assertSet('actionMessage', 'Account deletion scheduled. Will be deleted in 7 days unless cancelled.')
->assertSet('actionType', 'warning');
});
it('executes immediate deletion', function () {
Livewire::test(PlatformUserModalDouble::class)
->call('confirmDelete', true)
->call('scheduleDelete')
->assertSet('showDeleteConfirm', false)
->assertSet('actionMessage', 'User has been permanently deleted.')
->assertSet('actionType', 'success');
});
});
// =============================================================================
// Anonymization Tests
// =============================================================================
describe('PlatformUser anonymization', function () {
it('anonymizes user data', function () {
Livewire::test(PlatformUserModalDouble::class, ['id' => 42])
->call('anonymizeUser')
->assertSet('userName', 'Anonymized User')
->assertSet('userEmail', 'anon_42@anonymized.local')
->assertSet('editingTier', 'free')
->assertSet('editingVerified', false)
->assertSet('actionType', 'success');
});
it('displays anonymized name after anonymization', function () {
Livewire::test(PlatformUserModalDouble::class)
->call('anonymizeUser')
->assertSee('Anonymized User');
});
});
// =============================================================================
// Package Modal Tests
// =============================================================================
describe('PlatformUser package modal', function () {
it('opens package modal for a workspace', function () {
Livewire::test(PlatformUserModalDouble::class)
->call('openPackageModal', 5)
->assertSet('showPackageModal', true)
->assertSet('selectedWorkspaceId', 5)
->assertSet('selectedPackageCode', '')
->assertSee('Package Modal');
});
it('closes package modal and resets state', function () {
Livewire::test(PlatformUserModalDouble::class)
->call('openPackageModal', 5)
->set('selectedPackageCode', 'pro-plan')
->call('closePackageModal')
->assertSet('showPackageModal', false)
->assertSet('selectedWorkspaceId', null)
->assertSet('selectedPackageCode', '')
->assertDontSee('Package Modal');
});
it('warns when provisioning without workspace or package', function () {
Livewire::test(PlatformUserModalDouble::class)
->call('openPackageModal', 5)
->call('provisionPackage')
->assertSet('actionMessage', 'Please select a workspace and package.')
->assertSet('actionType', 'warning');
});
it('provisions package and closes modal on success', function () {
Livewire::test(PlatformUserModalDouble::class)
->call('openPackageModal', 5)
->set('selectedPackageCode', 'pro-plan')
->call('provisionPackage')
->assertSet('showPackageModal', false)
->assertSet('actionType', 'success');
});
});
// =============================================================================
// Entitlement Modal Tests
// =============================================================================
describe('PlatformUser entitlement modal', function () {
it('opens entitlement modal with defaults', function () {
Livewire::test(PlatformUserModalDouble::class)
->call('openEntitlementModal', 7)
->assertSet('showEntitlementModal', true)
->assertSet('entitlementWorkspaceId', 7)
->assertSet('entitlementFeatureCode', '')
->assertSet('entitlementType', 'enable')
->assertSet('entitlementLimit', null)
->assertSet('entitlementDuration', 'permanent')
->assertSet('entitlementExpiresAt', null)
->assertSee('Entitlement Modal');
});
it('closes entitlement modal and resets state', function () {
Livewire::test(PlatformUserModalDouble::class)
->call('openEntitlementModal', 7)
->set('entitlementFeatureCode', 'core.srv.bio')
->call('closeEntitlementModal')
->assertSet('showEntitlementModal', false)
->assertSet('entitlementWorkspaceId', null)
->assertSet('entitlementFeatureCode', '')
->assertDontSee('Entitlement Modal');
});
it('warns when provisioning without workspace or feature', function () {
Livewire::test(PlatformUserModalDouble::class)
->call('openEntitlementModal', 7)
->call('provisionEntitlement')
->assertSet('actionMessage', 'Please select a workspace and feature.')
->assertSet('actionType', 'warning');
});
it('provisions entitlement and closes modal on success', function () {
Livewire::test(PlatformUserModalDouble::class)
->call('openEntitlementModal', 7)
->set('entitlementFeatureCode', 'core.srv.bio')
->call('provisionEntitlement')
->assertSet('showEntitlementModal', false)
->assertSet('actionType', 'success');
});
it('supports different entitlement types', function () {
Livewire::test(PlatformUserModalDouble::class)
->call('openEntitlementModal', 7)
->set('entitlementType', 'add_limit')
->set('entitlementLimit', 100)
->assertSet('entitlementType', 'add_limit')
->assertSet('entitlementLimit', 100);
});
it('supports duration-based entitlements', function () {
Livewire::test(PlatformUserModalDouble::class)
->call('openEntitlementModal', 7)
->set('entitlementDuration', 'duration')
->set('entitlementExpiresAt', '2026-12-31')
->assertSet('entitlementDuration', 'duration')
->assertSet('entitlementExpiresAt', '2026-12-31');
});
});
// =============================================================================
// Data Export Tests
// =============================================================================
describe('PlatformUser data export', function () {
it('collects user data for export', function () {
Livewire::test(PlatformUserModalDouble::class, ['id' => 42])
->assertSet(fn ($component) => $component->collectUserData['account']['id'] === 42
&& $component->collectUserData['account']['name'] === 'Test User'
&& $component->collectUserData['account']['email'] === 'test@example.com');
});
it('triggers data export', function () {
Livewire::test(PlatformUserModalDouble::class)
->assertSet('dataExported', false)
->call('exportUserData')
->assertSet('dataExported', true);
});
});
// =============================================================================
// Action Message Tests
// =============================================================================
describe('PlatformUser action messages', function () {
it('clears action message between operations', function () {
$component = Livewire::test(PlatformUserModalDouble::class)
->set('editingTier', 'apollo')
->call('saveTier')
->assertSet('actionType', 'success');
// Next operation replaces the message
$component->set('editingVerified', true)
->call('resendVerification')
->assertSet('actionType', 'warning')
->assertSet('actionMessage', 'User email is already verified.');
});
it('renders action message in view', function () {
Livewire::test(PlatformUserModalDouble::class)
->set('editingTier', 'hades')
->call('saveTier')
->assertSee('Tier updated to hades.');
});
});

View file

@ -0,0 +1,707 @@
<?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');
});
});

View file

@ -0,0 +1,450 @@
<?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\Url;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\Livewire;
/**
* Tests for Settings modal component patterns.
*
* These tests verify the Settings modal behaviour using test doubles:
* tabbed navigation, profile updates, password changes, preference
* updates, confirmation modals, and form state resets.
*/
// =============================================================================
// Test Double Components
// =============================================================================
/**
* Settings-style component with tabbed sections and multiple forms.
*/
class SettingsModalDouble extends Component
{
#[Url(as: 'tab')]
public string $activeSection = 'profile';
// Profile Info
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|email|max:255')]
public string $email = '';
// Preferences
public string $locale = 'en_GB';
public string $timezone = 'Europe/London';
public int $time_format = 12;
public int $week_starts_on = 1;
// Password Change
public string $current_password = '';
public string $new_password = '';
public string $new_password_confirmation = '';
// Two-Factor Authentication
public bool $showTwoFactorSetup = false;
public bool $isTwoFactorEnabled = false;
public bool $userHasTwoFactorEnabled = false;
// Account Deletion
public bool $showDeleteConfirmation = false;
public string $deleteReason = '';
public bool $isDeleteAccountEnabled = true;
// Action tracking
public array $dispatched = [];
public array $toasts = [];
public function mount(): void
{
$this->name = 'Test User';
$this->email = 'test@example.com';
}
public function updateProfile(): void
{
$this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
]);
$this->dispatched[] = 'profile-updated';
$this->toasts[] = ['text' => 'Profile updated successfully.', 'variant' => 'success'];
}
public function updatePreferences(): void
{
$this->validate([
'locale' => ['required', 'string'],
'timezone' => ['required', 'timezone'],
'time_format' => ['required', 'in:12,24'],
'week_starts_on' => ['required', 'in:0,1'],
]);
$this->dispatched[] = 'preferences-updated';
$this->toasts[] = ['text' => 'Preferences updated.', 'variant' => 'success'];
}
public function updatePassword(): void
{
$this->validate([
'current_password' => ['required'],
'new_password' => ['required', 'confirmed', 'min:8'],
]);
$this->current_password = '';
$this->new_password = '';
$this->new_password_confirmation = '';
$this->dispatched[] = 'password-updated';
$this->toasts[] = ['text' => 'Password updated.', 'variant' => 'success'];
}
public function enableTwoFactor(): void
{
$this->toasts[] = ['text' => 'Two-factor upgrading.', 'variant' => 'warning'];
}
public function requestAccountDeletion(): void
{
$this->showDeleteConfirmation = false;
$this->deleteReason = '';
$this->toasts[] = ['text' => 'Deletion scheduled.', 'variant' => 'warning'];
}
public function cancelAccountDeletion(): void
{
$this->toasts[] = ['text' => 'Deletion cancelled.', 'variant' => 'success'];
}
public function render(): string
{
return <<<'HTML'
<div>
<nav>
<button wire:click="$set('activeSection', 'profile')">Profile</button>
<button wire:click="$set('activeSection', 'preferences')">Preferences</button>
<button wire:click="$set('activeSection', 'security')">Security</button>
<button wire:click="$set('activeSection', 'danger')">Danger Zone</button>
</nav>
<span>Section: {{ $activeSection }}</span>
@if($showDeleteConfirmation)
<div class="confirm-modal">Delete Confirmation</div>
@endif
</div>
HTML;
}
}
// =============================================================================
// Tab Navigation Tests
// =============================================================================
describe('Settings tab navigation', function () {
it('defaults to profile section', function () {
Livewire::test(SettingsModalDouble::class)
->assertSet('activeSection', 'profile');
});
it('switches to preferences section', function () {
Livewire::test(SettingsModalDouble::class)
->set('activeSection', 'preferences')
->assertSet('activeSection', 'preferences');
});
it('switches to security section', function () {
Livewire::test(SettingsModalDouble::class)
->set('activeSection', 'security')
->assertSet('activeSection', 'security');
});
it('switches to danger zone section', function () {
Livewire::test(SettingsModalDouble::class)
->set('activeSection', 'danger')
->assertSet('activeSection', 'danger');
});
it('renders active section label', function () {
Livewire::test(SettingsModalDouble::class)
->set('activeSection', 'preferences')
->assertSee('Section: preferences');
});
it('cycles through all sections', function () {
$component = Livewire::test(SettingsModalDouble::class);
foreach (['profile', 'preferences', 'security', 'danger'] as $section) {
$component->set('activeSection', $section)
->assertSet('activeSection', $section);
}
});
});
// =============================================================================
// Profile Update Tests
// =============================================================================
describe('Settings profile update', function () {
it('loads initial profile data on mount', function () {
Livewire::test(SettingsModalDouble::class)
->assertSet('name', 'Test User')
->assertSet('email', 'test@example.com');
});
it('validates required name', function () {
Livewire::test(SettingsModalDouble::class)
->set('name', '')
->call('updateProfile')
->assertHasErrors(['name']);
});
it('validates required email', function () {
Livewire::test(SettingsModalDouble::class)
->set('email', '')
->call('updateProfile')
->assertHasErrors(['email']);
});
it('validates email format', function () {
Livewire::test(SettingsModalDouble::class)
->set('email', 'not-an-email')
->call('updateProfile')
->assertHasErrors(['email']);
});
it('validates name max length', function () {
Livewire::test(SettingsModalDouble::class)
->set('name', str_repeat('A', 256))
->call('updateProfile')
->assertHasErrors(['name']);
});
it('succeeds with valid profile data', function () {
Livewire::test(SettingsModalDouble::class)
->set('name', 'Jane Doe')
->set('email', 'jane@example.com')
->call('updateProfile')
->assertHasNoErrors()
->assertSet('dispatched', ['profile-updated']);
});
it('records success toast on profile update', function () {
Livewire::test(SettingsModalDouble::class)
->set('name', 'Jane Doe')
->set('email', 'jane@example.com')
->call('updateProfile')
->assertSet('toasts', fn ($toasts) => count($toasts) === 1
&& $toasts[0]['variant'] === 'success');
});
});
// =============================================================================
// Preferences Update Tests
// =============================================================================
describe('Settings preferences update', function () {
it('has correct default preferences', function () {
Livewire::test(SettingsModalDouble::class)
->assertSet('locale', 'en_GB')
->assertSet('timezone', 'Europe/London')
->assertSet('time_format', 12)
->assertSet('week_starts_on', 1);
});
it('validates timezone', function () {
Livewire::test(SettingsModalDouble::class)
->set('timezone', 'Invalid/Zone')
->call('updatePreferences')
->assertHasErrors(['timezone']);
});
it('validates time_format values', function () {
Livewire::test(SettingsModalDouble::class)
->set('time_format', 99)
->call('updatePreferences')
->assertHasErrors(['time_format']);
});
it('validates week_starts_on values', function () {
Livewire::test(SettingsModalDouble::class)
->set('week_starts_on', 5)
->call('updatePreferences')
->assertHasErrors(['week_starts_on']);
});
it('accepts valid 24-hour format', function () {
Livewire::test(SettingsModalDouble::class)
->set('time_format', 24)
->call('updatePreferences')
->assertHasNoErrors(['time_format']);
});
it('accepts Monday start (1) and Sunday start (0)', function () {
Livewire::test(SettingsModalDouble::class)
->set('week_starts_on', 0)
->call('updatePreferences')
->assertHasNoErrors(['week_starts_on']);
Livewire::test(SettingsModalDouble::class)
->set('week_starts_on', 1)
->call('updatePreferences')
->assertHasNoErrors(['week_starts_on']);
});
it('dispatches preferences-updated on success', function () {
Livewire::test(SettingsModalDouble::class)
->set('locale', 'en_US')
->set('timezone', 'America/New_York')
->set('time_format', 24)
->set('week_starts_on', 0)
->call('updatePreferences')
->assertHasNoErrors()
->assertSet('dispatched', ['preferences-updated']);
});
});
// =============================================================================
// Password Change Tests
// =============================================================================
describe('Settings password change', function () {
it('validates current password required', function () {
Livewire::test(SettingsModalDouble::class)
->set('current_password', '')
->set('new_password', 'newpass123')
->set('new_password_confirmation', 'newpass123')
->call('updatePassword')
->assertHasErrors(['current_password']);
});
it('validates new password required', function () {
Livewire::test(SettingsModalDouble::class)
->set('current_password', 'oldpass')
->set('new_password', '')
->call('updatePassword')
->assertHasErrors(['new_password']);
});
it('validates password confirmation matches', function () {
Livewire::test(SettingsModalDouble::class)
->set('current_password', 'oldpass')
->set('new_password', 'newpass123')
->set('new_password_confirmation', 'different')
->call('updatePassword')
->assertHasErrors(['new_password']);
});
it('validates minimum password length', function () {
Livewire::test(SettingsModalDouble::class)
->set('current_password', 'oldpass')
->set('new_password', 'short')
->set('new_password_confirmation', 'short')
->call('updatePassword')
->assertHasErrors(['new_password']);
});
it('clears password fields on success', function () {
Livewire::test(SettingsModalDouble::class)
->set('current_password', 'oldpassword')
->set('new_password', 'newpassword123')
->set('new_password_confirmation', 'newpassword123')
->call('updatePassword')
->assertHasNoErrors()
->assertSet('current_password', '')
->assertSet('new_password', '')
->assertSet('new_password_confirmation', '');
});
it('dispatches password-updated event on success', function () {
Livewire::test(SettingsModalDouble::class)
->set('current_password', 'oldpassword')
->set('new_password', 'newpassword123')
->set('new_password_confirmation', 'newpassword123')
->call('updatePassword')
->assertSet('dispatched', ['password-updated']);
});
});
// =============================================================================
// Account Deletion Tests
// =============================================================================
describe('Settings account deletion', function () {
it('shows delete confirmation modal', function () {
Livewire::test(SettingsModalDouble::class)
->set('showDeleteConfirmation', true)
->assertSee('Delete Confirmation');
});
it('hides delete confirmation by default', function () {
Livewire::test(SettingsModalDouble::class)
->assertSet('showDeleteConfirmation', false)
->assertDontSee('Delete Confirmation');
});
it('resets state when deletion requested', function () {
Livewire::test(SettingsModalDouble::class)
->set('showDeleteConfirmation', true)
->set('deleteReason', 'No longer needed')
->call('requestAccountDeletion')
->assertSet('showDeleteConfirmation', false)
->assertSet('deleteReason', '');
});
it('records warning toast for deletion', function () {
Livewire::test(SettingsModalDouble::class)
->call('requestAccountDeletion')
->assertSet('toasts', fn ($toasts) => $toasts[0]['variant'] === 'warning');
});
it('records success toast for deletion cancellation', function () {
Livewire::test(SettingsModalDouble::class)
->call('cancelAccountDeletion')
->assertSet('toasts', fn ($toasts) => $toasts[0]['variant'] === 'success');
});
it('has delete account feature enabled by default', function () {
Livewire::test(SettingsModalDouble::class)
->assertSet('isDeleteAccountEnabled', true);
});
});
// =============================================================================
// Two-Factor Authentication Tests
// =============================================================================
describe('Settings two-factor authentication', function () {
it('has two-factor disabled by default', function () {
Livewire::test(SettingsModalDouble::class)
->assertSet('isTwoFactorEnabled', false)
->assertSet('userHasTwoFactorEnabled', false)
->assertSet('showTwoFactorSetup', false);
});
it('records warning toast when enabling two-factor (not yet implemented)', function () {
Livewire::test(SettingsModalDouble::class)
->call('enableTwoFactor')
->assertSet('toasts', fn ($toasts) => $toasts[0]['variant'] === 'warning');
});
});