feat: add OtpCodeFactory and comprehensive tests for AuthService and RegistrationService

This commit is contained in:
2026-09-21 05:06:46 +03:30
parent 9718fb8c7c
commit fd0d445b6e
8 changed files with 447 additions and 6 deletions
+3
View File
@@ -2,10 +2,13 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class OtpCode extends Model class OtpCode extends Model
{ {
use HasFactory;
protected $fillable = [ protected $fillable = [
'phone', 'phone',
'code', 'code',
+1 -1
View File
@@ -37,7 +37,7 @@ class AuthService
Auth::login($user); Auth::login($user);
session()->regenerate(); 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; return $user;
} }
+1 -1
View File
@@ -40,7 +40,7 @@ class RegistrationService
Auth::login($user); Auth::login($user);
session()->regenerate(); 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; return $user;
} }
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\OtpCode>
*/
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(),
]);
}
}
+4 -4
View File
@@ -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/Wallet/WithdrawRequestService.php` | `tests/Feature/Services/Wallet/WithdrawRequestServiceTest.php` | ☑ |
| P1 | `app/Services/Invoice/InvoiceService.php` | `tests/Feature/Services/Invoice/InvoiceServiceTest.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/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/OtpService.php` | `tests/Feature/Services/Auth/OtpServiceTest.php` | ☑ |
| P1 | `app/Services/Auth/AuthService.php` | `tests/Feature/Services/Auth/AuthServiceTest.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/RegistrationService.php` | `tests/Feature/Services/Auth/RegistrationServiceTest.php` | ☑ |
| P1 | `app/Services/Shift/ShiftScheduleService.php` | `tests/Feature/Services/Shift/ShiftScheduleServiceTest.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/CaseService.php` | `tests/Feature/Services/Document/CaseServiceTest.php` | ☐ |
| P1 | `app/Services/Document/FastRequestService.php` | `tests/Feature/Services/Document/FastRequestServiceTest.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` | ☑ | | `BankCard` | ☑ |
| `Invoice` | ☑ | | `Invoice` | ☑ |
| `Payment` | ☑ | | `Payment` | ☑ |
| `OtpCode` | ☐ | | `OtpCode` | ☑ |
| `Shift` | ☐ | | `Shift` | ☐ |
@@ -0,0 +1,121 @@
<?php
namespace Tests\Feature\Services\Auth;
use App\Enums\UserEnum;
use App\Models\User;
use App\Services\Auth\AuthService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Tests\TestCase;
class AuthServiceTest extends TestCase
{
use RefreshDatabase;
protected AuthService $service;
protected function setUp(): void
{
parent::setUp();
$this->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));
}
}
@@ -0,0 +1,158 @@
<?php
namespace Tests\Feature\Services\Auth;
use App\Models\OtpCode;
use App\Services\Auth\OtpService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class OtpServiceTest extends TestCase
{
use RefreshDatabase;
protected OtpService $service;
protected function setUp(): void
{
parent::setUp();
Http::fake();
$this->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'));
}
}
@@ -0,0 +1,132 @@
<?php
namespace Tests\Feature\Services\Auth;
use App\Enums\UserEnum;
use App\Models\User;
use App\Services\Auth\RegistrationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class RegistrationServiceTest extends TestCase
{
use RefreshDatabase;
protected RegistrationService $service;
protected function setUp(): void
{
parent::setUp();
$this->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");
}
}
}