reviewed epic 3
This commit is contained in:
@@ -16,6 +16,14 @@ So that **I can track completed sessions, handle no-shows, and maintain accurate
|
||||
- **Follows pattern:** Admin management dashboard
|
||||
- **Touch points:** Consultation status, payment tracking, admin notes
|
||||
|
||||
### Service Dependencies
|
||||
This story relies on services created in previous stories:
|
||||
|
||||
- **AvailabilityService** (from Story 3.3): Used for `getAvailableSlots(Carbon $date): array` to validate reschedule slot availability
|
||||
- **CalendarService** (from Story 3.6): Used for `generateIcs(Consultation $consultation): string` to generate new .ics file on reschedule
|
||||
|
||||
These services must be implemented before the reschedule functionality can work.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Consultations List View
|
||||
@@ -332,10 +340,18 @@ new class extends Component {
|
||||
|
||||
### Admin Notes
|
||||
```php
|
||||
// Add admin_notes column to consultations or separate table
|
||||
// Design Decision: JSON array on consultation vs separate table
|
||||
// Chosen: JSON array because:
|
||||
// - Notes are always fetched with consultation (no N+1)
|
||||
// - Simple CRUD without extra joins
|
||||
// - Audit trail embedded in consultation record
|
||||
// - No need for complex querying of notes independently
|
||||
//
|
||||
// Trade-off: Cannot easily query "all notes by admin X" - acceptable for this use case
|
||||
|
||||
// In Consultation model:
|
||||
protected $casts = [
|
||||
'admin_notes' => 'array', // [{text, admin_id, created_at}]
|
||||
'admin_notes' => 'array', // [{text, admin_id, created_at, updated_at?}]
|
||||
];
|
||||
|
||||
public function addNote(string $note): void
|
||||
@@ -348,6 +364,631 @@ public function addNote(string $note): void
|
||||
];
|
||||
$this->update(['admin_notes' => $notes]);
|
||||
}
|
||||
|
||||
public function updateNote(int $index, string $newText): void
|
||||
{
|
||||
$notes = $this->admin_notes ?? [];
|
||||
if (isset($notes[$index])) {
|
||||
$notes[$index]['text'] = $newText;
|
||||
$notes[$index]['updated_at'] = now()->toISOString();
|
||||
$this->update(['admin_notes' => $notes]);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteNote(int $index): void
|
||||
{
|
||||
$notes = $this->admin_notes ?? [];
|
||||
if (isset($notes[$index])) {
|
||||
array_splice($notes, $index, 1);
|
||||
$this->update(['admin_notes' => array_values($notes)]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Edge Cases
|
||||
|
||||
Handle these scenarios with appropriate validation and error handling:
|
||||
|
||||
**Status Transition Guards:**
|
||||
```php
|
||||
// In Consultation model - add these guards to status change methods
|
||||
public function markAsCompleted(): void
|
||||
{
|
||||
// Only approved consultations can be marked completed
|
||||
if ($this->status !== ConsultationStatus::Approved) {
|
||||
throw new \InvalidArgumentException(
|
||||
__('messages.invalid_status_transition', ['from' => $this->status->value, 'to' => 'completed'])
|
||||
);
|
||||
}
|
||||
$this->update(['status' => ConsultationStatus::Completed]);
|
||||
}
|
||||
|
||||
public function markAsNoShow(): void
|
||||
{
|
||||
// Only approved consultations can be marked as no-show
|
||||
if ($this->status !== ConsultationStatus::Approved) {
|
||||
throw new \InvalidArgumentException(
|
||||
__('messages.invalid_status_transition', ['from' => $this->status->value, 'to' => 'no_show'])
|
||||
);
|
||||
}
|
||||
$this->update(['status' => ConsultationStatus::NoShow]);
|
||||
}
|
||||
|
||||
public function cancel(): void
|
||||
{
|
||||
// Can cancel pending or approved, but not completed/no_show/already cancelled
|
||||
if (!in_array($this->status, [ConsultationStatus::Pending, ConsultationStatus::Approved])) {
|
||||
throw new \InvalidArgumentException(
|
||||
__('messages.cannot_cancel_consultation')
|
||||
);
|
||||
}
|
||||
$this->update(['status' => ConsultationStatus::Cancelled]);
|
||||
}
|
||||
|
||||
public function markPaymentReceived(): void
|
||||
{
|
||||
// Only paid consultations can have payment marked
|
||||
if ($this->type !== 'paid') {
|
||||
throw new \InvalidArgumentException(__('messages.not_paid_consultation'));
|
||||
}
|
||||
// Prevent double-marking
|
||||
if ($this->payment_status === PaymentStatus::Received) {
|
||||
throw new \InvalidArgumentException(__('messages.payment_already_received'));
|
||||
}
|
||||
$this->update([
|
||||
'payment_status' => PaymentStatus::Received,
|
||||
'payment_received_at' => now(),
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
**Reschedule Edge Cases:**
|
||||
- **Slot already booked:** Handled by `AvailabilityService::getAvailableSlots()` - returns only truly available slots
|
||||
- **Reschedule to past date:** Validation rule `after_or_equal:today` prevents this
|
||||
- **Same date/time selected:** Allow but skip notification if no change detected:
|
||||
```php
|
||||
// In reschedule() method
|
||||
if ($oldDate->format('Y-m-d') === $this->newDate && $oldTime === $this->newTime) {
|
||||
session()->flash('info', __('messages.no_changes_made'));
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
**Concurrent Modification:**
|
||||
```php
|
||||
// Use database transaction with locking in Volt component
|
||||
public function markCompleted(int $id): void
|
||||
{
|
||||
DB::transaction(function () use ($id) {
|
||||
$consultation = Consultation::lockForUpdate()->findOrFail($id);
|
||||
// ... rest of logic
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Notification Failures:**
|
||||
```php
|
||||
// In reschedule() - don't block on notification failure
|
||||
try {
|
||||
$this->consultation->user->notify(
|
||||
new ConsultationRescheduled($this->consultation, $oldDate, $oldTime, $icsContent)
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to send reschedule notification', [
|
||||
'consultation_id' => $this->consultation->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
// Continue - consultation is rescheduled, notification failure is non-blocking
|
||||
}
|
||||
```
|
||||
|
||||
**Missing User (Deleted Account):**
|
||||
```php
|
||||
// Guard against orphaned consultations
|
||||
if (!$this->consultation->user) {
|
||||
session()->flash('error', __('messages.client_account_not_found'));
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Examples
|
||||
|
||||
```php
|
||||
use App\Models\Consultation;
|
||||
use App\Models\User;
|
||||
use App\Notifications\ConsultationCancelled;
|
||||
use App\Notifications\ConsultationRescheduled;
|
||||
use App\Services\AvailabilityService;
|
||||
use App\Services\CalendarService;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
// ==========================================
|
||||
// CONSULTATIONS LIST VIEW TESTS
|
||||
// ==========================================
|
||||
|
||||
it('displays all consultations for admin', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultations = Consultation::factory()->count(3)->approved()->create();
|
||||
|
||||
Volt::test('admin.consultations.index')
|
||||
->actingAs($admin)
|
||||
->assertSee($consultations->first()->user->name)
|
||||
->assertStatus(200);
|
||||
});
|
||||
|
||||
it('filters consultations by status', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$approved = Consultation::factory()->approved()->create();
|
||||
$completed = Consultation::factory()->completed()->create();
|
||||
|
||||
Volt::test('admin.consultations.index')
|
||||
->actingAs($admin)
|
||||
->set('statusFilter', 'approved')
|
||||
->assertSee($approved->user->name)
|
||||
->assertDontSee($completed->user->name);
|
||||
});
|
||||
|
||||
it('filters consultations by payment status', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$pendingPayment = Consultation::factory()->approved()->create([
|
||||
'type' => 'paid',
|
||||
'payment_status' => 'pending',
|
||||
]);
|
||||
$received = Consultation::factory()->approved()->create([
|
||||
'type' => 'paid',
|
||||
'payment_status' => 'received',
|
||||
]);
|
||||
|
||||
Volt::test('admin.consultations.index')
|
||||
->actingAs($admin)
|
||||
->set('paymentFilter', 'pending')
|
||||
->assertSee($pendingPayment->user->name)
|
||||
->assertDontSee($received->user->name);
|
||||
});
|
||||
|
||||
it('searches consultations by client name or email', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$targetUser = User::factory()->create(['name' => 'John Doe', 'email' => 'john@test.com']);
|
||||
$otherUser = User::factory()->create(['name' => 'Jane Smith']);
|
||||
$targetConsultation = Consultation::factory()->for($targetUser)->approved()->create();
|
||||
$otherConsultation = Consultation::factory()->for($otherUser)->approved()->create();
|
||||
|
||||
Volt::test('admin.consultations.index')
|
||||
->actingAs($admin)
|
||||
->set('search', 'John')
|
||||
->assertSee('John Doe')
|
||||
->assertDontSee('Jane Smith');
|
||||
});
|
||||
|
||||
it('filters consultations by date range', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$oldConsultation = Consultation::factory()->approved()->create([
|
||||
'scheduled_date' => now()->subDays(10),
|
||||
]);
|
||||
$newConsultation = Consultation::factory()->approved()->create([
|
||||
'scheduled_date' => now()->addDays(5),
|
||||
]);
|
||||
|
||||
Volt::test('admin.consultations.index')
|
||||
->actingAs($admin)
|
||||
->set('dateFrom', now()->toDateString())
|
||||
->assertSee($newConsultation->user->name)
|
||||
->assertDontSee($oldConsultation->user->name);
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// STATUS MANAGEMENT TESTS
|
||||
// ==========================================
|
||||
|
||||
it('can mark consultation as completed', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->approved()->create();
|
||||
|
||||
Volt::test('admin.consultations.index')
|
||||
->actingAs($admin)
|
||||
->call('markCompleted', $consultation->id)
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($consultation->fresh()->status->value)->toBe('completed');
|
||||
});
|
||||
|
||||
it('cannot mark pending consultation as completed', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->pending()->create();
|
||||
|
||||
Volt::test('admin.consultations.index')
|
||||
->actingAs($admin)
|
||||
->call('markCompleted', $consultation->id)
|
||||
->assertHasErrors();
|
||||
|
||||
expect($consultation->fresh()->status->value)->toBe('pending');
|
||||
});
|
||||
|
||||
it('can mark consultation as no-show', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->approved()->create();
|
||||
|
||||
Volt::test('admin.consultations.index')
|
||||
->actingAs($admin)
|
||||
->call('markNoShow', $consultation->id)
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($consultation->fresh()->status->value)->toBe('no_show');
|
||||
});
|
||||
|
||||
it('cannot mark already completed consultation as no-show', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->completed()->create();
|
||||
|
||||
Volt::test('admin.consultations.index')
|
||||
->actingAs($admin)
|
||||
->call('markNoShow', $consultation->id)
|
||||
->assertHasErrors();
|
||||
});
|
||||
|
||||
it('can cancel approved consultation', function () {
|
||||
Notification::fake();
|
||||
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->approved()->create();
|
||||
|
||||
Volt::test('admin.consultations.index')
|
||||
->actingAs($admin)
|
||||
->call('cancel', $consultation->id)
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($consultation->fresh()->status->value)->toBe('cancelled');
|
||||
Notification::assertSentTo($consultation->user, ConsultationCancelled::class);
|
||||
});
|
||||
|
||||
it('can cancel pending consultation', function () {
|
||||
Notification::fake();
|
||||
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->pending()->create();
|
||||
|
||||
Volt::test('admin.consultations.index')
|
||||
->actingAs($admin)
|
||||
->call('cancel', $consultation->id)
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($consultation->fresh()->status->value)->toBe('cancelled');
|
||||
});
|
||||
|
||||
it('cannot cancel already cancelled consultation', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->cancelled()->create();
|
||||
|
||||
Volt::test('admin.consultations.index')
|
||||
->actingAs($admin)
|
||||
->call('cancel', $consultation->id)
|
||||
->assertHasErrors();
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// PAYMENT TRACKING TESTS
|
||||
// ==========================================
|
||||
|
||||
it('can mark payment as received for paid consultation', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->approved()->create([
|
||||
'type' => 'paid',
|
||||
'payment_amount' => 150.00,
|
||||
'payment_status' => 'pending',
|
||||
]);
|
||||
|
||||
Volt::test('admin.consultations.index')
|
||||
->actingAs($admin)
|
||||
->call('markPaymentReceived', $consultation->id)
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($consultation->fresh())
|
||||
->payment_status->value->toBe('received')
|
||||
->payment_received_at->not->toBeNull();
|
||||
});
|
||||
|
||||
it('cannot mark payment received for free consultation', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->approved()->create([
|
||||
'type' => 'free',
|
||||
'payment_status' => 'not_applicable',
|
||||
]);
|
||||
|
||||
Volt::test('admin.consultations.index')
|
||||
->actingAs($admin)
|
||||
->call('markPaymentReceived', $consultation->id)
|
||||
->assertHasErrors();
|
||||
});
|
||||
|
||||
it('cannot mark payment received twice', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->approved()->create([
|
||||
'type' => 'paid',
|
||||
'payment_status' => 'received',
|
||||
'payment_received_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
Volt::test('admin.consultations.index')
|
||||
->actingAs($admin)
|
||||
->call('markPaymentReceived', $consultation->id)
|
||||
->assertHasErrors();
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// RESCHEDULE TESTS
|
||||
// ==========================================
|
||||
|
||||
it('can reschedule consultation to available slot', function () {
|
||||
Notification::fake();
|
||||
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->approved()->create([
|
||||
'scheduled_date' => now()->addDays(3),
|
||||
'scheduled_time' => '10:00:00',
|
||||
]);
|
||||
|
||||
$newDate = now()->addDays(5)->format('Y-m-d');
|
||||
$newTime = '14:00:00';
|
||||
|
||||
// Mock availability service
|
||||
$this->mock(AvailabilityService::class)
|
||||
->shouldReceive('getAvailableSlots')
|
||||
->andReturn(['14:00:00', '15:00:00', '16:00:00']);
|
||||
|
||||
Volt::test('admin.consultations.reschedule', ['consultation' => $consultation])
|
||||
->actingAs($admin)
|
||||
->set('newDate', $newDate)
|
||||
->set('newTime', $newTime)
|
||||
->call('reschedule')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect(route('admin.consultations.index'));
|
||||
|
||||
expect($consultation->fresh())
|
||||
->scheduled_date->format('Y-m-d')->toBe($newDate)
|
||||
->scheduled_time->toBe($newTime);
|
||||
|
||||
Notification::assertSentTo($consultation->user, ConsultationRescheduled::class);
|
||||
});
|
||||
|
||||
it('cannot reschedule to unavailable slot', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->approved()->create();
|
||||
|
||||
$newDate = now()->addDays(5)->format('Y-m-d');
|
||||
$newTime = '14:00:00';
|
||||
|
||||
// Mock availability service - slot not available
|
||||
$this->mock(AvailabilityService::class)
|
||||
->shouldReceive('getAvailableSlots')
|
||||
->andReturn(['10:00:00', '11:00:00']); // 14:00 not in list
|
||||
|
||||
Volt::test('admin.consultations.reschedule', ['consultation' => $consultation])
|
||||
->actingAs($admin)
|
||||
->set('newDate', $newDate)
|
||||
->set('newTime', $newTime)
|
||||
->call('reschedule')
|
||||
->assertHasErrors(['newTime']);
|
||||
});
|
||||
|
||||
it('cannot reschedule to past date', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->approved()->create();
|
||||
|
||||
Volt::test('admin.consultations.reschedule', ['consultation' => $consultation])
|
||||
->actingAs($admin)
|
||||
->set('newDate', now()->subDay()->format('Y-m-d'))
|
||||
->set('newTime', '10:00:00')
|
||||
->call('reschedule')
|
||||
->assertHasErrors(['newDate']);
|
||||
});
|
||||
|
||||
it('generates new ics file on reschedule', function () {
|
||||
Notification::fake();
|
||||
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->approved()->create();
|
||||
|
||||
$newDate = now()->addDays(5)->format('Y-m-d');
|
||||
$newTime = '14:00:00';
|
||||
|
||||
$this->mock(AvailabilityService::class)
|
||||
->shouldReceive('getAvailableSlots')
|
||||
->andReturn(['14:00:00']);
|
||||
|
||||
$calendarMock = $this->mock(CalendarService::class);
|
||||
$calendarMock->shouldReceive('generateIcs')
|
||||
->once()
|
||||
->andReturn('BEGIN:VCALENDAR...');
|
||||
|
||||
Volt::test('admin.consultations.reschedule', ['consultation' => $consultation])
|
||||
->actingAs($admin)
|
||||
->set('newDate', $newDate)
|
||||
->set('newTime', $newTime)
|
||||
->call('reschedule')
|
||||
->assertHasNoErrors();
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// ADMIN NOTES TESTS
|
||||
// ==========================================
|
||||
|
||||
it('can add admin note to consultation', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->approved()->create();
|
||||
|
||||
Volt::test('admin.consultations.detail', ['consultation' => $consultation])
|
||||
->actingAs($admin)
|
||||
->set('newNote', 'Client requested Arabic documents')
|
||||
->call('addNote')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$notes = $consultation->fresh()->admin_notes;
|
||||
expect($notes)->toHaveCount(1)
|
||||
->and($notes[0]['text'])->toBe('Client requested Arabic documents')
|
||||
->and($notes[0]['admin_id'])->toBe($admin->id);
|
||||
});
|
||||
|
||||
it('can update admin note', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->approved()->create([
|
||||
'admin_notes' => [
|
||||
['text' => 'Original note', 'admin_id' => $admin->id, 'created_at' => now()->toISOString()],
|
||||
],
|
||||
]);
|
||||
|
||||
Volt::test('admin.consultations.detail', ['consultation' => $consultation])
|
||||
->actingAs($admin)
|
||||
->call('updateNote', 0, 'Updated note')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$notes = $consultation->fresh()->admin_notes;
|
||||
expect($notes[0]['text'])->toBe('Updated note')
|
||||
->and($notes[0])->toHaveKey('updated_at');
|
||||
});
|
||||
|
||||
it('can delete admin note', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->approved()->create([
|
||||
'admin_notes' => [
|
||||
['text' => 'Note 1', 'admin_id' => $admin->id, 'created_at' => now()->toISOString()],
|
||||
['text' => 'Note 2', 'admin_id' => $admin->id, 'created_at' => now()->toISOString()],
|
||||
],
|
||||
]);
|
||||
|
||||
Volt::test('admin.consultations.detail', ['consultation' => $consultation])
|
||||
->actingAs($admin)
|
||||
->call('deleteNote', 0)
|
||||
->assertHasNoErrors();
|
||||
|
||||
$notes = $consultation->fresh()->admin_notes;
|
||||
expect($notes)->toHaveCount(1)
|
||||
->and($notes[0]['text'])->toBe('Note 2');
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// AUDIT LOG TESTS
|
||||
// ==========================================
|
||||
|
||||
it('creates audit log entry on status change', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->approved()->create();
|
||||
|
||||
Volt::test('admin.consultations.index')
|
||||
->actingAs($admin)
|
||||
->call('markCompleted', $consultation->id);
|
||||
|
||||
$this->assertDatabaseHas('admin_logs', [
|
||||
'admin_id' => $admin->id,
|
||||
'action_type' => 'status_change',
|
||||
'target_type' => 'consultation',
|
||||
'target_id' => $consultation->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates audit log entry on payment received', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->approved()->create([
|
||||
'type' => 'paid',
|
||||
'payment_status' => 'pending',
|
||||
]);
|
||||
|
||||
Volt::test('admin.consultations.index')
|
||||
->actingAs($admin)
|
||||
->call('markPaymentReceived', $consultation->id);
|
||||
|
||||
$this->assertDatabaseHas('admin_logs', [
|
||||
'admin_id' => $admin->id,
|
||||
'action_type' => 'payment_received',
|
||||
'target_type' => 'consultation',
|
||||
'target_id' => $consultation->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates audit log entry on reschedule', function () {
|
||||
Notification::fake();
|
||||
|
||||
$admin = User::factory()->admin()->create();
|
||||
$consultation = Consultation::factory()->approved()->create();
|
||||
|
||||
$this->mock(AvailabilityService::class)
|
||||
->shouldReceive('getAvailableSlots')
|
||||
->andReturn(['14:00:00']);
|
||||
|
||||
Volt::test('admin.consultations.reschedule', ['consultation' => $consultation])
|
||||
->actingAs($admin)
|
||||
->set('newDate', now()->addDays(5)->format('Y-m-d'))
|
||||
->set('newTime', '14:00:00')
|
||||
->call('reschedule');
|
||||
|
||||
$this->assertDatabaseHas('admin_logs', [
|
||||
'admin_id' => $admin->id,
|
||||
'action_type' => 'reschedule',
|
||||
'target_type' => 'consultation',
|
||||
'target_id' => $consultation->id,
|
||||
]);
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// CLIENT HISTORY TESTS
|
||||
// ==========================================
|
||||
|
||||
it('displays consultation history for specific client', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$client = User::factory()->create();
|
||||
|
||||
$consultations = Consultation::factory()->count(3)->for($client)->create();
|
||||
|
||||
Volt::test('admin.clients.consultation-history', ['user' => $client])
|
||||
->actingAs($admin)
|
||||
->assertSee($consultations->first()->scheduled_date->format('Y-m-d'))
|
||||
->assertStatus(200);
|
||||
});
|
||||
|
||||
it('shows summary statistics for client', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
$client = User::factory()->create();
|
||||
|
||||
Consultation::factory()->for($client)->completed()->count(2)->create();
|
||||
Consultation::factory()->for($client)->cancelled()->create();
|
||||
Consultation::factory()->for($client)->create(['status' => 'no_show']);
|
||||
|
||||
Volt::test('admin.clients.consultation-history', ['user' => $client])
|
||||
->actingAs($admin)
|
||||
->assertSee('2') // completed count
|
||||
->assertSee('1'); // no-show count
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// BILINGUAL TESTS
|
||||
// ==========================================
|
||||
|
||||
it('displays labels in Arabic when locale is ar', function () {
|
||||
$admin = User::factory()->admin()->create(['preferred_language' => 'ar']);
|
||||
$consultation = Consultation::factory()->approved()->create();
|
||||
|
||||
app()->setLocale('ar');
|
||||
|
||||
Volt::test('admin.consultations.index')
|
||||
->actingAs($admin)
|
||||
->assertSee(__('admin.consultations'));
|
||||
});
|
||||
|
||||
it('sends cancellation notification in client preferred language', function () {
|
||||
Notification::fake();
|
||||
|
||||
$admin = User::factory()->admin()->create();
|
||||
$arabicUser = User::factory()->create(['preferred_language' => 'ar']);
|
||||
$consultation = Consultation::factory()->approved()->for($arabicUser)->create();
|
||||
|
||||
Volt::test('admin.consultations.index')
|
||||
->actingAs($admin)
|
||||
->call('cancel', $consultation->id);
|
||||
|
||||
Notification::assertSentTo($arabicUser, ConsultationCancelled::class, function ($notification) {
|
||||
return $notification->consultation->user->preferred_language === 'ar';
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Definition of Done
|
||||
@@ -368,9 +1009,10 @@ public function addNote(string $note): void
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Story 3.5:** Booking approval
|
||||
- **Story 3.6:** Calendar file generation
|
||||
- **Epic 8:** Email notifications
|
||||
- **Story 3.5:** `docs/stories/story-3.5-admin-booking-review-approval.md` - Creates approved consultations to manage; provides AdminLog pattern
|
||||
- **Story 3.6:** `docs/stories/story-3.6-calendar-file-generation.md` - CalendarService for .ics generation on reschedule
|
||||
- **Story 3.3:** `docs/stories/story-3.3-availability-calendar-display.md` - AvailabilityService for slot validation on reschedule
|
||||
- **Epic 8:** `docs/epics/epic-8-email-notifications.md` - ConsultationCancelled and ConsultationRescheduled notifications
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
|
||||
Reference in New Issue
Block a user