Add feature tests for Doctor-related functionalities

This commit is contained in:
2026-09-21 05:41:06 +03:30
parent e987a528ef
commit 6e92ebaada
29 changed files with 2634 additions and 29 deletions
@@ -0,0 +1,256 @@
<?php
namespace Tests\Feature\Http\Client;
use App\Enums\UserEnum;
use App\Models\OtpCode;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class AuthControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Http::fake();
}
public function test_phone_page_renders(): void
{
$this->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();
}
}
@@ -0,0 +1,112 @@
<?php
namespace Tests\Feature\Http\Client\Doctor;
use App\Enums\DocumentStatusEnum;
use App\Enums\InvoiceStatusEnum;
use App\Enums\UserEnum;
use App\Models\Document;
use App\Models\DocumentAssignment;
use App\Models\Invoice;
use App\Models\User;
use App\Models\Wallet;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class CaseControllerTest extends TestCase
{
use RefreshDatabase;
protected function doctor(): User
{
return User::factory()->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);
}
}
@@ -0,0 +1,51 @@
<?php
namespace Tests\Feature\Http\Client\Doctor;
use App\Enums\UserEnum;
use App\Models\Document;
use App\Models\DocumentAssignment;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class DashboardControllerTest extends TestCase
{
use RefreshDatabase;
public function test_index_requires_authentication(): void
{
$this->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));
}
}
@@ -0,0 +1,38 @@
<?php
namespace Tests\Feature\Http\Client\Doctor;
use App\Models\Document;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class DocumentControllerTest extends TestCase
{
use RefreshDatabase;
public function test_routes_require_authentication(): void
{
$this->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));
}
}
@@ -0,0 +1,97 @@
<?php
namespace Tests\Feature\Http\Client\Doctor;
use App\Enums\DocumentStatusEnum;
use App\Enums\UserEnum;
use App\Models\Document;
use App\Models\DocumentAssignment;
use App\Models\Report;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class ReportControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Queue::fake();
Storage::fake('public');
}
protected function doctor(): User
{
return User::factory()->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);
}
}
@@ -0,0 +1,99 @@
<?php
namespace Tests\Feature\Http\Client\Doctor;
use App\Enums\ShiftDayEnum;
use App\Enums\UserEnum;
use App\Models\Shift;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class ShiftControllerTest extends TestCase
{
use RefreshDatabase;
protected function doctor(): User
{
return User::factory()->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);
}
}
@@ -0,0 +1,92 @@
<?php
namespace Tests\Feature\Http\Client;
use App\Enums\DocumentModalityEnum;
use App\Enums\DocumentPriorityEnum;
use App\Enums\PetTypeEnum;
use App\Enums\UserEnum;
use App\Models\Pet;
use App\Models\Shift;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class FastRequestControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Queue::fake();
Storage::fake('public');
}
public function test_routes_require_authentication(): void
{
$this->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');
}
}
@@ -0,0 +1,69 @@
<?php
namespace Tests\Feature\Http\Client;
use App\Enums\InvoiceStatusEnum;
use App\Models\Invoice;
use App\Models\User;
use App\Models\Wallet;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class InvoiceControllerTest extends TestCase
{
use RefreshDatabase;
public function test_routes_require_authentication(): void
{
$this->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);
}
}
@@ -0,0 +1,59 @@
<?php
namespace Tests\Feature\Http\Client;
use App\Enums\MediaCollectionEnum;
use App\Models\Document;
use App\Models\Medium;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class MediaControllerTest extends TestCase
{
use RefreshDatabase;
protected function privateMediumFor(Document $document): Medium
{
Storage::fake('private');
$medium = Medium::factory()->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();
}
}
@@ -0,0 +1,146 @@
<?php
namespace Tests\Feature\Http\Client;
use App\Enums\InvoicePaymentMethodEnum;
use App\Enums\InvoiceStatusEnum;
use App\Enums\PaymentPurposeEnum;
use App\Enums\PaymentStatusEnum;
use App\Enums\WalletTransactionReasonEnum;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\User;
use App\Models\Wallet;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class PaymentControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Queue::fake();
}
public function test_store_requires_authentication(): void
{
$this->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);
}
}
@@ -0,0 +1,80 @@
<?php
namespace Tests\Feature\Http\Client;
use App\Models\Medium;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class ProfileControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
}
public function test_update_requires_authentication(): void
{
$this->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']);
}
}
@@ -0,0 +1,143 @@
<?php
namespace Tests\Feature\Http\Client;
use App\Enums\PaymentPurposeEnum;
use App\Enums\WithdrawRequestStatusEnum;
use App\Models\BankCard;
use App\Models\User;
use App\Models\Wallet;
use App\Models\WalletTransaction;
use App\Models\WithdrawRequest;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class WalletControllerTest extends TestCase
{
use RefreshDatabase;
public function test_routes_require_authentication(): void
{
$this->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]);
}
}