diff --git a/tests/Feature/Modal/ActivityLogModalTest.php b/tests/Feature/Modal/ActivityLogModalTest.php new file mode 100644 index 0000000..3647dd0 --- /dev/null +++ b/tests/Feature/Modal/ActivityLogModalTest.php @@ -0,0 +1,483 @@ +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' +
+ + Results: {{ count($this->filteredActivities) }} + Log: {{ $logName ?: 'all' }} + Event: {{ $event ?: 'all' }} + @foreach($this->activityItems as $item) +
{{ $item['description'] }}
+ @endforeach +
+ 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'); + }); +}); diff --git a/tests/Feature/Modal/PlatformUserModalTest.php b/tests/Feature/Modal/PlatformUserModalTest.php new file mode 100644 index 0000000..c9c7ab3 --- /dev/null +++ b/tests/Feature/Modal/PlatformUserModalTest.php @@ -0,0 +1,602 @@ +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' +
+

User: {{ $userName }}

+ Tab: {{ $activeTab }} + Tier: {{ $editingTier }} + @if($showDeleteConfirm) +
Delete Confirm
+ @endif + @if($showPackageModal) +
Package Modal
+ @endif + @if($showEntitlementModal) +
Entitlement Modal
+ @endif + @if($actionMessage) +
{{ $actionMessage }}
+ @endif +
+ 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.'); + }); +}); diff --git a/tests/Feature/Modal/ServiceManagerModalTest.php b/tests/Feature/Modal/ServiceManagerModalTest.php new file mode 100644 index 0000000..197873a --- /dev/null +++ b/tests/Feature/Modal/ServiceManagerModalTest.php @@ -0,0 +1,707 @@ +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' +
+ Services: {{ count($this->services) }} + Enabled: {{ $this->enabledCount }} + Featured: {{ $this->featuredCount }} + @if($showModal) +
+

Edit: {{ $name }}

+ Code: {{ $code }} + Module: {{ $module }} +
+ @endif + @foreach($flashMessages as $msg) +
{{ $msg }}
+ @endforeach +
+ 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'); + }); +}); diff --git a/tests/Feature/Modal/SettingsModalTest.php b/tests/Feature/Modal/SettingsModalTest.php new file mode 100644 index 0000000..af2c26b --- /dev/null +++ b/tests/Feature/Modal/SettingsModalTest.php @@ -0,0 +1,450 @@ +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' +
+ + Section: {{ $activeSection }} + @if($showDeleteConfirmation) +
Delete Confirmation
+ @endif +
+ 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'); + }); +});