feat: refactor ProfileService to utilize transaction management and photo synchronization; enhance Profile.vue with computed properties; add comprehensive tests for profile update functionality, including validation and media handling
This commit is contained in:
parent
2b7208a0c4
commit
37d9ac10a6
@ -3,27 +3,29 @@
|
|||||||
namespace App\Services\Account;
|
namespace App\Services\Account;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Concerns\RunsInTransaction;
|
||||||
|
use App\Services\Concerns\SyncsPhotos;
|
||||||
use App\Services\Media\MediaService;
|
use App\Services\Media\MediaService;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
|
|
||||||
class ProfileService
|
class ProfileService
|
||||||
{
|
{
|
||||||
|
use RunsInTransaction, SyncsPhotos;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MediaService $mediaService,
|
private readonly MediaService $mediaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function update(array $validated, User $user): void
|
public function update(array $validated, User $user): void
|
||||||
{
|
{
|
||||||
try {
|
$this->runInTransaction(
|
||||||
DB::transaction(function () use ($user, $validated): void {
|
function () use ($user, $validated): void {
|
||||||
$user->update([
|
$user->update([
|
||||||
'email' => $validated['email'],
|
'email' => $validated['email'],
|
||||||
'username' => $validated['username'],
|
'username' => $validated['username'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/** @var \App\Models\UserProfile $profile */
|
||||||
$profile = $user->profile()->updateOrCreate(
|
$profile = $user->profile()->updateOrCreate(
|
||||||
['user_id' => $user->id],
|
['user_id' => $user->id],
|
||||||
[
|
[
|
||||||
@ -35,29 +37,20 @@ public function update(array $validated, User $user): void
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$s3Keys = ! empty($validated['profile_s3_key']) ? [$validated['profile_s3_key']] : null;
|
$this->syncPhotos(
|
||||||
$removeIds = $validated['remove_profile_photo_ids'] ?? null;
|
|
||||||
|
|
||||||
$this->mediaService->syncCollection(
|
|
||||||
$profile,
|
$profile,
|
||||||
'profile_photo',
|
[
|
||||||
null,
|
'photos' => $validated['profile_photo'] ?? null,
|
||||||
$removeIds,
|
'remove_media_ids' => $validated['remove_profile_photo_ids'] ?? null,
|
||||||
1,
|
's3_keys' => ! empty($validated['profile_s3_key']) ? [$validated['profile_s3_key']] : null,
|
||||||
s3Keys: $s3Keys,
|
],
|
||||||
|
maxPhotos: 1,
|
||||||
|
required: false,
|
||||||
|
collection: 'profile_photo',
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
} catch (ValidationException $e) {
|
'Gagal memperbarui profil',
|
||||||
throw $e;
|
);
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::error('Gagal memperbarui profil: '.$e->getMessage(), [
|
|
||||||
'trace' => $e->getTraceAsString(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatePassword(User $user, string $password): void
|
public function updatePassword(User $user, string $password): void
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, useForm } from '@inertiajs/vue3';
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
import { Save } from '@lucide/vue';
|
import { Save } from '@lucide/vue';
|
||||||
import { ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { PhoneNumberInput } from '@/components/form/phone-number-input';
|
import { PhoneNumberInput } from '@/components/form/phone-number-input';
|
||||||
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||||
|
|||||||
315
tests/Feature/Admin/Account/ProfileTest.php
Normal file
315
tests/Feature/Admin/Account/ProfileTest.php
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\Permission as PermissionEnum;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserProfile;
|
||||||
|
use Database\Seeders\RolePermissionSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->seed(RolePermissionSeeder::class);
|
||||||
|
$disk = config('filesystems.default') === 's3' ? 's3' : config('filesystems.default', 'public');
|
||||||
|
Storage::fake($disk);
|
||||||
|
Storage::fake('public');
|
||||||
|
});
|
||||||
|
|
||||||
|
function createUserWithProfile(): User
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->givePermissionTo(PermissionEnum::DASHBOARD_VIEW->value);
|
||||||
|
$user->forgetCachedPermissions();
|
||||||
|
UserProfile::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'full_name' => fake()->name(),
|
||||||
|
'phone_number' => fake()->numerify('08##########'),
|
||||||
|
'gender' => fake()->randomElement(['male', 'female']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUserWithoutProfile(): User
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->givePermissionTo(PermissionEnum::DASHBOARD_VIEW->value);
|
||||||
|
$user->forgetCachedPermissions();
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validProfilePayload(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => 'updated@example.com',
|
||||||
|
'username' => 'updated_user',
|
||||||
|
'full_name' => 'Nama Updated',
|
||||||
|
'phone_number' => '081234567890',
|
||||||
|
'gender' => 'male',
|
||||||
|
'birth_date' => '1995-01-15',
|
||||||
|
'address' => 'Jl. Merdeka No. 1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Profile Edit ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Profile Edit', function () {
|
||||||
|
test('authenticated user can view profile edit form', function () {
|
||||||
|
$user = createUserWithProfile();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('admin.account.profile'))
|
||||||
|
->assertOk();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('guest is redirected to login', function () {
|
||||||
|
$this->get(route('admin.account.profile'))
|
||||||
|
->assertRedirect(route('login'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Profile Update ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Profile Update', function () {
|
||||||
|
test('authenticated user can update profile', function () {
|
||||||
|
$user = createUserWithProfile();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->put(route('admin.account.profile.update'), validProfilePayload())
|
||||||
|
->assertRedirect(route('admin.account.profile'));
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
expect($user->email)->toBe('updated@example.com');
|
||||||
|
expect($user->username)->toBe('updated_user');
|
||||||
|
expect($user->profile->full_name)->toBe('Nama Updated');
|
||||||
|
expect($user->profile->phone_number)->toBe('081234567890');
|
||||||
|
expect($user->profile->gender->value)->toBe('male');
|
||||||
|
expect($user->profile->birth_date->format('Y-m-d'))->toBe('1995-01-15');
|
||||||
|
expect($user->profile->address)->toBe('Jl. Merdeka No. 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('guest cannot update profile', function () {
|
||||||
|
$this->put(route('admin.account.profile.update'), validProfilePayload())
|
||||||
|
->assertRedirect(route('login'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile is created when user has no profile', function () {
|
||||||
|
$user = createUserWithoutProfile();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->put(route('admin.account.profile.update'), validProfilePayload())
|
||||||
|
->assertRedirect(route('admin.account.profile'));
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
expect($user->profile)->not->toBeNull();
|
||||||
|
expect($user->profile->full_name)->toBe('Nama Updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('required fields are validated', function () {
|
||||||
|
$user = createUserWithProfile();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->put(route('admin.account.profile.update'), [])
|
||||||
|
->assertSessionHasErrors(['email', 'username', 'full_name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('email must be unique on update (ignoring self)', function () {
|
||||||
|
$user = createUserWithProfile();
|
||||||
|
|
||||||
|
$payload = validProfilePayload();
|
||||||
|
$payload['email'] = $user->email;
|
||||||
|
$payload['username'] = $user->username;
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->put(route('admin.account.profile.update'), $payload)
|
||||||
|
->assertSessionHasNoErrors('email');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('email must be unique among other users', function () {
|
||||||
|
$user = createUserWithProfile();
|
||||||
|
$other = User::factory()->create(['email' => 'taken@example.com']);
|
||||||
|
|
||||||
|
$payload = validProfilePayload();
|
||||||
|
$payload['email'] = 'taken@example.com';
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->put(route('admin.account.profile.update'), $payload)
|
||||||
|
->assertSessionHasErrors('email');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('username must be unique on update (ignoring self)', function () {
|
||||||
|
$user = createUserWithProfile();
|
||||||
|
|
||||||
|
$payload = validProfilePayload();
|
||||||
|
$payload['email'] = $user->email;
|
||||||
|
$payload['username'] = $user->username;
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->put(route('admin.account.profile.update'), $payload)
|
||||||
|
->assertSessionHasNoErrors('username');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nullable fields can be null', function () {
|
||||||
|
$user = createUserWithProfile();
|
||||||
|
|
||||||
|
$payload = validProfilePayload();
|
||||||
|
$payload['phone_number'] = null;
|
||||||
|
$payload['gender'] = null;
|
||||||
|
$payload['birth_date'] = null;
|
||||||
|
$payload['address'] = null;
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->put(route('admin.account.profile.update'), $payload)
|
||||||
|
->assertRedirect(route('admin.account.profile'));
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
expect($user->profile->phone_number)->toBeNull();
|
||||||
|
expect($user->profile->gender)->toBeNull();
|
||||||
|
expect($user->profile->birth_date)->toBeNull();
|
||||||
|
expect($user->profile->address)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updating profile with s3 key links the photo', function () {
|
||||||
|
$user = createUserWithProfile();
|
||||||
|
|
||||||
|
$disk = config('filesystems.default') === 's3' ? 's3' : config('filesystems.default', 'public');
|
||||||
|
$imageContent = \Illuminate\Http\UploadedFile::fake()->image('avatar.jpg')->get();
|
||||||
|
Storage::disk($disk)->put('fake-s3-key.jpg', $imageContent);
|
||||||
|
|
||||||
|
$payload = validProfilePayload();
|
||||||
|
$payload['profile_s3_key'] = 'fake-s3-key.jpg';
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->put(route('admin.account.profile.update'), $payload);
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
expect($user->profile->getMedia('profile_photo')->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updating profile can upload new photo and delete existing photo', function () {
|
||||||
|
$user = createUserWithProfile();
|
||||||
|
|
||||||
|
$imageContent = \Illuminate\Http\UploadedFile::fake()->image('initial.jpg')->get();
|
||||||
|
Storage::disk('public')->put('initial.jpg', $imageContent);
|
||||||
|
$user->profile->addMediaFromDisk('initial.jpg', 'public')->toMediaCollection('profile_photo');
|
||||||
|
expect($user->profile->fresh()->getMedia('profile_photo')->count())->toBe(1);
|
||||||
|
$mediaId = $user->profile->fresh()->getFirstMedia('profile_photo')->id;
|
||||||
|
|
||||||
|
$disk = config('filesystems.default') === 's3' ? 's3' : config('filesystems.default', 'public');
|
||||||
|
$imageContent2 = \Illuminate\Http\UploadedFile::fake()->image('updated.jpg')->get();
|
||||||
|
Storage::disk($disk)->put('updated.jpg', $imageContent2);
|
||||||
|
|
||||||
|
$payload = validProfilePayload();
|
||||||
|
$payload['profile_s3_key'] = 'updated.jpg';
|
||||||
|
$payload['remove_profile_photo_ids'] = [$mediaId];
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->put(route('admin.account.profile.update'), $payload);
|
||||||
|
|
||||||
|
$freshProfile = $user->fresh()->profile;
|
||||||
|
expect($freshProfile->getMedia('profile_photo')->count())->toBe(1);
|
||||||
|
expect($freshProfile->getFirstMedia('profile_photo')->id)->not->toBe($mediaId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing profile photo without uploading new one results in no photo', function () {
|
||||||
|
$user = createUserWithProfile();
|
||||||
|
|
||||||
|
$imageContent = \Illuminate\Http\UploadedFile::fake()->image('initial.jpg')->get();
|
||||||
|
Storage::disk('public')->put('initial.jpg', $imageContent);
|
||||||
|
$user->profile->addMediaFromDisk('initial.jpg', 'public')->toMediaCollection('profile_photo');
|
||||||
|
$mediaId = $user->profile->fresh()->getFirstMedia('profile_photo')->id;
|
||||||
|
|
||||||
|
$payload = validProfilePayload();
|
||||||
|
$payload['remove_profile_photo_ids'] = [$mediaId];
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->put(route('admin.account.profile.update'), $payload);
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
expect($user->profile->getMedia('profile_photo')->count())->toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Password Edit ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Password Edit', function () {
|
||||||
|
test('authenticated user can view password edit form', function () {
|
||||||
|
$user = createUserWithProfile();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('admin.account.password'))
|
||||||
|
->assertOk();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('guest is redirected to login', function () {
|
||||||
|
$this->get(route('admin.account.password'))
|
||||||
|
->assertRedirect(route('login'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Password Update ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Password Update', function () {
|
||||||
|
test('authenticated user can update password', function () {
|
||||||
|
$user = User::factory()->create(['password' => Hash::make('old-password')]);
|
||||||
|
$user->givePermissionTo(PermissionEnum::DASHBOARD_VIEW->value);
|
||||||
|
$user->forgetCachedPermissions();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->put(route('admin.account.password.update'), [
|
||||||
|
'current_password' => 'old-password',
|
||||||
|
'password' => 'new-secure-password',
|
||||||
|
'password_confirmation' => 'new-secure-password',
|
||||||
|
])
|
||||||
|
->assertRedirect(route('admin.account.password'));
|
||||||
|
|
||||||
|
$this->assertTrue(Hash::check('new-secure-password', $user->fresh()->password));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('guest cannot update password', function () {
|
||||||
|
$this->put(route('admin.account.password.update'), [
|
||||||
|
'current_password' => 'old-password',
|
||||||
|
'password' => 'new-secure-password',
|
||||||
|
'password_confirmation' => 'new-secure-password',
|
||||||
|
])->assertRedirect(route('login'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('current password must be correct', function () {
|
||||||
|
$user = User::factory()->create(['password' => Hash::make('old-password')]);
|
||||||
|
$user->givePermissionTo(PermissionEnum::DASHBOARD_VIEW->value);
|
||||||
|
$user->forgetCachedPermissions();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->put(route('admin.account.password.update'), [
|
||||||
|
'current_password' => 'wrong-password',
|
||||||
|
'password' => 'new-secure-password',
|
||||||
|
'password_confirmation' => 'new-secure-password',
|
||||||
|
])
|
||||||
|
->assertSessionHasErrors('current_password');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('password confirmation must match', function () {
|
||||||
|
$user = User::factory()->create(['password' => Hash::make('old-password')]);
|
||||||
|
$user->givePermissionTo(PermissionEnum::DASHBOARD_VIEW->value);
|
||||||
|
$user->forgetCachedPermissions();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->put(route('admin.account.password.update'), [
|
||||||
|
'current_password' => 'old-password',
|
||||||
|
'password' => 'new-secure-password',
|
||||||
|
'password_confirmation' => 'different-password',
|
||||||
|
])
|
||||||
|
->assertSessionHasErrors('password');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('required fields are validated', function () {
|
||||||
|
$user = createUserWithoutProfile();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->put(route('admin.account.password.update'), [])
|
||||||
|
->assertSessionHasErrors(['current_password', 'password']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user