complete story 11.2 with qa tests
This commit is contained in:
parent
f32ea2b68d
commit
06ece9f4b2
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Consultation;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class GuestBookingSubmittedMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(public Consultation $consultation)
|
||||
{
|
||||
$this->locale = session('locale', 'ar');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$locale = session('locale', 'ar');
|
||||
|
||||
return new Envelope(
|
||||
subject: $locale === 'ar'
|
||||
? 'تم استلام طلب الاستشارة'
|
||||
: 'Your Consultation Request Has Been Submitted',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
$locale = session('locale', 'ar');
|
||||
|
||||
return new Content(
|
||||
markdown: 'emails.booking.guest-submitted.'.$locale,
|
||||
with: [
|
||||
'consultation' => $this->consultation,
|
||||
'guestName' => $this->consultation->guest_name,
|
||||
'summaryPreview' => $this->getSummaryPreview(),
|
||||
'formattedDate' => $this->getFormattedDate($locale),
|
||||
'formattedTime' => $this->getFormattedTime(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get truncated summary preview (max 200 characters).
|
||||
*/
|
||||
public function getSummaryPreview(): string
|
||||
{
|
||||
$summary = $this->consultation->problem_summary ?? '';
|
||||
|
||||
return strlen($summary) > 200
|
||||
? substr($summary, 0, 200).'...'
|
||||
: $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted date based on locale.
|
||||
*/
|
||||
public function getFormattedDate(string $locale): string
|
||||
{
|
||||
$date = $this->consultation->booking_date;
|
||||
|
||||
return $locale === 'ar'
|
||||
? $date->format('d/m/Y')
|
||||
: $date->format('m/d/Y');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted time.
|
||||
*/
|
||||
public function getFormattedTime(): string
|
||||
{
|
||||
$time = $this->consultation->booking_time;
|
||||
|
||||
return Carbon::parse($time)->format('h:i A');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
class CaptchaService
|
||||
{
|
||||
private const SESSION_KEY = 'captcha_answer';
|
||||
|
||||
/**
|
||||
* Generate a new math captcha question.
|
||||
*
|
||||
* @return array{question: string, question_ar: string}
|
||||
*/
|
||||
public function generate(): array
|
||||
{
|
||||
$num1 = rand(1, 10);
|
||||
$num2 = rand(1, 10);
|
||||
$answer = $num1 + $num2;
|
||||
|
||||
session([self::SESSION_KEY => $answer]);
|
||||
|
||||
return [
|
||||
'question' => "What is {$num1} + {$num2}?",
|
||||
'question_ar' => "ما هو {$num1} + {$num2}؟",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the user's captcha answer.
|
||||
*/
|
||||
public function validate(mixed $answer): bool
|
||||
{
|
||||
$expected = session(self::SESSION_KEY);
|
||||
|
||||
if (is_null($expected)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $answer === (int) $expected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current captcha from session.
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
session()->forget(self::SESSION_KEY);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
schema: 1
|
||||
story: "11.2"
|
||||
story_title: "Public Booking Form with Custom Captcha"
|
||||
gate: PASS
|
||||
status_reason: "All 17 acceptance criteria met with comprehensive test coverage (16 tests, 52 assertions). Strong security implementation with race condition handling and spam protection."
|
||||
reviewer: "Quinn (Test Architect)"
|
||||
updated: "2026-01-03T00:00:00Z"
|
||||
|
||||
waiver: { active: false }
|
||||
|
||||
top_issues: []
|
||||
|
||||
quality_score: 100
|
||||
|
||||
evidence:
|
||||
tests_reviewed: 16
|
||||
assertions: 52
|
||||
risks_identified: 0
|
||||
trace:
|
||||
ac_covered: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]
|
||||
ac_gaps: []
|
||||
|
||||
nfr_validation:
|
||||
security:
|
||||
status: PASS
|
||||
notes: "Math captcha + IP rate limiting (5/24h) + lockForUpdate() for race conditions + proper input validation"
|
||||
performance:
|
||||
status: PASS
|
||||
notes: "Queued emails, efficient single-insert transactions, no N+1 queries"
|
||||
reliability:
|
||||
status: PASS
|
||||
notes: "Multi-layer validation, graceful error handling, session-based captcha with refresh capability"
|
||||
maintainability:
|
||||
status: PASS
|
||||
notes: "Clean CaptchaService encapsulation, class-based Volt pattern, proper translation usage"
|
||||
|
||||
recommendations:
|
||||
immediate: []
|
||||
future:
|
||||
- action: "Consider phone validation regex for stricter format enforcement"
|
||||
refs: ["resources/views/livewire/pages/booking.blade.php:76"]
|
||||
- action: "Add ARIA labels to captcha for accessibility compliance"
|
||||
refs: ["resources/views/livewire/pages/booking.blade.php:269-280"]
|
||||
- action: "Consider logging failed booking attempts for security monitoring"
|
||||
refs: ["app/Services/CaptchaService.php"]
|
||||
|
||||
files_reviewed:
|
||||
- app/Services/CaptchaService.php
|
||||
- app/Mail/GuestBookingSubmittedMail.php
|
||||
- app/Mail/NewBookingAdminEmail.php
|
||||
- resources/views/livewire/pages/booking.blade.php
|
||||
- resources/views/livewire/pages/booking-success.blade.php
|
||||
- resources/views/emails/booking/guest-submitted/en.blade.php
|
||||
- resources/views/emails/booking/guest-submitted/ar.blade.php
|
||||
- routes/web.php
|
||||
- lang/en/booking.php
|
||||
- lang/ar/booking.php
|
||||
- tests/Feature/Public/GuestBookingTest.php
|
||||
|
|
@ -666,14 +666,148 @@ test('slot taken during submission shows error', function () {
|
|||
**Note:** The mailable classes used in this story (`GuestBookingSubmittedMail`, `NewBookingAdminEmail`) are created in Story 11.3. During implementation, either implement Story 11.3 first or create stub mailable classes temporarily.
|
||||
|
||||
## Definition of Done
|
||||
- [ ] Guest booking form functional at `/booking`
|
||||
- [ ] Logged-in users redirected to client booking
|
||||
- [ ] Availability calendar shows correct slots
|
||||
- [ ] Contact form validates all fields
|
||||
- [ ] Custom captcha prevents automated submissions
|
||||
- [ ] 1-per-day limit enforced by email
|
||||
- [ ] IP rate limiting working
|
||||
- [ ] Success page displays after submission
|
||||
- [ ] All translations in place (Arabic/English)
|
||||
- [ ] Mobile responsive
|
||||
- [ ] All tests pass
|
||||
- [x] Guest booking form functional at `/booking`
|
||||
- [x] Logged-in users redirected to client booking
|
||||
- [x] Availability calendar shows correct slots
|
||||
- [x] Contact form validates all fields
|
||||
- [x] Custom captcha prevents automated submissions
|
||||
- [x] 1-per-day limit enforced by email
|
||||
- [x] IP rate limiting working
|
||||
- [x] Success page displays after submission
|
||||
- [x] All translations in place (Arabic/English)
|
||||
- [x] Mobile responsive
|
||||
- [x] All tests pass
|
||||
|
||||
---
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### Completion Notes
|
||||
- Created `CaptchaService` for math-based captcha generation and validation
|
||||
- Created `GuestBookingSubmittedMail` mailable with bilingual email templates
|
||||
- Created guest booking Volt component at `pages/booking.blade.php` with Layout attribute pattern
|
||||
- Created success page at `pages/booking-success.blade.php`
|
||||
- Updated routes to use Volt routes for `/booking` and `/booking/success`
|
||||
- Added all required translation keys for both English and Arabic
|
||||
- Implemented 16 feature tests covering all acceptance criteria
|
||||
- All tests pass (16 tests, 52 assertions)
|
||||
|
||||
### File List
|
||||
**Created:**
|
||||
- `app/Services/CaptchaService.php`
|
||||
- `app/Mail/GuestBookingSubmittedMail.php`
|
||||
- `resources/views/emails/booking/guest-submitted/en.blade.php`
|
||||
- `resources/views/emails/booking/guest-submitted/ar.blade.php`
|
||||
- `resources/views/livewire/pages/booking.blade.php`
|
||||
- `resources/views/livewire/pages/booking-success.blade.php`
|
||||
- `tests/Feature/Public/GuestBookingTest.php`
|
||||
|
||||
**Modified:**
|
||||
- `routes/web.php` - Updated booking routes to use Volt
|
||||
- `lang/en/booking.php` - Added guest booking translations
|
||||
- `lang/ar/booking.php` - Added guest booking translations
|
||||
|
||||
### Change Log
|
||||
| Date | Change | Reason |
|
||||
|------|--------|--------|
|
||||
| 2026-01-03 | Initial implementation | Story 11.2 development |
|
||||
|
||||
---
|
||||
|
||||
## QA Results
|
||||
|
||||
### Review Date: 2026-01-03
|
||||
|
||||
### Reviewed By: Quinn (Test Architect)
|
||||
|
||||
### Code Quality Assessment
|
||||
|
||||
**Overall: Strong Implementation** - The guest booking form implementation demonstrates solid engineering practices with proper attention to security, race condition handling, and user experience. The code follows Laravel/Livewire best practices and the project's coding standards.
|
||||
|
||||
**Strengths Identified:**
|
||||
1. **Robust Concurrency Control**: The use of `lockForUpdate()` in database transactions prevents race conditions for both slot booking and 1-per-day email limit - excellent defensive coding
|
||||
2. **Comprehensive Validation**: Multi-layer validation at both `showConfirm()` and `submit()` stages provides proper user feedback and data integrity
|
||||
3. **Clean Architecture**: CaptchaService is well-encapsulated with single responsibility
|
||||
4. **Proper Rate Limiting**: IP-based rate limiting (5 attempts/24h) provides spam protection
|
||||
5. **Bilingual Support**: All user-facing strings use translation helpers, email templates available in both languages
|
||||
6. **Test Coverage**: 16 comprehensive tests covering all acceptance criteria and edge cases
|
||||
|
||||
### Requirements Traceability
|
||||
|
||||
| AC # | Acceptance Criteria | Test Coverage | Status |
|
||||
|------|---------------------|---------------|--------|
|
||||
| 1 | `/booking` route displays guest booking form | `guest can view booking page` | ✓ |
|
||||
| 2 | Logged-in users redirected to `/client/consultations/book` | `logged in user is redirected to client booking` | ✓ |
|
||||
| 3 | Page uses public layout | Uses `#[Layout('components.layouts.public')]` | ✓ |
|
||||
| 4 | Bilingual support (Arabic/English) | Translation files verified | ✓ |
|
||||
| 5 | Mobile responsive design | Flux UI + min-h-[44px] touch targets | ✓ |
|
||||
| 6 | Reuses existing availability-calendar component | `<livewire:availability-calendar />` used | ✓ |
|
||||
| 7 | Contact form validates all fields | `form validation requires all fields` | ✓ |
|
||||
| 8 | Name min 3 chars | `guest name must be at least 3 characters` | ✓ |
|
||||
| 9 | Problem summary min 20 chars | `problem summary must be at least 20 characters` | ✓ |
|
||||
| 10 | Custom captcha (math-based) | CaptchaService with session storage | ✓ |
|
||||
| 11 | Captcha refresh button | `refreshCaptcha()` method, `guest can refresh captcha` test | ✓ |
|
||||
| 12 | Captcha validation | `invalid captcha prevents submission` | ✓ |
|
||||
| 13 | 1-per-day limit by email | `guest cannot book twice on same day` | ✓ |
|
||||
| 14 | Rate limiting by IP | `rate limiting prevents excessive booking attempts` | ✓ |
|
||||
| 15 | Multi-step submission flow | selectSlot → showConfirm → submit flow verified | ✓ |
|
||||
| 16 | Success page with instructions | `success page is accessible after booking` | ✓ |
|
||||
| 17 | Slot concurrency protection | `slot taken during submission shows error` | ✓ |
|
||||
|
||||
### Compliance Check
|
||||
|
||||
- Coding Standards: ✓ Class-based Volt component with Layout attribute, Flux UI components used, Model::query() pattern followed
|
||||
- Project Structure: ✓ Files in correct locations per story specification
|
||||
- Testing Strategy: ✓ 16 Pest tests with Volt::test() pattern, Mail::fake() and RateLimiter::clear() used properly
|
||||
- All ACs Met: ✓ All 17 acceptance criteria items have corresponding implementation and tests
|
||||
|
||||
### Improvements Checklist
|
||||
|
||||
All items below are advisory recommendations - none are blocking issues:
|
||||
|
||||
- [x] Rate limiting implemented correctly (5 attempts/24h)
|
||||
- [x] Captcha service encapsulated properly
|
||||
- [x] Transaction with locks for race condition prevention
|
||||
- [x] Email notifications queued properly
|
||||
- [ ] **Consider** adding phone validation regex (currently max:50 only) - low priority enhancement
|
||||
- [ ] **Consider** adding ARIA labels to captcha for accessibility - enhancement for future accessibility audit
|
||||
- [ ] **Consider** logging failed booking attempts for security monitoring - future enhancement
|
||||
|
||||
### Security Review
|
||||
|
||||
**Status: PASS**
|
||||
|
||||
1. **Spam Protection**: Math captcha + IP rate limiting provides adequate protection for public form
|
||||
2. **Race Conditions**: Properly handled with `lockForUpdate()` in transactions
|
||||
3. **Input Validation**: All inputs validated with appropriate rules
|
||||
4. **XSS Prevention**: Blade templating with {{ }} escaping
|
||||
5. **Email Injection**: Using Laravel Mail facade with proper email validation
|
||||
6. **No sensitive data exposure**: Guest phone/email properly stored, no PII in URLs
|
||||
|
||||
**Minor Note**: The captcha uses simple addition (1-10 + 1-10). While sufficient for basic spam prevention, sophisticated bots could solve this. For a legal firm's booking system, this is acceptable given the rate limiting backup.
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
**Status: PASS**
|
||||
|
||||
1. **Database Queries**: Efficient with proper indexes assumed on `guest_email`, `booking_date`, `booking_time`
|
||||
2. **Email Sending**: Queued (`implements ShouldQueue`) - no blocking requests
|
||||
3. **Session Storage**: Captcha stored in session (minimal overhead)
|
||||
4. **No N+1**: Single consultation insert with direct attribute assignment
|
||||
|
||||
### Files Modified During Review
|
||||
|
||||
No files were modified during this review. Implementation is clean and follows standards.
|
||||
|
||||
### Gate Status
|
||||
|
||||
**Gate: PASS** → `docs/qa/gates/11.2-public-booking-form.yml`
|
||||
|
||||
### Recommended Status
|
||||
|
||||
**✓ Ready for Done**
|
||||
|
||||
The story implementation is complete, well-tested, and meets all acceptance criteria. The code demonstrates strong security practices with proper race condition handling and spam protection. All 16 tests pass with 52 assertions.
|
||||
|
|
|
|||
|
|
@ -64,4 +64,16 @@ return [
|
|||
'pending_count' => 'لديك :count طلبات معلقة',
|
||||
'limit_message' => 'ملاحظة: يمكنك حجز استشارة واحدة كحد أقصى في اليوم.',
|
||||
'user_booked' => 'حجزك',
|
||||
|
||||
// Guest booking
|
||||
'guest_intro' => 'اطلب موعد استشارة. لا حاجة لإنشاء حساب - ما عليك سوى ملء بياناتك أدناه.',
|
||||
'guest_name' => 'الاسم الكامل',
|
||||
'guest_email' => 'البريد الإلكتروني',
|
||||
'guest_phone' => 'رقم الهاتف',
|
||||
'guest_already_booked_this_day' => 'هذا البريد الإلكتروني لديه طلب حجز بالفعل للتاريخ المحدد. يرجى اختيار تاريخ آخر.',
|
||||
'guest_submitted_successfully' => 'تم تقديم طلب الحجز الخاص بك. يرجى التحقق من بريدك الإلكتروني للتأكيد.',
|
||||
'invalid_captcha' => 'إجابة خاطئة. يرجى المحاولة مرة أخرى.',
|
||||
'too_many_attempts' => 'محاولات حجز كثيرة جداً. يرجى المحاولة لاحقاً.',
|
||||
'success_title' => 'تم تقديم طلب الحجز!',
|
||||
'success_message' => 'لقد تلقينا طلب الاستشارة الخاص بك. ستتلقى رسالة تأكيد عبر البريد الإلكتروني قريباً. سيقوم فريقنا بمراجعة طلبك والتواصل معك.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -64,4 +64,16 @@ return [
|
|||
'pending_count' => 'You have :count pending requests',
|
||||
'limit_message' => 'Note: You can book a maximum of 1 consultation per day.',
|
||||
'user_booked' => 'Your booking',
|
||||
|
||||
// Guest booking
|
||||
'guest_intro' => 'Request a consultation appointment. No account required - simply fill in your details below.',
|
||||
'guest_name' => 'Full Name',
|
||||
'guest_email' => 'Email Address',
|
||||
'guest_phone' => 'Phone Number',
|
||||
'guest_already_booked_this_day' => 'This email already has a booking request for the selected date. Please choose a different date.',
|
||||
'guest_submitted_successfully' => 'Your booking request has been submitted. Please check your email for confirmation.',
|
||||
'invalid_captcha' => 'Incorrect answer. Please try again.',
|
||||
'too_many_attempts' => 'Too many booking attempts. Please try again later.',
|
||||
'success_title' => 'Booking Request Submitted!',
|
||||
'success_message' => 'We have received your consultation request. You will receive an email confirmation shortly. Our team will review your request and contact you.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<x-mail::message>
|
||||
<div dir="rtl" style="text-align: right;">
|
||||
# تم استلام طلب الاستشارة
|
||||
|
||||
عزيزي {{ $guestName }}،
|
||||
|
||||
تم استلام طلب الاستشارة الخاص بك بنجاح. سنقوم بمراجعة طلبك والرد عليك في أقرب وقت.
|
||||
|
||||
**تفاصيل الموعد:**
|
||||
|
||||
- **التاريخ:** {{ $formattedDate }}
|
||||
- **الوقت:** {{ $formattedTime }}
|
||||
|
||||
@if($summaryPreview)
|
||||
**ملخص المشكلة:**
|
||||
|
||||
{{ $summaryPreview }}
|
||||
@endif
|
||||
|
||||
<x-mail::panel>
|
||||
**الحالة:** قيد المراجعة
|
||||
|
||||
سنقوم بمراجعة طلبك والرد عليك خلال 1-2 أيام عمل.
|
||||
</x-mail::panel>
|
||||
|
||||
إذا كان لديك أي استفسار، لا تتردد في التواصل معنا.
|
||||
|
||||
مع أطيب التحيات،<br>
|
||||
{{ config('app.name') }}
|
||||
</div>
|
||||
</x-mail::message>
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<x-mail::message>
|
||||
# Your Consultation Request Has Been Submitted
|
||||
|
||||
Dear {{ $guestName }},
|
||||
|
||||
Your consultation request has been successfully submitted. We will review your request and get back to you as soon as possible.
|
||||
|
||||
**Appointment Details:**
|
||||
|
||||
- **Date:** {{ $formattedDate }}
|
||||
- **Time:** {{ $formattedTime }}
|
||||
|
||||
@if($summaryPreview)
|
||||
**Problem Summary:**
|
||||
|
||||
{{ $summaryPreview }}
|
||||
@endif
|
||||
|
||||
<x-mail::panel>
|
||||
**Status:** Pending Review
|
||||
|
||||
We will review your request and respond within 1-2 business days.
|
||||
</x-mail::panel>
|
||||
|
||||
If you have any questions, please don't hesitate to contact us.
|
||||
|
||||
Regards,<br>
|
||||
{{ config('app.name') }}
|
||||
</x-mail::message>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new #[Layout('components.layouts.public')] class extends Component
|
||||
{
|
||||
//
|
||||
}; ?>
|
||||
|
||||
<div class="max-w-2xl mx-auto py-16 px-4 text-center">
|
||||
<flux:icon name="check-circle" class="w-16 h-16 mx-auto text-green-500 mb-6" />
|
||||
|
||||
<flux:heading size="xl" class="mb-4">
|
||||
{{ __('booking.success_title') }}
|
||||
</flux:heading>
|
||||
|
||||
<p class="text-zinc-600 dark:text-zinc-400 mb-6">
|
||||
{{ __('booking.success_message') }}
|
||||
</p>
|
||||
|
||||
<flux:button href="{{ route('home') }}">
|
||||
{{ __('navigation.home') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,336 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ConsultationStatus;
|
||||
use App\Enums\PaymentStatus;
|
||||
use App\Mail\GuestBookingSubmittedMail;
|
||||
use App\Mail\NewBookingAdminEmail;
|
||||
use App\Models\Consultation;
|
||||
use App\Models\User;
|
||||
use App\Services\AvailabilityService;
|
||||
use App\Services\CaptchaService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new #[Layout('components.layouts.public')] class extends Component
|
||||
{
|
||||
public ?string $selectedDate = null;
|
||||
|
||||
public ?string $selectedTime = null;
|
||||
|
||||
public string $guestName = '';
|
||||
|
||||
public string $guestEmail = '';
|
||||
|
||||
public string $guestPhone = '';
|
||||
|
||||
public string $problemSummary = '';
|
||||
|
||||
public string $captchaAnswer = '';
|
||||
|
||||
public array $captchaQuestion = [];
|
||||
|
||||
public bool $showConfirmation = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
// Redirect logged-in users to client booking
|
||||
if (auth()->check()) {
|
||||
$this->redirect(route('client.consultations.book'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->refreshCaptcha();
|
||||
}
|
||||
|
||||
public function refreshCaptcha(): void
|
||||
{
|
||||
$this->captchaQuestion = app(CaptchaService::class)->generate();
|
||||
$this->captchaAnswer = '';
|
||||
}
|
||||
|
||||
public function selectSlot(string $date, string $time): void
|
||||
{
|
||||
$this->selectedDate = $date;
|
||||
$this->selectedTime = $time;
|
||||
}
|
||||
|
||||
public function clearSelection(): void
|
||||
{
|
||||
$this->selectedDate = null;
|
||||
$this->selectedTime = null;
|
||||
$this->showConfirmation = false;
|
||||
}
|
||||
|
||||
public function showConfirm(): void
|
||||
{
|
||||
$this->validate([
|
||||
'selectedDate' => ['required', 'date', 'after_or_equal:today'],
|
||||
'selectedTime' => ['required'],
|
||||
'guestName' => ['required', 'string', 'min:3', 'max:255'],
|
||||
'guestEmail' => ['required', 'email', 'max:255'],
|
||||
'guestPhone' => ['required', 'string', 'max:50'],
|
||||
'problemSummary' => ['required', 'string', 'min:20', 'max:2000'],
|
||||
'captchaAnswer' => ['required'],
|
||||
]);
|
||||
|
||||
// Validate captcha
|
||||
if (! app(CaptchaService::class)->validate($this->captchaAnswer)) {
|
||||
$this->addError('captchaAnswer', __('booking.invalid_captcha'));
|
||||
$this->refreshCaptcha();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check 1-per-day limit for this email
|
||||
$existingBooking = Consultation::query()
|
||||
->where('guest_email', $this->guestEmail)
|
||||
->whereDate('booking_date', $this->selectedDate)
|
||||
->whereIn('status', [ConsultationStatus::Pending, ConsultationStatus::Approved])
|
||||
->exists();
|
||||
|
||||
if ($existingBooking) {
|
||||
$this->addError('guestEmail', __('booking.guest_already_booked_this_day'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify slot still available
|
||||
$service = app(AvailabilityService::class);
|
||||
$availableSlots = $service->getAvailableSlots(Carbon::parse($this->selectedDate));
|
||||
|
||||
if (! in_array($this->selectedTime, $availableSlots)) {
|
||||
$this->addError('selectedTime', __('booking.slot_no_longer_available'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->showConfirmation = true;
|
||||
}
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
// Rate limiting by IP
|
||||
$ipKey = 'guest-booking:'.request()->ip();
|
||||
if (RateLimiter::tooManyAttempts($ipKey, 5)) {
|
||||
$this->addError('guestEmail', __('booking.too_many_attempts'));
|
||||
|
||||
return;
|
||||
}
|
||||
RateLimiter::hit($ipKey, 60 * 60 * 24); // 24 hours
|
||||
|
||||
try {
|
||||
DB::transaction(function () {
|
||||
// Double-check slot availability with lock
|
||||
$slotTaken = Consultation::query()
|
||||
->whereDate('booking_date', $this->selectedDate)
|
||||
->where('booking_time', $this->selectedTime)
|
||||
->whereIn('status', [ConsultationStatus::Pending, ConsultationStatus::Approved])
|
||||
->lockForUpdate()
|
||||
->exists();
|
||||
|
||||
if ($slotTaken) {
|
||||
throw new \Exception(__('booking.slot_taken'));
|
||||
}
|
||||
|
||||
// Double-check 1-per-day with lock
|
||||
$emailHasBooking = Consultation::query()
|
||||
->where('guest_email', $this->guestEmail)
|
||||
->whereDate('booking_date', $this->selectedDate)
|
||||
->whereIn('status', [ConsultationStatus::Pending, ConsultationStatus::Approved])
|
||||
->lockForUpdate()
|
||||
->exists();
|
||||
|
||||
if ($emailHasBooking) {
|
||||
throw new \Exception(__('booking.guest_already_booked_this_day'));
|
||||
}
|
||||
|
||||
// Create guest consultation
|
||||
$consultation = Consultation::create([
|
||||
'user_id' => null,
|
||||
'guest_name' => $this->guestName,
|
||||
'guest_email' => $this->guestEmail,
|
||||
'guest_phone' => $this->guestPhone,
|
||||
'booking_date' => $this->selectedDate,
|
||||
'booking_time' => $this->selectedTime,
|
||||
'problem_summary' => $this->problemSummary,
|
||||
'status' => ConsultationStatus::Pending,
|
||||
'payment_status' => PaymentStatus::NotApplicable,
|
||||
]);
|
||||
|
||||
// Send confirmation to guest
|
||||
Mail::to($this->guestEmail)->queue(
|
||||
new GuestBookingSubmittedMail($consultation)
|
||||
);
|
||||
|
||||
// Notify admin
|
||||
$admin = User::query()->where('user_type', 'admin')->first();
|
||||
if ($admin) {
|
||||
Mail::to($admin)->queue(
|
||||
new NewBookingAdminEmail($consultation)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear captcha
|
||||
app(CaptchaService::class)->clear();
|
||||
|
||||
session()->flash('success', __('booking.guest_submitted_successfully'));
|
||||
$this->redirect(route('booking.success'));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('selectedTime', $e->getMessage());
|
||||
$this->showConfirmation = false;
|
||||
$this->refreshCaptcha();
|
||||
}
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="max-w-4xl mx-auto py-8 px-4">
|
||||
<flux:heading size="xl" class="mb-6">
|
||||
{{ __('booking.request_consultation') }}
|
||||
</flux:heading>
|
||||
|
||||
@if(session('success'))
|
||||
<flux:callout variant="success" class="mb-6">
|
||||
{{ session('success') }}
|
||||
</flux:callout>
|
||||
@endif
|
||||
|
||||
@if(!$selectedDate || !$selectedTime)
|
||||
{{-- Step 1: Calendar Selection --}}
|
||||
<flux:callout class="mb-6">
|
||||
<p>{{ __('booking.guest_intro') }}</p>
|
||||
</flux:callout>
|
||||
|
||||
<p class="mb-4 text-zinc-600 dark:text-zinc-400">
|
||||
{{ __('booking.select_date_time') }}
|
||||
</p>
|
||||
|
||||
<livewire:availability-calendar />
|
||||
@else
|
||||
{{-- Step 2+: Contact Form & Confirmation --}}
|
||||
<div class="bg-amber-50 dark:bg-amber-900/20 p-4 rounded-lg mb-6 border border-amber-200 dark:border-amber-800">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<p class="font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{{ __('booking.selected_time') }}
|
||||
</p>
|
||||
<p class="text-zinc-600 dark:text-zinc-400">
|
||||
{{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('l, d M Y') }}
|
||||
</p>
|
||||
<p class="text-zinc-600 dark:text-zinc-400">
|
||||
{{ \Carbon\Carbon::parse($selectedTime)->format('g:i A') }}
|
||||
</p>
|
||||
</div>
|
||||
<flux:button size="sm" wire:click="clearSelection">
|
||||
{{ __('common.change') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(!$showConfirmation)
|
||||
{{-- Contact Form --}}
|
||||
<div class="space-y-4">
|
||||
<flux:field>
|
||||
<flux:label class="required">{{ __('booking.guest_name') }}</flux:label>
|
||||
<flux:input wire:model="guestName" type="text" />
|
||||
<flux:error name="guestName" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label class="required">{{ __('booking.guest_email') }}</flux:label>
|
||||
<flux:input wire:model="guestEmail" type="email" />
|
||||
<flux:error name="guestEmail" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label class="required">{{ __('booking.guest_phone') }}</flux:label>
|
||||
<flux:input wire:model="guestPhone" type="tel" />
|
||||
<flux:error name="guestPhone" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label class="required">{{ __('booking.problem_summary') }}</flux:label>
|
||||
<flux:textarea
|
||||
wire:model="problemSummary"
|
||||
rows="6"
|
||||
placeholder="{{ __('booking.problem_summary_placeholder') }}"
|
||||
/>
|
||||
<flux:description>{{ __('booking.problem_summary_help') }}</flux:description>
|
||||
<flux:error name="problemSummary" />
|
||||
</flux:field>
|
||||
|
||||
{{-- Custom Captcha --}}
|
||||
<flux:field>
|
||||
<flux:label class="required">
|
||||
{{ app()->getLocale() === 'ar' ? $captchaQuestion['question_ar'] : $captchaQuestion['question'] }}
|
||||
</flux:label>
|
||||
<div class="flex gap-2">
|
||||
<flux:input wire:model="captchaAnswer" type="text" class="w-32" />
|
||||
<flux:button size="sm" wire:click="refreshCaptcha" type="button">
|
||||
<flux:icon name="arrow-path" class="w-4 h-4" />
|
||||
</flux:button>
|
||||
</div>
|
||||
<flux:error name="captchaAnswer" />
|
||||
</flux:field>
|
||||
|
||||
<flux:button
|
||||
wire:click="showConfirm"
|
||||
class="w-full sm:w-auto min-h-[44px]"
|
||||
wire:loading.attr="disabled"
|
||||
>
|
||||
<span wire:loading.remove wire:target="showConfirm">{{ __('booking.continue') }}</span>
|
||||
<span wire:loading wire:target="showConfirm">{{ __('common.loading') }}</span>
|
||||
</flux:button>
|
||||
</div>
|
||||
@else
|
||||
{{-- Confirmation Step --}}
|
||||
<flux:callout>
|
||||
<flux:heading size="sm">{{ __('booking.confirm_booking') }}</flux:heading>
|
||||
<p class="text-zinc-600 dark:text-zinc-400">{{ __('booking.confirm_message') }}</p>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<p><strong>{{ __('booking.guest_name') }}:</strong> {{ $guestName }}</p>
|
||||
<p><strong>{{ __('booking.guest_email') }}:</strong> {{ $guestEmail }}</p>
|
||||
<p><strong>{{ __('booking.guest_phone') }}:</strong> {{ $guestPhone }}</p>
|
||||
<p><strong>{{ __('booking.date') }}:</strong>
|
||||
{{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('l, d M Y') }}</p>
|
||||
<p><strong>{{ __('booking.time') }}:</strong>
|
||||
{{ \Carbon\Carbon::parse($selectedTime)->format('g:i A') }}</p>
|
||||
<p><strong>{{ __('booking.duration') }}:</strong> 45 {{ __('common.minutes') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<p><strong>{{ __('booking.problem_summary') }}:</strong></p>
|
||||
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{{ $problemSummary }}</p>
|
||||
</div>
|
||||
</flux:callout>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3 mt-4">
|
||||
<flux:button wire:click="$set('showConfirmation', false)" class="w-full sm:w-auto min-h-[44px]">
|
||||
{{ __('common.back') }}
|
||||
</flux:button>
|
||||
<flux:button
|
||||
wire:click="submit"
|
||||
variant="primary"
|
||||
wire:loading.attr="disabled"
|
||||
class="w-full sm:w-auto min-h-[44px]"
|
||||
>
|
||||
<span wire:loading.remove wire:target="submit">{{ __('booking.submit_request') }}</span>
|
||||
<span wire:loading wire:target="submit">{{ __('common.submitting') }}</span>
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
@error('selectedTime')
|
||||
<flux:callout variant="danger" class="mt-4">
|
||||
{{ $message }}
|
||||
</flux:callout>
|
||||
@enderror
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -11,9 +11,8 @@ Route::get('/', function () {
|
|||
return view('pages.home');
|
||||
})->name('home');
|
||||
|
||||
Route::get('/booking', function () {
|
||||
return view('pages.booking');
|
||||
})->name('booking');
|
||||
Volt::route('/booking', 'pages.booking')->name('booking');
|
||||
Volt::route('/booking/success', 'pages.booking-success')->name('booking.success');
|
||||
|
||||
Volt::route('/posts', 'pages.posts.index')->name('posts.index');
|
||||
Volt::route('/posts/{post}', 'pages.posts.show')->name('posts.show');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,315 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ConsultationStatus;
|
||||
use App\Models\Consultation;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkingHour;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
beforeEach(function () {
|
||||
Mail::fake();
|
||||
RateLimiter::clear('guest-booking:127.0.0.1');
|
||||
});
|
||||
|
||||
test('guest can view booking page', function () {
|
||||
$this->get(route('booking'))
|
||||
->assertOk()
|
||||
->assertSee(__('booking.request_consultation'));
|
||||
});
|
||||
|
||||
test('logged in user is redirected to client booking', function () {
|
||||
$user = User::factory()->client()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('booking'))
|
||||
->assertRedirect(route('client.consultations.book'));
|
||||
});
|
||||
|
||||
test('guest booking page shows calendar', function () {
|
||||
$this->get(route('booking'))
|
||||
->assertOk()
|
||||
->assertSee(__('booking.guest_intro'));
|
||||
});
|
||||
|
||||
test('guest can select a slot and see contact form', function () {
|
||||
WorkingHour::factory()->create([
|
||||
'day_of_week' => now()->addDay()->dayOfWeek,
|
||||
'is_active' => true,
|
||||
'start_time' => '09:00',
|
||||
'end_time' => '17:00',
|
||||
]);
|
||||
|
||||
$date = now()->addDay()->format('Y-m-d');
|
||||
|
||||
Volt::test('pages.booking')
|
||||
->call('selectSlot', $date, '09:00')
|
||||
->assertSet('selectedDate', $date)
|
||||
->assertSet('selectedTime', '09:00')
|
||||
->assertSee(__('booking.guest_name'));
|
||||
});
|
||||
|
||||
test('guest can submit booking request', function () {
|
||||
WorkingHour::factory()->create([
|
||||
'day_of_week' => now()->addDay()->dayOfWeek,
|
||||
'is_active' => true,
|
||||
'start_time' => '09:00',
|
||||
'end_time' => '17:00',
|
||||
]);
|
||||
|
||||
$date = now()->addDay()->format('Y-m-d');
|
||||
|
||||
$component = Volt::test('pages.booking')
|
||||
->call('selectSlot', $date, '09:00')
|
||||
->set('guestName', 'John Doe')
|
||||
->set('guestEmail', 'john@example.com')
|
||||
->set('guestPhone', '+970599123456')
|
||||
->set('problemSummary', 'I need legal advice regarding a contract dispute with my employer.')
|
||||
->set('captchaAnswer', session('captcha_answer'))
|
||||
->call('showConfirm')
|
||||
->assertSet('showConfirmation', true)
|
||||
->call('submit')
|
||||
->assertRedirect(route('booking.success'));
|
||||
|
||||
$this->assertDatabaseHas('consultations', [
|
||||
'guest_email' => 'john@example.com',
|
||||
'guest_name' => 'John Doe',
|
||||
'user_id' => null,
|
||||
'status' => ConsultationStatus::Pending->value,
|
||||
]);
|
||||
});
|
||||
|
||||
test('guest cannot book twice on same day', function () {
|
||||
$date = now()->addDay()->format('Y-m-d');
|
||||
|
||||
WorkingHour::factory()->create([
|
||||
'day_of_week' => now()->addDay()->dayOfWeek,
|
||||
'is_active' => true,
|
||||
'start_time' => '09:00',
|
||||
'end_time' => '17:00',
|
||||
]);
|
||||
|
||||
// Create existing booking for this email
|
||||
Consultation::factory()->guest()->create([
|
||||
'guest_email' => 'john@example.com',
|
||||
'booking_date' => $date,
|
||||
'status' => ConsultationStatus::Pending,
|
||||
]);
|
||||
|
||||
Volt::test('pages.booking')
|
||||
->call('selectSlot', $date, '10:00')
|
||||
->set('guestName', 'John Doe')
|
||||
->set('guestEmail', 'john@example.com')
|
||||
->set('guestPhone', '+970599123456')
|
||||
->set('problemSummary', 'Another consultation request for testing purposes.')
|
||||
->set('captchaAnswer', session('captcha_answer'))
|
||||
->call('showConfirm')
|
||||
->assertHasErrors(['guestEmail']);
|
||||
});
|
||||
|
||||
test('invalid captcha prevents submission', function () {
|
||||
WorkingHour::factory()->create([
|
||||
'day_of_week' => now()->addDay()->dayOfWeek,
|
||||
'is_active' => true,
|
||||
'start_time' => '09:00',
|
||||
'end_time' => '17:00',
|
||||
]);
|
||||
|
||||
Volt::test('pages.booking')
|
||||
->call('selectSlot', now()->addDay()->format('Y-m-d'), '09:00')
|
||||
->set('guestName', 'John Doe')
|
||||
->set('guestEmail', 'john@example.com')
|
||||
->set('guestPhone', '+970599123456')
|
||||
->set('problemSummary', 'I need legal advice regarding a contract dispute.')
|
||||
->set('captchaAnswer', 'wrong-answer')
|
||||
->call('showConfirm')
|
||||
->assertHasErrors(['captchaAnswer']);
|
||||
});
|
||||
|
||||
test('rate limiting prevents excessive booking attempts', function () {
|
||||
$ipKey = 'guest-booking:127.0.0.1';
|
||||
|
||||
// Exhaust the rate limit (5 attempts)
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
RateLimiter::hit($ipKey, 60 * 60 * 24);
|
||||
}
|
||||
|
||||
WorkingHour::factory()->create([
|
||||
'day_of_week' => now()->addDay()->dayOfWeek,
|
||||
'is_active' => true,
|
||||
'start_time' => '09:00',
|
||||
'end_time' => '17:00',
|
||||
]);
|
||||
|
||||
$date = now()->addDay()->format('Y-m-d');
|
||||
|
||||
Volt::test('pages.booking')
|
||||
->call('selectSlot', $date, '09:00')
|
||||
->set('guestName', 'John Doe')
|
||||
->set('guestEmail', 'john@example.com')
|
||||
->set('guestPhone', '+970599123456')
|
||||
->set('problemSummary', 'I need legal advice regarding a contract dispute with my employer.')
|
||||
->set('captchaAnswer', session('captcha_answer'))
|
||||
->call('showConfirm')
|
||||
->call('submit')
|
||||
->assertHasErrors(['guestEmail']);
|
||||
|
||||
RateLimiter::clear($ipKey);
|
||||
});
|
||||
|
||||
test('slot taken during submission shows error', function () {
|
||||
WorkingHour::factory()->create([
|
||||
'day_of_week' => now()->addDay()->dayOfWeek,
|
||||
'is_active' => true,
|
||||
'start_time' => '09:00',
|
||||
'end_time' => '17:00',
|
||||
]);
|
||||
|
||||
$date = now()->addDay()->format('Y-m-d');
|
||||
|
||||
// Start the booking process
|
||||
$component = Volt::test('pages.booking')
|
||||
->call('selectSlot', $date, '09:00')
|
||||
->set('guestName', 'John Doe')
|
||||
->set('guestEmail', 'john@example.com')
|
||||
->set('guestPhone', '+970599123456')
|
||||
->set('problemSummary', 'I need legal advice regarding a contract dispute with my employer.')
|
||||
->set('captchaAnswer', session('captcha_answer'))
|
||||
->call('showConfirm')
|
||||
->assertSet('showConfirmation', true);
|
||||
|
||||
// Simulate another booking taking the slot before submission
|
||||
Consultation::factory()->guest()->create([
|
||||
'booking_date' => $date,
|
||||
'booking_time' => '09:00',
|
||||
'status' => ConsultationStatus::Pending,
|
||||
]);
|
||||
|
||||
// Try to submit - should fail with slot taken error
|
||||
$component->call('submit')
|
||||
->assertHasErrors(['selectedTime']);
|
||||
});
|
||||
|
||||
test('guest can clear slot selection', function () {
|
||||
WorkingHour::factory()->create([
|
||||
'day_of_week' => now()->addDay()->dayOfWeek,
|
||||
'is_active' => true,
|
||||
'start_time' => '09:00',
|
||||
'end_time' => '17:00',
|
||||
]);
|
||||
|
||||
$date = now()->addDay()->format('Y-m-d');
|
||||
|
||||
Volt::test('pages.booking')
|
||||
->call('selectSlot', $date, '09:00')
|
||||
->assertSet('selectedDate', $date)
|
||||
->call('clearSelection')
|
||||
->assertSet('selectedDate', null)
|
||||
->assertSet('selectedTime', null);
|
||||
});
|
||||
|
||||
test('guest can refresh captcha', function () {
|
||||
$component = Volt::test('pages.booking');
|
||||
|
||||
$firstQuestion = $component->get('captchaQuestion');
|
||||
|
||||
$component->call('refreshCaptcha');
|
||||
|
||||
// Just verify the captcha answer was reset
|
||||
$component->assertSet('captchaAnswer', '');
|
||||
});
|
||||
|
||||
test('success page is accessible after booking', function () {
|
||||
$this->withSession(['success' => 'Test success message'])
|
||||
->get(route('booking.success'))
|
||||
->assertOk()
|
||||
->assertSee(__('booking.success_title'));
|
||||
});
|
||||
|
||||
test('form validation requires all fields', function () {
|
||||
WorkingHour::factory()->create([
|
||||
'day_of_week' => now()->addDay()->dayOfWeek,
|
||||
'is_active' => true,
|
||||
'start_time' => '09:00',
|
||||
'end_time' => '17:00',
|
||||
]);
|
||||
|
||||
Volt::test('pages.booking')
|
||||
->call('selectSlot', now()->addDay()->format('Y-m-d'), '09:00')
|
||||
->set('guestName', '')
|
||||
->set('guestEmail', '')
|
||||
->set('guestPhone', '')
|
||||
->set('problemSummary', '')
|
||||
->set('captchaAnswer', '')
|
||||
->call('showConfirm')
|
||||
->assertHasErrors(['guestName', 'guestEmail', 'guestPhone', 'problemSummary', 'captchaAnswer']);
|
||||
});
|
||||
|
||||
test('guest name must be at least 3 characters', function () {
|
||||
WorkingHour::factory()->create([
|
||||
'day_of_week' => now()->addDay()->dayOfWeek,
|
||||
'is_active' => true,
|
||||
'start_time' => '09:00',
|
||||
'end_time' => '17:00',
|
||||
]);
|
||||
|
||||
Volt::test('pages.booking')
|
||||
->call('selectSlot', now()->addDay()->format('Y-m-d'), '09:00')
|
||||
->set('guestName', 'AB')
|
||||
->set('guestEmail', 'test@example.com')
|
||||
->set('guestPhone', '+970599123456')
|
||||
->set('problemSummary', 'This is a valid problem summary for testing.')
|
||||
->set('captchaAnswer', session('captcha_answer'))
|
||||
->call('showConfirm')
|
||||
->assertHasErrors(['guestName']);
|
||||
});
|
||||
|
||||
test('problem summary must be at least 20 characters', function () {
|
||||
WorkingHour::factory()->create([
|
||||
'day_of_week' => now()->addDay()->dayOfWeek,
|
||||
'is_active' => true,
|
||||
'start_time' => '09:00',
|
||||
'end_time' => '17:00',
|
||||
]);
|
||||
|
||||
Volt::test('pages.booking')
|
||||
->call('selectSlot', now()->addDay()->format('Y-m-d'), '09:00')
|
||||
->set('guestName', 'John Doe')
|
||||
->set('guestEmail', 'test@example.com')
|
||||
->set('guestPhone', '+970599123456')
|
||||
->set('problemSummary', 'Too short')
|
||||
->set('captchaAnswer', session('captcha_answer'))
|
||||
->call('showConfirm')
|
||||
->assertHasErrors(['problemSummary']);
|
||||
});
|
||||
|
||||
test('slot no longer available error is shown', function () {
|
||||
// Create a working hour but no available slots
|
||||
WorkingHour::factory()->create([
|
||||
'day_of_week' => now()->addDay()->dayOfWeek,
|
||||
'is_active' => true,
|
||||
'start_time' => '09:00',
|
||||
'end_time' => '10:00',
|
||||
]);
|
||||
|
||||
$date = now()->addDay()->format('Y-m-d');
|
||||
|
||||
// Book the only available slot
|
||||
Consultation::factory()->guest()->create([
|
||||
'booking_date' => $date,
|
||||
'booking_time' => '09:00',
|
||||
'status' => ConsultationStatus::Approved,
|
||||
]);
|
||||
|
||||
// Try to book the same slot
|
||||
Volt::test('pages.booking')
|
||||
->call('selectSlot', $date, '09:00')
|
||||
->set('guestName', 'John Doe')
|
||||
->set('guestEmail', 'different@example.com')
|
||||
->set('guestPhone', '+970599123456')
|
||||
->set('problemSummary', 'I need legal advice regarding a contract dispute.')
|
||||
->set('captchaAnswer', session('captcha_answer'))
|
||||
->call('showConfirm')
|
||||
->assertHasErrors(['selectedTime']);
|
||||
});
|
||||
Loading…
Reference in New Issue