From f77ff527e70a00df7c809527153a3d61b8a021d0 Mon Sep 17 00:00:00 2001 From: mehrab esmailnia Date: Mon, 21 Sep 2026 05:17:45 +0330 Subject: [PATCH] Add feature tests for Document, FastRequest, FileStorage, Shift, and SMS services --- app/Services/FileStorageService.php | 2 +- database/factories/ShiftFactory.php | 65 ++++ docs/TESTING.md | 16 +- .../Services/Document/CaseServiceTest.php | 329 ++++++++++++++++ .../Document/DocumentCodeServiceTest.php | 58 +++ .../Document/FastRequestServiceTest.php | 204 ++++++++++ .../Services/FileStorageServiceTest.php | 182 +++++++++ .../Shift/ShiftScheduleServiceTest.php | 365 ++++++++++++++++++ .../Services/Sms/IranPayamakServiceTest.php | 74 ++++ .../Sms/SmsNotificationServiceTest.php | 82 ++++ 10 files changed, 1368 insertions(+), 9 deletions(-) create mode 100644 database/factories/ShiftFactory.php create mode 100644 tests/Feature/Services/Document/CaseServiceTest.php create mode 100644 tests/Feature/Services/Document/DocumentCodeServiceTest.php create mode 100644 tests/Feature/Services/Document/FastRequestServiceTest.php create mode 100644 tests/Feature/Services/FileStorageServiceTest.php create mode 100644 tests/Feature/Services/Shift/ShiftScheduleServiceTest.php create mode 100644 tests/Feature/Services/Sms/IranPayamakServiceTest.php create mode 100644 tests/Feature/Services/Sms/SmsNotificationServiceTest.php diff --git a/app/Services/FileStorageService.php b/app/Services/FileStorageService.php index a841d28..a460682 100644 --- a/app/Services/FileStorageService.php +++ b/app/Services/FileStorageService.php @@ -49,7 +49,7 @@ class FileStorageService $new = Storage::disk($newDisk); $stream = $old->readStream($medium->path); - if ($stream === false) return false; + if (! is_resource($stream)) return false; $new->put($medium->path, $stream); if (is_resource($stream)) fclose($stream); diff --git a/database/factories/ShiftFactory.php b/database/factories/ShiftFactory.php new file mode 100644 index 0000000..9a0f0b0 --- /dev/null +++ b/database/factories/ShiftFactory.php @@ -0,0 +1,65 @@ + + */ +class ShiftFactory extends Factory +{ + public function definition(): array + { + return [ + 'day_of_week' => fake()->randomElement(ShiftDayEnum::cases())->value, + 'date' => null, + 'start_time' => '08:00', + 'end_time' => '16:00', + 'is_active' => true, + 'user_id' => null, + 'created_by' => null, + ]; + } + + public function onDate(string $date): static + { + return $this->state(fn (array $attributes) => [ + 'day_of_week' => null, + 'date' => $date, + ]); + } + + public function weekly(ShiftDayEnum $day): static + { + return $this->state(fn (array $attributes) => [ + 'day_of_week' => $day->value, + 'date' => null, + ]); + } + + public function between(string $start, string $end): static + { + return $this->state(fn (array $attributes) => [ + 'start_time' => $start, + 'end_time' => $end, + ]); + } + + public function forDoctor(?User $doctor = null): static + { + return $this->state(fn (array $attributes) => [ + 'user_id' => $doctor?->id ?? User::factory()->state(['level' => UserEnum::Doctor->value]), + ]); + } + + public function inactive(): static + { + return $this->state(fn (array $attributes) => [ + 'is_active' => false, + ]); + } +} diff --git a/docs/TESTING.md b/docs/TESTING.md index e97750d..2b0ba4c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -108,13 +108,13 @@ Legend: `☐` not written · `☑` written · `—` intentionally not tested | P1 | `app/Services/Auth/OtpService.php` | `tests/Feature/Services/Auth/OtpServiceTest.php` | ☑ | | P1 | `app/Services/Auth/AuthService.php` | `tests/Feature/Services/Auth/AuthServiceTest.php` | ☑ | | P1 | `app/Services/Auth/RegistrationService.php` | `tests/Feature/Services/Auth/RegistrationServiceTest.php` | ☑ | -| P1 | `app/Services/Shift/ShiftScheduleService.php` | `tests/Feature/Services/Shift/ShiftScheduleServiceTest.php` | ☐ | -| P1 | `app/Services/Document/CaseService.php` | `tests/Feature/Services/Document/CaseServiceTest.php` | ☐ | -| P1 | `app/Services/Document/FastRequestService.php` | `tests/Feature/Services/Document/FastRequestServiceTest.php` | ☐ | -| P2 | `app/Services/Document/DocumentCodeService.php` | `tests/Feature/Services/Document/DocumentCodeServiceTest.php` | ☐ | -| P2 | `app/Services/Sms/SmsNotificationService.php` | `tests/Feature/Services/Sms/SmsNotificationServiceTest.php` | ☐ | -| P2 | `app/Services/FileStorageService.php` | `tests/Feature/Services/FileStorageServiceTest.php` | ☐ | -| P3 | `app/Services/Sms/IranPayamakService.php` | `tests/Feature/Services/Sms/IranPayamakServiceTest.php` | ☐ | +| P1 | `app/Services/Shift/ShiftScheduleService.php` | `tests/Feature/Services/Shift/ShiftScheduleServiceTest.php` | ☑ | +| P1 | `app/Services/Document/CaseService.php` | `tests/Feature/Services/Document/CaseServiceTest.php` | ☑ | +| P1 | `app/Services/Document/FastRequestService.php` | `tests/Feature/Services/Document/FastRequestServiceTest.php` | ☑ | +| P2 | `app/Services/Document/DocumentCodeService.php` | `tests/Feature/Services/Document/DocumentCodeServiceTest.php` | ☑ | +| P2 | `app/Services/Sms/SmsNotificationService.php` | `tests/Feature/Services/Sms/SmsNotificationServiceTest.php` | ☑ | +| P2 | `app/Services/FileStorageService.php` | `tests/Feature/Services/FileStorageServiceTest.php` | ☑ | +| P3 | `app/Services/Sms/IranPayamakService.php` | `tests/Feature/Services/Sms/IranPayamakServiceTest.php` | ☑ | ### Form Requests @@ -201,4 +201,4 @@ Missing — create when first needed by a test: | `Invoice` | ☑ | | `Payment` | ☑ | | `OtpCode` | ☑ | -| `Shift` | ☐ | +| `Shift` | ☑ | diff --git a/tests/Feature/Services/Document/CaseServiceTest.php b/tests/Feature/Services/Document/CaseServiceTest.php new file mode 100644 index 0000000..890faaa --- /dev/null +++ b/tests/Feature/Services/Document/CaseServiceTest.php @@ -0,0 +1,329 @@ +service = app(CaseService::class); + } + + protected function doctor(): User + { + return User::factory()->create(['level' => UserEnum::Doctor->value]); + } + + protected function assignedInProgressDocument(User $doctor): Document + { + $document = Document::factory()->inProgress()->create(); + DocumentAssignment::factory()->create(['document_id' => $document->id, 'user_id' => $doctor->id]); + + return $document; + } + + public function test_is_doctor_checks_user_level(): void + { + $this->assertTrue($this->service->isDoctor($this->doctor())); + $this->assertFalse($this->service->isDoctor(User::factory()->create(['level' => UserEnum::Vet->value]))); + } + + public function test_pending_for_user_returns_registered_and_in_progress_documents_of_requester(): void + { + $requester = User::factory()->create(); + $registered = Document::factory()->registered()->create(['requester_id' => $requester->id]); + $inProgress = Document::factory()->inProgress()->create(['requester_id' => $requester->id]); + Document::factory()->completed()->create(['requester_id' => $requester->id]); + Document::factory()->registered()->create(); + + $ids = $this->service->pendingForUser($requester)->pluck('id'); + + $this->assertEqualsCanonicalizing([$registered->id, $inProgress->id], $ids->all()); + } + + public function test_pending_for_user_includes_documents_assigned_to_non_doctor(): void + { + $user = User::factory()->create(['level' => UserEnum::Vet->value]); + $assigned = Document::factory()->registered()->create(); + DocumentAssignment::factory()->create(['document_id' => $assigned->id, 'user_id' => $user->id]); + + $this->assertTrue($this->service->pendingForUser($user)->pluck('id')->contains($assigned->id)); + } + + public function test_pending_for_doctor_returns_only_assigned_in_progress_documents(): void + { + $doctor = $this->doctor(); + $inProgress = $this->assignedInProgressDocument($doctor); + $registered = Document::factory()->registered()->create(); + DocumentAssignment::factory()->create(['document_id' => $registered->id, 'user_id' => $doctor->id]); + Document::factory()->inProgress()->create(['requester_id' => $doctor->id]); + + $this->assertSame([$inProgress->id], $this->service->pendingForUser($doctor)->pluck('id')->all()); + } + + public function test_pending_for_user_orders_by_latest_submitted_at(): void + { + $requester = User::factory()->create(); + $older = Document::factory()->registered()->create(['requester_id' => $requester->id, 'submitted_at' => now()->subDay()]); + $newer = Document::factory()->registered()->create(['requester_id' => $requester->id, 'submitted_at' => now()]); + + $this->assertSame([$newer->id, $older->id], $this->service->pendingForUser($requester)->pluck('id')->all()); + } + + public function test_can_access_allows_requester_and_assignees_only(): void + { + $document = Document::factory()->create(); + $assignee = User::factory()->create(); + DocumentAssignment::factory()->create(['document_id' => $document->id, 'user_id' => $assignee->id]); + + $this->assertTrue($this->service->canAccess($document, $document->requester)); + $this->assertTrue($this->service->canAccess($document, $assignee)); + $this->assertFalse($this->service->canAccess($document, User::factory()->create())); + } + + public function test_can_access_medium_through_document_attachment(): void + { + $document = Document::factory()->create(); + $medium = Medium::factory()->create(); + $document->media()->attach($medium->id, ['collection' => MediaCollectionEnum::Primary->value]); + + $this->assertTrue($this->service->canAccessMedium($medium, $document->requester)); + $this->assertFalse($this->service->canAccessMedium($medium, User::factory()->create())); + } + + public function test_can_access_medium_through_report_attachment(): void + { + $report = Report::factory()->create(); + $medium = Medium::factory()->create(); + $report->media()->attach($medium->id, ['collection' => MediaCollectionEnum::Attachments->value]); + + $this->assertTrue($this->service->canAccessMedium($medium, $report->document->requester)); + } + + public function test_can_access_medium_denies_unattached_medium(): void + { + $this->assertFalse($this->service->canAccessMedium(Medium::factory()->create(), User::factory()->create())); + } + + public function test_can_report_requires_doctor_in_progress_status_and_assignment(): void + { + $doctor = $this->doctor(); + $document = $this->assignedInProgressDocument($doctor); + $unassignedDoctor = $this->doctor(); + $nonDoctor = User::factory()->create(['level' => UserEnum::Vet->value]); + DocumentAssignment::factory()->create(['document_id' => $document->id, 'user_id' => $nonDoctor->id]); + $registered = Document::factory()->registered()->create(); + DocumentAssignment::factory()->create(['document_id' => $registered->id, 'user_id' => $doctor->id]); + + $this->assertTrue($this->service->canReport($document, $doctor)); + $this->assertFalse($this->service->canReport($document, $unassignedDoctor)); + $this->assertFalse($this->service->canReport($document, $nonDoctor)); + $this->assertFalse($this->service->canReport($registered, $doctor)); + } + + public function test_submit_report_creates_approved_report_and_completes_document(): void + { + Carbon::setTestNow('2026-09-21 10:00:00'); + $doctor = $this->doctor(); + $document = $this->assignedInProgressDocument($doctor); + + $report = $this->service->submitReport($document, $doctor, [ + 'technique' => 'CT', + 'finding' => 'finding text', + 'diagnose' => 'diagnose text', + 'recommendation' => 'recommendation text', + ]); + + $this->assertSame(ReportStatusEnum::Approved, $report->status); + $this->assertDatabaseHas('reports', [ + 'id' => $report->id, + 'document_id' => $document->id, + 'doctor_id' => $doctor->id, + 'technique' => 'CT', + 'finding' => 'finding text', + 'diagnose' => 'diagnose text', + 'recommendation' => 'recommendation text', + ]); + $document->refresh(); + $this->assertSame(DocumentStatusEnum::Completed, $document->status); + $this->assertSame($doctor->id, $document->finalized_by); + $this->assertTrue($document->finalized_at->eq(Carbon::parse('2026-09-21 10:00:00'))); + } + + public function test_submit_report_stores_files_as_report_attachments(): void + { + $doctor = $this->doctor(); + $document = $this->assignedInProgressDocument($doctor); + + $report = $this->service->submitReport($document, $doctor, [], [ + UploadedFile::fake()->image('scan.jpg'), + UploadedFile::fake()->create('notes.pdf', 10, 'application/pdf'), + ]); + + $this->assertCount(2, $report->attachments); + $this->assertDatabaseCount('media', 2); + Storage::disk('public')->assertExists($report->attachments->first()->path); + } + + public function test_submit_report_dispatches_report_ready_sms(): void + { + config(['services.iranpayamak.patterns.report_ready' => 'pattern-code']); + $doctor = $this->doctor(); + $document = $this->assignedInProgressDocument($doctor); + + $this->service->submitReport($document, $doctor, []); + + Queue::assertPushed(SendPatternSms::class, 1); + } + + public function test_submit_report_throws_when_doctor_cannot_report(): void + { + $document = Document::factory()->inProgress()->create(); + + try { + $this->service->submitReport($document, $this->doctor(), []); + $this->fail('ValidationException was not thrown'); + } catch (ValidationException $e) { + $this->assertArrayHasKey('document', $e->errors()); + } + + $this->assertDatabaseCount('reports', 0); + $this->assertSame(DocumentStatusEnum::InProgress, $document->fresh()->status); + } + + public function test_reject_marks_document_rejected_with_reason(): void + { + Carbon::setTestNow('2026-09-21 10:00:00'); + $doctor = $this->doctor(); + $document = $this->assignedInProgressDocument($doctor); + + $rejected = $this->service->reject($document, $doctor, 'تصاویر ناخوانا'); + + $this->assertSame(DocumentStatusEnum::Rejected, $rejected->status); + $this->assertSame('تصاویر ناخوانا', $rejected->rejection_reason); + $this->assertSame($doctor->id, $rejected->finalized_by); + $this->assertTrue($rejected->finalized_at->eq(Carbon::parse('2026-09-21 10:00:00'))); + } + + public function test_reject_refunds_paid_invoice_to_requester_wallet(): void + { + $doctor = $this->doctor(); + $document = $this->assignedInProgressDocument($doctor); + $wallet = Wallet::factory()->create(['user_id' => $document->requester_id, 'balance' => 1000]); + $invoice = Invoice::factory()->paid()->create(['document_id' => $document->id, 'user_id' => $document->requester_id, 'amount' => 500000]); + + $this->service->reject($document, $doctor, 'reason'); + + $this->assertSame(InvoiceStatusEnum::Canceled, $invoice->fresh()->status); + $this->assertSame(501000, $wallet->fresh()->balance); + $this->assertDatabaseHas('wallet_transactions', [ + 'wallet_id' => $wallet->id, + 'reason' => WalletTransactionReasonEnum::Refund->value, + 'amount' => 500000, + ]); + } + + public function test_reject_does_not_refund_unpaid_invoice(): void + { + $doctor = $this->doctor(); + $document = $this->assignedInProgressDocument($doctor); + $invoice = Invoice::factory()->create(['document_id' => $document->id, 'user_id' => $document->requester_id]); + + $this->service->reject($document, $doctor, 'reason'); + + $this->assertSame(InvoiceStatusEnum::Pending, $invoice->fresh()->status); + $this->assertDatabaseCount('wallet_transactions', 0); + } + + public function test_reject_throws_when_doctor_cannot_report(): void + { + $document = Document::factory()->inProgress()->create(); + + $this->expectException(ValidationException::class); + + $this->service->reject($document, $this->doctor(), 'reason'); + } + + public function test_stats_for_requester_counts_pending_monthly_reviewed_and_paid_amount(): void + { + Carbon::setTestNow('2026-09-21 10:00:00'); + $requester = User::factory()->create(['level' => UserEnum::Vet->value]); + Document::factory()->registered()->create(['requester_id' => $requester->id]); + $thisMonth = Document::factory()->completed()->create(['requester_id' => $requester->id, 'finalized_at' => now()->subDay()]); + $lastMonth = Document::factory()->completed()->create(['requester_id' => $requester->id, 'finalized_at' => now()->subMonths(2)]); + Invoice::factory()->paid()->create(['document_id' => $thisMonth->id, 'user_id' => $requester->id, 'amount' => 300000, 'paid_at' => now()->subDay()]); + Invoice::factory()->paid()->create(['document_id' => $lastMonth->id, 'user_id' => $requester->id, 'amount' => 900000, 'paid_at' => now()->subMonths(2)]); + + $stats = $this->service->statsForUser($requester); + + $this->assertSame(1, $stats['pending_cases']); + $this->assertSame(1, $stats['monthly_reviewed']); + $this->assertSame(300000, $stats['monthly_amount']); + $this->assertNull($stats['next_shift_hours']); + $this->assertFalse($stats['is_doctor']); + } + + public function test_stats_for_doctor_counts_finalized_documents_and_next_shift_hours(): void + { + Carbon::setTestNow('2026-09-21 10:00:00'); + $doctor = $this->doctor(); + $this->assignedInProgressDocument($doctor); + $completed = Document::factory()->completed()->create(['finalized_by' => $doctor->id, 'finalized_at' => now()->subDay()]); + Invoice::factory()->paid()->create(['document_id' => $completed->id, 'user_id' => $completed->requester_id, 'amount' => 700000]); + Shift::factory()->onDate('2026-09-21')->between('14:30', '18:00')->forDoctor($doctor)->create(); + + $stats = $this->service->statsForUser($doctor); + + $this->assertSame(1, $stats['pending_cases']); + $this->assertSame(1, $stats['monthly_reviewed']); + $this->assertSame(700000, $stats['monthly_amount']); + $this->assertSame(5, $stats['next_shift_hours']); + $this->assertTrue($stats['is_doctor']); + } + + public function test_stats_for_doctor_reports_zero_hours_when_on_shift(): void + { + Carbon::setTestNow('2026-09-21 10:00:00'); + $doctor = $this->doctor(); + Shift::factory()->onDate('2026-09-21')->between('08:00', '16:00')->forDoctor($doctor)->create(); + + $this->assertSame(0, $this->service->statsForUser($doctor)['next_shift_hours']); + } + + public function test_stats_for_doctor_reports_null_hours_without_upcoming_shift(): void + { + $this->assertNull($this->service->statsForUser($this->doctor())['next_shift_hours']); + } +} diff --git a/tests/Feature/Services/Document/DocumentCodeServiceTest.php b/tests/Feature/Services/Document/DocumentCodeServiceTest.php new file mode 100644 index 0000000..fa21569 --- /dev/null +++ b/tests/Feature/Services/Document/DocumentCodeServiceTest.php @@ -0,0 +1,58 @@ +service = app(DocumentCodeService::class); + } + + public function test_generate_returns_six_digit_zero_padded_code(): void + { + $this->assertMatchesRegularExpression('/^\d{6}$/', $this->service->generate()); + } + + public function test_generate_returns_code_not_used_by_existing_document(): void + { + Document::factory()->count(5)->create(); + + $code = $this->service->generate(); + + $this->assertDatabaseMissing('documents', ['code' => $code]); + } + + public function test_generate_throws_when_no_unique_code_found_within_tries(): void + { + $this->expectException(RuntimeException::class); + + $this->service->generate(0); + } + + public function test_document_observer_assigns_code_on_create(): void + { + $document = Document::factory()->create(['code' => null]); + + $this->assertMatchesRegularExpression('/^\d{6}$/', $document->code); + } + + public function test_document_observer_keeps_explicit_code(): void + { + $document = Document::factory()->create(['code' => '123456']); + + $this->assertSame('123456', $document->code); + } +} diff --git a/tests/Feature/Services/Document/FastRequestServiceTest.php b/tests/Feature/Services/Document/FastRequestServiceTest.php new file mode 100644 index 0000000..7e91aaa --- /dev/null +++ b/tests/Feature/Services/Document/FastRequestServiceTest.php @@ -0,0 +1,204 @@ +service = app(FastRequestService::class); + } + + protected function payload(array $overrides = []): array + { + return array_merge([ + 'modality' => DocumentModalityEnum::Mri->value, + 'priority' => DocumentPriorityEnum::High->name, + 'pet' => ['name' => 'Rex', 'type' => PetTypeEnum::Dog->name], + ], $overrides); + } + + public function test_create_stores_document_with_generated_code_and_registered_status(): void + { + Carbon::setTestNow('2026-09-21 10:00:00'); + $requester = User::factory()->create(); + + $document = $this->service->create($requester, $this->payload(['note' => 'یادداشت'])); + + $this->assertMatchesRegularExpression('/^\d{6}$/', $document->code); + $this->assertSame(DocumentStatusEnum::Registered, $document->fresh()->status); + $this->assertSame(DocumentPriorityEnum::High, $document->priority); + $this->assertTrue($document->submitted_at->eq(Carbon::parse('2026-09-21 10:00:00'))); + $this->assertDatabaseHas('documents', [ + 'id' => $document->id, + 'modality' => DocumentModalityEnum::Mri->value, + 'note' => 'یادداشت', + 'requester_id' => $requester->id, + ]); + } + + public function test_create_creates_pending_invoice_priced_by_modality(): void + { + $document = $this->service->create(User::factory()->create(), $this->payload()); + + $this->assertDatabaseHas('invoices', [ + 'document_id' => $document->id, + 'user_id' => $document->requester_id, + 'amount' => DocumentModalityEnum::Mri->price(), + 'status' => InvoiceStatusEnum::Pending->value, + ]); + } + + public function test_create_creates_pet_for_requester_from_payload(): void + { + Carbon::setTestNow('2026-09-21 10:00:00'); + $requester = User::factory()->create(); + + $document = $this->service->create($requester, $this->payload(['pet' => [ + 'name' => 'Rex', + 'type' => PetTypeEnum::Dog->name, + 'gender' => PetGenderEnum::Male->name, + 'breed' => 'ژرمن', + 'weight' => 20, + 'sterilized' => true, + 'age' => 3, + ]])); + + $this->assertDatabaseHas('pets', [ + 'id' => $document->pet_id, + 'name' => 'Rex', + 'type' => PetTypeEnum::Dog->value, + 'gender' => PetGenderEnum::Male->value, + 'breed' => 'ژرمن', + 'weight' => 20, + 'sterilized' => 1, + 'owner_id' => $requester->id, + ]); + $this->assertTrue(Pet::find($document->pet_id)->birthday->isSameDay(Carbon::parse('2023-09-21'))); + } + + public function test_create_defaults_pet_gender_to_unknown(): void + { + $document = $this->service->create(User::factory()->create(), $this->payload()); + + $this->assertSame(PetGenderEnum::Unknown, Pet::find($document->pet_id)->gender); + } + + public function test_create_uses_existing_pet_of_requester(): void + { + $requester = User::factory()->create(); + $pet = Pet::factory()->create(['owner_id' => $requester->id]); + + $document = $this->service->create($requester, $this->payload(['pet_id' => $pet->id, 'pet' => null])); + + $this->assertSame($pet->id, $document->pet_id); + $this->assertDatabaseCount('pets', 1); + } + + public function test_create_rejects_pet_of_another_user(): void + { + $pet = Pet::factory()->create(); + + $this->expectException(ModelNotFoundException::class); + + $this->service->create(User::factory()->create(), $this->payload(['pet_id' => $pet->id, 'pet' => null])); + } + + public function test_create_attaches_history_and_scan_files_to_their_collections(): void + { + $document = $this->service->create( + User::factory()->create(), + $this->payload(), + [UploadedFile::fake()->create('history.pdf', 10, 'application/pdf')], + [UploadedFile::fake()->image('scan1.jpg'), UploadedFile::fake()->image('scan2.jpg')] + ); + + $this->assertCount(1, $document->historyFiles); + $this->assertCount(2, $document->primaryFiles); + $this->assertDatabaseCount('media', 3); + Storage::disk('public')->assertExists($document->primaryFiles->first()->path); + } + + public function test_create_assigns_given_doctor_as_radiologist(): void + { + $doctor = User::factory()->create(['level' => UserEnum::Doctor->value]); + + $document = $this->service->create(User::factory()->create(), $this->payload(['doctor_id' => $doctor->id])); + + $this->assertDatabaseHas('document_assignments', [ + 'document_id' => $document->id, + 'user_id' => $doctor->id, + 'role' => 'رادیولوژیست', + ]); + } + + public function test_create_auto_selects_on_shift_doctor_when_none_given(): void + { + Carbon::setTestNow('2026-09-21 10:00:00'); + $doctor = User::factory()->create(['level' => UserEnum::Doctor->value]); + Shift::factory()->onDate('2026-09-21')->between('08:00', '16:00')->forDoctor($doctor)->create(); + + $document = $this->service->create(User::factory()->create(), $this->payload()); + + $this->assertDatabaseHas('document_assignments', ['document_id' => $document->id, 'user_id' => $doctor->id]); + } + + public function test_create_leaves_document_unassigned_when_no_doctor_is_available(): void + { + $document = $this->service->create(User::factory()->create(), $this->payload()); + + $this->assertDatabaseCount('document_assignments', 0); + $this->assertSame($document->id, $document->fresh()->id); + } + + public function test_create_dispatches_request_registered_sms(): void + { + config(['services.iranpayamak.patterns.request_registered' => 'pattern-code']); + + $this->service->create(User::factory()->create(), $this->payload()); + + Queue::assertPushed(SendPatternSms::class, 1); + } + + public function test_create_rolls_back_everything_when_pet_lookup_fails(): void + { + $requester = User::factory()->create(); + + try { + $this->service->create($requester, $this->payload(['pet_id' => 999, 'pet' => null])); + } catch (ModelNotFoundException) { + } + + $this->assertDatabaseCount('documents', 0); + $this->assertDatabaseCount('invoices', 0); + Queue::assertNothingPushed(); + } +} diff --git a/tests/Feature/Services/FileStorageServiceTest.php b/tests/Feature/Services/FileStorageServiceTest.php new file mode 100644 index 0000000..975227e --- /dev/null +++ b/tests/Feature/Services/FileStorageServiceTest.php @@ -0,0 +1,182 @@ +service = app(FileStorageService::class); + } + + protected function storedMedium(string $disk = 'private', string $content = 'hello'): Medium + { + $medium = Medium::factory()->create(['disk' => $disk, 'path' => 'files/sample.txt', 'filename' => 'sample.txt', 'mime_type' => 'text/plain']); + Storage::disk($disk)->put($medium->path, $content); + + return $medium; + } + + public function test_store_file_saves_public_file_and_medium_with_url(): void + { + $file = UploadedFile::fake()->create('My Scan.pdf', 12, 'application/pdf'); + + $medium = $this->service->storeFile($file); + + $this->assertSame(MediaDiskEnum::Public, $medium->disk); + $this->assertMatchesRegularExpression('/^\d+_[A-Za-z0-9]{6}_my-scan\.pdf$/', $medium->filename); + $this->assertSame('files/' . $medium->filename, $medium->path); + $this->assertSame(Storage::disk('public')->url($medium->path), $medium->url); + $this->assertSame(12 * 1024, $medium->size); + $this->assertSame('application/pdf', $medium->mime_type); + Storage::disk('public')->assertExists($medium->path); + } + + public function test_store_file_saves_private_file_without_url_in_custom_directory(): void + { + $medium = $this->service->storeFile(UploadedFile::fake()->image('x.png'), 'private', 'reports'); + + $this->assertSame(MediaDiskEnum::Private, $medium->disk); + $this->assertNull($medium->url); + $this->assertStringStartsWith('reports/', $medium->path); + Storage::disk('private')->assertExists($medium->path); + Storage::disk('public')->assertMissing($medium->path); + } + + public function test_store_file_generates_distinct_filenames_for_same_upload_name(): void + { + $first = $this->service->storeFile(UploadedFile::fake()->image('same.png')); + $second = $this->service->storeFile(UploadedFile::fake()->image('same.png')); + + $this->assertNotSame($first->filename, $second->filename); + } + + public function test_move_to_disk_moves_file_from_private_to_public_and_sets_url(): void + { + $medium = $this->storedMedium('private'); + + $this->assertTrue($this->service->moveToDisk($medium, 'public')); + + $medium->refresh(); + $this->assertSame(MediaDiskEnum::Public, $medium->disk); + $this->assertSame(Storage::disk('public')->url('files/sample.txt'), $medium->url); + Storage::disk('public')->assertExists('files/sample.txt'); + Storage::disk('private')->assertMissing('files/sample.txt'); + $this->assertSame('hello', Storage::disk('public')->get('files/sample.txt')); + } + + public function test_move_to_disk_moves_file_to_private_and_clears_url(): void + { + $medium = $this->storedMedium('public'); + $medium->update(['url' => 'http://example.test/files/sample.txt']); + + $this->assertTrue($this->service->moveToDisk($medium, 'private')); + + $medium->refresh(); + $this->assertSame(MediaDiskEnum::Private, $medium->disk); + $this->assertNull($medium->url); + Storage::disk('private')->assertExists('files/sample.txt'); + Storage::disk('public')->assertMissing('files/sample.txt'); + } + + public function test_move_to_disk_returns_true_without_changes_for_same_disk(): void + { + $medium = $this->storedMedium('private'); + + $this->assertTrue($this->service->moveToDisk($medium, 'private')); + Storage::disk('private')->assertExists('files/sample.txt'); + } + + public function test_move_to_disk_returns_false_when_source_file_is_missing(): void + { + $medium = Medium::factory()->private()->create(['path' => 'files/missing.txt']); + + $this->assertFalse($this->service->moveToDisk($medium, 'public')); + $this->assertSame(MediaDiskEnum::Private, $medium->fresh()->disk); + } + + public function test_delete_medium_removes_file_and_record(): void + { + $medium = $this->storedMedium('private'); + + $this->service->deleteMedium($medium); + + Storage::disk('private')->assertMissing('files/sample.txt'); + $this->assertDatabaseMissing('media', ['id' => $medium->id]); + } + + public function test_delete_medium_removes_record_even_when_file_is_missing(): void + { + $medium = Medium::factory()->private()->create(['path' => 'files/missing.txt']); + + $this->service->deleteMedium($medium); + + $this->assertDatabaseMissing('media', ['id' => $medium->id]); + } + + public function test_download_file_returns_attachment_for_private_existing_file(): void + { + $medium = $this->storedMedium('private'); + + $response = $this->service->downloadFile($medium); + + $this->assertStringContainsString('attachment', $response->headers->get('Content-Disposition')); + $this->assertStringContainsString('sample.txt', $response->headers->get('Content-Disposition')); + } + + public function test_download_file_aborts_for_public_medium(): void + { + $medium = $this->storedMedium('public'); + + $this->expectException(NotFoundHttpException::class); + + $this->service->downloadFile($medium); + } + + public function test_download_file_aborts_when_file_is_missing(): void + { + $medium = Medium::factory()->private()->create(['path' => 'files/missing.txt']); + + $this->expectException(NotFoundHttpException::class); + + $this->service->downloadFile($medium); + } + + public function test_serve_file_returns_inline_content_with_mime_type(): void + { + $medium = $this->storedMedium('private', 'inline body'); + + $response = $this->service->serveFile($medium); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('inline body', $response->getContent()); + $this->assertSame('text/plain', $response->headers->get('Content-Type')); + $this->assertSame('inline; filename="sample.txt"', $response->headers->get('Content-Disposition')); + } + + public function test_serve_file_aborts_for_public_medium(): void + { + $medium = $this->storedMedium('public'); + + $this->expectException(NotFoundHttpException::class); + + $this->service->serveFile($medium); + } +} diff --git a/tests/Feature/Services/Shift/ShiftScheduleServiceTest.php b/tests/Feature/Services/Shift/ShiftScheduleServiceTest.php new file mode 100644 index 0000000..6634099 --- /dev/null +++ b/tests/Feature/Services/Shift/ShiftScheduleServiceTest.php @@ -0,0 +1,365 @@ +service = app(ShiftScheduleService::class); + } + + protected function doctor(): User + { + return User::factory()->create(['level' => UserEnum::Doctor->value]); + } + + public function test_shifts_covering_moment_includes_dated_shift_within_hours(): void + { + $shift = Shift::factory()->onDate('2026-09-21')->between('08:00', '16:00')->create(); + + $covering = $this->service->shiftsCoveringMoment(Carbon::parse('2026-09-21 10:00')); + + $this->assertTrue($covering->contains('id', $shift->id)); + } + + public function test_shifts_covering_moment_excludes_dated_shift_outside_hours(): void + { + Shift::factory()->onDate('2026-09-21')->between('08:00', '16:00')->create(); + + $this->assertCount(0, $this->service->shiftsCoveringMoment(Carbon::parse('2026-09-21 16:00'))); + $this->assertCount(0, $this->service->shiftsCoveringMoment(Carbon::parse('2026-09-21 07:59'))); + } + + public function test_shifts_covering_moment_includes_start_and_excludes_end(): void + { + Shift::factory()->onDate('2026-09-21')->between('08:00', '16:00')->create(); + + $this->assertCount(1, $this->service->shiftsCoveringMoment(Carbon::parse('2026-09-21 08:00'))); + $this->assertCount(0, $this->service->shiftsCoveringMoment(Carbon::parse('2026-09-21 16:00'))); + } + + public function test_shifts_covering_moment_includes_weekly_shift_on_matching_weekday(): void + { + $shift = Shift::factory()->weekly(ShiftDayEnum::Monday)->between('08:00', '16:00')->create(); + + $this->assertTrue($this->service->shiftsCoveringMoment(Carbon::parse('2026-09-21 10:00'))->contains('id', $shift->id)); + $this->assertCount(0, $this->service->shiftsCoveringMoment(Carbon::parse('2026-09-22 10:00'))); + } + + public function test_shifts_covering_moment_handles_overnight_shift_before_and_after_midnight(): void + { + $shift = Shift::factory()->weekly(ShiftDayEnum::Monday)->between('22:00', '02:00')->create(); + + $this->assertTrue($this->service->shiftsCoveringMoment(Carbon::parse('2026-09-21 23:30'))->contains('id', $shift->id)); + $this->assertTrue($this->service->shiftsCoveringMoment(Carbon::parse('2026-09-22 01:59'))->contains('id', $shift->id)); + $this->assertCount(0, $this->service->shiftsCoveringMoment(Carbon::parse('2026-09-22 02:00'))); + $this->assertCount(0, $this->service->shiftsCoveringMoment(Carbon::parse('2026-09-21 21:59'))); + } + + public function test_shifts_covering_moment_handles_overnight_dated_shift_on_next_day(): void + { + $shift = Shift::factory()->onDate('2026-09-21')->between('22:00', '02:00')->create(); + + $this->assertTrue($this->service->shiftsCoveringMoment(Carbon::parse('2026-09-22 01:00'))->contains('id', $shift->id)); + $this->assertCount(0, $this->service->shiftsCoveringMoment(Carbon::parse('2026-09-23 01:00'))); + } + + public function test_shifts_covering_moment_ignores_inactive_shifts(): void + { + Shift::factory()->onDate('2026-09-21')->between('08:00', '16:00')->inactive()->create(); + + $this->assertCount(0, $this->service->shiftsCoveringMoment(Carbon::parse('2026-09-21 10:00'))); + } + + public function test_available_doctors_at_returns_only_doctors_on_covering_shifts(): void + { + $doctor = $this->doctor(); + $supervisor = User::factory()->create(['level' => UserEnum::Supervisor->value]); + Shift::factory()->onDate('2026-09-21')->forDoctor($doctor)->create(); + Shift::factory()->onDate('2026-09-21')->create(['user_id' => $supervisor->id]); + Shift::factory()->onDate('2026-09-21')->create(); + + $available = $this->service->availableDoctorsAt(Carbon::parse('2026-09-21 10:00')); + + $this->assertCount(1, $available); + $this->assertTrue($available->first()->is($doctor)); + } + + public function test_available_doctors_at_returns_empty_when_no_shift_covers_moment(): void + { + Shift::factory()->onDate('2026-09-21')->forDoctor()->create(); + + $this->assertCount(0, $this->service->availableDoctorsAt(Carbon::parse('2026-09-22 10:00'))); + } + + public function test_is_on_shift_reflects_doctor_assignment(): void + { + $doctor = $this->doctor(); + $other = $this->doctor(); + Shift::factory()->onDate('2026-09-21')->forDoctor($doctor)->create(); + + $this->assertTrue($this->service->isOnShift($doctor, Carbon::parse('2026-09-21 10:00'))); + $this->assertFalse($this->service->isOnShift($other, Carbon::parse('2026-09-21 10:00'))); + $this->assertFalse($this->service->isOnShift($doctor, Carbon::parse('2026-09-21 18:00'))); + } + + public function test_auto_select_doctor_picks_on_shift_doctor_with_fewest_open_documents(): void + { + $busy = $this->doctor(); + $free = $this->doctor(); + Shift::factory()->onDate('2026-09-21')->forDoctor($busy)->create(); + Shift::factory()->onDate('2026-09-21')->forDoctor($free)->create(); + DocumentAssignment::factory()->count(2)->create(['user_id' => $busy->id, 'document_id' => fn () => Document::factory()->inProgress()->create()->id]); + DocumentAssignment::factory()->create(['user_id' => $free->id, 'document_id' => Document::factory()->completed()->create()->id]); + + $selected = $this->service->autoSelectDoctor(Carbon::parse('2026-09-21 10:00')); + + $this->assertTrue($selected->is($free)); + } + + public function test_auto_select_doctor_breaks_ties_by_lowest_id(): void + { + $first = $this->doctor(); + $second = $this->doctor(); + Shift::factory()->onDate('2026-09-21')->forDoctor($second)->create(); + Shift::factory()->onDate('2026-09-21')->forDoctor($first)->create(); + + $this->assertTrue($this->service->autoSelectDoctor(Carbon::parse('2026-09-21 10:00'))->is($first)); + } + + public function test_auto_select_doctor_falls_back_to_nearest_upcoming_shift(): void + { + $later = $this->doctor(); + $sooner = $this->doctor(); + Shift::factory()->onDate('2026-09-23')->between('08:00', '12:00')->forDoctor($later)->create(); + Shift::factory()->onDate('2026-09-22')->between('08:00', '12:00')->forDoctor($sooner)->create(); + + $this->assertTrue($this->service->autoSelectDoctor(Carbon::parse('2026-09-21 10:00'))->is($sooner)); + } + + public function test_auto_select_doctor_returns_null_when_no_doctor_has_any_shift(): void + { + Shift::factory()->onDate('2026-09-21')->create(); + + $this->assertNull($this->service->autoSelectDoctor(Carbon::parse('2026-09-21 10:00'))); + } + + public function test_next_shift_start_returns_todays_shift_when_it_has_not_started(): void + { + $doctor = $this->doctor(); + Shift::factory()->weekly(ShiftDayEnum::Monday)->between('14:00', '18:00')->forDoctor($doctor)->create(); + + $next = $this->service->nextShiftStart($doctor, Carbon::parse('2026-09-21 10:00')); + + $this->assertTrue($next->eq(Carbon::parse('2026-09-21 14:00'))); + } + + public function test_next_shift_start_skips_shift_that_already_started_today(): void + { + $doctor = $this->doctor(); + Shift::factory()->weekly(ShiftDayEnum::Monday)->between('08:00', '12:00')->forDoctor($doctor)->create(); + + $next = $this->service->nextShiftStart($doctor, Carbon::parse('2026-09-21 10:00')); + + $this->assertTrue($next->eq(Carbon::parse('2026-09-28 08:00'))); + } + + public function test_next_shift_start_returns_earliest_among_multiple_shifts(): void + { + $doctor = $this->doctor(); + Shift::factory()->weekly(ShiftDayEnum::Wednesday)->between('08:00', '12:00')->forDoctor($doctor)->create(); + Shift::factory()->onDate('2026-09-22')->between('09:00', '12:00')->forDoctor($doctor)->create(); + + $next = $this->service->nextShiftStart($doctor, Carbon::parse('2026-09-21 10:00')); + + $this->assertTrue($next->eq(Carbon::parse('2026-09-22 09:00'))); + } + + public function test_next_shift_start_returns_null_when_doctor_has_no_upcoming_shift(): void + { + $doctor = $this->doctor(); + Shift::factory()->onDate('2026-09-20')->forDoctor($doctor)->create(); + Shift::factory()->onDate('2026-09-30')->forDoctor($doctor)->create(); + + $this->assertNull($this->service->nextShiftStart($doctor, Carbon::parse('2026-09-21 10:00'))); + } + + public function test_has_conflict_returns_false_without_user(): void + { + $this->assertFalse($this->service->hasConflict(['day_of_week' => ShiftDayEnum::Monday->value, 'start_time' => '08:00', 'end_time' => '12:00'])); + } + + public function test_has_conflict_detects_overlapping_weekly_shift_for_same_doctor(): void + { + $doctor = $this->doctor(); + Shift::factory()->weekly(ShiftDayEnum::Monday)->between('08:00', '12:00')->forDoctor($doctor)->create(); + + $this->assertTrue($this->service->hasConflict(['user_id' => $doctor->id, 'day_of_week' => ShiftDayEnum::Monday->value, 'date' => null, 'start_time' => '11:00', 'end_time' => '14:00'])); + $this->assertFalse($this->service->hasConflict(['user_id' => $doctor->id, 'day_of_week' => ShiftDayEnum::Monday->value, 'date' => null, 'start_time' => '12:00', 'end_time' => '14:00'])); + } + + public function test_has_conflict_ignores_other_doctors_and_inactive_shifts(): void + { + $doctor = $this->doctor(); + Shift::factory()->weekly(ShiftDayEnum::Monday)->between('08:00', '12:00')->forDoctor()->create(); + Shift::factory()->weekly(ShiftDayEnum::Monday)->between('08:00', '12:00')->forDoctor($doctor)->inactive()->create(); + + $this->assertFalse($this->service->hasConflict(['user_id' => $doctor->id, 'day_of_week' => ShiftDayEnum::Monday->value, 'date' => null, 'start_time' => '09:00', 'end_time' => '11:00'])); + } + + public function test_has_conflict_ignores_given_shift_id(): void + { + $doctor = $this->doctor(); + $shift = Shift::factory()->weekly(ShiftDayEnum::Monday)->between('08:00', '12:00')->forDoctor($doctor)->create(); + + $this->assertFalse($this->service->hasConflict(['user_id' => $doctor->id, 'day_of_week' => ShiftDayEnum::Monday->value, 'date' => null, 'start_time' => '09:00', 'end_time' => '11:00'], $shift->id)); + } + + public function test_claim_assigns_open_shift_to_doctor(): void + { + $doctor = $this->doctor(); + $shift = Shift::factory()->onDate('2026-09-21')->create(); + + $claimed = $this->service->claim($shift->id, $doctor); + + $this->assertSame($doctor->id, $claimed->user_id); + $this->assertDatabaseHas('shifts', ['id' => $shift->id, 'user_id' => $doctor->id]); + } + + public function test_claim_throws_when_shift_is_already_taken(): void + { + $doctor = $this->doctor(); + $shift = Shift::factory()->onDate('2026-09-21')->forDoctor()->create(); + + try { + $this->service->claim($shift->id, $doctor); + $this->fail('ValidationException was not thrown'); + } catch (ValidationException $e) { + $this->assertArrayHasKey('shift', $e->errors()); + } + + $this->assertNotSame($doctor->id, $shift->fresh()->user_id); + } + + public function test_claim_throws_when_shift_is_inactive(): void + { + $shift = Shift::factory()->onDate('2026-09-21')->inactive()->create(); + + $this->expectException(ValidationException::class); + + $this->service->claim($shift->id, $this->doctor()); + } + + public function test_claim_throws_when_shift_conflicts_with_doctors_schedule(): void + { + $doctor = $this->doctor(); + Shift::factory()->onDate('2026-09-21')->between('08:00', '12:00')->forDoctor($doctor)->create(); + $shift = Shift::factory()->onDate('2026-09-21')->between('10:00', '14:00')->create(); + + $this->expectException(ValidationException::class); + + $this->service->claim($shift->id, $doctor); + } + + public function test_claim_throws_for_unknown_shift(): void + { + $this->expectException(ModelNotFoundException::class); + + $this->service->claim(999, $this->doctor()); + } + + public function test_release_clears_doctor_from_own_shift(): void + { + $doctor = $this->doctor(); + $shift = Shift::factory()->onDate('2026-09-21')->forDoctor($doctor)->create(); + + $released = $this->service->release($shift, $doctor); + + $this->assertNull($released->user_id); + $this->assertDatabaseHas('shifts', ['id' => $shift->id, 'user_id' => null]); + } + + public function test_release_throws_when_shift_belongs_to_another_doctor(): void + { + $shift = Shift::factory()->onDate('2026-09-21')->forDoctor()->create(); + + $this->expectException(ValidationException::class); + + $this->service->release($shift, $this->doctor()); + } + + public function test_overlaps_detects_same_weekday_time_overlap(): void + { + $first = new Shift(['day_of_week' => ShiftDayEnum::Monday->value, 'start_time' => '08:00', 'end_time' => '12:00']); + $second = new Shift(['day_of_week' => ShiftDayEnum::Monday->value, 'start_time' => '11:59', 'end_time' => '13:00']); + $third = new Shift(['day_of_week' => ShiftDayEnum::Tuesday->value, 'start_time' => '08:00', 'end_time' => '12:00']); + + $this->assertTrue($this->service->overlaps($first, $second)); + $this->assertFalse($this->service->overlaps($first, $third)); + } + + public function test_overlaps_treats_touching_boundaries_as_non_overlapping(): void + { + $first = new Shift(['day_of_week' => ShiftDayEnum::Monday->value, 'start_time' => '08:00', 'end_time' => '12:00']); + $second = new Shift(['day_of_week' => ShiftDayEnum::Monday->value, 'start_time' => '12:00', 'end_time' => '16:00']); + + $this->assertFalse($this->service->overlaps($first, $second)); + } + + public function test_overlaps_compares_dated_shifts_by_date(): void + { + $first = new Shift(['date' => '2026-09-21', 'start_time' => '08:00', 'end_time' => '12:00']); + $sameDay = new Shift(['date' => '2026-09-21', 'start_time' => '10:00', 'end_time' => '14:00']); + $nextWeek = new Shift(['date' => '2026-09-28', 'start_time' => '10:00', 'end_time' => '14:00']); + + $this->assertTrue($this->service->overlaps($first, $sameDay)); + $this->assertFalse($this->service->overlaps($first, $nextWeek)); + } + + public function test_overlaps_compares_dated_and_weekly_shifts_by_weekday(): void + { + $dated = new Shift(['date' => '2026-09-21', 'start_time' => '08:00', 'end_time' => '12:00']); + $weekly = new Shift(['day_of_week' => ShiftDayEnum::Monday->value, 'start_time' => '10:00', 'end_time' => '14:00']); + + $this->assertTrue($this->service->overlaps($dated, $weekly)); + } + + public function test_overlaps_handles_overnight_shift_spilling_into_next_day(): void + { + $overnight = new Shift(['day_of_week' => ShiftDayEnum::Monday->value, 'start_time' => '22:00', 'end_time' => '02:00']); + $tuesdayMorning = new Shift(['day_of_week' => ShiftDayEnum::Tuesday->value, 'start_time' => '01:00', 'end_time' => '05:00']); + $tuesdayLater = new Shift(['day_of_week' => ShiftDayEnum::Tuesday->value, 'start_time' => '02:00', 'end_time' => '05:00']); + + $this->assertTrue($this->service->overlaps($overnight, $tuesdayMorning)); + $this->assertFalse($this->service->overlaps($overnight, $tuesdayLater)); + } + + public function test_overlaps_wraps_friday_overnight_into_saturday(): void + { + $overnight = new Shift(['day_of_week' => ShiftDayEnum::Friday->value, 'start_time' => '22:00', 'end_time' => '02:00']); + $saturday = new Shift(['day_of_week' => ShiftDayEnum::Saturday->value, 'start_time' => '01:00', 'end_time' => '05:00']); + + $this->assertTrue($this->service->overlaps($overnight, $saturday)); + } +} diff --git a/tests/Feature/Services/Sms/IranPayamakServiceTest.php b/tests/Feature/Services/Sms/IranPayamakServiceTest.php new file mode 100644 index 0000000..62e11df --- /dev/null +++ b/tests/Feature/Services/Sms/IranPayamakServiceTest.php @@ -0,0 +1,74 @@ + 'https://sms.test/pattern', + 'services.iranpayamak.api_key' => 'api-key', + 'services.iranpayamak.line_number' => '5000', + 'services.iranpayamak.patterns.verify_code' => 'pattern-1', + ]); + $this->service = app(IranPayamakService::class); + } + + public function test_send_pattern_posts_expected_payload_and_headers(): void + { + Http::fake(['sms.test/pattern' => Http::response(['status' => 'ok'])]); + + $result = $this->service->sendPattern('09120000000', 'verify_code', ['otp' => '1234']); + + $this->assertSame(['status' => 'ok'], $result); + Http::assertSent(function (Request $request) { + return $request->url() === 'https://sms.test/pattern' + && $request->hasHeader('Api-Key', 'api-key') + && $request['code'] === 'pattern-1' + && $request['attributes'] === ['otp' => '1234'] + && $request['recipient'] === '09120000000' + && $request['line_number'] === '5000' + && $request['number_format'] === 'english'; + }); + } + + public function test_send_pattern_converts_plus98_prefix_to_local_format(): void + { + Http::fake(['sms.test/pattern' => Http::response([])]); + + $this->service->sendPattern('+989120000000', 'verify_code', []); + + Http::assertSent(fn (Request $request) => $request['recipient'] === '09120000000'); + } + + public function test_send_pattern_returns_empty_array_for_empty_body(): void + { + Http::fake(['sms.test/pattern' => Http::response('', 200)]); + + $this->assertSame([], $this->service->sendPattern('09120000000', 'verify_code', [])); + } + + public function test_send_pattern_throws_on_unsuccessful_response(): void + { + Http::fake(['sms.test/pattern' => Http::response('unauthorized', 401)]); + + try { + $this->service->sendPattern('09120000000', 'verify_code', []); + $this->fail('RuntimeException was not thrown'); + } catch (RuntimeException $e) { + $this->assertStringContainsString('401', $e->getMessage()); + $this->assertStringContainsString('unauthorized', $e->getMessage()); + } + } +} diff --git a/tests/Feature/Services/Sms/SmsNotificationServiceTest.php b/tests/Feature/Services/Sms/SmsNotificationServiceTest.php new file mode 100644 index 0000000..5220ea5 --- /dev/null +++ b/tests/Feature/Services/Sms/SmsNotificationServiceTest.php @@ -0,0 +1,82 @@ + [ + 'request_registered' => 'p-registered', + 'payment_confirmed' => 'p-payment', + 'report_ready' => 'p-report', + ]]); + $this->service = app(SmsNotificationService::class); + } + + public function test_request_registered_dispatches_sms_to_requester(): void + { + $this->service->requestRegistered(Document::factory()->create()); + + Queue::assertPushed(SendPatternSms::class, 1); + } + + public function test_payment_confirmed_dispatches_sms_to_invoice_user(): void + { + $this->service->paymentConfirmed(Invoice::factory()->create()); + + Queue::assertPushed(SendPatternSms::class, 1); + } + + public function test_report_ready_dispatches_sms_to_requester(): void + { + $this->service->reportReady(Document::factory()->create()); + + Queue::assertPushed(SendPatternSms::class, 1); + } + + public function test_nothing_is_dispatched_when_pattern_is_missing(): void + { + config(['services.iranpayamak.patterns.request_registered' => null]); + + $this->service->requestRegistered(Document::factory()->create()); + + Queue::assertNothingPushed(); + } + + public function test_nothing_is_dispatched_when_document_has_no_requester(): void + { + $document = Document::factory()->create(); + $document->requester()->dissociate(); + $document->setRelation('requester', null); + + $this->service->reportReady($document); + + Queue::assertNothingPushed(); + } + + public function test_nothing_is_dispatched_when_user_phone_is_empty(): void + { + $document = Document::factory()->create(); + $document->requester->phone = ''; + + $this->service->requestRegistered($document); + + Queue::assertNothingPushed(); + } +}