From fd0d445b6ed9542b6c2dea7b748ae96b34f2afb9 Mon Sep 17 00:00:00 2001 From: mehrab esmailnia Date: Mon, 21 Sep 2026 05:06:46 +0330 Subject: [PATCH] feat: add OtpCodeFactory and comprehensive tests for AuthService and RegistrationService --- app/Models/OtpCode.php | 3 + app/Services/Auth/AuthService.php | 2 +- app/Services/Auth/RegistrationService.php | 2 +- database/factories/OtpCodeFactory.php | 27 +++ docs/TESTING.md | 8 +- .../Feature/Services/Auth/AuthServiceTest.php | 121 ++++++++++++++ .../Feature/Services/Auth/OtpServiceTest.php | 158 ++++++++++++++++++ .../Services/Auth/RegistrationServiceTest.php | 132 +++++++++++++++ 8 files changed, 447 insertions(+), 6 deletions(-) create mode 100644 database/factories/OtpCodeFactory.php create mode 100644 tests/Feature/Services/Auth/AuthServiceTest.php create mode 100644 tests/Feature/Services/Auth/OtpServiceTest.php create mode 100644 tests/Feature/Services/Auth/RegistrationServiceTest.php diff --git a/app/Models/OtpCode.php b/app/Models/OtpCode.php index b9c48ef..d32fbbf 100644 --- a/app/Models/OtpCode.php +++ b/app/Models/OtpCode.php @@ -2,10 +2,13 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class OtpCode extends Model { + use HasFactory; + protected $fillable = [ 'phone', 'code', diff --git a/app/Services/Auth/AuthService.php b/app/Services/Auth/AuthService.php index 89ec03c..e62e8f7 100644 --- a/app/Services/Auth/AuthService.php +++ b/app/Services/Auth/AuthService.php @@ -37,7 +37,7 @@ class AuthService Auth::login($user); session()->regenerate(); - session()->forget('register_phone' , 'verified_phone' , 'otp_verified' , 'login_phone' , 'login_level' , 'available_levels'); + session()->forget(['register_phone', 'verified_phone', 'otp_verified', 'login_phone', 'login_level', 'available_levels']); return $user; } diff --git a/app/Services/Auth/RegistrationService.php b/app/Services/Auth/RegistrationService.php index e07b7b2..4b0d6d0 100644 --- a/app/Services/Auth/RegistrationService.php +++ b/app/Services/Auth/RegistrationService.php @@ -40,7 +40,7 @@ class RegistrationService Auth::login($user); session()->regenerate(); - session()->forget('register_phone' , 'verified_phone' , 'otp_verified' , 'login_phone' , 'login_level' , 'available_levels'); + session()->forget(['register_phone', 'verified_phone', 'otp_verified', 'login_phone', 'login_level', 'available_levels']); return $user; } diff --git a/database/factories/OtpCodeFactory.php b/database/factories/OtpCodeFactory.php new file mode 100644 index 0000000..767e3a3 --- /dev/null +++ b/database/factories/OtpCodeFactory.php @@ -0,0 +1,27 @@ + + */ +class OtpCodeFactory extends Factory +{ + public function definition(): array + { + return [ + 'phone' => '09' . fake()->numerify('#########'), + 'code' => (string) fake()->numberBetween(1000, 9999), + 'expires_at' => now()->addMinutes(3), + ]; + } + + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'expires_at' => now()->subMinute(), + ]); + } +} diff --git a/docs/TESTING.md b/docs/TESTING.md index 57b08a0..e97750d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -105,9 +105,9 @@ Legend: `☐` not written · `☑` written · `—` intentionally not tested | P1 | `app/Services/Wallet/WithdrawRequestService.php` | `tests/Feature/Services/Wallet/WithdrawRequestServiceTest.php` | ☑ | | P1 | `app/Services/Invoice/InvoiceService.php` | `tests/Feature/Services/Invoice/InvoiceServiceTest.php` | ☑ | | P1 | `app/Services/Payment/ZibalPaymentService.php` | `tests/Feature/Services/Payment/ZibalPaymentServiceTest.php` | ☑ | -| P1 | `app/Services/Auth/OtpService.php` | `tests/Feature/Services/Auth/OtpServiceTest.php` | ☐ | -| P1 | `app/Services/Auth/AuthService.php` | `tests/Feature/Services/Auth/AuthServiceTest.php` | ☐ | -| P1 | `app/Services/Auth/RegistrationService.php` | `tests/Feature/Services/Auth/RegistrationServiceTest.php` | ☐ | +| P1 | `app/Services/Auth/OtpService.php` | `tests/Feature/Services/Auth/OtpServiceTest.php` | ☑ | +| P1 | `app/Services/Auth/AuthService.php` | `tests/Feature/Services/Auth/AuthServiceTest.php` | ☑ | +| P1 | `app/Services/Auth/RegistrationService.php` | `tests/Feature/Services/Auth/RegistrationServiceTest.php` | ☑ | | P1 | `app/Services/Shift/ShiftScheduleService.php` | `tests/Feature/Services/Shift/ShiftScheduleServiceTest.php` | ☐ | | P1 | `app/Services/Document/CaseService.php` | `tests/Feature/Services/Document/CaseServiceTest.php` | ☐ | | P1 | `app/Services/Document/FastRequestService.php` | `tests/Feature/Services/Document/FastRequestServiceTest.php` | ☐ | @@ -200,5 +200,5 @@ Missing — create when first needed by a test: | `BankCard` | ☑ | | `Invoice` | ☑ | | `Payment` | ☑ | -| `OtpCode` | ☐ | +| `OtpCode` | ☑ | | `Shift` | ☐ | diff --git a/tests/Feature/Services/Auth/AuthServiceTest.php b/tests/Feature/Services/Auth/AuthServiceTest.php new file mode 100644 index 0000000..0b0ed41 --- /dev/null +++ b/tests/Feature/Services/Auth/AuthServiceTest.php @@ -0,0 +1,121 @@ +service = app(AuthService::class); + } + + public function test_get_levels_for_phone_maps_user_levels_to_slugs(): void + { + User::factory()->create(['phone' => '09120000000', 'level' => UserEnum::Doctor->value]); + User::factory()->create(['phone' => '09120000000', 'level' => UserEnum::Supervisor->value]); + User::factory()->create(['phone' => '09120000000', 'level' => UserEnum::Vet->value]); + + $levels = $this->service->getLevelsForPhone('09120000000'); + + $this->assertEqualsCanonicalizing(['doctor', 'supervisor', 'veterinarian'], $levels); + } + + public function test_get_levels_for_phone_excludes_admin_level(): void + { + User::factory()->create(['phone' => '09120000000', 'level' => UserEnum::Admin->value]); + User::factory()->create(['phone' => '09120000000', 'level' => UserEnum::Doctor->value]); + + $this->assertSame(['doctor'], $this->service->getLevelsForPhone('09120000000')); + } + + public function test_get_levels_for_phone_returns_unique_levels(): void + { + User::factory()->count(2)->create(['phone' => '09120000000', 'level' => UserEnum::Doctor->value]); + + $this->assertSame(['doctor'], $this->service->getLevelsForPhone('09120000000')); + } + + public function test_get_levels_for_phone_returns_empty_array_for_unknown_phone(): void + { + User::factory()->create(['phone' => '09130000000']); + + $this->assertSame([], $this->service->getLevelsForPhone('09120000000')); + } + + public function test_get_levels_for_phone_flushes_session(): void + { + session(['login_phone' => '09120000000']); + + $this->service->getLevelsForPhone('09120000000'); + + $this->assertFalse(session()->has('login_phone')); + } + + public function test_authenticate_logs_in_user_matching_phone_level_and_password(): void + { + $user = User::factory()->create(['phone' => '09120000000', 'level' => UserEnum::Doctor->value, 'password' => 'secret123']); + + $authenticated = $this->service->authenticateByPhoneAndLevel('09120000000', UserEnum::Doctor->value, 'secret123'); + + $this->assertTrue($authenticated->is($user)); + $this->assertTrue(Auth::check()); + $this->assertTrue(Auth::user()->is($user)); + } + + public function test_authenticate_clears_login_flow_session_keys(): void + { + User::factory()->create(['phone' => '09120000000', 'level' => UserEnum::Doctor->value, 'password' => 'secret123']); + session(['register_phone' => 'x', 'verified_phone' => 'x', 'otp_verified' => true, 'login_phone' => 'x', 'login_level' => 'x', 'available_levels' => []]); + + $this->service->authenticateByPhoneAndLevel('09120000000', UserEnum::Doctor->value, 'secret123'); + + foreach (['register_phone', 'verified_phone', 'otp_verified', 'login_phone', 'login_level', 'available_levels'] as $key) { + $this->assertFalse(session()->has($key), "session key {$key} was not forgotten"); + } + } + + public function test_authenticate_returns_null_for_wrong_password(): void + { + User::factory()->create(['phone' => '09120000000', 'level' => UserEnum::Doctor->value, 'password' => 'secret123']); + + $this->assertNull($this->service->authenticateByPhoneAndLevel('09120000000', UserEnum::Doctor->value, 'wrong')); + $this->assertFalse(Auth::check()); + } + + public function test_authenticate_returns_null_for_wrong_level(): void + { + User::factory()->create(['phone' => '09120000000', 'level' => UserEnum::Doctor->value, 'password' => 'secret123']); + + $this->assertNull($this->service->authenticateByPhoneAndLevel('09120000000', UserEnum::Supervisor->value, 'secret123')); + $this->assertFalse(Auth::check()); + } + + public function test_authenticate_returns_null_for_unknown_phone(): void + { + $this->assertNull($this->service->authenticateByPhoneAndLevel('09120000000', UserEnum::Doctor->value, 'secret123')); + $this->assertFalse(Auth::check()); + } + + public function test_authenticate_picks_account_with_requested_level_when_phone_has_many(): void + { + User::factory()->create(['phone' => '09120000000', 'level' => UserEnum::Doctor->value, 'password' => 'doctor-pass']); + $supervisor = User::factory()->create(['phone' => '09120000000', 'level' => UserEnum::Supervisor->value, 'password' => 'supervisor-pass']); + + $authenticated = $this->service->authenticateByPhoneAndLevel('09120000000', UserEnum::Supervisor->value, 'supervisor-pass'); + + $this->assertTrue($authenticated->is($supervisor)); + } +} diff --git a/tests/Feature/Services/Auth/OtpServiceTest.php b/tests/Feature/Services/Auth/OtpServiceTest.php new file mode 100644 index 0000000..9cfb70d --- /dev/null +++ b/tests/Feature/Services/Auth/OtpServiceTest.php @@ -0,0 +1,158 @@ +service = app(OtpService::class); + } + + public function test_generate_code_returns_four_digit_numeric_string(): void + { + $code = $this->service->generateCode(); + + $this->assertMatchesRegularExpression('/^[1-9]\d{3}$/', $code); + } + + public function test_send_for_phone_stores_code_with_expiry(): void + { + Carbon::setTestNow('2026-09-21 10:00:00'); + + $otpCode = $this->service->sendForPhone('09120000000'); + + $this->assertSame('09120000000', $otpCode->phone); + $this->assertMatchesRegularExpression('/^\d{4}$/', $otpCode->code); + $this->assertTrue($otpCode->expires_at->eq(Carbon::parse('2026-09-21 10:03:00'))); + $this->assertDatabaseCount('otp_codes', 1); + } + + public function test_send_for_phone_uses_custom_minutes(): void + { + Carbon::setTestNow('2026-09-21 10:00:00'); + + $otpCode = $this->service->sendForPhone('09120000000', 10); + + $this->assertTrue($otpCode->expires_at->eq(Carbon::parse('2026-09-21 10:10:00'))); + } + + public function test_send_for_phone_replaces_existing_code_for_same_phone(): void + { + $existing = OtpCode::factory()->create(['phone' => '09120000000', 'code' => '1111']); + + $otpCode = $this->service->sendForPhone('09120000000'); + + $this->assertTrue($otpCode->is($existing)); + $this->assertDatabaseCount('otp_codes', 1); + $this->assertDatabaseMissing('otp_codes', ['phone' => '09120000000', 'code' => '1111']); + } + + public function test_send_for_phone_sends_verify_code_pattern_sms(): void + { + config(['services.iranpayamak.patterns.verify_code' => 'verify-pattern']); + + $otpCode = $this->service->sendForPhone('09120000000'); + + Http::assertSent(function (Request $request) use ($otpCode) { + return $request['code'] === 'verify-pattern' + && $request['recipient'] === '09120000000' + && $request['attributes']['otp'] === $otpCode->code; + }); + } + + public function test_send_for_phone_still_stores_code_when_sms_fails(): void + { + Http::fake([config('services.iranpayamak.api_url') => Http::response('error', 500)]); + + $otpCode = $this->service->sendForPhone('09120000000'); + + $this->assertDatabaseHas('otp_codes', ['id' => $otpCode->id, 'phone' => '09120000000']); + } + + public function test_verify_code_returns_true_and_deletes_valid_code(): void + { + OtpCode::factory()->create(['phone' => '09120000000', 'code' => '1234']); + + $this->assertTrue($this->service->verifyCode('09120000000', '1234')); + $this->assertDatabaseCount('otp_codes', 0); + } + + public function test_verify_code_returns_false_for_wrong_code_and_keeps_record(): void + { + OtpCode::factory()->create(['phone' => '09120000000', 'code' => '1234']); + + $this->assertFalse($this->service->verifyCode('09120000000', '0000')); + $this->assertDatabaseCount('otp_codes', 1); + } + + public function test_verify_code_returns_false_for_other_phone(): void + { + OtpCode::factory()->create(['phone' => '09120000000', 'code' => '1234']); + + $this->assertFalse($this->service->verifyCode('09130000000', '1234')); + $this->assertDatabaseCount('otp_codes', 1); + } + + public function test_verify_code_returns_false_for_expired_code(): void + { + OtpCode::factory()->expired()->create(['phone' => '09120000000', 'code' => '1234']); + + $this->assertFalse($this->service->verifyCode('09120000000', '1234')); + $this->assertDatabaseCount('otp_codes', 1); + } + + public function test_verify_code_returns_false_when_code_expires_exactly_now(): void + { + Carbon::setTestNow('2026-09-21 10:00:00'); + OtpCode::factory()->create(['phone' => '09120000000', 'code' => '1234', 'expires_at' => now()]); + + $this->assertFalse($this->service->verifyCode('09120000000', '1234')); + } + + public function test_verify_code_cannot_be_reused(): void + { + OtpCode::factory()->create(['phone' => '09120000000', 'code' => '1234']); + + $this->service->verifyCode('09120000000', '1234'); + + $this->assertFalse($this->service->verifyCode('09120000000', '1234')); + } + + public function test_exists_valid_returns_true_without_deleting_code(): void + { + OtpCode::factory()->create(['phone' => '09120000000', 'code' => '1234']); + + $this->assertTrue($this->service->existsValid('09120000000', '1234')); + $this->assertDatabaseCount('otp_codes', 1); + } + + public function test_exists_valid_returns_false_for_wrong_code(): void + { + OtpCode::factory()->create(['phone' => '09120000000', 'code' => '1234']); + + $this->assertFalse($this->service->existsValid('09120000000', '0000')); + } + + public function test_exists_valid_returns_false_for_expired_code(): void + { + OtpCode::factory()->expired()->create(['phone' => '09120000000', 'code' => '1234']); + + $this->assertFalse($this->service->existsValid('09120000000', '1234')); + } +} diff --git a/tests/Feature/Services/Auth/RegistrationServiceTest.php b/tests/Feature/Services/Auth/RegistrationServiceTest.php new file mode 100644 index 0000000..42f3ec0 --- /dev/null +++ b/tests/Feature/Services/Auth/RegistrationServiceTest.php @@ -0,0 +1,132 @@ +service = app(RegistrationService::class); + } + + protected function payload(array $overrides = []): array + { + return array_merge([ + 'phone' => '09120000000', + 'national_code' => '0012345678', + 'level' => UserEnum::Vet->value, + ], $overrides); + } + + public function test_create_user_stores_all_profile_fields(): void + { + $user = $this->service->createUser($this->payload([ + 'email' => 'vet@example.com', + 'fullname' => 'دکتر نمونه', + 'clinic_name' => 'کلینیک نمونه', + 'clinic_phone' => '02100000000', + 'province' => 'تهران', + 'city' => 'تهران', + 'address' => 'خیابان نمونه', + 'specialty' => 'داخلی', + ])); + + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'phone' => '09120000000', + 'national_code' => '0012345678', + 'level' => UserEnum::Vet->value, + 'email' => 'vet@example.com', + 'fullname' => 'دکتر نمونه', + 'clinic_name' => 'کلینیک نمونه', + 'clinic_phone' => '02100000000', + 'province' => 'تهران', + 'city' => 'تهران', + 'address' => 'خیابان نمونه', + 'specialty' => 'داخلی', + ]); + } + + public function test_create_user_generates_prefixed_random_username(): void + { + $user = $this->service->createUser($this->payload()); + + $this->assertMatchesRegularExpression('/^rdl_[A-Za-z0-9]{10}$/', $user->username); + } + + public function test_create_user_generates_unique_usernames(): void + { + $first = $this->service->createUser($this->payload()); + $second = $this->service->createUser($this->payload()); + + $this->assertNotSame($first->username, $second->username); + } + + public function test_create_user_hashes_given_password(): void + { + $user = $this->service->createUser($this->payload(['password' => 'secret123'])); + + $this->assertNotSame('secret123', $user->password); + $this->assertTrue(Hash::check('secret123', $user->password)); + } + + public function test_create_user_sets_random_password_when_none_given(): void + { + $user = $this->service->createUser($this->payload()); + + $this->assertNotEmpty($user->password); + $this->assertFalse(Hash::check('', $user->password)); + } + + public function test_create_user_leaves_optional_fields_null(): void + { + $user = $this->service->createUser($this->payload()); + + $this->assertNull($user->email); + $this->assertNull($user->fullname); + $this->assertNull($user->clinic_name); + $this->assertNull($user->specialty); + } + + public function test_create_user_does_not_log_in(): void + { + $this->service->createUser($this->payload()); + + $this->assertFalse(Auth::check()); + } + + public function test_register_and_login_creates_user_and_authenticates(): void + { + $user = $this->service->registerAndLogin($this->payload()); + + $this->assertInstanceOf(User::class, $user); + $this->assertTrue(Auth::check()); + $this->assertTrue(Auth::user()->is($user)); + $this->assertDatabaseCount('users', 1); + } + + public function test_register_and_login_clears_registration_flow_session_keys(): void + { + session(['register_phone' => 'x', 'verified_phone' => 'x', 'otp_verified' => true, 'login_phone' => 'x', 'login_level' => 'x', 'available_levels' => []]); + + $this->service->registerAndLogin($this->payload()); + + foreach (['register_phone', 'verified_phone', 'otp_verified', 'login_phone', 'login_level', 'available_levels'] as $key) { + $this->assertFalse(session()->has($key), "session key {$key} was not forgotten"); + } + } +}