feat: add CORS configuration and frontend assets for Italian migration site

This commit is contained in:
mark 2025-05-28 09:03:25 +08:00
parent ebae7fdb8a
commit 4d44f4c8b7
32 changed files with 7277 additions and 69 deletions

View File

@ -5,9 +5,7 @@ namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
@ -19,19 +17,16 @@ class AuthController extends Controller
*/
public function register(Request $request): JsonResponse
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8',
// 'is_admin' => 'boolean'
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
// 'is_admin' => $request->is_admin ?? false,
]);
return response()->json([
@ -41,6 +36,26 @@ class AuthController extends Controller
], 201);
}
public function getAllUsers(Request $request): JsonResponse
{
$user = $request->user();
// Optional: Ensure only users with 'admin' ability can access this
if (!$user || !$request->user()->tokenCan('admin')) {
return response()->json([
'success' => false,
'message' => 'Unauthorized'
], 403);
}
$users = User::all();
return response()->json([
'success' => true,
'data' => $users
]);
}
/**
* Login and generate token
*
@ -49,7 +64,6 @@ class AuthController extends Controller
*/
public function login(Request $request): JsonResponse
{
// Always return JSON responses from API endpoints
$request->headers->set('Accept', 'application/json');
$request->validate([
@ -67,16 +81,14 @@ class AuthController extends Controller
], 401);
}
// Delete any existing tokens for this device name if provided
// Delete existing tokens for the same device name
if ($request->device_name) {
$user->tokens()->where('name', $request->device_name)->delete();
}
// Create token with appropriate abilities based on user role
$abilities = $user->is_admin ? ['admin'] : ['user'];
$token = $user->createToken($request->device_name ?? 'api_token', $abilities);
// All users will get the same 'admin' ability (since dashboard is admin-only)
$token = $user->createToken($request->device_name ?? 'api_token', ['admin']);
// Get token expiration time if configured
$tokenExpiration = null;
$expirationMinutes = config('sanctum.expiration');
if ($expirationMinutes) {
@ -93,12 +105,54 @@ class AuthController extends Controller
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'is_admin' => $user->is_admin,
'abilities' => $abilities
'abilities' => ['admin']
]
]);
}
/**
* Update authenticated user's account
*
* @param Request $request
* @return JsonResponse
*/
public function update(Request $request)
{
$user = $request->user();
// Validate incoming request
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email,' . $user->id,
'current_password' => 'required|string',
'password' => 'nullable|string|confirmed|min:6',
]);
// Check if current password is correct
if (!\Hash::check($request->current_password, $user->password)) {
return response()->json([
'success' => false,
'message' => 'Current password is incorrect',
], 422);
}
// Update user data
$user->name = $request->name;
$user->email = $request->email;
if ($request->filled('password')) {
$user->password = bcrypt($request->password);
}
$user->save();
return response()->json([
'success' => true,
'message' => 'Account updated successfully',
'user' => $user,
]);
}
/**
* Logout (revoke token)
*
@ -107,7 +161,6 @@ class AuthController extends Controller
*/
public function logout(Request $request): JsonResponse
{
// Revoke the token that was used to authenticate the current request
$request->user()->currentAccessToken()->delete();
return response()->json([
@ -126,6 +179,13 @@ class AuthController extends Controller
{
$user = $request->user();
if (!$user) {
return response()->json([
'success' => false,
'message' => 'User not authenticated',
], 401);
}
return response()->json([
'success' => true,
'data' => [
@ -133,10 +193,10 @@ class AuthController extends Controller
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'is_admin' => $user->is_admin,
],
'abilities' => $request->user()->currentAccessToken()->abilities
'abilities' => $request->user()->currentAccessToken()?->abilities ?? [],
]
]);
}
}

View File

@ -1,35 +1,39 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class Cors
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next): Response
public function handle(Request $request, Closure $next)
{
$response = $next($request);
$allowedOrigins = [
'http://localhost:5173', // React dev server URL
'http://127.0.0.1:5173', // Alternative localhost
// Add your production domain when ready
// 'https://yourdomain.com'
];
// Add CORS headers to the response
$response->headers->set('Access-Control-Allow-Origin', '*');
$response->headers->set('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
$response->headers->set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, Accept, X-CSRF-TOKEN');
$response->headers->set('Access-Control-Max-Age', '86400'); // 24 hours
$origin = $request->headers->get('Origin');
// Handle preflight OPTIONS requests
if ($request->isMethod('OPTIONS')) {
return response()->json('', 200);
if ($request->getMethod() === 'OPTIONS') {
$response = response('', 200);
} else {
$response = $next($request);
}
// Set CORS headers
if (in_array($origin, $allowedOrigins)) {
$response->headers->set('Access-Control-Allow-Origin', $origin);
}
$response->headers->set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH');
$response->headers->set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, Accept, Origin');
$response->headers->set('Access-Control-Allow-Credentials', 'true');
$response->headers->set('Access-Control-Max-Age', '86400'); // Cache preflight for 24 hours
return $response;
}
}

34
config/cors.php Normal file
View File

@ -0,0 +1,34 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Cross-Origin Resource Sharing (CORS) Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your settings for cross-origin resource sharing
| or "CORS". This determines what cross-origin operations may execute
| in web browsers. You are free to adjust these settings as needed.
|
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
*/
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => ['http://localhost:8000'],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true,
];

View File

@ -17,7 +17,7 @@ return [
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
'localhost,localhost:3000,localhost:8000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort(),
// Sanctum::currentRequestHost(),
))),

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

BIN
public/assets/hero.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

14
public/assets/index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<script type="module" crossorigin src="/assets/index-DhLmNHHP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DP8Dc-el.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

BIN
public/assets/ital.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
public/assets/slide1.avif Normal file

Binary file not shown.

BIN
public/assets/slide2.avif Normal file

Binary file not shown.

BIN
public/assets/slide3.avif Normal file

Binary file not shown.

BIN
public/assets/slide4.avif Normal file

Binary file not shown.

1
public/assets/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

14
public/index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<script type="module" crossorigin src="/assets/index-Bqf3G0Hi.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BTau5zUE.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>Laravel</title>

View File

@ -9,14 +9,13 @@ use App\Http\Controllers\ActivityLogController;
// Public routes
Route::post('/login', [AuthController::class, 'login'])->name('login');
Route::post('/register', [AuthController::class, 'register'])->name('register');
Route::get('/migrants', [MigrantController::class, 'index']);
Route::get('/migrants/{id}', [MigrantController::class, 'show']);
Route::get('/migrants/{id}/photos', [MigrantController::class, 'getPhotos']);
// Protected routes - require Sanctum authentication
Route::middleware('auth:sanctum')->group(function () {
Route::put('/user/account', [AuthController::class, 'update']);
// User routes
Route::get('/user', [AuthController::class, 'me'])->name('user.profile');
@ -27,20 +26,17 @@ Route::get('/dashboard/stats', [DashboardController::class, 'getStats']);
Route::get('/activity-logs', [ActivityLogController::class, 'index']);
// Migrant CRUD routes (now includes photo uploads)
Route::post('/migrants', [MigrantController::class, 'store']); // Can include photos
Route::put('/migrants/{id}', [MigrantController::class, 'update']); // Can include photos
Route::patch('/migrants/{id}', [MigrantController::class, 'update']); // Can include photos
Route::delete('/migrants/{id}', [MigrantController::class, 'destroy']); // Deletes photos too
Route::post('/migrants', [MigrantController::class, 'store']);
Route::put('/migrants/{id}', [MigrantController::class, 'update']);
Route::patch('/migrants/{id}', [MigrantController::class, 'update']);
Route::delete('/migrants/{id}', [MigrantController::class, 'destroy']);
// Photo management routes (all handled by MigrantController)
// Photo management routes
Route::post('/migrants/{id}/photos', [MigrantController::class, 'uploadPhotos']);
Route::post('/migrants/photos/{photoId}/set-as-profile', [MigrantController::class, 'setAsProfilePhoto']);
Route::put('/migrants/photos/{photoId}/caption', [MigrantController::class, 'updatePhotoCaption']);
Route::delete('/migrants/photos/{photoId}', [MigrantController::class, 'deletePhoto']);
Route::post('/register', [AuthController::class, 'register'])->name('register');
Route::get('/users', [AuthController::class, 'getAllUsers'])->name('users.index');
});
// Admin-only routes
// Route::middleware('ability:admin')->group(function () {
// Route::post('/register', [AuthController::class, 'register'])->name('register');
// });

View File

@ -2,8 +2,6 @@
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
Route::fallback(function () {
return file_get_contents(public_path('index.html'));
});

View File

@ -177,4 +177,288 @@ class MigrantPhotoTest extends TestCase
->where('is_profile_photo', true)
->count());
}
/**
* Test updating a migrant with photo operations (upload, set as profile, remove).
*/
public function test_can_update_migrant_with_photo_operations(): void
{
// Create fake storage disk for testing
Storage::fake('public');
// Create a migrant
$person = Person::factory()->create([
'surname' => 'Smith',
'christian_name' => 'John',
'occupation' => 'Teacher'
]);
// Create two existing photos for this migrant
$photos = [];
$captions = ['Existing Photo 1', 'Existing Photo 2'];
for ($i = 0; $i < 2; $i++) {
$filename = "existing_photo{$i}.jpg";
$file = UploadedFile::fake()->create($filename, 100, 'image/jpeg');
// Upload the file to the storage
$path = $file->storeAs("photos/{$person->person_id}", $filename, 'public');
// Create the photo record
$photo = Photo::create([
'person_id' => $person->person_id,
'filename' => $filename,
'original_filename' => $filename,
'file_path' => Storage::url($path),
'mime_type' => 'image/jpeg',
'file_size' => $file->getSize() / 1024,
'caption' => $captions[$i],
'is_profile_photo' => $i === 0 // Make the first one the profile initially
]);
$photos[] = $photo;
}
// Create a new photo to upload during the update
$newPhoto = UploadedFile::fake()->create('new_photo.jpg', 100, 'image/jpeg');
// Prepare update data
$updateData = [
'surname' => 'Smith-Jones', // Update the surname
'occupation' => 'Professor', // Update the occupation
'photos' => [$newPhoto], // Upload a new photo
'captions' => ['New Profile Photo'], // Caption for the new photo
'set_as_profile' => true, // Set the new photo as profile
'profile_photo_index' => 0, // Set the first (and only) new photo as profile
'existing_photos' => [
// Update caption of the first photo
[
'id' => $photos[0]->id,
'caption' => 'Updated Caption'
],
// Remove the second photo (we'll handle this after the update)
]
];
// Make the API request to update the migrant
$response = $this->putJson("/api/migrants/{$person->person_id}", $updateData);
// Assert the response is successful
$response->assertStatus(200)
->assertJsonStructure([
'success',
'data' => [
'person',
'uploaded_photos',
],
'message',
])
->assertJson([
'success' => true,
'message' => 'Person updated successfully',
]);
// Assert the person data was updated
$this->assertDatabaseHas('person', [
'person_id' => $person->person_id,
'surname' => 'Smith-Jones',
'occupation' => 'Professor'
]);
// Assert that we now have 3 photos (2 existing + 1 new)
$this->assertEquals(3, Photo::where('person_id', $person->person_id)->count());
// Assert that the caption of the first photo was updated
$this->assertDatabaseHas('photos', [
'id' => $photos[0]->id,
'caption' => 'Updated Caption'
]);
// Assert that the new photo was set as profile
$profilePhoto = Photo::where('person_id', $person->person_id)
->where('is_profile_photo', true)
->first();
$this->assertEquals('New Profile Photo', $profilePhoto->caption);
// Assert that the old profile photo is no longer the profile
$this->assertDatabaseHas('photos', [
'id' => $photos[0]->id,
'is_profile_photo' => false
]);
// Now test removing a photo
$photoToRemove = $photos[1]->id;
$response = $this->deleteJson("/api/migrants/photos/{$photoToRemove}");
$response->assertStatus(200)
->assertJson([
'success' => true,
'message' => 'Photo deleted successfully',
]);
// Assert that the photo was removed from the database
$this->assertDatabaseMissing('photos', [
'id' => $photoToRemove
]);
// Assert that the file was removed from storage
Storage::disk('public')->assertMissing('photos/' . $person->person_id . '/' . $photos[1]->filename);
// Assert that we now have 2 photos (originally 2 + 1 new - 1 deleted)
$this->assertEquals(2, Photo::where('person_id', $person->person_id)->count());
}
/**
* Test adding a single photo, then multiple photos, and finally deleting photos
*/
public function test_can_add_single_and_multiple_photos_and_delete_them(): void
{
// Create fake storage disk for testing
Storage::fake('public');
// Create a migrant without photos first
$migrantData = [
'surname' => $this->faker->lastName,
'christian_name' => $this->faker->firstName,
'date_of_birth' => $this->faker->date(),
'place_of_birth' => $this->faker->city,
'occupation' => $this->faker->jobTitle,
'id_card_no' => (string)$this->faker->unique()->randomNumber(6),
'migration' => [
'date_of_arrival_aus' => $this->faker->date(),
'date_of_arrival_nt' => $this->faker->date(),
'arrival_period' => '1950-1960',
]
];
// 1. Create a migrant without any photos
$response = $this->postJson('/api/migrants', $migrantData);
// Assert the response is successful
$response->assertStatus(201)
->assertJson([
'success' => true,
'message' => 'Person created successfully',
]);
// Get the created person ID
$personId = $response->json('data.person.person_id');
$person = Person::find($personId);
// Assert that no photos exist for this person yet
$this->assertEquals(0, Photo::where('person_id', $personId)->count());
// 2. Now add a single photo and set it as profile
$singlePhoto = UploadedFile::fake()->create('single_photo.jpg', 100, 'image/jpeg');
$updateData = [
'photos' => [$singlePhoto],
'captions' => ['My First Photo'],
'set_as_profile' => true,
'profile_photo_index' => 0
];
$response = $this->putJson("/api/migrants/{$personId}", $updateData);
// Assert the response is successful
$response->assertStatus(200)
->assertJson([
'success' => true,
'message' => 'Person updated successfully',
]);
// Assert that 1 photo was added
$this->assertEquals(1, Photo::where('person_id', $personId)->count());
// Assert that the photo is set as profile
$this->assertEquals(1, Photo::where('person_id', $personId)
->where('is_profile_photo', true)
->count());
// Get the photo details
$firstPhoto = Photo::where('person_id', $personId)->first();
$this->assertEquals('My First Photo', $firstPhoto->caption);
$this->assertTrue($firstPhoto->is_profile_photo);
// Assert the file was stored
Storage::disk('public')->assertExists('photos/' . $personId . '/' . $firstPhoto->filename);
// 3. Now add multiple additional photos and set one as profile
$photo1 = UploadedFile::fake()->create('additional1.jpg', 100, 'image/jpeg');
$photo2 = UploadedFile::fake()->create('additional2.jpg', 100, 'image/jpeg');
$photo3 = UploadedFile::fake()->create('additional3.jpg', 100, 'image/jpeg');
$updateData = [
'photos' => [$photo1, $photo2, $photo3],
'captions' => ['Additional 1', 'Additional 2', 'Additional 3'],
'set_as_profile' => true,
'profile_photo_index' => 1 // Set the second new photo as profile
];
$response = $this->putJson("/api/migrants/{$personId}", $updateData);
// Assert the response is successful
$response->assertStatus(200)
->assertJson([
'success' => true,
'message' => 'Person updated successfully',
]);
// Assert that we now have 4 photos (1 existing + 3 new)
$this->assertEquals(4, Photo::where('person_id', $personId)->count());
// Get all photos for this person
$photos = Photo::where('person_id', $personId)->get();
// Assert that exactly one photo is set as profile
$this->assertEquals(1, $photos->where('is_profile_photo', true)->count());
// Find the profile photo
$profilePhoto = $photos->where('is_profile_photo', true)->first();
// Assert that the second new photo was set as profile
$this->assertEquals('Additional 2', $profilePhoto->caption);
// Assert the first photo is no longer the profile
$this->assertFalse($firstPhoto->fresh()->is_profile_photo);
// 4. Change the profile photo using profile_photo_id (existing photo)
$nonProfilePhoto = $photos->where('caption', 'Additional 1')->first();
$updateData = [
'set_as_profile' => true,
'profile_photo_id' => $nonProfilePhoto->id
];
$response = $this->putJson("/api/migrants/{$personId}", $updateData);
// Assert the response is successful
$response->assertStatus(200);
// Assert that the specified photo is now the profile
$this->assertTrue($nonProfilePhoto->fresh()->is_profile_photo);
$this->assertFalse($profilePhoto->fresh()->is_profile_photo);
// 5. Delete photos one by one
$photosToDelete = $photos->whereNotIn('caption', ['Additional 1'])->pluck('id');
foreach ($photosToDelete as $photoId) {
$response = $this->deleteJson("/api/migrants/photos/{$photoId}");
$response->assertStatus(200)
->assertJson([
'success' => true,
'message' => 'Photo deleted successfully',
]);
// Assert the photo was removed from the database
$this->assertDatabaseMissing('photos', [
'id' => $photoId
]);
}
// Assert that only one photo remains (the profile photo)
$this->assertEquals(1, Photo::where('person_id', $personId)->count());
$this->assertEquals('Additional 1', Photo::where('person_id', $personId)->first()->caption);
$this->assertTrue(Photo::where('person_id', $personId)->first()->is_profile_photo);
}
}