feat: add routes, lang, tests, stubs, docs, and docker configurations

This commit is contained in:
2026-05-21 16:05:16 +07:00
parent fad70d096b
commit 28a06315b8
3385 changed files with 177070 additions and 0 deletions
+199
View File
@@ -0,0 +1,199 @@
<?php
use App\Models\PasswordHistory;
use App\Models\User;
use App\Services\Auth\PasswordPolicyService;
use App\Services\SystemConfig\SystemConfigService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Spatie\Permission\Models\Role;
beforeEach(function () {
$ref = new ReflectionClass(SystemConfigService::class);
$prop = $ref->getProperty('resolvedSettings');
$prop->setAccessible(true);
$prop->setValue(null, null);
Cache::flush();
Role::findOrCreate('User', 'web');
});
// ── deleteAccount ─────────────────────────────────────────────────────────────
test('delete account succeeds when correct password is supplied', function () {
$user = User::factory()->create(['password' => Hash::make('secret')]);
$token = $user->createToken('app')->plainTextToken;
$this->withHeader('Authorization', "Bearer {$token}")
->deleteJson('/api/v1/profile/delete', ['password' => 'secret'])
->assertOk()
->assertJsonPath('status', 'success');
expect(User::find($user->id))->toBeNull();
});
test('delete account is rejected with wrong password', function () {
$user = User::factory()->create(['password' => Hash::make('secret')]);
$token = $user->createToken('app')->plainTextToken;
$this->withHeader('Authorization', "Bearer {$token}")
->deleteJson('/api/v1/profile/delete', ['password' => 'wrong'])
->assertStatus(422);
expect(User::find($user->id))->not->toBeNull();
});
test('delete account requires password field', function () {
$user = User::factory()->create();
$token = $user->createToken('app')->plainTextToken;
$this->withHeader('Authorization', "Bearer {$token}")
->deleteJson('/api/v1/profile/delete', [])
->assertStatus(422);
});
test('delete account is not accessible to guests', function () {
$this->deleteJson('/api/v1/profile/delete', ['password' => 'x'])
->assertUnauthorized();
});
test('delete account also revokes all user tokens', function () {
$user = User::factory()->create(['password' => Hash::make('secret')]);
$user->createToken('device-a');
$user->createToken('device-b');
$token = $user->createToken('device-c')->plainTextToken;
$this->withHeader('Authorization', "Bearer {$token}")
->deleteJson('/api/v1/profile/delete', ['password' => 'secret'])
->assertOk();
expect($user->tokens()->count())->toBe(0);
});
// ── updatePassword (API) ──────────────────────────────────────────────────────
test('api password update succeeds with valid credentials', function () {
$user = User::factory()->create(['password' => Hash::make('old-pass')]);
$token = $user->createToken('app')->plainTextToken;
$this->withHeader('Authorization', "Bearer {$token}")
->postJson('/api/v1/profile/password', [
'current_password' => 'old-pass',
'password' => 'New-Api-Pass1',
'password_confirmation' => 'New-Api-Pass1',
])
->assertOk()
->assertJsonPath('status', 'success');
expect(Hash::check('New-Api-Pass1', $user->fresh()->password))->toBeTrue();
});
test('api password update is rejected when current password is wrong', function () {
$user = User::factory()->create(['password' => Hash::make('real-pass')]);
$token = $user->createToken('app')->plainTextToken;
$this->withHeader('Authorization', "Bearer {$token}")
->postJson('/api/v1/profile/password', [
'current_password' => 'not-real',
'password' => 'New-Api-Pass2',
'password_confirmation' => 'New-Api-Pass2',
])
->assertStatus(422);
});
test('api password update is rejected when reusing a history password', function () {
app(SystemConfigService::class)->update(['password_history_count' => 3]);
$user = User::factory()->create(['password' => Hash::make('current')]);
PasswordHistory::create(['user_id' => $user->id, 'password' => Hash::make('Recycled-Api!1')]);
$token = $user->createToken('app')->plainTextToken;
$this->withHeader('Authorization', "Bearer {$token}")
->postJson('/api/v1/profile/password', [
'current_password' => 'current',
'password' => 'Recycled-Api!1',
'password_confirmation' => 'Recycled-Api!1',
])
->assertStatus(422)
->assertJsonPath('status', 'error');
});
test('api password update records change in password history', function () {
app(SystemConfigService::class)->update(['password_history_count' => 5]);
$user = User::factory()->create(['password' => Hash::make('old')]);
$token = $user->createToken('app')->plainTextToken;
$this->withHeader('Authorization', "Bearer {$token}")
->postJson('/api/v1/profile/password', [
'current_password' => 'old',
'password' => 'Api-NewPass-99',
'password_confirmation' => 'Api-NewPass-99',
]);
expect(PasswordHistory::where('user_id', $user->id)->count())->toBe(1);
});
// ── register with PasswordPolicyService ──────────────────────────────────────
test('register enforces min length from password policy setting', function () {
app(SystemConfigService::class)->update(['password_min_length' => 12]);
$this->postJson('/api/v1/register', [
'name' => 'Test',
'email' => 'policy@example.com',
'password' => 'short',
])->assertStatus(422);
});
test('register succeeds when password meets policy min length', function () {
app(SystemConfigService::class)->update(['password_min_length' => 6]);
$this->postJson('/api/v1/register', [
'name' => 'Policy User',
'email' => 'policy2@example.com',
'password' => 'longenough',
])->assertStatus(201);
});
test('register enforces numeric requirement when setting is enabled', function () {
app(SystemConfigService::class)->update([
'password_require_numeric' => true,
'password_min_length' => 6,
]);
$this->postJson('/api/v1/register', [
'name' => 'No Digits',
'email' => 'nodigits@example.com',
'password' => 'NoDigitsHere',
])->assertStatus(422);
});
// ── updateProfile ─────────────────────────────────────────────────────────────
test('authenticated user can update their name via api', function () {
$user = User::factory()->create();
$token = $user->createToken('app')->plainTextToken;
$this->withHeader('Authorization', "Bearer {$token}")
->postJson('/api/v1/profile/update', [
'name' => 'Updated Name',
'email' => $user->email,
])
->assertOk()
->assertJsonPath('status', 'success');
expect($user->fresh()->name)->toBe('Updated Name');
});
test('profile update rejects name longer than 255 chars', function () {
$user = User::factory()->create();
$token = $user->createToken('app')->plainTextToken;
$this->withHeader('Authorization', "Bearer {$token}")
->postJson('/api/v1/profile/update', [
'name' => str_repeat('a', 256),
'email' => $user->email,
])
->assertStatus(422);
});
+101
View File
@@ -0,0 +1,101 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Spatie\Permission\Models\Role;
beforeEach(function () {
// Ensure the User role exists so register() can assign it
Role::findOrCreate('User', 'web');
});
// ── Register ────────────────────────────────────────────────────────────────
test('user can register', function () {
$response = $this->postJson('/api/v1/register', [
'name' => 'Test User',
'email' => 'newuser@example.com',
'password' => 'password123',
]);
$response->assertStatus(201)
->assertJsonPath('status', 'success')
->assertJsonStructure(['data' => ['user', 'token']]);
});
test('register rejects duplicate email', function () {
User::factory()->create(['email' => 'existing@example.com']);
$this->postJson('/api/v1/register', [
'name' => 'Another',
'email' => 'existing@example.com',
'password' => 'password123',
])->assertStatus(422);
});
// ── Login ────────────────────────────────────────────────────────────────────
test('user can login with valid credentials', function () {
$user = User::factory()->create([
'password' => Hash::make('secret123'),
'is_active' => true,
]);
$user->assignRole('User');
$this->postJson('/api/v1/login', [
'email' => $user->email,
'password' => 'secret123',
])->assertOk()
->assertJsonPath('status', 'success')
->assertJsonStructure(['data' => ['token']]);
});
test('login rejects wrong password', function () {
$user = User::factory()->create(['password' => Hash::make('correct')]);
$this->postJson('/api/v1/login', [
'email' => $user->email,
'password' => 'wrong',
])->assertUnauthorized()
->assertJsonPath('status', 'error');
});
test('login rejects inactive user', function () {
$user = User::factory()->create([
'password' => Hash::make('password'),
'is_active' => false,
]);
$this->postJson('/api/v1/login', [
'email' => $user->email,
'password' => 'password',
])->assertForbidden();
});
// ── Logout ───────────────────────────────────────────────────────────────────
test('authenticated user can logout', function () {
$user = User::factory()->create();
$token = $user->createToken('test')->plainTextToken;
$this->withHeader('Authorization', "Bearer {$token}")
->postJson('/api/v1/logout')
->assertOk()
->assertJsonPath('status', 'success');
});
test('unauthenticated request to logout returns 401', function () {
$this->postJson('/api/v1/logout')->assertUnauthorized();
});
// ── Get User ─────────────────────────────────────────────────────────────────
test('authenticated user can fetch own profile', function () {
$user = User::factory()->create();
$this->actingAs($user, 'sanctum')
->getJson('/api/v1/user')
->assertOk()
->assertJsonPath('status', 'success')
->assertJsonPath('data.user.email', $user->email);
});
+58
View File
@@ -0,0 +1,58 @@
<?php
use App\Models\DeviceToken;
use App\Models\User;
test('guest cannot register device token', function () {
$this->postJson('/api/v1/devices/register', [
'token' => 'some-fcm-token',
'platform' => 'android',
])->assertUnauthorized();
});
test('authenticated user can register device token', function () {
$user = User::factory()->create();
$this->actingAs($user, 'sanctum')
->postJson('/api/v1/devices/register', [
'token' => 'fcm-token-'.uniqid(),
'platform' => 'android',
'device_name' => 'Samsung Galaxy',
'app_version' => '1.2.3',
])->assertOk()
->assertJsonPath('status', 'success')
->assertJsonStructure(['data' => ['device_id']]);
});
test('duplicate token upserts without error', function () {
$user = User::factory()->create();
$token = 'fcm-stable-token';
$this->actingAs($user, 'sanctum')
->postJson('/api/v1/devices/register', ['token' => $token, 'platform' => 'ios'])
->assertOk();
// Second call with same token — should update, not duplicate
$this->actingAs($user, 'sanctum')
->postJson('/api/v1/devices/register', ['token' => $token, 'platform' => 'ios'])
->assertOk();
expect(DeviceToken::where('token', $token)->count())->toBe(1);
});
test('authenticated user can unregister device token', function () {
$user = User::factory()->create();
$token = 'fcm-token-to-remove';
DeviceToken::create([
'user_id' => $user->id,
'token' => $token,
'platform' => 'android',
]);
$this->actingAs($user, 'sanctum')
->deleteJson('/api/v1/devices/unregister', ['token' => $token])
->assertOk();
expect(DeviceToken::where('token', $token)->exists())->toBeFalse();
});
+27
View File
@@ -0,0 +1,27 @@
<?php
test('health endpoint returns 200 when no check fails', function () {
$response = $this->getJson('/api/health');
$response->assertOk()
->assertJsonStructure(['status', 'timestamp', 'checks' => ['database', 'storage', 'queue']]);
expect($response->json('status'))->toBeIn(['healthy', 'warn']);
});
test('health endpoint returns JSON with timestamp', function () {
$response = $this->getJson('/api/health');
$response->assertOk()->assertJsonStructure(['timestamp']);
expect($response->json('timestamp'))->toBeString();
});
test('health endpoint reports per-check status keys', function () {
$checks = $this->getJson('/api/health')->json('checks');
foreach (['database', 'redis', 'storage', 'queue'] as $key) {
expect($checks)->toHaveKey($key);
expect($checks[$key]['status'])->toBeIn(['ok', 'warn', 'fail', 'unknown']);
}
});
+31
View File
@@ -0,0 +1,31 @@
<?php
use Illuminate\Support\Facades\Mail;
test('otp send requires email', function () {
$this->postJson('/api/v1/otp/send', [])
->assertStatus(422);
});
test('otp send accepts valid email and queues mail', function () {
Mail::fake();
$this->postJson('/api/v1/otp/send', ['email' => 'test@example.com'])
->assertOk()
->assertJsonPath('status', 'success');
});
test('otp verify rejects invalid code', function () {
$this->postJson('/api/v1/otp/verify', [
'email' => 'test@example.com',
'code' => '000000',
])->assertStatus(422)
->assertJsonPath('status', 'error');
});
test('otp verify requires 6-digit code', function () {
$this->postJson('/api/v1/otp/verify', [
'email' => 'test@example.com',
'code' => '123',
])->assertStatus(422);
});