Add feature tests for Document, FastRequest, FileStorage, Shift, and SMS services
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\ShiftDayEnum;
|
||||
use App\Enums\UserEnum;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Shift>
|
||||
*/
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -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` | ☑ |
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Services\Document;
|
||||
|
||||
use App\Enums\DocumentStatusEnum;
|
||||
use App\Enums\InvoiceStatusEnum;
|
||||
use App\Enums\MediaCollectionEnum;
|
||||
use App\Enums\ReportStatusEnum;
|
||||
use App\Enums\UserEnum;
|
||||
use App\Enums\WalletTransactionReasonEnum;
|
||||
use App\Jobs\SendPatternSms;
|
||||
use App\Models\Document;
|
||||
use App\Models\DocumentAssignment;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Medium;
|
||||
use App\Models\Report;
|
||||
use App\Models\Shift;
|
||||
use App\Models\User;
|
||||
use App\Models\Wallet;
|
||||
use App\Services\Document\CaseService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CaseServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected CaseService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Queue::fake();
|
||||
Storage::fake('public');
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Services\Document;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Services\Document\DocumentCodeService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DocumentCodeServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected DocumentCodeService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Services\Document;
|
||||
|
||||
use App\Enums\DocumentModalityEnum;
|
||||
use App\Enums\DocumentPriorityEnum;
|
||||
use App\Enums\DocumentStatusEnum;
|
||||
use App\Enums\InvoiceStatusEnum;
|
||||
use App\Enums\PetGenderEnum;
|
||||
use App\Enums\PetTypeEnum;
|
||||
use App\Enums\UserEnum;
|
||||
use App\Jobs\SendPatternSms;
|
||||
use App\Models\Pet;
|
||||
use App\Models\Shift;
|
||||
use App\Models\User;
|
||||
use App\Services\Document\FastRequestService;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class FastRequestServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected FastRequestService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Queue::fake();
|
||||
Storage::fake('public');
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Services;
|
||||
|
||||
use App\Enums\MediaDiskEnum;
|
||||
use App\Models\Medium;
|
||||
use App\Services\FileStorageService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class FileStorageServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected FileStorageService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Storage::fake('public');
|
||||
Storage::fake('private');
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Services\Shift;
|
||||
|
||||
use App\Enums\DocumentStatusEnum;
|
||||
use App\Enums\ShiftDayEnum;
|
||||
use App\Enums\UserEnum;
|
||||
use App\Models\Document;
|
||||
use App\Models\DocumentAssignment;
|
||||
use App\Models\Shift;
|
||||
use App\Models\User;
|
||||
use App\Services\Shift\ShiftScheduleService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ShiftScheduleServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected ShiftScheduleService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Services\Sms;
|
||||
|
||||
use App\Services\Sms\IranPayamakService;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class IranPayamakServiceTest extends TestCase
|
||||
{
|
||||
protected IranPayamakService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'services.iranpayamak.api_url' => '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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Services\Sms;
|
||||
|
||||
use App\Jobs\SendPatternSms;
|
||||
use App\Models\Document;
|
||||
use App\Models\Invoice;
|
||||
use App\Services\Sms\SmsNotificationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SmsNotificationServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected SmsNotificationService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Queue::fake();
|
||||
config(['services.iranpayamak.patterns' => [
|
||||
'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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user