feat: add routes, lang, tests, stubs, docs, and docker configurations
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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']);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user