cmplete story 4.1 with qa tests

This commit is contained in:
Naser Mansour 2025-12-27 00:17:37 +02:00
parent f28ee5624d
commit e669e97ca1
9 changed files with 876 additions and 29 deletions

View File

@ -0,0 +1,53 @@
# Quality Gate: Story 4.1 - Timeline Creation
# Generated by Quinn (Test Architect) on 2025-12-27
schema: 1
story: "4.1"
story_title: "Timeline Creation"
gate: PASS
status_reason: "All acceptance criteria met with comprehensive test coverage (22 tests). Clean implementation following established patterns. No security or performance concerns."
reviewer: "Quinn (Test Architect)"
updated: "2025-12-27T00:00:00Z"
waiver: { active: false }
top_issues: []
risk_summary:
totals: { critical: 0, high: 0, medium: 0, low: 0 }
recommendations:
must_fix: []
monitor: []
quality_score: 100
expires: "2026-01-10T00:00:00Z"
evidence:
tests_reviewed: 22
tests_passing: 22
risks_identified: 0
trace:
ac_covered: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
ac_gaps: []
nfr_validation:
security:
status: PASS
notes: "Admin middleware protects route. Validation on all inputs. Parameterized queries via Eloquent. CSRF handled by Livewire."
performance:
status: PASS
notes: "Search limited to 10 results. 300ms debounce on input. 2-char minimum threshold."
reliability:
status: PASS
notes: "Proper error handling via Livewire validation. Flash messages for user feedback."
maintainability:
status: PASS
notes: "Clean class-based Volt component. Follows existing admin CRUD patterns. Well-structured translations."
recommendations:
immediate: []
future:
- action: "Consider adding loading state indicator during form submission"
refs: ["resources/views/livewire/admin/timelines/create.blade.php"]
- action: "Consider pagination if client list could exceed 100+ records"
refs: ["resources/views/livewire/admin/timelines/create.blade.php:42-57"]

View File

@ -208,44 +208,44 @@ new class extends Component {
All tests should use Pest and be placed in `tests/Feature/Admin/TimelineCreationTest.php`.
### Happy Path Tests
- [ ] `test_admin_can_view_timeline_creation_form` - Admin can access /admin/timelines/create
- [ ] `test_admin_can_search_clients_by_name` - Search returns matching users
- [ ] `test_admin_can_search_clients_by_email` - Search returns matching users
- [ ] `test_admin_can_create_timeline_with_required_fields` - Timeline created with case_name only
- [ ] `test_admin_can_create_timeline_with_case_reference` - Timeline created with optional reference
- [ ] `test_initial_notes_creates_first_timeline_update` - TimelineUpdate record created
- [ ] `test_audit_log_created_on_timeline_creation` - AdminLog entry exists
- [x] `test_admin_can_view_timeline_creation_form` - Admin can access /admin/timelines/create
- [x] `test_admin_can_search_clients_by_name` - Search returns matching users
- [x] `test_admin_can_search_clients_by_email` - Search returns matching users
- [x] `test_admin_can_create_timeline_with_required_fields` - Timeline created with case_name only
- [x] `test_admin_can_create_timeline_with_case_reference` - Timeline created with optional reference
- [x] `test_initial_notes_creates_first_timeline_update` - TimelineUpdate record created
- [x] `test_audit_log_created_on_timeline_creation` - AdminLog entry exists
### Validation Tests
- [ ] `test_case_name_is_required` - Validation error without case_name
- [ ] `test_case_reference_must_be_unique` - Validation error on duplicate reference
- [ ] `test_case_reference_allows_multiple_nulls` - Multiple timelines without reference allowed
- [ ] `test_client_selection_is_required` - Validation error without selecting client
- [ ] `test_selected_client_must_exist` - Validation error for non-existent user_id
- [x] `test_case_name_is_required` - Validation error without case_name
- [x] `test_case_reference_must_be_unique` - Validation error on duplicate reference
- [x] `test_case_reference_allows_multiple_nulls` - Multiple timelines without reference allowed
- [x] `test_client_selection_is_required` - Validation error without selecting client
- [x] `test_selected_client_must_exist` - Validation error for non-existent user_id
### Authorization Tests
- [ ] `test_non_admin_cannot_access_timeline_creation` - Redirect or 403 for non-admin users
- [ ] `test_guest_cannot_access_timeline_creation` - Redirect to login
- [x] `test_non_admin_cannot_access_timeline_creation` - Redirect or 403 for non-admin users
- [x] `test_guest_cannot_access_timeline_creation` - Redirect to login
### Edge Case Tests
- [ ] `test_search_only_returns_individual_and_company_users` - Admin users not in results
- [ ] `test_search_only_returns_active_users` - Deactivated users not in results
- [ ] `test_can_create_multiple_timelines_for_same_client` - No unique constraint on user_id
- [x] `test_search_only_returns_individual_and_company_users` - Admin users not in results
- [x] `test_search_only_returns_active_users` - Deactivated users not in results
- [x] `test_can_create_multiple_timelines_for_same_client` - No unique constraint on user_id
## Definition of Done
- [ ] Volt component created at `resources/views/livewire/pages/admin/timelines/create.blade.php`
- [ ] Route registered for admin timeline creation
- [ ] Can search and select client (individual/company only)
- [ ] Can enter case name and reference
- [ ] Timeline created with correct data
- [ ] Initial notes saved as first update
- [ ] Unique reference validation works
- [ ] Client can view timeline immediately (verified via client dashboard)
- [ ] Audit log created
- [ ] All translation keys added (AR/EN)
- [ ] All tests pass
- [ ] Code formatted with Pint
- [x] Volt component created at `resources/views/livewire/admin/timelines/create.blade.php`
- [x] Route registered for admin timeline creation
- [x] Can search and select client (individual/company only)
- [x] Can enter case name and reference
- [x] Timeline created with correct data
- [x] Initial notes saved as first update
- [x] Unique reference validation works
- [ ] Client can view timeline immediately (verified via client dashboard) - *Requires future story for client timeline view*
- [x] Audit log created
- [x] All translation keys added (AR/EN)
- [x] All tests pass
- [x] Code formatted with Pint
## Dependencies
@ -256,3 +256,138 @@ All tests should use Pest and be placed in `tests/Feature/Admin/TimelineCreation
## Estimation
**Complexity:** Low-Medium
---
## QA Results
### Review Date: 2025-12-27
### Reviewed By: Quinn (Test Architect)
### Code Quality Assessment
**Overall: Excellent implementation.** The timeline creation feature is well-structured, follows established codebase patterns, and demonstrates strong adherence to Laravel/Livewire best practices. The code is clean, maintainable, and properly tested.
**Strengths:**
- Class-based Volt component pattern matches existing admin components exactly
- Proper use of User model scopes (`clients()`, `active()`) instead of raw queries
- Correct implementation of AdminLog with existing field names (`action` vs story spec's `action_type`)
- Flux UI components used consistently throughout
- Bilingual support complete with both EN/AR translation files
- Comprehensive test coverage (22 tests) covering happy paths, validation, authorization, and edge cases
### Refactoring Performed
None required. The implementation is clean and follows established patterns.
### Compliance Check
- Coding Standards: ✓ Follows class-based Volt pattern, uses Flux UI, proper validation
- Project Structure: ✓ Files placed in correct locations matching existing structure
- Testing Strategy: ✓ Pest tests with Volt::test() pattern, comprehensive coverage
- All ACs Met: ✓ All acceptance criteria fulfilled (see traceability below)
### Requirements Traceability (Given-When-Then)
| AC# | Acceptance Criteria | Test Coverage | Status |
|-----|---------------------|---------------|--------|
| 1 | Select client (search by name/email) - only individual/company users | `test_admin_can_search_clients_by_name`, `test_admin_can_search_clients_by_email`, `test_search_only_returns_individual_and_company_users` | ✓ |
| 2 | Case name/title (required) | `test_case_name_is_required`, `test_admin_can_create_timeline_with_required_fields` | ✓ |
| 3 | Case reference (optional, unique if provided) | `test_admin_can_create_timeline_with_case_reference`, `test_case_reference_must_be_unique`, `test_case_reference_allows_multiple_nulls` | ✓ |
| 4 | Initial notes (optional) | `test_initial_notes_creates_first_timeline_update`, `test_timeline_without_initial_notes_has_no_updates` | ✓ |
| 5 | Timeline assigned to selected client | `test_admin_can_create_timeline_with_required_fields` (verifies user_id) | ✓ |
| 6 | Creation date automatically recorded | Timestamps handled by Eloquent | ✓ |
| 7 | Status defaults to 'active' | `test_timeline_status_defaults_to_active` | ✓ |
| 8 | Can create multiple timelines per client | `test_can_create_multiple_timelines_for_same_client` | ✓ |
| 9 | Confirmation message on success | Component uses `session()->flash('success', ...)` | ✓ |
| 10 | Client must exist validation | `test_selected_client_must_exist` | ✓ |
| 11 | Audit log entry created | `test_audit_log_created_on_timeline_creation` | ✓ |
| 12 | Bilingual labels/messages | EN/AR translation files complete | ✓ |
| 13 | Authorization tests | `test_non_admin_cannot_access_timeline_creation`, `test_guest_cannot_access_timeline_creation` | ✓ |
| 14 | Search only returns active users | `test_search_only_returns_active_users` | ✓ |
### Improvements Checklist
All items completed by the developer:
- [x] Volt component created with proper class-based pattern
- [x] Client search implemented with debounce for performance
- [x] Validation rules properly defined
- [x] AdminLog integration working correctly
- [x] Translation files complete for both languages
- [x] All 22 tests passing
- [x] Code formatted with Pint
### Security Review
**Status: PASS**
- Authorization: Admin middleware properly protects the route (`routes/web.php:83-85`)
- Validation: All user inputs validated before database operations
- SQL Injection: Uses Eloquent query builder with parameterized queries
- CSRF: Handled by Livewire automatically
- Mass Assignment: Only fillable attributes used
### Performance Considerations
**Status: PASS**
- Client search uses `limit(10)` to prevent excessive results
- Debounce (300ms) on search input reduces server requests
- Minimum 2-character search threshold prevents over-querying
- Eager loading not needed (no relationship hydration in search)
### Files Modified During Review
None. No refactoring was necessary.
### Gate Status
Gate: **PASS** → docs/qa/gates/4.1-timeline-creation.yml
### Recommended Status
✓ **Ready for Done**
All acceptance criteria have been met, tests pass, code follows established patterns, and no security or performance concerns were identified.
---
## Dev Agent Record
### Status
**Ready for Review**
### Agent Model Used
Claude Opus 4.5 (claude-opus-4-5-20251101)
### File List
**Created:**
- `resources/views/livewire/admin/timelines/create.blade.php` - Volt component for timeline creation
- `lang/en/timelines.php` - English translation keys for timelines
- `lang/ar/timelines.php` - Arabic translation keys for timelines
- `tests/Feature/Admin/TimelineCreationTest.php` - 22 Pest tests for timeline creation
**Modified:**
- `routes/web.php` - Added admin.timelines.create route
- `lang/en/messages.php` - Added timeline_created message
- `lang/ar/messages.php` - Added timeline_created message (Arabic)
### Change Log
1. Created Volt component with client search, case name/reference inputs, initial notes
2. Implemented searchable client dropdown (individual/company users only, active only)
3. Added validation rules (case name required, case reference unique)
4. Created audit logging on timeline creation
5. Initial notes saved as first TimelineUpdate if provided
6. Added bilingual translation support (EN/AR)
7. Registered route at `/admin/timelines/create`
8. Created 22 comprehensive Pest tests covering all scenarios
### Completion Notes
- Component path adjusted from `pages/admin/timelines/` to `admin/timelines/` to match existing codebase structure
- Redirect after creation goes to `admin.dashboard` (not `admin.timelines.show`) since show route is in a future story
- Used existing User model scopes (`clients()`, `active()`) instead of raw queries
- AdminLog uses `action` field (not `action_type` as in story spec) to match existing model
- One DoD item remains unchecked: "Client can view timeline immediately" requires client dashboard timeline view (future story)

View File

@ -24,4 +24,7 @@ return [
'not_paid_consultation' => 'هذه ليست استشارة مدفوعة.',
'payment_already_received' => 'تم تحديد الدفع كمستلم مسبقاً.',
'client_account_not_found' => 'حساب العميل غير موجود.',
// Timeline Management
'timeline_created' => 'تم إنشاء الجدول الزمني بنجاح.',
];

33
lang/ar/timelines.php Normal file
View File

@ -0,0 +1,33 @@
<?php
return [
// Page titles and navigation
'timelines' => 'الجداول الزمنية',
'create_timeline' => 'إنشاء جدول زمني',
'back_to_timelines' => 'العودة إلى الجداول الزمنية',
// Form labels
'case_name' => 'اسم القضية',
'case_reference' => 'رقم مرجع القضية',
'initial_notes' => 'ملاحظات أولية',
'select_client' => 'اختر العميل',
'search_client' => 'البحث بالاسم أو البريد الإلكتروني...',
'selected_client' => 'العميل المحدد',
// Form actions
'create' => 'إنشاء',
'cancel' => 'إلغاء',
// Placeholders
'case_name_placeholder' => 'أدخل اسم القضية',
'case_reference_placeholder' => 'أدخل رقم مرجع القضية (اختياري)',
'initial_notes_placeholder' => 'أضف ملاحظات أولية لهذا الجدول الزمني (اختياري)',
// Validation messages
'case_reference_exists' => 'رقم مرجع القضية هذا مستخدم بالفعل.',
'client_required' => 'يرجى اختيار عميل.',
// Search
'no_clients_found' => 'لم يتم العثور على عملاء مطابقين لبحثك.',
'type_to_search' => 'اكتب حرفين على الأقل للبحث...',
];

View File

@ -24,4 +24,7 @@ return [
'not_paid_consultation' => 'This is not a paid consultation.',
'payment_already_received' => 'Payment has already been marked as received.',
'client_account_not_found' => 'Client account not found.',
// Timeline Management
'timeline_created' => 'Timeline created successfully.',
];

33
lang/en/timelines.php Normal file
View File

@ -0,0 +1,33 @@
<?php
return [
// Page titles and navigation
'timelines' => 'Timelines',
'create_timeline' => 'Create Timeline',
'back_to_timelines' => 'Back to Timelines',
// Form labels
'case_name' => 'Case Name',
'case_reference' => 'Case Reference',
'initial_notes' => 'Initial Notes',
'select_client' => 'Select Client',
'search_client' => 'Search by name or email...',
'selected_client' => 'Selected Client',
// Form actions
'create' => 'Create',
'cancel' => 'Cancel',
// Placeholders
'case_name_placeholder' => 'Enter case name',
'case_reference_placeholder' => 'Enter case reference (optional)',
'initial_notes_placeholder' => 'Add initial notes for this timeline (optional)',
// Validation messages
'case_reference_exists' => 'This case reference is already in use.',
'client_required' => 'Please select a client.',
// Search
'no_clients_found' => 'No clients found matching your search.',
'type_to_search' => 'Type at least 2 characters to search...',
];

View File

@ -0,0 +1,223 @@
<?php
use App\Enums\TimelineStatus;
use App\Models\AdminLog;
use App\Models\Timeline;
use App\Models\User;
use Livewire\Volt\Component;
new class extends Component {
public string $search = '';
public ?int $selectedUserId = null;
public ?User $selectedUser = null;
public string $caseName = '';
public string $caseReference = '';
public string $initialNotes = '';
public function rules(): array
{
return [
'selectedUserId' => ['required', 'exists:users,id'],
'caseName' => ['required', 'string', 'max:255'],
'caseReference' => ['nullable', 'string', 'max:50', 'unique:timelines,case_reference'],
];
}
public function messages(): array
{
return [
'selectedUserId.required' => __('timelines.client_required'),
'caseReference.unique' => __('timelines.case_reference_exists'),
];
}
public function updatedSearch(): void
{
if ($this->selectedUser && ! str_contains(strtolower($this->selectedUser->full_name), strtolower($this->search))) {
$this->selectedUserId = null;
$this->selectedUser = null;
}
}
public function getClientsProperty()
{
if (strlen($this->search) < 2) {
return collect();
}
return User::query()
->clients()
->active()
->where(function ($query) {
$query->where('full_name', 'like', "%{$this->search}%")
->orWhere('email', 'like', "%{$this->search}%");
})
->limit(10)
->get();
}
public function selectUser(int $userId): void
{
$this->selectedUserId = $userId;
$this->selectedUser = User::find($userId);
$this->search = $this->selectedUser->full_name;
}
public function clearSelection(): void
{
$this->selectedUserId = null;
$this->selectedUser = null;
$this->search = '';
}
public function create(): void
{
$this->validate();
$timeline = Timeline::create([
'user_id' => $this->selectedUserId,
'case_name' => $this->caseName,
'case_reference' => $this->caseReference ?: null,
'status' => TimelineStatus::Active,
]);
if ($this->initialNotes) {
$timeline->updates()->create([
'admin_id' => auth()->id(),
'update_text' => $this->initialNotes,
]);
}
AdminLog::create([
'admin_id' => auth()->id(),
'action' => 'create',
'target_type' => 'timeline',
'target_id' => $timeline->id,
'new_values' => $timeline->toArray(),
'ip_address' => request()->ip(),
'created_at' => now(),
]);
session()->flash('success', __('messages.timeline_created'));
$this->redirect(route('admin.dashboard'), navigate: true);
}
}; ?>
<div>
<div class="mb-6">
<flux:button variant="ghost" :href="route('admin.dashboard')" wire:navigate icon="arrow-left">
{{ __('timelines.back_to_timelines') }}
</flux:button>
</div>
<div class="mb-6">
<flux:heading size="xl">{{ __('timelines.create_timeline') }}</flux:heading>
</div>
<div class="rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-800">
<form wire:submit="create" class="space-y-6">
{{-- Client Selection --}}
<flux:field>
<flux:label>{{ __('timelines.select_client') }} *</flux:label>
@if($selectedUser)
<div class="flex items-center gap-3 rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-900">
<flux:avatar size="sm" name="{{ $selectedUser->full_name }}" />
<div class="flex-1">
<div class="font-medium text-zinc-900 dark:text-zinc-100">{{ $selectedUser->full_name }}</div>
<div class="text-sm text-zinc-500 dark:text-zinc-400">{{ $selectedUser->email }}</div>
</div>
<flux:button variant="ghost" size="sm" wire:click="clearSelection" icon="x-mark" />
</div>
@else
<div class="relative">
<flux:input
wire:model.live.debounce.300ms="search"
type="text"
placeholder="{{ __('timelines.search_client') }}"
icon="magnifying-glass"
autocomplete="off"
/>
@if(strlen($search) >= 2)
<div class="absolute z-10 mt-1 w-full rounded-lg border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
@forelse($this->clients as $client)
<button
type="button"
wire:key="client-{{ $client->id }}"
wire:click="selectUser({{ $client->id }})"
class="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-zinc-50 dark:hover:bg-zinc-700 first:rounded-t-lg last:rounded-b-lg"
>
<flux:avatar size="sm" name="{{ $client->full_name }}" />
<div>
<div class="font-medium text-zinc-900 dark:text-zinc-100">{{ $client->full_name }}</div>
<div class="text-sm text-zinc-500 dark:text-zinc-400">{{ $client->email }}</div>
</div>
</button>
@empty
<div class="px-4 py-3 text-sm text-zinc-500 dark:text-zinc-400">
{{ __('timelines.no_clients_found') }}
</div>
@endforelse
</div>
@elseif(strlen($search) > 0 && strlen($search) < 2)
<div class="absolute z-10 mt-1 w-full rounded-lg border border-zinc-200 bg-white p-3 shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
<div class="text-sm text-zinc-500 dark:text-zinc-400">
{{ __('timelines.type_to_search') }}
</div>
</div>
@endif
</div>
@endif
<flux:error name="selectedUserId" />
</flux:field>
<div class="grid gap-6 sm:grid-cols-2">
{{-- Case Name --}}
<flux:field>
<flux:label>{{ __('timelines.case_name') }} *</flux:label>
<flux:input
wire:model="caseName"
type="text"
required
placeholder="{{ __('timelines.case_name_placeholder') }}"
/>
<flux:error name="caseName" />
</flux:field>
{{-- Case Reference --}}
<flux:field>
<flux:label>{{ __('timelines.case_reference') }} <span class="text-zinc-400">({{ __('common.optional') }})</span></flux:label>
<flux:input
wire:model="caseReference"
type="text"
placeholder="{{ __('timelines.case_reference_placeholder') }}"
/>
<flux:error name="caseReference" />
</flux:field>
</div>
{{-- Initial Notes --}}
<flux:field>
<flux:label>{{ __('timelines.initial_notes') }} <span class="text-zinc-400">({{ __('common.optional') }})</span></flux:label>
<flux:textarea
wire:model="initialNotes"
rows="4"
placeholder="{{ __('timelines.initial_notes_placeholder') }}"
/>
<flux:error name="initialNotes" />
</flux:field>
<div class="flex items-center justify-end gap-4 border-t border-zinc-200 pt-6 dark:border-zinc-700">
<flux:button variant="ghost" :href="route('admin.dashboard')" wire:navigate>
{{ __('timelines.cancel') }}
</flux:button>
<flux:button variant="primary" type="submit">
{{ __('timelines.create') }}
</flux:button>
</div>
</form>
</div>
</div>

View File

@ -79,6 +79,11 @@ Route::middleware(['auth', 'active'])->group(function () {
Volt::route('/clients/{user}/consultations', 'admin.clients.consultation-history')
->name('admin.clients.consultation-history');
// Timelines Management
Route::prefix('timelines')->name('admin.timelines.')->group(function () {
Volt::route('/create', 'admin.timelines.create')->name('create');
});
// Admin Settings
Route::prefix('settings')->name('admin.settings.')->group(function () {
Volt::route('/working-hours', 'admin.settings.working-hours')->name('working-hours');

View File

@ -0,0 +1,359 @@
<?php
use App\Enums\TimelineStatus;
use App\Models\AdminLog;
use App\Models\Timeline;
use App\Models\User;
use Livewire\Volt\Volt;
beforeEach(function () {
$this->admin = User::factory()->admin()->create();
});
// ===========================================
// Happy Path Tests
// ===========================================
test('admin can view timeline creation form', function () {
$this->actingAs($this->admin)
->get(route('admin.timelines.create'))
->assertOk();
});
test('admin can search clients by name', function () {
$client = User::factory()->individual()->create(['full_name' => 'John Doe']);
User::factory()->individual()->create(['full_name' => 'Jane Smith']);
$this->actingAs($this->admin);
$component = Volt::test('admin.timelines.create')
->set('search', 'John');
expect($component->get('clients'))->toHaveCount(1);
expect($component->get('clients')->first()->full_name)->toBe('John Doe');
});
test('admin can search clients by email', function () {
$client = User::factory()->individual()->create([
'full_name' => 'John Doe',
'email' => 'john@example.com',
]);
User::factory()->individual()->create([
'full_name' => 'Jane Smith',
'email' => 'jane@example.com',
]);
$this->actingAs($this->admin);
$component = Volt::test('admin.timelines.create')
->set('search', 'john@');
expect($component->get('clients'))->toHaveCount(1);
expect($component->get('clients')->first()->email)->toBe('john@example.com');
});
test('admin can create timeline with required fields', function () {
$client = User::factory()->individual()->create();
$this->actingAs($this->admin);
Volt::test('admin.timelines.create')
->set('selectedUserId', $client->id)
->call('selectUser', $client->id)
->set('caseName', 'Test Case')
->call('create')
->assertHasNoErrors()
->assertRedirect(route('admin.dashboard'));
expect(Timeline::where('case_name', 'Test Case')->exists())->toBeTrue();
$timeline = Timeline::where('case_name', 'Test Case')->first();
expect($timeline->user_id)->toBe($client->id);
expect($timeline->status)->toBe(TimelineStatus::Active);
expect($timeline->case_reference)->toBeNull();
});
test('admin can create timeline with case reference', function () {
$client = User::factory()->individual()->create();
$this->actingAs($this->admin);
Volt::test('admin.timelines.create')
->set('selectedUserId', $client->id)
->call('selectUser', $client->id)
->set('caseName', 'Test Case')
->set('caseReference', 'REF-001')
->call('create')
->assertHasNoErrors();
$timeline = Timeline::where('case_name', 'Test Case')->first();
expect($timeline->case_reference)->toBe('REF-001');
});
test('initial notes creates first timeline update', function () {
$client = User::factory()->individual()->create();
$this->actingAs($this->admin);
Volt::test('admin.timelines.create')
->set('selectedUserId', $client->id)
->call('selectUser', $client->id)
->set('caseName', 'Test Case')
->set('initialNotes', 'Initial case notes here')
->call('create')
->assertHasNoErrors();
$timeline = Timeline::where('case_name', 'Test Case')->first();
expect($timeline->updates)->toHaveCount(1);
$update = $timeline->updates->first();
expect($update->update_text)->toBe('Initial case notes here');
expect($update->admin_id)->toBe($this->admin->id);
});
test('audit log created on timeline creation', function () {
$client = User::factory()->individual()->create();
$this->actingAs($this->admin);
Volt::test('admin.timelines.create')
->set('selectedUserId', $client->id)
->call('selectUser', $client->id)
->set('caseName', 'Test Case')
->call('create')
->assertHasNoErrors();
expect(AdminLog::where('action', 'create')
->where('target_type', 'timeline')
->where('admin_id', $this->admin->id)
->exists())->toBeTrue();
});
// ===========================================
// Validation Tests
// ===========================================
test('case name is required', function () {
$client = User::factory()->individual()->create();
$this->actingAs($this->admin);
Volt::test('admin.timelines.create')
->set('selectedUserId', $client->id)
->call('selectUser', $client->id)
->set('caseName', '')
->call('create')
->assertHasErrors(['caseName' => 'required']);
});
test('case reference must be unique', function () {
$client = User::factory()->individual()->create();
Timeline::factory()->create(['case_reference' => 'REF-001']);
$this->actingAs($this->admin);
Volt::test('admin.timelines.create')
->set('selectedUserId', $client->id)
->call('selectUser', $client->id)
->set('caseName', 'Test Case')
->set('caseReference', 'REF-001')
->call('create')
->assertHasErrors(['caseReference' => 'unique']);
});
test('case reference allows multiple nulls', function () {
$client = User::factory()->individual()->create();
// Create a timeline without case_reference
Timeline::factory()->create([
'user_id' => $client->id,
'case_reference' => null,
]);
$this->actingAs($this->admin);
// Creating another timeline without case_reference should succeed
Volt::test('admin.timelines.create')
->set('selectedUserId', $client->id)
->call('selectUser', $client->id)
->set('caseName', 'Test Case')
->set('caseReference', '')
->call('create')
->assertHasNoErrors();
expect(Timeline::where('user_id', $client->id)->whereNull('case_reference')->count())->toBe(2);
});
test('client selection is required', function () {
$this->actingAs($this->admin);
Volt::test('admin.timelines.create')
->set('caseName', 'Test Case')
->call('create')
->assertHasErrors(['selectedUserId' => 'required']);
});
test('selected client must exist', function () {
$this->actingAs($this->admin);
Volt::test('admin.timelines.create')
->set('selectedUserId', 99999)
->set('caseName', 'Test Case')
->call('create')
->assertHasErrors(['selectedUserId' => 'exists']);
});
// ===========================================
// Authorization Tests
// ===========================================
test('non-admin cannot access timeline creation', function () {
$client = User::factory()->individual()->create();
$this->actingAs($client)
->get(route('admin.timelines.create'))
->assertForbidden();
});
test('guest cannot access timeline creation', function () {
$this->get(route('admin.timelines.create'))
->assertRedirect(route('login'));
});
// ===========================================
// Edge Case Tests
// ===========================================
test('search only returns individual and company users', function () {
$individualClient = User::factory()->individual()->create(['full_name' => 'Individual Client']);
$companyClient = User::factory()->company()->create(['full_name' => 'Company Client']);
$adminUser = User::factory()->admin()->create(['full_name' => 'Admin User']);
$this->actingAs($this->admin);
$component = Volt::test('admin.timelines.create')
->set('search', 'Client');
$clientNames = $component->get('clients')->pluck('full_name')->toArray();
expect($clientNames)->toContain('Individual Client');
expect($clientNames)->toContain('Company Client');
expect($clientNames)->not->toContain('Admin User');
});
test('search only returns active users', function () {
$activeClient = User::factory()->individual()->create(['full_name' => 'Active Client']);
$deactivatedClient = User::factory()->individual()->deactivated()->create(['full_name' => 'Deactivated Client']);
$this->actingAs($this->admin);
$component = Volt::test('admin.timelines.create')
->set('search', 'Client');
$clientNames = $component->get('clients')->pluck('full_name')->toArray();
expect($clientNames)->toContain('Active Client');
expect($clientNames)->not->toContain('Deactivated Client');
});
test('can create multiple timelines for same client', function () {
$client = User::factory()->individual()->create();
$this->actingAs($this->admin);
// Create first timeline
Volt::test('admin.timelines.create')
->set('selectedUserId', $client->id)
->call('selectUser', $client->id)
->set('caseName', 'First Case')
->call('create')
->assertHasNoErrors();
// Create second timeline for same client
Volt::test('admin.timelines.create')
->set('selectedUserId', $client->id)
->call('selectUser', $client->id)
->set('caseName', 'Second Case')
->call('create')
->assertHasNoErrors();
expect(Timeline::where('user_id', $client->id)->count())->toBe(2);
});
// ===========================================
// Additional Tests
// ===========================================
test('search requires at least 2 characters', function () {
User::factory()->individual()->create(['full_name' => 'John Doe']);
$this->actingAs($this->admin);
$component = Volt::test('admin.timelines.create')
->set('search', 'J');
expect($component->get('clients'))->toHaveCount(0);
$component->set('search', 'Jo');
expect($component->get('clients'))->toHaveCount(1);
});
test('selecting user sets search to user name', function () {
$client = User::factory()->individual()->create(['full_name' => 'John Doe']);
$this->actingAs($this->admin);
$component = Volt::test('admin.timelines.create')
->set('search', 'John')
->call('selectUser', $client->id);
expect($component->get('selectedUserId'))->toBe($client->id);
expect($component->get('search'))->toBe('John Doe');
});
test('clearing selection resets user and search', function () {
$client = User::factory()->individual()->create(['full_name' => 'John Doe']);
$this->actingAs($this->admin);
$component = Volt::test('admin.timelines.create')
->call('selectUser', $client->id)
->call('clearSelection');
expect($component->get('selectedUserId'))->toBeNull();
expect($component->get('selectedUser'))->toBeNull();
expect($component->get('search'))->toBe('');
});
test('timeline without initial notes has no updates', function () {
$client = User::factory()->individual()->create();
$this->actingAs($this->admin);
Volt::test('admin.timelines.create')
->set('selectedUserId', $client->id)
->call('selectUser', $client->id)
->set('caseName', 'Test Case')
->set('initialNotes', '')
->call('create')
->assertHasNoErrors();
$timeline = Timeline::where('case_name', 'Test Case')->first();
expect($timeline->updates)->toHaveCount(0);
});
test('timeline status defaults to active', function () {
$client = User::factory()->individual()->create();
$this->actingAs($this->admin);
Volt::test('admin.timelines.create')
->set('selectedUserId', $client->id)
->call('selectUser', $client->id)
->set('caseName', 'Test Case')
->call('create')
->assertHasNoErrors();
$timeline = Timeline::where('case_name', 'Test Case')->first();
expect($timeline->status)->toBe(TimelineStatus::Active);
});