484 lines
16 KiB
PHP
484 lines
16 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
/*
|
||
|
|
* Core PHP Framework
|
||
|
|
*
|
||
|
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||
|
|
* See LICENSE file for details.
|
||
|
|
*/
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
use Livewire\Attributes\Computed;
|
||
|
|
use Livewire\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');
|
||
|
|
});
|
||
|
|
});
|