From 06ece9f4b2f93ab306b48acef6bc3f691b095998 Mon Sep 17 00:00:00 2001 From: Naser Mansour Date: Sat, 3 Jan 2026 19:15:07 +0200 Subject: [PATCH] complete story 11.2 with qa tests --- app/Mail/GuestBookingSubmittedMail.php | 102 ++++++ app/Services/CaptchaService.php | 49 +++ docs/qa/gates/11.2-public-booking-form.yml | 58 +++ .../stories/story-11.2-public-booking-form.md | 156 +++++++- lang/ar/booking.php | 12 + lang/en/booking.php | 12 + .../booking/guest-submitted/ar.blade.php | 31 ++ .../booking/guest-submitted/en.blade.php | 29 ++ .../livewire/pages/booking-success.blade.php | 25 ++ .../views/livewire/pages/booking.blade.php | 336 ++++++++++++++++++ routes/web.php | 5 +- tests/Feature/Public/GuestBookingTest.php | 315 ++++++++++++++++ 12 files changed, 1116 insertions(+), 14 deletions(-) create mode 100644 app/Mail/GuestBookingSubmittedMail.php create mode 100644 app/Services/CaptchaService.php create mode 100644 docs/qa/gates/11.2-public-booking-form.yml create mode 100644 resources/views/emails/booking/guest-submitted/ar.blade.php create mode 100644 resources/views/emails/booking/guest-submitted/en.blade.php create mode 100644 resources/views/livewire/pages/booking-success.blade.php create mode 100644 resources/views/livewire/pages/booking.blade.php create mode 100644 tests/Feature/Public/GuestBookingTest.php diff --git a/app/Mail/GuestBookingSubmittedMail.php b/app/Mail/GuestBookingSubmittedMail.php new file mode 100644 index 0000000..f6ac493 --- /dev/null +++ b/app/Mail/GuestBookingSubmittedMail.php @@ -0,0 +1,102 @@ +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 + */ + public function attachments(): array + { + return []; + } +} diff --git a/app/Services/CaptchaService.php b/app/Services/CaptchaService.php new file mode 100644 index 0000000..d6cb987 --- /dev/null +++ b/app/Services/CaptchaService.php @@ -0,0 +1,49 @@ + $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); + } +} diff --git a/docs/qa/gates/11.2-public-booking-form.yml b/docs/qa/gates/11.2-public-booking-form.yml new file mode 100644 index 0000000..2305e5f --- /dev/null +++ b/docs/qa/gates/11.2-public-booking-form.yml @@ -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 diff --git a/docs/stories/story-11.2-public-booking-form.md b/docs/stories/story-11.2-public-booking-form.md index 5fad3ba..11c8a8c 100644 --- a/docs/stories/story-11.2-public-booking-form.md +++ b/docs/stories/story-11.2-public-booking-form.md @@ -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 | `` 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. diff --git a/lang/ar/booking.php b/lang/ar/booking.php index 88ca3bc..d853b3a 100644 --- a/lang/ar/booking.php +++ b/lang/ar/booking.php @@ -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' => 'لقد تلقينا طلب الاستشارة الخاص بك. ستتلقى رسالة تأكيد عبر البريد الإلكتروني قريباً. سيقوم فريقنا بمراجعة طلبك والتواصل معك.', ]; diff --git a/lang/en/booking.php b/lang/en/booking.php index aabc966..2a68035 100644 --- a/lang/en/booking.php +++ b/lang/en/booking.php @@ -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.', ]; diff --git a/resources/views/emails/booking/guest-submitted/ar.blade.php b/resources/views/emails/booking/guest-submitted/ar.blade.php new file mode 100644 index 0000000..d7eea94 --- /dev/null +++ b/resources/views/emails/booking/guest-submitted/ar.blade.php @@ -0,0 +1,31 @@ + +
+# تم استلام طلب الاستشارة + +عزيزي {{ $guestName }}، + +تم استلام طلب الاستشارة الخاص بك بنجاح. سنقوم بمراجعة طلبك والرد عليك في أقرب وقت. + +**تفاصيل الموعد:** + +- **التاريخ:** {{ $formattedDate }} +- **الوقت:** {{ $formattedTime }} + +@if($summaryPreview) +**ملخص المشكلة:** + +{{ $summaryPreview }} +@endif + + +**الحالة:** قيد المراجعة + +سنقوم بمراجعة طلبك والرد عليك خلال 1-2 أيام عمل. + + +إذا كان لديك أي استفسار، لا تتردد في التواصل معنا. + +مع أطيب التحيات،
+{{ config('app.name') }} +
+
diff --git a/resources/views/emails/booking/guest-submitted/en.blade.php b/resources/views/emails/booking/guest-submitted/en.blade.php new file mode 100644 index 0000000..1e2b7c8 --- /dev/null +++ b/resources/views/emails/booking/guest-submitted/en.blade.php @@ -0,0 +1,29 @@ + +# 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 + + +**Status:** Pending Review + +We will review your request and respond within 1-2 business days. + + +If you have any questions, please don't hesitate to contact us. + +Regards,
+{{ config('app.name') }} +
diff --git a/resources/views/livewire/pages/booking-success.blade.php b/resources/views/livewire/pages/booking-success.blade.php new file mode 100644 index 0000000..3cb66b0 --- /dev/null +++ b/resources/views/livewire/pages/booking-success.blade.php @@ -0,0 +1,25 @@ + + +
+ + + + {{ __('booking.success_title') }} + + +

+ {{ __('booking.success_message') }} +

+ + + {{ __('navigation.home') }} + +
diff --git a/resources/views/livewire/pages/booking.blade.php b/resources/views/livewire/pages/booking.blade.php new file mode 100644 index 0000000..0494587 --- /dev/null +++ b/resources/views/livewire/pages/booking.blade.php @@ -0,0 +1,336 @@ +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(); + } + } +}; ?> + +
+ + {{ __('booking.request_consultation') }} + + + @if(session('success')) + + {{ session('success') }} + + @endif + + @if(!$selectedDate || !$selectedTime) + {{-- Step 1: Calendar Selection --}} + +

{{ __('booking.guest_intro') }}

+
+ +

+ {{ __('booking.select_date_time') }} +

+ + + @else + {{-- Step 2+: Contact Form & Confirmation --}} +
+
+
+

+ {{ __('booking.selected_time') }} +

+

+ {{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('l, d M Y') }} +

+

+ {{ \Carbon\Carbon::parse($selectedTime)->format('g:i A') }} +

+
+ + {{ __('common.change') }} + +
+
+ + @if(!$showConfirmation) + {{-- Contact Form --}} +
+ + {{ __('booking.guest_name') }} + + + + + + {{ __('booking.guest_email') }} + + + + + + {{ __('booking.guest_phone') }} + + + + + + {{ __('booking.problem_summary') }} + + {{ __('booking.problem_summary_help') }} + + + + {{-- Custom Captcha --}} + + + {{ app()->getLocale() === 'ar' ? $captchaQuestion['question_ar'] : $captchaQuestion['question'] }} + +
+ + + + +
+ +
+ + + {{ __('booking.continue') }} + {{ __('common.loading') }} + +
+ @else + {{-- Confirmation Step --}} + + {{ __('booking.confirm_booking') }} +

{{ __('booking.confirm_message') }}

+ +
+

{{ __('booking.guest_name') }}: {{ $guestName }}

+

{{ __('booking.guest_email') }}: {{ $guestEmail }}

+

{{ __('booking.guest_phone') }}: {{ $guestPhone }}

+

{{ __('booking.date') }}: + {{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('l, d M Y') }}

+

{{ __('booking.time') }}: + {{ \Carbon\Carbon::parse($selectedTime)->format('g:i A') }}

+

{{ __('booking.duration') }}: 45 {{ __('common.minutes') }}

+
+ +
+

{{ __('booking.problem_summary') }}:

+

{{ $problemSummary }}

+
+
+ +
+ + {{ __('common.back') }} + + + {{ __('booking.submit_request') }} + {{ __('common.submitting') }} + +
+ + @error('selectedTime') + + {{ $message }} + + @enderror + @endif + @endif +
diff --git a/routes/web.php b/routes/web.php index 47423ff..838b5c3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/Feature/Public/GuestBookingTest.php b/tests/Feature/Public/GuestBookingTest.php new file mode 100644 index 0000000..65a14a4 --- /dev/null +++ b/tests/Feature/Public/GuestBookingTest.php @@ -0,0 +1,315 @@ +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']); +});