diff --git a/app/Http/Resources/Admin/ReportResource.php b/app/Http/Resources/Admin/ReportResource.php index f3f0903..508342f 100644 --- a/app/Http/Resources/Admin/ReportResource.php +++ b/app/Http/Resources/Admin/ReportResource.php @@ -17,10 +17,10 @@ class ReportResource extends JsonResource { return [ 'id' => $this->id, - 'document' => $this->relationLoaded('document') ? $this->document->only(['id','code']) : null, + 'document' => $this->relationLoaded('document') ? optional($this->document)->only(['id','code']) : null, 'status' => $this->status, 'body' => $this->body, - 'doctor' => $this->relationLoaded('doctor') ? $this->doctor->only(['id','fullname']) : null, + 'doctor' => $this->relationLoaded('doctor') ? optional($this->doctor)->only(['id','fullname']) : null, 'submitted_at' => verta($this->submitted_at)->format('%Y/%m/%d - H:i'), 'submitted_at_form' => $this->submitted_at, ]; diff --git a/database/factories/WalletTransactionFactory.php b/database/factories/WalletTransactionFactory.php new file mode 100644 index 0000000..1e98705 --- /dev/null +++ b/database/factories/WalletTransactionFactory.php @@ -0,0 +1,39 @@ + + */ +class WalletTransactionFactory extends Factory +{ + public function definition(): array + { + $amount = fake()->numberBetween(10000, 1000000); + + return [ + 'type' => WalletTransactionTypeEnum::Deposit, + 'reason' => WalletTransactionReasonEnum::TopUp, + 'amount' => $amount, + 'balance_after' => $amount, + 'description' => null, + 'wallet_id' => Wallet::factory(), + 'invoice_id' => null, + 'payment_id' => null, + 'withdraw_request_id' => null, + ]; + } + + public function withdrawal(WalletTransactionReasonEnum $reason = WalletTransactionReasonEnum::InvoicePayment): static + { + return $this->state(fn (array $attributes) => [ + 'type' => WalletTransactionTypeEnum::Withdrawal, + 'reason' => $reason, + ]); + } +} diff --git a/docs/TESTING.md b/docs/TESTING.md index 48b809c..288d21a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -58,7 +58,8 @@ app/Models/Shift.php → tests/Feature/Models/ShiftTes | `FileStorageService`, `Medium` | `Storage::fake('public')`, `Storage::fake('private')` | | Time-dependent logic (shifts, OTP expiry, processing hours) | `Carbon::setTestNow(...)` | | Authenticated user | `$this->actingAs(User::factory()->create())` | -| Spatie roles/permissions | seed `PermissionSeeder` in the test, then `$user->givePermissionTo(...)` / `assignRole(...)` | +| Spatie roles/permissions | `InteractsWithAdmin::adminWithAllPermissions()` (seeds `PermissionSeeder`) or `$user->givePermissionTo(...)` | +| Excel exports (`Maatwebsite`) | `Excel::fake()` + `Excel::assertDownloaded('file.xlsx')` | ## 4. Required coverage per layer @@ -71,6 +72,11 @@ Cover `authorize()` when it is not `return true`; each rule with an invalid inpu **HTTP** — guest is redirected to login; wrong role or non-owner gets 403/404; happy path returns the expected status and persisted state; Inertia page tests assert the component name and key props. +`Tests\TestCase` already calls `withoutVite()` and points `inertia.testing.page_paths` at +`resources/js/Client/Pages` and `resources/js/Admin/Pages`. Admin tests use the `Tests\Concerns\InteractsWithAdmin` +trait: `adminWithAllPermissions()` seeds `PermissionSeeder` and returns `admin@gmail.com`; `adminWithoutPermissions()` +returns a bare `مدیر` user for 403 checks. A resource collection passed as an Inertia prop is wrapped in `data` +(`has('items.data', n)`). Use `Excel::fake()` + `Excel::assertDownloaded()` for export routes. **Enum** — every method containing logic, including edge values. @@ -143,30 +149,30 @@ Legend: `☐` not written · `☑` written · `—` intentionally not tested | Priority | Source | Test | Status | |----------|--------|------|--------| -| P1 | `app/Http/Controllers/Client/AuthController.php` | `tests/Feature/Http/Client/AuthControllerTest.php` | ☐ | -| P1 | `app/Http/Controllers/Client/PaymentController.php` | `tests/Feature/Http/Client/PaymentControllerTest.php` | ☐ | -| P1 | `app/Http/Controllers/Client/WalletController.php` | `tests/Feature/Http/Client/WalletControllerTest.php` | ☐ | -| P1 | `app/Http/Controllers/Client/FastRequestController.php` | `tests/Feature/Http/Client/FastRequestControllerTest.php` | ☐ | -| P1 | `app/Http/Controllers/Client/InvoiceController.php` | `tests/Feature/Http/Client/InvoiceControllerTest.php` | ☐ | -| P1 | `app/Http/Controllers/Admin/WithdrawRequestController.php` | `tests/Feature/Http/Admin/WithdrawRequestControllerTest.php` | ☐ | -| P2 | `app/Http/Controllers/Client/Doctor/CaseController.php` | `tests/Feature/Http/Client/Doctor/CaseControllerTest.php` | ☐ | -| P2 | `app/Http/Controllers/Client/Doctor/ReportController.php` | `tests/Feature/Http/Client/Doctor/ReportControllerTest.php` | ☐ | -| P2 | `app/Http/Controllers/Client/Doctor/ShiftController.php` | `tests/Feature/Http/Client/Doctor/ShiftControllerTest.php` | ☐ | -| P2 | `app/Http/Controllers/Client/Doctor/DocumentController.php` | `tests/Feature/Http/Client/Doctor/DocumentControllerTest.php` | ☐ | -| P2 | `app/Http/Controllers/Client/Doctor/DashboardController.php` | `tests/Feature/Http/Client/Doctor/DashboardControllerTest.php` | ☐ | -| P2 | `app/Http/Controllers/Client/MediaController.php` | `tests/Feature/Http/Client/MediaControllerTest.php` | ☐ | -| P2 | `app/Http/Controllers/Client/ProfileController.php` | `tests/Feature/Http/Client/ProfileControllerTest.php` | ☐ | -| P2 | `app/Http/Controllers/Admin/AuthController.php` | `tests/Feature/Http/Admin/AuthControllerTest.php` | ☐ | -| P2 | `app/Http/Controllers/Admin/DocumentController.php` | `tests/Feature/Http/Admin/DocumentControllerTest.php` | ☐ | -| P2 | `app/Http/Controllers/Admin/ShiftController.php` | `tests/Feature/Http/Admin/ShiftControllerTest.php` | ☐ | -| P2 | `app/Http/Controllers/Admin/UserController.php` | `tests/Feature/Http/Admin/UserControllerTest.php` | ☐ | -| P2 | `app/Http/Controllers/Admin/ReportController.php` | `tests/Feature/Http/Admin/ReportControllerTest.php` | ☐ | -| P2 | `app/Http/Controllers/Admin/PetController.php` | `tests/Feature/Http/Admin/PetControllerTest.php` | ☐ | -| P2 | `app/Http/Controllers/Admin/MediumController.php` | `tests/Feature/Http/Admin/MediumControllerTest.php` | ☐ | -| P2 | `app/Http/Controllers/Admin/SettingUserController.php` | `tests/Feature/Http/Admin/SettingUserControllerTest.php` | ☐ | -| P3 | `app/Http/Controllers/Admin/Role/RoleController.php` | `tests/Feature/Http/Admin/Role/RoleControllerTest.php` | ☐ | -| P3 | `app/Http/Controllers/Admin/Role/PermissionController.php` | `tests/Feature/Http/Admin/Role/PermissionControllerTest.php` | ☐ | -| P3 | `app/Http/Controllers/Admin/PanelController.php` | `tests/Feature/Http/Admin/PanelControllerTest.php` | ☐ | +| P1 | `app/Http/Controllers/Client/AuthController.php` | `tests/Feature/Http/Client/AuthControllerTest.php` | ☑ | +| P1 | `app/Http/Controllers/Client/PaymentController.php` | `tests/Feature/Http/Client/PaymentControllerTest.php` | ☑ | +| P1 | `app/Http/Controllers/Client/WalletController.php` | `tests/Feature/Http/Client/WalletControllerTest.php` | ☑ | +| P1 | `app/Http/Controllers/Client/FastRequestController.php` | `tests/Feature/Http/Client/FastRequestControllerTest.php` | ☑ | +| P1 | `app/Http/Controllers/Client/InvoiceController.php` | `tests/Feature/Http/Client/InvoiceControllerTest.php` | ☑ | +| P1 | `app/Http/Controllers/Admin/WithdrawRequestController.php` | `tests/Feature/Http/Admin/WithdrawRequestControllerTest.php` | ☑ | +| P2 | `app/Http/Controllers/Client/Doctor/CaseController.php` | `tests/Feature/Http/Client/Doctor/CaseControllerTest.php` | ☑ | +| P2 | `app/Http/Controllers/Client/Doctor/ReportController.php` | `tests/Feature/Http/Client/Doctor/ReportControllerTest.php` | ☑ | +| P2 | `app/Http/Controllers/Client/Doctor/ShiftController.php` | `tests/Feature/Http/Client/Doctor/ShiftControllerTest.php` | ☑ | +| P2 | `app/Http/Controllers/Client/Doctor/DocumentController.php` | `tests/Feature/Http/Client/Doctor/DocumentControllerTest.php` | ☑ | +| P2 | `app/Http/Controllers/Client/Doctor/DashboardController.php` | `tests/Feature/Http/Client/Doctor/DashboardControllerTest.php` | ☑ | +| P2 | `app/Http/Controllers/Client/MediaController.php` | `tests/Feature/Http/Client/MediaControllerTest.php` | ☑ | +| P2 | `app/Http/Controllers/Client/ProfileController.php` | `tests/Feature/Http/Client/ProfileControllerTest.php` | ☑ | +| P2 | `app/Http/Controllers/Admin/AuthController.php` | `tests/Feature/Http/Admin/AuthControllerTest.php` | ☑ (known: `loginAction` returns `request()->json(...)` instead of `response()->json(...)`, works by accident) | +| P2 | `app/Http/Controllers/Admin/DocumentController.php` | `tests/Feature/Http/Admin/DocumentControllerTest.php` | ☑ | +| P2 | `app/Http/Controllers/Admin/ShiftController.php` | `tests/Feature/Http/Admin/ShiftControllerTest.php` | ☑ | +| P2 | `app/Http/Controllers/Admin/UserController.php` | `tests/Feature/Http/Admin/UserControllerTest.php` | ☑ (known: `update()` writes nullable `username`/`national_code` straight to NOT NULL columns; the form always sends them) | +| P2 | `app/Http/Controllers/Admin/ReportController.php` | `tests/Feature/Http/Admin/ReportControllerTest.php` | ☑ (known: `edit()` renders `Reports/edit` which has no Vue page; `Report` model lacks `SoftDeletes` despite `deleted_at` column) | +| P2 | `app/Http/Controllers/Admin/PetController.php` | `tests/Feature/Http/Admin/PetControllerTest.php` | ☑ | +| P2 | `app/Http/Controllers/Admin/MediumController.php` | `tests/Feature/Http/Admin/MediumControllerTest.php` | ☑ | +| P2 | `app/Http/Controllers/Admin/SettingUserController.php` | `tests/Feature/Http/Admin/SettingUserControllerTest.php` | ☑ | +| P3 | `app/Http/Controllers/Admin/Role/RoleController.php` | `tests/Feature/Http/Admin/Role/RoleControllerTest.php` | ☑ | +| P3 | `app/Http/Controllers/Admin/Role/PermissionController.php` | `tests/Feature/Http/Admin/Role/PermissionControllerTest.php` | ☑ | +| P3 | `app/Http/Controllers/Admin/PanelController.php` | `tests/Feature/Http/Admin/PanelControllerTest.php` | ☑ (known: guests hit `auth` before `admin.access`, so they land on the client login, not `admin.auth.login`) | ### Enums @@ -196,7 +202,7 @@ Missing — create when first needed by a test: | Model | Status | |-------|--------| | `Wallet` | ☑ | -| `WalletTransaction` | ☐ | +| `WalletTransaction` | ☑ | | `WithdrawRequest` | ☑ | | `BankCard` | ☑ | | `Invoice` | ☑ | diff --git a/tests/Concerns/InteractsWithAdmin.php b/tests/Concerns/InteractsWithAdmin.php new file mode 100644 index 0000000..b2d3b80 --- /dev/null +++ b/tests/Concerns/InteractsWithAdmin.php @@ -0,0 +1,22 @@ +seed(PermissionSeeder::class); + + return User::where('email', 'admin@gmail.com')->firstOrFail(); + } + + protected function adminWithoutPermissions(): User + { + return User::factory()->create(['level' => UserEnum::Admin->value]); + } +} diff --git a/tests/Feature/Http/Admin/AuthControllerTest.php b/tests/Feature/Http/Admin/AuthControllerTest.php new file mode 100644 index 0000000..be7438d --- /dev/null +++ b/tests/Feature/Http/Admin/AuthControllerTest.php @@ -0,0 +1,70 @@ +get(route('admin.auth.login'))->assertOk()->assertInertia(fn (Assert $page) => $page->component('Auth/login')); + } + + public function test_login_page_redirects_authenticated_users_to_dashboard(): void + { + $this->actingAs(User::factory()->create())->get(route('admin.auth.login'))->assertRedirect('/admin/dashboard'); + } + + public function test_login_action_authenticates_with_email_and_password(): void + { + $user = User::factory()->create(['email' => 'admin@example.com', 'password' => 'secret123', 'level' => UserEnum::Admin->value]); + + $this->postJson('/admin/auth/login', ['email' => 'admin@example.com', 'password' => 'secret123'])->assertOk(); + + $this->assertAuthenticatedAs($user); + } + + public function test_login_action_rejects_wrong_password(): void + { + User::factory()->create(['email' => 'admin@example.com', 'password' => 'secret123']); + + $this->postJson('/admin/auth/login', ['email' => 'admin@example.com', 'password' => 'wrongpass']) + ->assertUnauthorized() + ->assertJsonPath('errors.email.0', 'ایمیل و کلمه عبور شما با هم مطابقت ندارن'); + + $this->assertGuest(); + } + + public function test_login_action_validates_input(): void + { + $this->postJson('/admin/auth/login', ['email' => 'unknown@example.com', 'password' => 'short']) + ->assertUnprocessable() + ->assertJsonValidationErrors(['email', 'password']); + } + + public function test_register_page_renders_for_guests(): void + { + $this->get(route('admin.auth.register'))->assertOk()->assertInertia(fn (Assert $page) => $page->component('Auth/register')); + } + + public function test_register_action_validates_input(): void + { + $this->postJson('/admin/auth/register', ['name' => 'a', 'email' => 'x', 'password' => 'short']) + ->assertUnprocessable() + ->assertJsonValidationErrors(['name', 'email', 'password']); + } + + public function test_logout_ends_session_and_redirects_to_login(): void + { + $this->actingAs(User::factory()->create())->get('/admin/auth/logout')->assertRedirect('/admin/auth/login'); + + $this->assertGuest(); + } +} diff --git a/tests/Feature/Http/Admin/DocumentControllerTest.php b/tests/Feature/Http/Admin/DocumentControllerTest.php new file mode 100644 index 0000000..4ce13ca --- /dev/null +++ b/tests/Feature/Http/Admin/DocumentControllerTest.php @@ -0,0 +1,165 @@ + Pet::factory()->create()->id, + 'status' => DocumentStatusEnum::Registered->name, + 'priority' => DocumentPriorityEnum::High->name, + 'modality' => 'mri', + ], $overrides); + } + + public function test_guest_is_redirected(): void + { + $this->get(route('admin.documents.index'))->assertRedirect(route('login')); + } + + public function test_admin_without_permission_is_forbidden(): void + { + $admin = $this->adminWithoutPermissions(); + $document = Document::factory()->create(); + + $this->actingAs($admin)->get(route('admin.documents.index'))->assertForbidden(); + $this->actingAs($admin)->get(route('admin.documents.create'))->assertForbidden(); + $this->actingAs($admin)->post(route('admin.documents.store'), $this->payload())->assertForbidden(); + $this->actingAs($admin)->get(route('admin.documents.edit', $document))->assertForbidden(); + $this->actingAs($admin)->put(route('admin.documents.update', $document), $this->payload())->assertForbidden(); + $this->actingAs($admin)->delete(route('admin.documents.destroy', $document))->assertForbidden(); + $this->actingAs($admin)->post('/admin/documents-upload')->assertForbidden(); + $this->actingAs($admin)->get('/admin/documents-options')->assertForbidden(); + } + + public function test_index_renders_with_filters(): void + { + $admin = $this->adminWithAllPermissions(); + $pet = Pet::factory()->create(); + Document::factory()->registered()->high()->create(['code' => '111111', 'pet_id' => $pet->id]); + Document::factory()->completed()->medium()->create(['code' => '222222']); + + $this->actingAs($admin)->get(route('admin.documents.index'))->assertOk()->assertInertia(fn (Assert $page) => $page->component('Documents/index')->has('documents.data', 2)); + $this->actingAs($admin)->get(route('admin.documents.index', ['status' => DocumentStatusEnum::Completed->name]))->assertInertia(fn (Assert $page) => $page->has('documents.data', 1)); + $this->actingAs($admin)->get(route('admin.documents.index', ['priority' => DocumentPriorityEnum::High->name]))->assertInertia(fn (Assert $page) => $page->has('documents.data', 1)); + $this->actingAs($admin)->get(route('admin.documents.index', ['pet' => $pet->id]))->assertInertia(fn (Assert $page) => $page->has('documents.data', 1)); + $this->actingAs($admin)->get(route('admin.documents.index', ['search' => '2222']))->assertInertia(fn (Assert $page) => $page->has('documents.data', 1)); + } + + public function test_create_and_edit_pages_render(): void + { + $admin = $this->adminWithAllPermissions(); + $document = Document::factory()->create(); + + $this->actingAs($admin)->get(route('admin.documents.create'))->assertInertia(fn (Assert $page) => $page->component('Documents/create_and_edit')->has('pets')); + $this->actingAs($admin)->get(route('admin.documents.edit', $document))->assertInertia(fn (Assert $page) => $page->component('Documents/create_and_edit')->where('is_edit', true)->where('document.id', $document->id)); + } + + public function test_store_creates_document_with_files_attached_by_collection(): void + { + $admin = $this->adminWithAllPermissions(); + $primary = Medium::factory()->create(); + $history = Medium::factory()->create(); + + $this->actingAs($admin)->post(route('admin.documents.store'), $this->payload([ + 'primary_files' => [$primary->id], + 'history_files' => [$history->id], + ]))->assertOk()->assertSessionHas('success'); + + $document = Document::first(); + $this->assertSame(DocumentStatusEnum::Registered, $document->status); + $this->assertSame(DocumentPriorityEnum::High, $document->priority); + $this->assertSame($admin->id, $document->requester_id); + $this->assertSame([$primary->id], $document->primaryFiles->pluck('id')->all()); + $this->assertSame([$history->id], $document->historyFiles->pluck('id')->all()); + } + + public function test_store_validates_pet(): void + { + $this->actingAs($this->adminWithAllPermissions())->postJson(route('admin.documents.store'), ['pet_id' => 999])->assertUnprocessable()->assertJsonValidationErrors('pet_id'); + } + + public function test_update_replaces_file_collections(): void + { + $admin = $this->adminWithAllPermissions(); + $document = Document::factory()->create(); + $old = Medium::factory()->create(); + $new = Medium::factory()->create(); + $document->media()->attach($old->id, ['collection' => 'primary']); + + $this->actingAs($admin)->put(route('admin.documents.update', $document), $this->payload([ + 'status' => DocumentStatusEnum::InProgress->name, + 'primary_files' => [$new->id], + ]))->assertOk(); + + $document->refresh(); + $this->assertSame(DocumentStatusEnum::InProgress, $document->status); + $this->assertSame([$new->id], $document->primaryFiles->pluck('id')->all()); + } + + public function test_destroy_deletes_document(): void + { + $document = Document::factory()->create(); + + $this->actingAs($this->adminWithAllPermissions())->delete(route('admin.documents.destroy', $document))->assertOk(); + + $this->assertDatabaseMissing('documents', ['id' => $document->id]); + } + + public function test_upload_file_stores_private_medium(): void + { + Storage::fake('private'); + + $this->actingAs($this->adminWithAllPermissions())->postJson('/admin/documents-upload', ['file' => UploadedFile::fake()->create('scan.pdf', 10, 'application/pdf')]) + ->assertOk() + ->assertJsonPath('disk', MediaDiskEnum::Private->value); + + $medium = Medium::first(); + $this->assertStringStartsWith('documents/', $medium->path); + Storage::disk('private')->assertExists($medium->path); + } + + public function test_upload_file_validates_presence(): void + { + $this->actingAs($this->adminWithAllPermissions())->postJson('/admin/documents-upload', [])->assertUnprocessable()->assertJsonValidationErrors('file'); + } + + public function test_get_options_returns_matching_documents(): void + { + $document = Document::factory()->create(['code' => '123456', 'modality' => 'mri']); + Document::factory()->create(['code' => '999999']); + + $this->actingAs($this->adminWithAllPermissions())->getJson('/admin/documents-options?search=1234') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.value', $document->id) + ->assertJsonPath('data.0.label', 'پرونده 123456 - mri'); + } + + public function test_export_data_downloads_excel(): void + { + Excel::fake(); + + $this->actingAs($this->adminWithAllPermissions())->get('/admin/documents-export-data')->assertOk(); + + Excel::assertDownloaded('documents.xlsx'); + } +} diff --git a/tests/Feature/Http/Admin/MediumControllerTest.php b/tests/Feature/Http/Admin/MediumControllerTest.php new file mode 100644 index 0000000..11ae11c --- /dev/null +++ b/tests/Feature/Http/Admin/MediumControllerTest.php @@ -0,0 +1,100 @@ +get('/admin/medium')->assertRedirect(route('login')); + } + + public function test_admin_without_permission_is_forbidden(): void + { + $admin = $this->adminWithoutPermissions(); + $medium = Medium::factory()->create(); + + $this->actingAs($admin)->get('/admin/medium')->assertForbidden(); + $this->actingAs($admin)->post('/admin/medium')->assertForbidden(); + $this->actingAs($admin)->delete("/admin/medium/{$medium->id}")->assertForbidden(); + $this->actingAs($admin)->get('/admin/get-medium')->assertForbidden(); + $this->actingAs($admin)->get("/admin/medium/{$medium->id}/download")->assertForbidden(); + $this->actingAs($admin)->get("/admin/medium/{$medium->id}/serve")->assertForbidden(); + } + + public function test_index_renders_paginated_media(): void + { + Medium::factory()->count(2)->create(); + + $this->actingAs($this->adminWithAllPermissions())->get('/admin/medium')->assertOk()->assertInertia(fn (Assert $page) => $page->component('Medium')->has('medium.data', 2)); + } + + public function test_store_uploads_public_file_into_media_directory(): void + { + $this->actingAs($this->adminWithAllPermissions())->postJson('/admin/medium', ['file' => UploadedFile::fake()->image('pic.png')]) + ->assertOk() + ->assertJsonPath('disk', 'public'); + + $medium = Medium::first(); + $this->assertStringStartsWith('media/', $medium->path); + Storage::disk('public')->assertExists($medium->path); + } + + public function test_store_validates_file(): void + { + $this->actingAs($this->adminWithAllPermissions())->postJson('/admin/medium', [])->assertUnprocessable()->assertJsonValidationErrors('file'); + } + + public function test_destroy_removes_file_and_record(): void + { + $medium = Medium::factory()->public()->create(['path' => 'media/a.png']); + Storage::disk('public')->put('media/a.png', 'x'); + + $this->actingAs($this->adminWithAllPermissions())->from('/admin/medium')->delete("/admin/medium/{$medium->id}")->assertRedirect('/admin/medium')->assertSessionHas('success'); + + Storage::disk('public')->assertMissing('media/a.png'); + $this->assertDatabaseMissing('media', ['id' => $medium->id]); + } + + public function test_get_medium_returns_paginated_json(): void + { + Medium::factory()->count(3)->create(); + + $this->actingAs($this->adminWithAllPermissions())->getJson('/admin/get-medium')->assertOk()->assertJsonCount(3, 'data'); + } + + public function test_download_and_serve_private_file(): void + { + $medium = Medium::factory()->private()->create(['path' => 'documents/f.txt', 'filename' => 'f.txt', 'mime_type' => 'text/plain']); + Storage::disk('private')->put('documents/f.txt', 'body'); + $admin = $this->adminWithAllPermissions(); + + $this->actingAs($admin)->get("/admin/medium/{$medium->id}/download")->assertOk()->assertDownload('f.txt'); + $this->actingAs($admin)->get("/admin/medium/{$medium->id}/serve")->assertOk()->assertHeader('Content-Type', 'text/plain; charset=UTF-8')->assertSee('body', false); + } + + public function test_download_public_file_is_not_found(): void + { + $medium = Medium::factory()->public()->create(); + + $this->actingAs($this->adminWithAllPermissions())->get("/admin/medium/{$medium->id}/download")->assertNotFound(); + } +} diff --git a/tests/Feature/Http/Admin/PanelControllerTest.php b/tests/Feature/Http/Admin/PanelControllerTest.php new file mode 100644 index 0000000..680488f --- /dev/null +++ b/tests/Feature/Http/Admin/PanelControllerTest.php @@ -0,0 +1,31 @@ +get(route('admin.dashboard'))->assertRedirect(route('login')); + } + + public function test_non_admin_user_gets_not_found(): void + { + $this->actingAs(User::factory()->create(['level' => UserEnum::Doctor->value]))->get(route('admin.dashboard'))->assertNotFound(); + } + + public function test_admin_sees_dashboard(): void + { + $this->actingAs(User::factory()->create(['level' => UserEnum::Admin->value]))->get(route('admin.dashboard')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page->component('index')); + } +} diff --git a/tests/Feature/Http/Admin/PetControllerTest.php b/tests/Feature/Http/Admin/PetControllerTest.php new file mode 100644 index 0000000..58d3631 --- /dev/null +++ b/tests/Feature/Http/Admin/PetControllerTest.php @@ -0,0 +1,124 @@ + 'Rex', + 'type' => PetTypeEnum::Dog->value, + 'gender' => PetGenderEnum::Male->value, + 'breed' => 'ژرمن', + 'weight' => '20', + 'sterilized' => true, + 'birthday' => '2020-01-01', + ], $overrides); + } + + public function test_guest_is_redirected(): void + { + $this->get(route('admin.pets.index'))->assertRedirect(route('login')); + } + + public function test_admin_without_permission_is_forbidden(): void + { + $admin = $this->adminWithoutPermissions(); + $pet = Pet::factory()->create(); + + $this->actingAs($admin)->get(route('admin.pets.index'))->assertForbidden(); + $this->actingAs($admin)->post(route('admin.pets.store'), $this->payload())->assertForbidden(); + $this->actingAs($admin)->put(route('admin.pets.update', $pet), $this->payload())->assertForbidden(); + $this->actingAs($admin)->delete(route('admin.pets.destroy', $pet))->assertForbidden(); + $this->actingAs($admin)->get('/admin/pets-options')->assertForbidden(); + } + + public function test_index_renders_with_filters(): void + { + $admin = $this->adminWithAllPermissions(); + Pet::factory()->dog()->male()->create(['name' => 'Rex']); + Pet::factory()->cat()->female()->create(['name' => 'Kitty']); + + $this->actingAs($admin)->get(route('admin.pets.index'))->assertOk()->assertInertia(fn (Assert $page) => $page->component('Pets/index')->has('pets.data', 2)); + $this->actingAs($admin)->get(route('admin.pets.index', ['type' => [PetTypeEnum::Cat->value]]))->assertInertia(fn (Assert $page) => $page->has('pets.data', 1)); + $this->actingAs($admin)->get(route('admin.pets.index', ['gender' => PetGenderEnum::Male->value]))->assertInertia(fn (Assert $page) => $page->has('pets.data', 1)); + $this->actingAs($admin)->get(route('admin.pets.index', ['search' => 'Kit']))->assertInertia(fn (Assert $page) => $page->has('pets.data', 1)); + } + + public function test_create_and_edit_pages_render(): void + { + $admin = $this->adminWithAllPermissions(); + $pet = Pet::factory()->create(); + + $this->actingAs($admin)->get(route('admin.pets.create'))->assertInertia(fn (Assert $page) => $page->component('Pets/create_and_edit')); + $this->actingAs($admin)->get(route('admin.pets.edit', $pet))->assertInertia(fn (Assert $page) => $page->component('Pets/create_and_edit')->where('is_edit', true)->where('pet.id', $pet->id)); + } + + public function test_store_creates_pet_owned_by_admin(): void + { + $admin = $this->adminWithAllPermissions(); + + $this->actingAs($admin)->post(route('admin.pets.store'), $this->payload(['weight' => '۲۰']))->assertOk()->assertSessionHas('success'); + + $this->assertDatabaseHas('pets', ['name' => 'Rex', 'type' => PetTypeEnum::Dog->value, 'weight' => 20, 'owner_id' => $admin->id]); + } + + public function test_store_validates_input(): void + { + $this->actingAs($this->adminWithAllPermissions())->postJson(route('admin.pets.store'), [])->assertUnprocessable()->assertJsonValidationErrors(['name', 'type', 'weight']); + } + + public function test_update_changes_pet(): void + { + $admin = $this->adminWithAllPermissions(); + $pet = Pet::factory()->create(); + + $this->actingAs($admin)->put(route('admin.pets.update', $pet), $this->payload(['name' => 'Max']))->assertOk(); + + $this->assertDatabaseHas('pets', ['id' => $pet->id, 'name' => 'Max']); + } + + public function test_destroy_and_bulk_delete_soft_delete_pets(): void + { + $admin = $this->adminWithAllPermissions(); + $pets = Pet::factory()->count(3)->create(); + + $this->actingAs($admin)->delete(route('admin.pets.destroy', $pets[0]))->assertOk(); + $this->actingAs($admin)->post('/admin/pets-bulk-delete', ['ids' => [$pets[1]->id, $pets[2]->id]])->assertOk(); + + $this->assertSame(0, Pet::count()); + $this->assertSame(3, Pet::withTrashed()->count()); + } + + public function test_get_options_returns_label_value_pairs(): void + { + $pet = Pet::factory()->create(['name' => 'Rex']); + Pet::factory()->create(['name' => 'Kitty']); + + $this->actingAs($this->adminWithAllPermissions())->getJson('/admin/pets-options?search=Rex') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0', ['label' => 'Rex', 'value' => $pet->id]); + } + + public function test_export_data_downloads_excel(): void + { + Excel::fake(); + + $this->actingAs($this->adminWithAllPermissions())->get('/admin/pets-export-data')->assertOk(); + + Excel::assertDownloaded('pets.xlsx'); + } +} diff --git a/tests/Feature/Http/Admin/ReportControllerTest.php b/tests/Feature/Http/Admin/ReportControllerTest.php new file mode 100644 index 0000000..f55c723 --- /dev/null +++ b/tests/Feature/Http/Admin/ReportControllerTest.php @@ -0,0 +1,91 @@ + Document::factory()->create()->id, + 'doctor_id' => User::factory()->create()->id, + 'status' => ReportStatusEnum::Approved->name, + 'body' => 'متن', + 'submitted_at' => '2026-09-21 10:00:00', + ], $overrides); + } + + public function test_guest_and_non_admin_are_blocked(): void + { + $this->get(route('admin.reports.index'))->assertRedirect(route('login')); + $this->actingAs(User::factory()->create())->get(route('admin.reports.index'))->assertNotFound(); + } + + public function test_index_renders_with_filters(): void + { + $admin = $this->adminWithoutPermissions(); + $document = Document::factory()->create(['code' => '123456']); + $doctor = User::factory()->create(['level' => UserEnum::Doctor->value]); + Report::factory()->draft()->create(['created_at' => now()->subMinute()]); + Report::factory()->approved()->create(['document_id' => $document->id, 'doctor_id' => $doctor->id]); + + $this->actingAs($admin)->get(route('admin.reports.index'))->assertOk()->assertInertia(fn (Assert $page) => $page + ->component('Reports/index') + ->has('reports.data', 2) + ->where('reports.data.1.doctor', null) + ->where('reports.data.0.doctor.id', $doctor->id)); + $this->actingAs($admin)->get(route('admin.reports.index', ['status' => ReportStatusEnum::Draft->value]))->assertInertia(fn (Assert $page) => $page->has('reports.data', 1)); + $this->actingAs($admin)->get(route('admin.reports.index', ['search' => '1234']))->assertInertia(fn (Assert $page) => $page->has('reports.data', 1)); + } + + public function test_store_creates_report(): void + { + $payload = $this->payload(); + + $this->actingAs($this->adminWithoutPermissions())->post(route('admin.reports.store'), $payload)->assertOk()->assertSessionHas('success'); + + $this->assertDatabaseHas('reports', ['document_id' => $payload['document_id'], 'doctor_id' => $payload['doctor_id'], 'status' => ReportStatusEnum::Approved->value, 'body' => 'متن']); + } + + public function test_store_validates_input(): void + { + $this->actingAs($this->adminWithoutPermissions())->postJson(route('admin.reports.store'), [])->assertUnprocessable()->assertJsonValidationErrors(['document_id', 'doctor_id', 'status', 'body', 'submitted_at']); + } + + public function test_edit_renders_report_prop_for_missing_page(): void + { + $report = Report::factory()->create(); + + $this->actingAs($this->adminWithoutPermissions())->get(route('admin.reports.edit', $report))->assertInertia(fn (Assert $page) => $page->component('Reports/edit', false)->where('report.id', $report->id)); + } + + public function test_update_changes_report(): void + { + $report = Report::factory()->draft()->create(); + + $this->actingAs($this->adminWithoutPermissions())->put(route('admin.reports.update', $report), $this->payload(['status' => ReportStatusEnum::Rejected->name, 'body' => 'جدید']))->assertOk(); + + $this->assertDatabaseHas('reports', ['id' => $report->id, 'status' => ReportStatusEnum::Rejected->value, 'body' => 'جدید']); + } + + public function test_destroy_deletes_report(): void + { + $report = Report::factory()->create(); + + $this->actingAs($this->adminWithoutPermissions())->delete(route('admin.reports.destroy', $report))->assertOk(); + + $this->assertDatabaseMissing('reports', ['id' => $report->id]); + } +} diff --git a/tests/Feature/Http/Admin/Role/PermissionControllerTest.php b/tests/Feature/Http/Admin/Role/PermissionControllerTest.php new file mode 100644 index 0000000..5e4376c --- /dev/null +++ b/tests/Feature/Http/Admin/Role/PermissionControllerTest.php @@ -0,0 +1,99 @@ +get(route('admin.permissions.index'))->assertRedirect(route('login')); + } + + public function test_admin_without_permission_is_forbidden(): void + { + $admin = $this->adminWithoutPermissions(); + + $this->actingAs($admin)->get(route('admin.permissions.index'))->assertForbidden(); + $this->actingAs($admin)->post(route('admin.permissions.store'), ['name' => 'x', 'label' => 'y'])->assertForbidden(); + $this->actingAs($admin)->post('/admin/permissions-bulk-delete', ['ids' => []])->assertForbidden(); + } + + public function test_index_renders_with_search(): void + { + $admin = $this->adminWithAllPermissions(); + + $this->actingAs($admin)->get(route('admin.permissions.index'))->assertOk()->assertInertia(fn (Assert $page) => $page->component('Permissions/index')->has('permissions.data', 20)); + $this->actingAs($admin)->get(route('admin.permissions.index', ['search' => 'withdrawals']))->assertInertia(fn (Assert $page) => $page->has('permissions.data', 2)); + } + + public function test_create_and_edit_pages_render_with_parent_permissions(): void + { + $admin = $this->adminWithAllPermissions(); + $child = Permission::where('name', 'users.index')->first(); + $parents = Permission::where('parent_id', 0)->count(); + + $this->actingAs($admin)->get(route('admin.permissions.create'))->assertInertia(fn (Assert $page) => $page->component('Permissions/create_and_edit')->has('permissions.data', $parents)); + $this->actingAs($admin)->get(route('admin.permissions.edit', $child))->assertInertia(fn (Assert $page) => $page->where('is_edit', true)->where('permission.id', $child->id)->where('permission.parent_id', $child->parent_id)); + } + + public function test_store_creates_permission_with_parent(): void + { + $admin = $this->adminWithAllPermissions(); + $parent = Permission::where('name', 'users')->first(); + + $this->actingAs($admin)->post(route('admin.permissions.store'), ['name' => 'users.export', 'label' => 'خروجی', 'parent_id' => $parent->id])->assertCreated()->assertSessionHas('success'); + + $this->assertDatabaseHas('permissions', ['name' => 'users.export', 'label' => 'خروجی', 'parent_id' => $parent->id]); + } + + public function test_store_defaults_parent_to_zero_and_validates(): void + { + $admin = $this->adminWithAllPermissions(); + + $this->actingAs($admin)->post(route('admin.permissions.store'), ['name' => 'reports2', 'label' => 'x'])->assertCreated(); + $this->assertDatabaseHas('permissions', ['name' => 'reports2', 'parent_id' => 0]); + + $this->actingAs($admin)->postJson(route('admin.permissions.store'), [])->assertUnprocessable()->assertJsonValidationErrors(['name', 'label']); + } + + public function test_update_changes_permission(): void + { + $admin = $this->adminWithAllPermissions(); + $permission = Permission::where('name', 'users.index')->first(); + + $this->actingAs($admin)->put(route('admin.permissions.update', $permission), ['name' => 'users.list', 'label' => 'لیست'])->assertOk(); + + $this->assertDatabaseHas('permissions', ['id' => $permission->id, 'name' => 'users.list', 'label' => 'لیست', 'parent_id' => 0]); + } + + public function test_destroy_and_bulk_delete_remove_permissions(): void + { + $admin = $this->adminWithAllPermissions(); + $targets = Permission::where('name', 'like', 'pets.%')->take(3)->get(); + + $this->actingAs($admin)->delete(route('admin.permissions.destroy', $targets[0]))->assertOk(); + $this->actingAs($admin)->post('/admin/permissions-bulk-delete', ['ids' => [$targets[1]->id, $targets[2]->id]])->assertOk(); + + foreach ($targets as $permission) { + $this->assertDatabaseMissing('permissions', ['id' => $permission->id]); + } + } + + public function test_export_data_downloads_excel(): void + { + Excel::fake(); + + $this->actingAs($this->adminWithAllPermissions())->get('/admin/permissions-export-data')->assertOk(); + + Excel::assertDownloaded('permissions.xlsx'); + } +} diff --git a/tests/Feature/Http/Admin/Role/RoleControllerTest.php b/tests/Feature/Http/Admin/Role/RoleControllerTest.php new file mode 100644 index 0000000..0eb7ec2 --- /dev/null +++ b/tests/Feature/Http/Admin/Role/RoleControllerTest.php @@ -0,0 +1,103 @@ +get(route('admin.roles.index'))->assertRedirect(route('login')); + } + + public function test_admin_without_permission_is_forbidden(): void + { + $admin = $this->adminWithoutPermissions(); + + $this->actingAs($admin)->get(route('admin.roles.index'))->assertForbidden(); + $this->actingAs($admin)->post(route('admin.roles.store'), ['name' => 'x', 'label' => 'y'])->assertForbidden(); + $this->actingAs($admin)->post('/admin/roles-bulk-delete', ['ids' => []])->assertForbidden(); + } + + public function test_index_renders_with_search(): void + { + $admin = $this->adminWithAllPermissions(); + Role::create(['name' => 'editor', 'label' => 'ویرایشگر']); + + $this->actingAs($admin)->get(route('admin.roles.index'))->assertOk()->assertInertia(fn (Assert $page) => $page->component('Roles/index')->has('roles.data', 2)); + $this->actingAs($admin)->get(route('admin.roles.index', ['search' => 'edit']))->assertInertia(fn (Assert $page) => $page->has('roles.data', 1)); + } + + public function test_create_and_edit_pages_render_with_grouped_permissions(): void + { + $admin = $this->adminWithAllPermissions(); + $role = Role::where('name', 'admin')->first(); + $parent = Permission::where('name', 'users')->first(); + + $this->actingAs($admin)->get(route('admin.roles.create'))->assertInertia(fn (Assert $page) => $page->component('Roles/create_and_edit')->has('permissions')); + $this->actingAs($admin)->get(route('admin.roles.edit', $role))->assertInertia(fn (Assert $page) => $page + ->component('Roles/create_and_edit') + ->where('is_edit', true) + ->where('role.id', $role->id) + ->has("role.permissions.{$parent->id}")); + } + + public function test_store_creates_role_with_permissions(): void + { + $admin = $this->adminWithAllPermissions(); + $permissionIds = Permission::where('name', 'like', 'users.%')->pluck('id')->all(); + + $this->actingAs($admin)->post(route('admin.roles.store'), ['name' => 'editor', 'label' => 'ویرایشگر', 'selectedPermissionIds' => [$permissionIds]])->assertCreated()->assertSessionHas('success'); + + $role = Role::where('name', 'editor')->first(); + $this->assertNotNull($role); + $this->assertEqualsCanonicalizing($permissionIds, $role->permissions->pluck('id')->all()); + } + + public function test_store_validates_input(): void + { + $this->actingAs($this->adminWithAllPermissions())->postJson(route('admin.roles.store'), [])->assertUnprocessable()->assertJsonValidationErrors(['name', 'label']); + } + + public function test_update_changes_role_and_permissions(): void + { + $admin = $this->adminWithAllPermissions(); + $role = Role::create(['name' => 'editor', 'label' => 'x']); + $permission = Permission::where('name', 'pets.index')->first(); + + $this->actingAs($admin)->put(route('admin.roles.update', $role), ['name' => 'editor2', 'label' => 'y', 'selectedPermissionIds' => [[$permission->id]]])->assertOk(); + + $role->refresh(); + $this->assertSame('editor2', $role->name); + $this->assertTrue($role->hasPermissionTo($permission)); + } + + public function test_destroy_and_bulk_delete_remove_roles(): void + { + $admin = $this->adminWithAllPermissions(); + $roles = collect(['a', 'b', 'c'])->map(fn ($name) => Role::create(['name' => $name, 'label' => $name])); + + $this->actingAs($admin)->delete(route('admin.roles.destroy', $roles[0]))->assertOk(); + $this->actingAs($admin)->post('/admin/roles-bulk-delete', ['ids' => [$roles[1]->id, $roles[2]->id]])->assertOk(); + + $this->assertSame(1, Role::count()); + } + + public function test_export_data_downloads_excel(): void + { + Excel::fake(); + + $this->actingAs($this->adminWithAllPermissions())->get('/admin/roles-export-data')->assertOk(); + + Excel::assertDownloaded('roles.xlsx'); + } +} diff --git a/tests/Feature/Http/Admin/SettingUserControllerTest.php b/tests/Feature/Http/Admin/SettingUserControllerTest.php new file mode 100644 index 0000000..cf07c76 --- /dev/null +++ b/tests/Feature/Http/Admin/SettingUserControllerTest.php @@ -0,0 +1,122 @@ + $id, 'user_id' => $user->id, 'ip_address' => '127.0.0.1', 'user_agent' => 'phpunit', 'payload' => '', 'last_activity' => now()->timestamp]); + } + + public function test_guest_is_redirected(): void + { + $this->get('/admin/setting-profile')->assertRedirect(route('login')); + } + + public function test_pages_render(): void + { + $admin = $this->adminWithoutPermissions(); + $this->storedSession($admin, 'sess-1'); + $this->storedSession(User::factory()->create(), 'sess-2'); + + $this->actingAs($admin)->get('/admin/setting-profile')->assertOk()->assertInertia(fn (Assert $page) => $page->component('Settings/User/Profile')); + $this->actingAs($admin)->get('/admin/setting-profile/security')->assertOk()->assertInertia(fn (Assert $page) => $page->component('Settings/User/Security')); + $this->actingAs($admin)->get('/admin/setting-profile/sessions')->assertOk()->assertInertia(fn (Assert $page) => $page->component('Settings/User/Sessions')->has('sessions', 1)->where('sessions.0.id', 'sess-1')); + } + + public function test_update_data_forbids_editing_another_user(): void + { + $admin = $this->adminWithoutPermissions(); + $other = User::factory()->create(); + + $this->actingAs($admin)->post("/admin/setting-profile/update-data/{$other->id}", ['name' => 'x'])->assertForbidden(); + } + + public function test_upload_avatar_stores_file_and_replaces_previous(): void + { + Storage::fake('public'); + $old = Medium::factory()->public()->create(['path' => 'profile/old.png', 'url' => 'http://example.test/storage/profile/old.png']); + Storage::disk('public')->put($old->path, 'old'); + $admin = $this->adminWithoutPermissions(); + $admin->update(['profile_url' => $old->url]); + + $response = $this->actingAs($admin)->post('/admin/setting-profile/update-avatar', ['file' => UploadedFile::fake()->image('new.png')]); + + $response->assertOk(); + $this->assertDatabaseMissing('media', ['id' => $old->id]); + $this->assertSame($response->json('profile_url'), $admin->fresh()->profile_url); + $this->assertStringStartsWith('profile/', Medium::first()->path); + } + + public function test_upload_avatar_validates_type_and_size(): void + { + $this->actingAs($this->adminWithoutPermissions())->postJson('/admin/setting-profile/update-avatar', ['file' => UploadedFile::fake()->create('a.pdf', 10, 'application/pdf')]) + ->assertUnprocessable() + ->assertJsonValidationErrors('file'); + } + + public function test_delete_session_removes_own_session_only(): void + { + $admin = $this->adminWithoutPermissions(); + $this->storedSession($admin, 'own'); + $this->storedSession(User::factory()->create(), 'foreign'); + + $this->actingAs($admin)->delete('/admin/setting-profile/sessions/own')->assertOk()->assertSessionHas('success'); + $this->actingAs($admin)->delete('/admin/setting-profile/sessions/foreign')->assertStatus(400)->assertSessionHas('error'); + + $this->assertDatabaseMissing('sessions', ['id' => 'own']); + $this->assertDatabaseHas('sessions', ['id' => 'foreign']); + } + + public function test_bulk_delete_removes_sessions(): void + { + $admin = $this->adminWithoutPermissions(); + $this->storedSession($admin, 'a'); + $this->storedSession($admin, 'b'); + + $this->actingAs($admin)->post('/admin/setting-profile/sessions-bulk-delete', ['ids' => ['a', 'b']])->assertOk(); + + $this->assertDatabaseCount('sessions', 0); + } + + public function test_change_password_updates_password_when_current_matches(): void + { + $admin = $this->adminWithoutPermissions(); + $admin->update(['password' => 'oldsecret1']); + + $this->actingAs($admin)->post('/admin/setting-profile/change-password', ['password' => 'oldsecret1', 'newPassword' => 'newsecret1', 'newPassword_confirmation' => 'newsecret1'])->assertOk()->assertSessionHas('success'); + + $this->assertTrue(Hash::check('newsecret1', $admin->fresh()->password)); + } + + public function test_change_password_rejects_wrong_current_password(): void + { + $admin = $this->adminWithoutPermissions(); + $admin->update(['password' => 'oldsecret1']); + + $this->actingAs($admin)->postJson('/admin/setting-profile/change-password', ['password' => 'wrongpass1', 'newPassword' => 'newsecret1', 'newPassword_confirmation' => 'newsecret1'])->assertUnauthorized(); + + $this->assertTrue(Hash::check('oldsecret1', $admin->fresh()->password)); + } + + public function test_change_password_validates_input(): void + { + $this->actingAs($this->adminWithoutPermissions())->postJson('/admin/setting-profile/change-password', ['password' => 'short', 'newPassword' => 'newsecret1', 'newPassword_confirmation' => 'other']) + ->assertUnprocessable() + ->assertJsonValidationErrors(['password', 'newPassword']); + } +} diff --git a/tests/Feature/Http/Admin/ShiftControllerTest.php b/tests/Feature/Http/Admin/ShiftControllerTest.php new file mode 100644 index 0000000..da44fe5 --- /dev/null +++ b/tests/Feature/Http/Admin/ShiftControllerTest.php @@ -0,0 +1,126 @@ + ShiftDayEnum::Monday->value, + 'start_time' => '08:00', + 'end_time' => '16:00', + 'is_active' => true, + ], $overrides); + } + + public function test_guest_is_redirected(): void + { + $this->get(route('admin.shifts.index'))->assertRedirect(route('login')); + } + + public function test_admin_without_permission_is_forbidden(): void + { + $admin = $this->adminWithoutPermissions(); + $shift = Shift::factory()->create(); + + $this->actingAs($admin)->get(route('admin.shifts.index'))->assertForbidden(); + $this->actingAs($admin)->get(route('admin.shifts.create'))->assertForbidden(); + $this->actingAs($admin)->post(route('admin.shifts.store'), $this->payload())->assertForbidden(); + $this->actingAs($admin)->get(route('admin.shifts.edit', $shift))->assertForbidden(); + $this->actingAs($admin)->put(route('admin.shifts.update', $shift), $this->payload())->assertForbidden(); + $this->actingAs($admin)->delete(route('admin.shifts.destroy', $shift))->assertForbidden(); + $this->actingAs($admin)->post('/admin/shifts-bulk-delete', ['ids' => [$shift->id]])->assertForbidden(); + $this->actingAs($admin)->get('/admin/shifts-calendar-data')->assertForbidden(); + } + + public function test_index_renders_with_filters(): void + { + $admin = $this->adminWithAllPermissions(); + $doctor = User::factory()->create(['level' => UserEnum::Doctor->value, 'fullname' => 'دکتر الف']); + Shift::factory()->weekly(ShiftDayEnum::Monday)->forDoctor($doctor)->create(); + Shift::factory()->weekly(ShiftDayEnum::Tuesday)->create(); + Shift::factory()->weekly(ShiftDayEnum::Tuesday)->inactive()->create(); + + $this->actingAs($admin)->get(route('admin.shifts.index')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page->component('Shifts/index')->has('shifts.data', 3)->where('permissions_edit', false)->where('permissions_delete', false)); + + $this->actingAs($admin)->get(route('admin.shifts.index', ['day_of_week' => ShiftDayEnum::Tuesday->value]))->assertInertia(fn (Assert $page) => $page->has('shifts.data', 2)); + $this->actingAs($admin)->get(route('admin.shifts.index', ['status' => 'فعال']))->assertInertia(fn (Assert $page) => $page->has('shifts.data', 2)); + $this->actingAs($admin)->get(route('admin.shifts.index', ['occupancy' => 'آزاد']))->assertInertia(fn (Assert $page) => $page->has('shifts.data', 2)); + $this->actingAs($admin)->get(route('admin.shifts.index', ['search' => 'الف']))->assertInertia(fn (Assert $page) => $page->has('shifts.data', 1)); + } + + public function test_create_and_edit_pages_render(): void + { + $admin = $this->adminWithAllPermissions(); + $shift = Shift::factory()->create(); + + $this->actingAs($admin)->get(route('admin.shifts.create'))->assertInertia(fn (Assert $page) => $page->component('Shifts/create_and_edit')->missing('is_edit')); + $this->actingAs($admin)->get(route('admin.shifts.edit', $shift))->assertInertia(fn (Assert $page) => $page->component('Shifts/create_and_edit')->where('is_edit', true)->where('shift.id', $shift->id)); + } + + public function test_store_creates_shift_with_creator(): void + { + $admin = $this->adminWithAllPermissions(); + $doctor = User::factory()->create(['level' => UserEnum::Doctor->value]); + + $this->actingAs($admin)->post(route('admin.shifts.store'), $this->payload(['user_id' => $doctor->id]))->assertOk()->assertSessionHas('success'); + + $this->assertDatabaseHas('shifts', ['day_of_week' => ShiftDayEnum::Monday->value, 'start_time' => '08:00', 'end_time' => '16:00', 'user_id' => $doctor->id, 'created_by' => $admin->id, 'is_active' => 1]); + } + + public function test_store_rejects_conflicting_shift(): void + { + $admin = $this->adminWithAllPermissions(); + $doctor = User::factory()->create(['level' => UserEnum::Doctor->value]); + Shift::factory()->weekly(ShiftDayEnum::Monday)->between('08:00', '12:00')->forDoctor($doctor)->create(); + + $this->actingAs($admin)->postJson(route('admin.shifts.store'), $this->payload(['user_id' => $doctor->id, 'start_time' => '10:00', 'end_time' => '14:00'])) + ->assertUnprocessable() + ->assertJsonValidationErrors('start_time'); + } + + public function test_update_changes_shift(): void + { + $admin = $this->adminWithAllPermissions(); + $shift = Shift::factory()->weekly(ShiftDayEnum::Monday)->create(); + + $this->actingAs($admin)->put(route('admin.shifts.update', $shift), $this->payload(['day_of_week' => null, 'date' => '2026-09-21', 'is_active' => false]))->assertOk(); + + $shift->refresh(); + $this->assertNull($shift->day_of_week); + $this->assertSame('2026-09-21', $shift->date->toDateString()); + $this->assertFalse($shift->is_active); + } + + public function test_destroy_and_bulk_delete_remove_shifts(): void + { + $admin = $this->adminWithAllPermissions(); + $shifts = Shift::factory()->count(3)->create(); + + $this->actingAs($admin)->delete(route('admin.shifts.destroy', $shifts[0]))->assertOk(); + $this->actingAs($admin)->post('/admin/shifts-bulk-delete', ['ids' => [$shifts[1]->id, $shifts[2]->id]])->assertOk(); + + $this->assertDatabaseCount('shifts', 0); + } + + public function test_calendar_data_returns_all_shifts(): void + { + Shift::factory()->count(2)->create(); + + $this->actingAs($this->adminWithAllPermissions())->getJson('/admin/shifts-calendar-data')->assertOk()->assertJsonPath('success', true)->assertJsonCount(2, 'data'); + } +} diff --git a/tests/Feature/Http/Admin/UserControllerTest.php b/tests/Feature/Http/Admin/UserControllerTest.php new file mode 100644 index 0000000..08e7b58 --- /dev/null +++ b/tests/Feature/Http/Admin/UserControllerTest.php @@ -0,0 +1,155 @@ + 'newuser', + 'phone' => '09120000000', + 'fullname' => 'کاربر جدید', + 'email' => 'new@example.com', + 'password' => 'secret123', + 'password_confirmation' => 'secret123', + 'level' => UserEnum::Doctor->value, + 'national_code' => '0012345678', + ], $overrides); + } + + public function test_guest_is_redirected(): void + { + $this->get(route('admin.users.index'))->assertRedirect(route('login')); + } + + public function test_admin_without_permission_is_forbidden(): void + { + $admin = $this->adminWithoutPermissions(); + + $this->actingAs($admin)->get(route('admin.users.index'))->assertForbidden(); + $this->actingAs($admin)->post(route('admin.users.store'), $this->payload())->assertForbidden(); + $this->actingAs($admin)->post('/admin/users-bulk-delete', ['ids' => []])->assertForbidden(); + $this->actingAs($admin)->get('/admin/doctors-options')->assertForbidden(); + } + + public function test_index_renders_with_filters(): void + { + $admin = $this->adminWithAllPermissions(); + User::factory()->create(['level' => UserEnum::Doctor->value, 'fullname' => 'دکتر الف']); + User::factory()->create(['level' => UserEnum::Vet->value]); + + $this->actingAs($admin)->get(route('admin.users.index'))->assertOk()->assertInertia(fn (Assert $page) => $page->component('Users/index')->has('users.data', 3)); + $this->actingAs($admin)->get(route('admin.users.index', ['level' => UserEnum::Doctor->value]))->assertInertia(fn (Assert $page) => $page->has('users.data', 1)); + $this->actingAs($admin)->get(route('admin.users.index', ['search' => 'الف']))->assertInertia(fn (Assert $page) => $page->has('users.data', 1)); + } + + public function test_create_and_edit_pages_render_with_roles(): void + { + $admin = $this->adminWithAllPermissions(); + $user = User::factory()->create(); + + $this->actingAs($admin)->get(route('admin.users.create'))->assertInertia(fn (Assert $page) => $page->component('Users/create_and_edit')->has('roles', 1)); + $this->actingAs($admin)->get(route('admin.users.edit', $user))->assertInertia(fn (Assert $page) => $page->component('Users/create_and_edit')->where('is_edit', true)->where('user.id', $user->id)->where('user.role', [])); + } + + public function test_store_creates_user_with_hashed_password_and_roles(): void + { + $admin = $this->adminWithAllPermissions(); + $role = Role::first(); + + $this->actingAs($admin)->post(route('admin.users.store'), $this->payload(['role' => [$role->id]]))->assertOk()->assertSessionHas('success'); + + $user = User::where('username', 'newuser')->first(); + $this->assertNotNull($user); + $this->assertTrue(Hash::check('secret123', $user->password)); + $this->assertSame(UserEnum::Doctor->value, $user->level); + $this->assertTrue($user->hasRole($role)); + } + + public function test_store_validates_input(): void + { + $this->actingAs($this->adminWithAllPermissions())->postJson(route('admin.users.store'), ['level' => UserEnum::Vet->value]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['phone', 'password', 'level']); + } + + public function test_update_changes_user_and_syncs_roles(): void + { + $admin = $this->adminWithAllPermissions(); + $role = Role::first(); + $user = User::factory()->create(['level' => UserEnum::Doctor->value]); + $user->assignRole($role); + $originalPassword = $user->password; + + $this->actingAs($admin)->put(route('admin.users.update', $user), $this->payload([ + 'username' => 'renamed', + 'level' => UserEnum::Supervisor->value, + 'password' => null, + 'password_confirmation' => null, + ]))->assertOk(); + + $user->refresh(); + $this->assertSame('renamed', $user->username); + $this->assertSame(UserEnum::Supervisor->value, $user->level); + $this->assertSame($originalPassword, $user->password); + $this->assertFalse($user->hasRole($role)); + } + + public function test_update_changes_password_when_provided(): void + { + $user = User::factory()->create(); + + $this->actingAs($this->adminWithAllPermissions())->put(route('admin.users.update', $user), $this->payload([ + 'password' => 'newsecret1', + 'password_confirmation' => 'newsecret1', + ]))->assertOk(); + + $this->assertTrue(Hash::check('newsecret1', $user->fresh()->password)); + } + + public function test_destroy_and_bulk_delete_soft_delete_users(): void + { + $admin = $this->adminWithAllPermissions(); + $users = User::factory()->count(3)->create(); + + $this->actingAs($admin)->delete(route('admin.users.destroy', $users[0]))->assertOk(); + $this->actingAs($admin)->post('/admin/users-bulk-delete', ['ids' => [$users[1]->id, $users[2]->id]])->assertOk(); + + foreach ($users as $user) { + $this->assertDatabaseMissing('users', ['id' => $user->id]); + } + } + + public function test_get_doctors_options_returns_only_doctors(): void + { + $doctor = User::factory()->create(['level' => UserEnum::Doctor->value, 'fullname' => 'دکتر ب']); + User::factory()->create(['level' => UserEnum::Vet->value, 'fullname' => 'دکتر ب']); + + $this->actingAs($this->adminWithAllPermissions())->getJson('/admin/doctors-options?search=ب') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.value', $doctor->id); + } + + public function test_export_data_downloads_excel(): void + { + Excel::fake(); + + $this->actingAs($this->adminWithAllPermissions())->get('/admin/users-export-data')->assertOk(); + + Excel::assertDownloaded('users.xlsx'); + } +} diff --git a/tests/Feature/Http/Admin/WithdrawRequestControllerTest.php b/tests/Feature/Http/Admin/WithdrawRequestControllerTest.php new file mode 100644 index 0000000..1a6a6fd --- /dev/null +++ b/tests/Feature/Http/Admin/WithdrawRequestControllerTest.php @@ -0,0 +1,104 @@ +create(); + + $this->get(route('admin.withdraw-requests.index'))->assertRedirect(route('login')); + $this->actingAs(User::factory()->create(['level' => UserEnum::Doctor->value]))->post(route('admin.withdraw-requests.approve', $withdrawRequest))->assertNotFound(); + } + + public function test_index_renders_requests_with_stats_and_filters(): void + { + $admin = $this->adminWithoutPermissions(); + Wallet::factory()->system()->create(['balance' => 900]); + WithdrawRequest::factory()->create(['amount' => 100000]); + WithdrawRequest::factory()->create(['amount' => 250000]); + WithdrawRequest::factory()->approved()->create(['amount' => 500000]); + + $this->actingAs($admin)->get(route('admin.withdraw-requests.index')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('WithdrawRequests/index') + ->has('withdrawRequests.data', 3) + ->where('stats.pending_count', 2) + ->where('stats.pending_amount', 350000) + ->where('stats.system_wallet_balance', 900)); + + $this->actingAs($admin)->get(route('admin.withdraw-requests.index', ['status' => WithdrawRequestStatusEnum::Approved->value])) + ->assertInertia(fn (Assert $page) => $page->has('withdrawRequests.data', 1)); + } + + public function test_index_searches_by_user_fullname(): void + { + $admin = $this->adminWithoutPermissions(); + $user = User::factory()->create(['fullname' => 'کاربر خاص']); + WithdrawRequest::factory()->create(['user_id' => $user->id]); + WithdrawRequest::factory()->create(); + + $this->actingAs($admin)->get(route('admin.withdraw-requests.index', ['search' => 'خاص'])) + ->assertInertia(fn (Assert $page) => $page->has('withdrawRequests.data', 1)); + } + + public function test_approve_moves_funds_and_marks_request_approved(): void + { + $admin = $this->adminWithoutPermissions(); + $wallet = Wallet::factory()->create(['balance' => 500000]); + $withdrawRequest = WithdrawRequest::factory()->create(['user_id' => $wallet->user_id, 'amount' => 200000]); + + $this->actingAs($admin)->post(route('admin.withdraw-requests.approve', $withdrawRequest))->assertOk()->assertSessionHas('success'); + + $withdrawRequest->refresh(); + $this->assertSame(WithdrawRequestStatusEnum::Approved, $withdrawRequest->status); + $this->assertSame($admin->id, $withdrawRequest->reviewed_by); + $this->assertSame(300000, $wallet->fresh()->balance); + $this->assertDatabaseHas('wallets', ['user_id' => null, 'balance' => 200000]); + } + + public function test_approve_returns_validation_error_for_reviewed_request(): void + { + $withdrawRequest = WithdrawRequest::factory()->rejected()->create(); + + $this->actingAs($this->adminWithoutPermissions())->postJson(route('admin.withdraw-requests.approve', $withdrawRequest)) + ->assertUnprocessable() + ->assertJsonValidationErrors('withdraw_request'); + } + + public function test_reject_marks_request_rejected_with_note(): void + { + $admin = $this->adminWithoutPermissions(); + $withdrawRequest = WithdrawRequest::factory()->create(); + + $this->actingAs($admin)->post(route('admin.withdraw-requests.reject', $withdrawRequest), ['note' => 'کارت نامعتبر'])->assertOk(); + + $withdrawRequest->refresh(); + $this->assertSame(WithdrawRequestStatusEnum::Rejected, $withdrawRequest->status); + $this->assertSame('کارت نامعتبر', $withdrawRequest->admin_note); + $this->assertSame($admin->id, $withdrawRequest->reviewed_by); + } + + public function test_reject_validates_note_length(): void + { + $withdrawRequest = WithdrawRequest::factory()->create(); + + $this->actingAs($this->adminWithoutPermissions())->postJson(route('admin.withdraw-requests.reject', $withdrawRequest), ['note' => str_repeat('a', 1001)]) + ->assertUnprocessable() + ->assertJsonValidationErrors('note'); + } +} diff --git a/tests/Feature/Http/Client/AuthControllerTest.php b/tests/Feature/Http/Client/AuthControllerTest.php new file mode 100644 index 0000000..0112cd0 --- /dev/null +++ b/tests/Feature/Http/Client/AuthControllerTest.php @@ -0,0 +1,256 @@ +get(route('login'))->assertOk()->assertInertia(fn (Assert $page) => $page->component('Auth/Phone')); + } + + public function test_authenticated_user_is_redirected_away_from_guest_routes(): void + { + $this->actingAs(User::factory()->create())->get(route('login'))->assertRedirect(); + } + + public function test_check_phone_redirects_to_password_when_phone_has_single_level(): void + { + User::factory()->create(['phone' => '+989120000000', 'level' => UserEnum::Doctor->value]); + + $response = $this->post(route('checkPhone'), ['prefix' => '+98', 'phone' => '09120000000']); + + $response->assertRedirect(route('password')); + $response->assertSessionHas('login_phone', '+989120000000'); + $response->assertSessionHas('login_level', 'doctor'); + } + + public function test_check_phone_redirects_to_portal_when_phone_has_multiple_levels(): void + { + User::factory()->create(['phone' => '+989120000000', 'level' => UserEnum::Doctor->value]); + User::factory()->create(['phone' => '+989120000000', 'level' => UserEnum::Supervisor->value]); + + $response = $this->post(route('checkPhone'), ['prefix' => '+98', 'phone' => '09120000000']); + + $response->assertRedirect(route('portal')); + $response->assertSessionHas('available_levels'); + } + + public function test_check_phone_redirects_to_registration_for_unknown_phone(): void + { + $response = $this->post(route('checkPhone'), ['prefix' => '+98', 'phone' => '09120000000']); + + $response->assertRedirect(route('register.chooseType')); + $response->assertSessionHas('register_phone', '+989120000000'); + } + + public function test_check_phone_validates_input(): void + { + $this->post(route('checkPhone'), ['phone' => 'abc'])->assertSessionHasErrors(['prefix', 'phone']); + } + + public function test_choose_portal_requires_session_state(): void + { + $this->get(route('portal'))->assertRedirect(route('login')); + } + + public function test_choose_portal_renders_available_levels(): void + { + $this->withSession(['login_phone' => '+989120000000', 'available_levels' => ['doctor', 'supervisor']]) + ->get(route('portal')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page->component('Auth/ChoosePortal')->where('availableLevels', ['doctor', 'supervisor'])); + } + + public function test_choose_portal_with_level_stores_it_and_redirects_to_password(): void + { + $this->withSession(['login_phone' => '+989120000000', 'available_levels' => ['doctor', 'supervisor']]) + ->get(route('portal', 'doctor')) + ->assertRedirect(route('password')) + ->assertSessionHas('login_level', 'doctor'); + } + + public function test_password_form_requires_session_state(): void + { + $this->get(route('password'))->assertRedirect(route('login')); + } + + public function test_password_form_renders(): void + { + $this->withSession(['login_phone' => '+989120000000', 'login_level' => 'doctor']) + ->get(route('password')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page->component('Auth/Password')->where('level', 'doctor')); + } + + public function test_authenticate_logs_in_and_redirects_to_dashboard(): void + { + $user = User::factory()->create(['phone' => '+989120000000', 'level' => UserEnum::Doctor->value, 'password' => 'secret123']); + + $this->withSession(['login_phone' => '+989120000000', 'login_level' => 'doctor']) + ->post(route('authenticate'), ['password' => 'secret123']) + ->assertRedirect(route('dashboard.index')); + + $this->assertAuthenticatedAs($user); + } + + public function test_authenticate_rejects_wrong_password(): void + { + User::factory()->create(['phone' => '+989120000000', 'level' => UserEnum::Doctor->value, 'password' => 'secret123']); + + $this->withSession(['login_phone' => '+989120000000', 'login_level' => 'doctor']) + ->from(route('password')) + ->post(route('authenticate'), ['password' => 'wrong']) + ->assertRedirect(route('password')) + ->assertSessionHasErrors('password'); + + $this->assertGuest(); + } + + public function test_authenticate_without_session_redirects_to_login(): void + { + $this->post(route('authenticate'), ['password' => 'secret123'])->assertRedirect(route('login')); + } + + public function test_send_otp_stores_code_and_redirects_to_verification(): void + { + $this->withSession(['login_phone' => '+989120000000']) + ->post(route('sendOtp')) + ->assertRedirect(route('otpVerification')); + + $this->assertDatabaseHas('otp_codes', ['phone' => '+989120000000']); + } + + public function test_send_otp_without_phone_redirects_to_login(): void + { + $this->post(route('sendOtp'))->assertRedirect(route('login')); + $this->assertDatabaseCount('otp_codes', 0); + } + + public function test_otp_verification_page_renders_with_phone(): void + { + $this->withSession(['login_phone' => '+989120000000']) + ->get(route('otpVerification')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page->component('Auth/OtpVerification')->where('phone', '+989120000000')); + } + + public function test_verify_otp_marks_session_verified_and_redirects_to_reset(): void + { + OtpCode::factory()->create(['phone' => '+989120000000', 'code' => '1234']); + + $this->withSession(['login_phone' => '+989120000000']) + ->post(route('verifyOtp'), ['otp_code' => '1234']) + ->assertRedirect(route('resetPassword')) + ->assertSessionHas('otp_verified', true) + ->assertSessionHas('verified_phone', '+989120000000'); + + $this->assertDatabaseCount('otp_codes', 0); + } + + public function test_verify_otp_rejects_invalid_code(): void + { + OtpCode::factory()->create(['phone' => '+989120000000', 'code' => '1234']); + + $this->withSession(['login_phone' => '+989120000000']) + ->post(route('verifyOtp'), ['otp_code' => '0000']) + ->assertSessionHasErrors('otp_code'); + } + + public function test_reset_password_page_requires_verified_session(): void + { + $this->get(route('resetPassword'))->assertRedirect(route('login')); + + $this->withSession(['otp_verified' => true, 'verified_phone' => '+989120000000']) + ->get(route('resetPassword')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page->component('Auth/ResetPassword')); + } + + public function test_update_password_changes_password_and_flushes_session(): void + { + $user = User::factory()->create(['phone' => '+989120000000', 'level' => UserEnum::Doctor->value]); + + $this->withSession(['otp_verified' => true, 'verified_phone' => '+989120000000', 'login_level' => 'doctor']) + ->post(route('updatePassword'), ['password' => 'newsecret1', 'password_confirmation' => 'newsecret1']) + ->assertRedirect(route('login')) + ->assertSessionMissing('otp_verified'); + + $this->assertTrue(Hash::check('newsecret1', $user->fresh()->password)); + } + + public function test_update_password_validates_confirmation_and_length(): void + { + $this->withSession(['otp_verified' => true, 'verified_phone' => '+989120000000', 'login_level' => 'doctor']) + ->post(route('updatePassword'), ['password' => 'short', 'password_confirmation' => 'other']) + ->assertSessionHasErrors('password'); + } + + public function test_update_password_without_verified_session_redirects_to_login(): void + { + $this->post(route('updatePassword'), ['password' => 'newsecret1', 'password_confirmation' => 'newsecret1']) + ->assertRedirect(route('login')); + } + + public function test_update_password_for_unknown_user_redirects_to_login_with_error(): void + { + $this->withSession(['otp_verified' => true, 'verified_phone' => '+989120000000', 'login_level' => 'doctor']) + ->post(route('updatePassword'), ['password' => 'newsecret1', 'password_confirmation' => 'newsecret1']) + ->assertRedirect(route('login')) + ->assertSessionHas('error'); + } + + public function test_register_pages_render_with_session_phone(): void + { + $session = ['register_phone' => '+989120000000']; + + $this->withSession($session)->get(route('register.chooseType'))->assertInertia(fn (Assert $page) => $page->component('Auth/ChooseRegisterType')->where('phone', '+989120000000')); + $this->withSession($session)->get(route('register.supervisor'))->assertInertia(fn (Assert $page) => $page->component('Auth/RegisterSupervisor')); + $this->withSession($session)->get(route('register.veterinarian'))->assertInertia(fn (Assert $page) => $page->component('Auth/RegisterVet')); + } + + public function test_store_registers_user_with_session_phone_and_logs_in(): void + { + $this->withSession(['register_phone' => '+989120000000']) + ->post(route('register.store'), ['phone' => '09999999999', 'national_code' => '0012345678', 'level' => UserEnum::Vet->value]) + ->assertRedirect(route('dashboard.index')); + + $this->assertAuthenticated(); + $this->assertDatabaseHas('users', ['phone' => '+989120000000', 'national_code' => '0012345678']); + $this->assertDatabaseMissing('users', ['phone' => '09999999999']); + } + + public function test_store_without_session_phone_redirects_to_login(): void + { + $this->post(route('register.store'), ['phone' => '09120000000', 'national_code' => '0012345678']) + ->assertRedirect(route('login')); + + $this->assertGuest(); + $this->assertDatabaseCount('users', 0); + } + + public function test_logout_ends_session(): void + { + $this->actingAs(User::factory()->create())->get(route('dashboard.logout'))->assertRedirect(route('login')); + + $this->assertGuest(); + } +} diff --git a/tests/Feature/Http/Client/Doctor/CaseControllerTest.php b/tests/Feature/Http/Client/Doctor/CaseControllerTest.php new file mode 100644 index 0000000..2245037 --- /dev/null +++ b/tests/Feature/Http/Client/Doctor/CaseControllerTest.php @@ -0,0 +1,112 @@ +create(['level' => UserEnum::Doctor->value]); + } + + protected function assign(Document $document, User $doctor): void + { + DocumentAssignment::factory()->create(['document_id' => $document->id, 'user_id' => $doctor->id]); + } + + public function test_routes_require_authentication(): void + { + $document = Document::factory()->create(); + + $this->get(route('dashboard.cases.index'))->assertRedirect(route('login')); + $this->get(route('dashboard.cases.show', $document))->assertRedirect(route('login')); + $this->post(route('dashboard.cases.reject', $document))->assertRedirect(route('login')); + } + + public function test_index_paginates_pending_cases_of_user(): void + { + $user = User::factory()->create(); + Document::factory()->count(12)->registered()->create(['requester_id' => $user->id]); + Document::factory()->completed()->create(['requester_id' => $user->id]); + + $this->actingAs($user)->get(route('dashboard.cases.index')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page->component('Dashboard/Cases/Index')->has('cases.data', 10)->where('cases.meta.total', 12)); + } + + public function test_show_renders_case_for_requester_without_report_permission(): void + { + $document = Document::factory()->inProgress()->create(); + + $this->actingAs($document->requester)->get(route('dashboard.cases.show', $document)) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page->component('Dashboard/Cases/show')->where('case.id', $document->id)->where('canReport', false)); + } + + public function test_show_allows_assigned_doctor_to_report(): void + { + $doctor = $this->doctor(); + $document = Document::factory()->inProgress()->create(); + $this->assign($document, $doctor); + + $this->actingAs($doctor)->get(route('dashboard.cases.show', $document)) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page->where('canReport', true)); + } + + public function test_show_forbids_unrelated_user(): void + { + $document = Document::factory()->create(); + + $this->actingAs(User::factory()->create())->get(route('dashboard.cases.show', $document))->assertForbidden(); + } + + public function test_reject_by_assigned_doctor_rejects_case_and_refunds(): void + { + $doctor = $this->doctor(); + $document = Document::factory()->inProgress()->create(); + $this->assign($document, $doctor); + $wallet = Wallet::factory()->create(['user_id' => $document->requester_id, 'balance' => 0]); + Invoice::factory()->paid()->create(['document_id' => $document->id, 'user_id' => $document->requester_id, 'amount' => 300000]); + + $this->actingAs($doctor)->post(route('dashboard.cases.reject', $document), ['reason' => 'ناخوانا']) + ->assertRedirect(route('dashboard.cases.index')) + ->assertSessionHas('success'); + + $this->assertSame(DocumentStatusEnum::Rejected, $document->fresh()->status); + $this->assertSame(300000, $wallet->fresh()->balance); + $this->assertDatabaseHas('invoices', ['document_id' => $document->id, 'status' => InvoiceStatusEnum::Canceled->value]); + } + + public function test_reject_validates_reason(): void + { + $doctor = $this->doctor(); + $document = Document::factory()->inProgress()->create(); + $this->assign($document, $doctor); + + $this->actingAs($doctor)->post(route('dashboard.cases.reject', $document), [])->assertSessionHasErrors('reason'); + } + + public function test_reject_by_unassigned_user_returns_validation_error(): void + { + $document = Document::factory()->inProgress()->create(); + + $this->actingAs($this->doctor())->post(route('dashboard.cases.reject', $document), ['reason' => 'x'])->assertSessionHasErrors('document'); + + $this->assertSame(DocumentStatusEnum::InProgress, $document->fresh()->status); + } +} diff --git a/tests/Feature/Http/Client/Doctor/DashboardControllerTest.php b/tests/Feature/Http/Client/Doctor/DashboardControllerTest.php new file mode 100644 index 0000000..a34551e --- /dev/null +++ b/tests/Feature/Http/Client/Doctor/DashboardControllerTest.php @@ -0,0 +1,51 @@ +get(route('dashboard.index'))->assertRedirect(route('login')); + } + + public function test_index_renders_requester_pending_cases_and_stats(): void + { + $user = User::factory()->create(['level' => UserEnum::Vet->value]); + Document::factory()->count(7)->registered()->create(['requester_id' => $user->id]); + Document::factory()->registered()->create(); + + $this->actingAs($user)->get(route('dashboard.index')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('Dashboard/Dashboard') + ->has('cases.data', 5) + ->where('stats.pending_cases', 7) + ->where('stats.is_doctor', false)); + } + + public function test_index_renders_doctor_assigned_cases(): void + { + $doctor = User::factory()->create(['level' => UserEnum::Doctor->value]); + $document = Document::factory()->inProgress()->create(); + DocumentAssignment::factory()->create(['document_id' => $document->id, 'user_id' => $doctor->id]); + Document::factory()->inProgress()->create(); + + $this->actingAs($doctor)->get(route('dashboard.index')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->has('cases.data', 1) + ->where('cases.data.0.id', $document->id) + ->where('stats.is_doctor', true)); + } +} diff --git a/tests/Feature/Http/Client/Doctor/DocumentControllerTest.php b/tests/Feature/Http/Client/Doctor/DocumentControllerTest.php new file mode 100644 index 0000000..9043c37 --- /dev/null +++ b/tests/Feature/Http/Client/Doctor/DocumentControllerTest.php @@ -0,0 +1,38 @@ +get(route('dashboard.documents.index'))->assertRedirect(route('login')); + } + + public function test_index_lists_pending_documents_of_user(): void + { + $user = User::factory()->create(); + Document::factory()->count(3)->registered()->create(['requester_id' => $user->id]); + Document::factory()->registered()->create(); + + $this->actingAs($user)->get(route('dashboard.documents.index')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page->component('Dashboard/Documents/Index')->has('documents.data', 3)); + } + + public function test_show_redirects_to_case_page(): void + { + $document = Document::factory()->create(); + + $this->actingAs($document->requester)->get(route('dashboard.documents.show', $document)) + ->assertRedirect(route('dashboard.cases.show', $document)); + } +} diff --git a/tests/Feature/Http/Client/Doctor/ReportControllerTest.php b/tests/Feature/Http/Client/Doctor/ReportControllerTest.php new file mode 100644 index 0000000..6bb6b5e --- /dev/null +++ b/tests/Feature/Http/Client/Doctor/ReportControllerTest.php @@ -0,0 +1,97 @@ +create(['level' => UserEnum::Doctor->value]); + } + + public function test_routes_require_authentication(): void + { + $this->get(route('dashboard.reports.index'))->assertRedirect(route('login')); + $this->post(route('dashboard.reports.store'))->assertRedirect(route('login')); + } + + public function test_index_shows_reports_written_by_doctor_and_pending_cases(): void + { + $doctor = $this->doctor(); + Report::factory()->count(2)->create(['doctor_id' => $doctor->id]); + Report::factory()->create(); + $pending = Document::factory()->inProgress()->create(); + DocumentAssignment::factory()->create(['document_id' => $pending->id, 'user_id' => $doctor->id]); + + $this->actingAs($doctor)->get(route('dashboard.reports.index')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page->component('Dashboard/Reports/Index')->has('reports.data', 2)->has('pendingCases.data', 1)); + } + + public function test_index_shows_reports_of_requesters_documents_without_pending_cases(): void + { + $requester = User::factory()->create(['level' => UserEnum::Vet->value]); + $document = Document::factory()->create(['requester_id' => $requester->id]); + Report::factory()->create(['document_id' => $document->id]); + Report::factory()->create(); + + $this->actingAs($requester)->get(route('dashboard.reports.index')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page->has('reports.data', 1)->where('pendingCases', null)); + } + + public function test_store_submits_report_and_completes_document(): void + { + $doctor = $this->doctor(); + $document = Document::factory()->inProgress()->create(); + DocumentAssignment::factory()->create(['document_id' => $document->id, 'user_id' => $doctor->id]); + + $this->actingAs($doctor)->post(route('dashboard.reports.store'), [ + 'document_id' => $document->id, + 'finding' => 'یافته', + 'files' => [UploadedFile::fake()->image('a.jpg')], + ])->assertRedirect(route('dashboard.cases.index'))->assertSessionHas('success'); + + $this->assertDatabaseHas('reports', ['document_id' => $document->id, 'doctor_id' => $doctor->id, 'finding' => 'یافته']); + $this->assertSame(DocumentStatusEnum::Completed, $document->fresh()->status); + $this->assertDatabaseCount('media', 1); + } + + public function test_store_validates_input(): void + { + $this->actingAs($this->doctor())->post(route('dashboard.reports.store'), [])->assertSessionHasErrors(['document_id', 'finding']); + } + + public function test_store_rejects_unassigned_doctor(): void + { + $document = Document::factory()->inProgress()->create(); + + $this->actingAs($this->doctor())->post(route('dashboard.reports.store'), ['document_id' => $document->id, 'finding' => 'x']) + ->assertSessionHasErrors('document'); + + $this->assertDatabaseCount('reports', 0); + } +} diff --git a/tests/Feature/Http/Client/Doctor/ShiftControllerTest.php b/tests/Feature/Http/Client/Doctor/ShiftControllerTest.php new file mode 100644 index 0000000..cae60bf --- /dev/null +++ b/tests/Feature/Http/Client/Doctor/ShiftControllerTest.php @@ -0,0 +1,99 @@ +create(['level' => UserEnum::Doctor->value]); + } + + public function test_routes_require_authentication(): void + { + $shift = Shift::factory()->create(); + + $this->get(route('dashboard.shifts.index'))->assertRedirect(route('login')); + $this->put(route('dashboard.shifts.update', $shift))->assertRedirect(route('login')); + $this->delete(route('dashboard.shifts.destroy', $shift))->assertRedirect(route('login')); + } + + public function test_routes_are_forbidden_for_non_doctors(): void + { + $vet = User::factory()->create(['level' => UserEnum::Vet->value]); + $shift = Shift::factory()->create(); + + $this->actingAs($vet)->get(route('dashboard.shifts.index'))->assertForbidden(); + $this->actingAs($vet)->put(route('dashboard.shifts.update', $shift))->assertForbidden(); + $this->actingAs($vet)->delete(route('dashboard.shifts.destroy', $shift))->assertForbidden(); + } + + public function test_index_lists_my_shifts_and_open_upcoming_shifts_sorted(): void + { + Carbon::setTestNow('2026-09-21 10:00:00'); + $doctor = $this->doctor(); + $mine = Shift::factory()->weekly(ShiftDayEnum::Monday)->forDoctor($doctor)->create(); + Shift::factory()->weekly(ShiftDayEnum::Monday)->forDoctor()->create(); + $openLater = Shift::factory()->onDate('2026-09-25')->create(); + $openWeekly = Shift::factory()->weekly(ShiftDayEnum::Saturday)->create(); + Shift::factory()->onDate('2026-09-20')->create(); + Shift::factory()->weekly(ShiftDayEnum::Sunday)->inactive()->create(); + + $this->actingAs($doctor)->get(route('dashboard.shifts.index')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('Dashboard/Shifts/Index') + ->has('myShifts.data', 1) + ->where('myShifts.data.0.id', $mine->id) + ->has('openShifts.data', 2) + ->where('openShifts.data.0.id', $openWeekly->id) + ->where('openShifts.data.1.id', $openLater->id)); + } + + public function test_update_claims_open_shift(): void + { + $doctor = $this->doctor(); + $shift = Shift::factory()->onDate('2026-09-21')->create(); + + $this->actingAs($doctor)->put(route('dashboard.shifts.update', $shift))->assertOk(); + + $this->assertDatabaseHas('shifts', ['id' => $shift->id, 'user_id' => $doctor->id]); + } + + public function test_update_rejects_taken_shift(): void + { + $shift = Shift::factory()->onDate('2026-09-21')->forDoctor()->create(); + + $this->actingAs($this->doctor())->putJson(route('dashboard.shifts.update', $shift))->assertUnprocessable()->assertJsonValidationErrors('shift'); + } + + public function test_destroy_releases_own_shift(): void + { + $doctor = $this->doctor(); + $shift = Shift::factory()->onDate('2026-09-21')->forDoctor($doctor)->create(); + + $this->actingAs($doctor)->delete(route('dashboard.shifts.destroy', $shift))->assertOk(); + + $this->assertDatabaseHas('shifts', ['id' => $shift->id, 'user_id' => null]); + } + + public function test_destroy_rejects_another_doctors_shift(): void + { + $shift = Shift::factory()->onDate('2026-09-21')->forDoctor()->create(); + + $this->actingAs($this->doctor())->deleteJson(route('dashboard.shifts.destroy', $shift))->assertUnprocessable(); + + $this->assertNotNull($shift->fresh()->user_id); + } +} diff --git a/tests/Feature/Http/Client/FastRequestControllerTest.php b/tests/Feature/Http/Client/FastRequestControllerTest.php new file mode 100644 index 0000000..4f9eb2e --- /dev/null +++ b/tests/Feature/Http/Client/FastRequestControllerTest.php @@ -0,0 +1,92 @@ +get(route('dashboard.fastRequests.create'))->assertRedirect(route('login')); + $this->post(route('dashboard.fastRequests.store'))->assertRedirect(route('login')); + } + + public function test_create_returns_own_pets_and_doctors_currently_on_shift(): void + { + Carbon::setTestNow('2026-09-21 10:00:00'); + $user = User::factory()->create(); + Pet::factory()->count(2)->create(['owner_id' => $user->id]); + Pet::factory()->create(); + $onShift = User::factory()->create(['level' => UserEnum::Doctor->value]); + Shift::factory()->onDate('2026-09-21')->between('08:00', '16:00')->forDoctor($onShift)->create(); + Shift::factory()->onDate('2026-09-22')->forDoctor()->create(); + + $this->actingAs($user)->getJson(route('dashboard.fastRequests.create')) + ->assertOk() + ->assertJsonCount(2, 'pets') + ->assertJsonCount(1, 'doctors') + ->assertJsonPath('doctors.0.id', $onShift->id); + } + + public function test_store_creates_document_with_pet_invoice_and_files(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->postJson(route('dashboard.fastRequests.store'), [ + 'modality' => DocumentModalityEnum::CtScan->value, + 'priority' => DocumentPriorityEnum::Medium->name, + 'pet' => ['name' => 'Rex', 'type' => PetTypeEnum::Dog->name], + 'scan_files' => [UploadedFile::fake()->image('scan.jpg')], + 'history_files' => [UploadedFile::fake()->image('history.jpg')], + ]); + + $response->assertOk() + ->assertJsonPath('document.modality', DocumentModalityEnum::CtScan->value) + ->assertJsonPath('document.pet.name', 'Rex') + ->assertJsonPath('document.invoice.amount', DocumentModalityEnum::CtScan->price()); + $this->assertDatabaseCount('documents', 1); + $this->assertDatabaseCount('invoices', 1); + $this->assertDatabaseCount('media', 2); + } + + public function test_store_validates_payload(): void + { + $this->actingAs(User::factory()->create())->postJson(route('dashboard.fastRequests.store'), []) + ->assertUnprocessable() + ->assertJsonValidationErrors(['modality', 'priority', 'pet']); + } + + public function test_store_rejects_pet_of_another_user(): void + { + $pet = Pet::factory()->create(); + + $this->actingAs(User::factory()->create())->postJson(route('dashboard.fastRequests.store'), [ + 'modality' => DocumentModalityEnum::CtScan->value, + 'priority' => DocumentPriorityEnum::Medium->name, + 'pet_id' => $pet->id, + ])->assertUnprocessable()->assertJsonValidationErrors('pet_id'); + } +} diff --git a/tests/Feature/Http/Client/InvoiceControllerTest.php b/tests/Feature/Http/Client/InvoiceControllerTest.php new file mode 100644 index 0000000..bf23134 --- /dev/null +++ b/tests/Feature/Http/Client/InvoiceControllerTest.php @@ -0,0 +1,69 @@ +get(route('dashboard.invoices.index'))->assertRedirect(route('login')); + $this->post(route('dashboard.invoices.payWallet', 1))->assertRedirect(route('login')); + } + + public function test_index_lists_only_own_invoices(): void + { + $user = User::factory()->create(); + Invoice::factory()->count(3)->create(['user_id' => $user->id]); + Invoice::factory()->create(); + + $this->actingAs($user)->get(route('dashboard.invoices.index')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page->component('Dashboard/Invoices/Index')->has('invoices.data', 3)); + } + + public function test_pay_with_wallet_pays_own_pending_invoice(): void + { + Queue::fake(); + $wallet = Wallet::factory()->create(['balance' => 1000000]); + $invoice = Invoice::factory()->create(['user_id' => $wallet->user_id, 'amount' => 400000]); + + $this->actingAs($wallet->user)->postJson(route('dashboard.invoices.payWallet', $invoice)) + ->assertOk() + ->assertJsonPath('invoice.id', $invoice->id); + + $this->assertSame(InvoiceStatusEnum::Paid, $invoice->fresh()->status); + $this->assertSame(600000, $wallet->fresh()->balance); + } + + public function test_pay_with_wallet_forbids_another_users_invoice(): void + { + $invoice = Invoice::factory()->create(); + + $this->actingAs(User::factory()->create())->postJson(route('dashboard.invoices.payWallet', $invoice))->assertForbidden(); + + $this->assertSame(InvoiceStatusEnum::Pending, $invoice->fresh()->status); + } + + public function test_pay_with_wallet_returns_validation_error_on_insufficient_balance(): void + { + $wallet = Wallet::factory()->create(['balance' => 1000]); + $invoice = Invoice::factory()->create(['user_id' => $wallet->user_id, 'amount' => 400000]); + + $this->actingAs($wallet->user)->postJson(route('dashboard.invoices.payWallet', $invoice)) + ->assertUnprocessable() + ->assertJsonValidationErrors('amount'); + + $this->assertSame(InvoiceStatusEnum::Pending, $invoice->fresh()->status); + } +} diff --git a/tests/Feature/Http/Client/MediaControllerTest.php b/tests/Feature/Http/Client/MediaControllerTest.php new file mode 100644 index 0000000..4384693 --- /dev/null +++ b/tests/Feature/Http/Client/MediaControllerTest.php @@ -0,0 +1,59 @@ +private()->create(['path' => 'documents/scan.jpg', 'filename' => 'scan.jpg']); + Storage::disk('private')->put($medium->path, 'content'); + $document->media()->attach($medium->id, ['collection' => MediaCollectionEnum::Primary->value]); + + return $medium; + } + + public function test_download_requires_authentication(): void + { + $this->get(route('dashboard.media.download', Medium::factory()->create()))->assertRedirect(route('login')); + } + + public function test_requester_can_download_document_file(): void + { + $document = Document::factory()->create(); + $medium = $this->privateMediumFor($document); + + $this->actingAs($document->requester)->get(route('dashboard.media.download', $medium)) + ->assertOk() + ->assertDownload('scan.jpg'); + } + + public function test_unrelated_user_is_forbidden(): void + { + $document = Document::factory()->create(); + $medium = $this->privateMediumFor($document); + + $this->actingAs(User::factory()->create())->get(route('dashboard.media.download', $medium))->assertForbidden(); + } + + public function test_missing_file_returns_not_found(): void + { + Storage::fake('private'); + $document = Document::factory()->create(); + $medium = Medium::factory()->private()->create(['path' => 'documents/missing.jpg']); + $document->media()->attach($medium->id, ['collection' => MediaCollectionEnum::Primary->value]); + + $this->actingAs($document->requester)->get(route('dashboard.media.download', $medium))->assertNotFound(); + } +} diff --git a/tests/Feature/Http/Client/PaymentControllerTest.php b/tests/Feature/Http/Client/PaymentControllerTest.php new file mode 100644 index 0000000..105a4d1 --- /dev/null +++ b/tests/Feature/Http/Client/PaymentControllerTest.php @@ -0,0 +1,146 @@ +post(route('dashboard.payments.store'), ['invoice_id' => 1])->assertRedirect(route('login')); + } + + public function test_store_creates_gateway_payment_for_own_pending_invoice(): void + { + Http::fake(['gateway.zibal.ir/v1/request' => Http::response(['result' => 100, 'trackId' => 777])]); + $invoice = Invoice::factory()->create(['amount' => 500000]); + + $response = $this->actingAs($invoice->user)->postJson(route('dashboard.payments.store'), ['invoice_id' => $invoice->id]); + + $response->assertOk() + ->assertJsonPath('payment.track_id', '777') + ->assertJsonPath('url', 'https://gateway.zibal.ir/start/777'); + $this->assertDatabaseHas('payments', [ + 'track_id' => '777', + 'amount' => 500000, + 'invoice_id' => $invoice->id, + 'payer_id' => $invoice->user_id, + 'purpose' => PaymentPurposeEnum::Invoice->value, + ]); + } + + public function test_store_rejects_invoice_of_another_user(): void + { + Http::fake(); + $invoice = Invoice::factory()->create(); + + $this->actingAs(User::factory()->create())->postJson(route('dashboard.payments.store'), ['invoice_id' => $invoice->id])->assertNotFound(); + Http::assertNothingSent(); + } + + public function test_store_rejects_non_pending_invoice(): void + { + Http::fake(); + $invoice = Invoice::factory()->paid()->create(); + + $this->actingAs($invoice->user)->postJson(route('dashboard.payments.store'), ['invoice_id' => $invoice->id])->assertNotFound(); + } + + public function test_store_validates_invoice_id(): void + { + $this->actingAs(User::factory()->create())->postJson(route('dashboard.payments.store'), [])->assertUnprocessable()->assertJsonValidationErrors('invoice_id'); + } + + public function test_callback_with_unknown_track_id_redirects_to_dashboard(): void + { + $this->get(route('dashboard.payments.callback', ['trackId' => 'nope', 'success' => 1]))->assertRedirect(route('dashboard.index')); + } + + public function test_callback_settles_successful_invoice_payment(): void + { + Http::fake(['gateway.zibal.ir/v1/verify' => Http::response(['result' => 100, 'refNumber' => 'REF'])]); + $invoice = Invoice::factory()->create(['amount' => 500000]); + $payment = Payment::factory()->create(['invoice_id' => $invoice->id, 'payer_id' => $invoice->user_id, 'amount' => 500000, 'track_id' => '555']); + + $response = $this->get(route('dashboard.payments.callback', ['trackId' => '555', 'success' => 1])); + + $response->assertRedirect(route('dashboard.index')); + $response->assertSessionHas('payment_result', ['status' => 'paid', 'track_id' => '555', 'purpose' => 'Invoice']); + $this->assertSame(PaymentStatusEnum::Paid, $payment->fresh()->status); + $invoice->refresh(); + $this->assertSame(InvoiceStatusEnum::Paid, $invoice->status); + $this->assertSame(InvoicePaymentMethodEnum::Gateway, $invoice->payment_method); + $this->assertDatabaseHas('wallet_transactions', ['invoice_id' => $invoice->id, 'payment_id' => $payment->id, 'reason' => WalletTransactionReasonEnum::InvoicePayment->value]); + } + + public function test_callback_settles_successful_wallet_top_up(): void + { + Http::fake(['gateway.zibal.ir/v1/verify' => Http::response(['result' => 100])]); + $wallet = Wallet::factory()->create(['balance' => 1000]); + Payment::factory()->walletTopUp()->create(['payer_id' => $wallet->user_id, 'amount' => 250000, 'track_id' => '556']); + + $this->get(route('dashboard.payments.callback', ['trackId' => '556', 'success' => 1]))->assertRedirect(route('dashboard.index')); + + $this->assertSame(251000, $wallet->fresh()->balance); + $this->assertDatabaseHas('wallet_transactions', ['wallet_id' => $wallet->id, 'reason' => WalletTransactionReasonEnum::TopUp->value, 'amount' => 250000]); + } + + public function test_callback_marks_payment_failed_when_gateway_reports_failure(): void + { + Http::fake(); + $invoice = Invoice::factory()->create(); + $payment = Payment::factory()->create(['invoice_id' => $invoice->id, 'payer_id' => $invoice->user_id, 'track_id' => '557']); + + $response = $this->get(route('dashboard.payments.callback', ['trackId' => '557', 'success' => 0])); + + $response->assertSessionHas('payment_result.status', 'failed'); + $this->assertSame(PaymentStatusEnum::Failed, $payment->fresh()->status); + $this->assertSame(InvoiceStatusEnum::Pending, $invoice->fresh()->status); + Http::assertNothingSent(); + } + + public function test_callback_marks_payment_failed_when_verification_fails(): void + { + Http::fake(['gateway.zibal.ir/v1/verify' => Http::response(['result' => 202])]); + $payment = Payment::factory()->create(['track_id' => '558']); + + $this->get(route('dashboard.payments.callback', ['trackId' => '558', 'success' => 1])); + + $this->assertSame(PaymentStatusEnum::Failed, $payment->fresh()->status); + $this->assertDatabaseCount('wallet_transactions', 0); + } + + public function test_callback_does_not_settle_twice_for_already_paid_payment(): void + { + Http::fake(); + $wallet = Wallet::factory()->create(['balance' => 1000]); + Payment::factory()->paid()->walletTopUp()->create(['payer_id' => $wallet->user_id, 'amount' => 250000, 'track_id' => '559']); + + $this->get(route('dashboard.payments.callback', ['trackId' => '559', 'success' => 1]))->assertSessionHas('payment_result.status', 'paid'); + + $this->assertSame(1000, $wallet->fresh()->balance); + $this->assertDatabaseCount('wallet_transactions', 0); + } +} diff --git a/tests/Feature/Http/Client/ProfileControllerTest.php b/tests/Feature/Http/Client/ProfileControllerTest.php new file mode 100644 index 0000000..cd6f382 --- /dev/null +++ b/tests/Feature/Http/Client/ProfileControllerTest.php @@ -0,0 +1,80 @@ +post(route('dashboard.profile.update'))->assertRedirect(route('login')); + } + + public function test_update_saves_profile_fields(): void + { + $user = User::factory()->create(); + + $this->actingAs($user)->from('/dashboard')->post(route('dashboard.profile.update'), [ + 'fullname' => 'نام جدید', + 'national_code' => '0012345678', + 'email' => 'new@example.com', + 'city' => 'تهران', + ])->assertRedirect('/dashboard')->assertSessionHas('success'); + + $this->assertDatabaseHas('users', ['id' => $user->id, 'fullname' => 'نام جدید', 'national_code' => '0012345678', 'email' => 'new@example.com', 'city' => 'تهران']); + } + + public function test_update_stores_avatar_and_sets_profile_url(): void + { + $user = User::factory()->create(['profile_url' => null]); + + $this->actingAs($user)->post(route('dashboard.profile.update'), [ + 'fullname' => 'n', + 'national_code' => '1', + 'avatar' => UploadedFile::fake()->image('me.png'), + ]); + + $medium = Medium::first(); + $this->assertNotNull($medium); + $this->assertStringStartsWith('profile/', $medium->path); + $this->assertSame($medium->url, $user->fresh()->profile_url); + Storage::disk('public')->assertExists($medium->path); + } + + public function test_update_replaces_previous_avatar(): void + { + $old = Medium::factory()->public()->create(['path' => 'profile/old.png', 'url' => 'http://example.test/storage/profile/old.png']); + Storage::disk('public')->put($old->path, 'old'); + $user = User::factory()->create(['profile_url' => $old->url]); + + $this->actingAs($user)->post(route('dashboard.profile.update'), [ + 'fullname' => 'n', + 'national_code' => '1', + 'avatar' => UploadedFile::fake()->image('new.png'), + ]); + + $this->assertDatabaseMissing('media', ['id' => $old->id]); + Storage::disk('public')->assertMissing('profile/old.png'); + $this->assertNotSame($old->url, $user->fresh()->profile_url); + } + + public function test_update_validates_required_fields(): void + { + $this->actingAs(User::factory()->create())->post(route('dashboard.profile.update'), [])->assertSessionHasErrors(['fullname', 'national_code']); + } +} diff --git a/tests/Feature/Http/Client/WalletControllerTest.php b/tests/Feature/Http/Client/WalletControllerTest.php new file mode 100644 index 0000000..f9c7050 --- /dev/null +++ b/tests/Feature/Http/Client/WalletControllerTest.php @@ -0,0 +1,143 @@ +get(route('dashboard.wallet.index'))->assertRedirect(route('login')); + $this->post(route('dashboard.wallet.deposit'))->assertRedirect(route('login')); + $this->post(route('dashboard.wallet.withdrawRequests.store'))->assertRedirect(route('login')); + $this->post(route('dashboard.wallet.bankCards.store'))->assertRedirect(route('login')); + } + + public function test_index_renders_own_transactions_withdraw_requests_and_bank_cards(): void + { + $wallet = Wallet::factory()->create(['balance' => 5000]); + WalletTransaction::factory()->count(2)->create(['wallet_id' => $wallet->id]); + WalletTransaction::factory()->create(); + $bankCard = BankCard::factory()->create(['user_id' => $wallet->user_id]); + WithdrawRequest::factory()->create(['user_id' => $wallet->user_id, 'bank_card_id' => $bankCard->id]); + WithdrawRequest::factory()->create(); + + $this->actingAs($wallet->user)->get(route('dashboard.wallet.index')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('Dashboard/Wallet/Index') + ->has('transactions.data', 2) + ->has('withdrawRequests', 1) + ->has('bankCards', 1)); + } + + public function test_index_creates_wallet_for_user_without_one(): void + { + $user = User::factory()->create(); + + $this->actingAs($user)->get(route('dashboard.wallet.index'))->assertOk(); + + $this->assertDatabaseHas('wallets', ['user_id' => $user->id, 'balance' => 0]); + } + + public function test_deposit_creates_top_up_payment_and_returns_gateway_url(): void + { + Http::fake(['gateway.zibal.ir/v1/request' => Http::response(['result' => 100, 'trackId' => 900])]); + $user = User::factory()->create(); + + $this->actingAs($user)->postJson(route('dashboard.wallet.deposit'), ['amount' => 100000]) + ->assertOk() + ->assertJsonPath('url', 'https://gateway.zibal.ir/start/900'); + + $this->assertDatabaseHas('payments', ['track_id' => '900', 'amount' => 100000, 'payer_id' => $user->id, 'purpose' => PaymentPurposeEnum::WalletTopUp->value, 'invoice_id' => null]); + } + + public function test_deposit_validates_amount(): void + { + $this->actingAs(User::factory()->create())->postJson(route('dashboard.wallet.deposit'), ['amount' => 100])->assertUnprocessable()->assertJsonValidationErrors('amount'); + } + + public function test_store_withdraw_request_creates_pending_request(): void + { + $wallet = Wallet::factory()->create(['balance' => 500000]); + $bankCard = BankCard::factory()->create(['user_id' => $wallet->user_id]); + + $this->actingAs($wallet->user) + ->post(route('dashboard.wallet.withdrawRequests.store'), ['amount' => 200000, 'bank_card_id' => $bankCard->id]) + ->assertRedirect(route('dashboard.wallet.index')) + ->assertSessionHas('success'); + + $this->assertDatabaseHas('withdraw_requests', ['user_id' => $wallet->user_id, 'amount' => 200000, 'status' => WithdrawRequestStatusEnum::Pending->value]); + } + + public function test_store_withdraw_request_rejects_insufficient_balance(): void + { + $wallet = Wallet::factory()->create(['balance' => 100000]); + $bankCard = BankCard::factory()->create(['user_id' => $wallet->user_id]); + + $this->actingAs($wallet->user) + ->post(route('dashboard.wallet.withdrawRequests.store'), ['amount' => 200000, 'bank_card_id' => $bankCard->id]) + ->assertSessionHasErrors('amount'); + + $this->assertDatabaseCount('withdraw_requests', 0); + } + + public function test_store_withdraw_request_rejects_another_users_bank_card(): void + { + $wallet = Wallet::factory()->create(['balance' => 500000]); + $bankCard = BankCard::factory()->create(); + + $this->actingAs($wallet->user) + ->post(route('dashboard.wallet.withdrawRequests.store'), ['amount' => 100000, 'bank_card_id' => $bankCard->id]) + ->assertSessionHasErrors('bank_card_id'); + } + + public function test_store_bank_card_creates_card_for_user(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->post(route('dashboard.wallet.bankCards.store'), ['card_number' => '6037991234567890', 'holder_name' => 'علی']) + ->assertRedirect(route('dashboard.wallet.index')); + + $this->assertDatabaseHas('bank_cards', ['user_id' => $user->id, 'card_number' => '6037991234567890', 'holder_name' => 'علی']); + } + + public function test_store_bank_card_validates_input(): void + { + $this->actingAs(User::factory()->create()) + ->post(route('dashboard.wallet.bankCards.store'), ['card_number' => '123']) + ->assertSessionHasErrors(['card_number', 'holder_name']); + } + + public function test_destroy_bank_card_deletes_own_card(): void + { + $bankCard = BankCard::factory()->create(); + + $this->actingAs($bankCard->user)->delete(route('dashboard.wallet.bankCards.destroy', $bankCard))->assertRedirect(route('dashboard.wallet.index')); + + $this->assertDatabaseMissing('bank_cards', ['id' => $bankCard->id]); + } + + public function test_destroy_bank_card_forbids_another_users_card(): void + { + $bankCard = BankCard::factory()->create(); + + $this->actingAs(User::factory()->create())->delete(route('dashboard.wallet.bankCards.destroy', $bankCard))->assertForbidden(); + + $this->assertDatabaseHas('bank_cards', ['id' => $bankCard->id]); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index fe1ffc2..95a3971 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,5 +6,11 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { - // + protected function setUp(): void + { + parent::setUp(); + + $this->withoutVite(); + config(['inertia.testing.page_paths' => [resource_path('js/Client/Pages'), resource_path('js/Admin/Pages')]]); + } }