@@ -437,6 +458,12 @@ export default function SelectedCandidates({
Select "In Country" to enable this filter
diff --git a/routes/api.php b/routes/api.php index b500737..42609b9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,6 +13,8 @@ use App\Http\Controllers\Api\SponsorAuthController; use App\Http\Controllers\Api\SponsorController; use App\Http\Controllers\Api\GoogleVisionOcrController; +use App\Http\Controllers\Api\WorkerNotificationController; +use App\Http\Controllers\Api\EmployerNotificationController; /* |-------------------------------------------------------------------------- @@ -75,6 +77,7 @@ Route::post('/workers/profile/mark-hired', [WorkerProfileController::class, 'markHired']); Route::get('/workers/dashboard/views', [WorkerProfileController::class, 'getProfileViews']); Route::get('/workers/dashboard', [WorkerProfileController::class, 'getDashboard']); + Route::get('/workers/employers', [WorkerProfileController::class, 'getEmployers']); Route::post('/workers/change-password', [WorkerProfileController::class, 'changePassword']); @@ -117,6 +120,13 @@ Route::post('/workers/reviews', [\App\Http\Controllers\Api\WorkerReviewController::class, 'addReview']); Route::get('/workers/reviews/{id}', [\App\Http\Controllers\Api\WorkerReviewController::class, 'viewReview']); Route::put('/workers/reviews/{id}', [\App\Http\Controllers\Api\WorkerReviewController::class, 'editReview']); + + // Notification Management (Worker) + Route::get('/workers/notifications', [WorkerNotificationController::class, 'index']); + Route::put('/workers/notifications/read', [WorkerNotificationController::class, 'markAsRead']); + Route::put('/workers/notifications/{id}/read', [WorkerNotificationController::class, 'markAsRead']); + Route::delete('/workers/notifications', [WorkerNotificationController::class, 'destroy']); + Route::delete('/workers/notifications/{id}', [WorkerNotificationController::class, 'destroy']); }); // Protected Employer Mobile Endpoints (Token Authenticated via Bearer Token) @@ -176,6 +186,13 @@ Route::delete('/employers/jobs/{id}', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerDeleteJob']); Route::get('/employers/jobs/{id}/applicants', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerJobApplicants']); Route::post('/employers/applications/{id}/status', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerUpdateApplicationStatus']); + + // Notification Management (Employer) + Route::get('/employers/notifications', [EmployerNotificationController::class, 'index']); + Route::put('/employers/notifications/read', [EmployerNotificationController::class, 'markAsRead']); + Route::put('/employers/notifications/{id}/read', [EmployerNotificationController::class, 'markAsRead']); + Route::delete('/employers/notifications', [EmployerNotificationController::class, 'destroy']); + Route::delete('/employers/notifications/{id}', [EmployerNotificationController::class, 'destroy']); }); // Protected Sponsor Mobile Endpoints (Token Authenticated via Bearer Token) diff --git a/tests/Feature/EmployerWorkerFilterApiTest.php b/tests/Feature/EmployerWorkerFilterApiTest.php index 433fe79..627d229 100644 --- a/tests/Feature/EmployerWorkerFilterApiTest.php +++ b/tests/Feature/EmployerWorkerFilterApiTest.php @@ -49,6 +49,7 @@ protected function setUp(): void 'live_in_out' => 'live_in', 'in_country' => true, 'visa_status' => 'Residence Visa', + 'main_profession' => 'Housemaid', 'age' => 25, 'gender' => 'female', 'salary' => 1500, @@ -80,6 +81,7 @@ protected function setUp(): void 'live_in_out' => 'live_out', 'in_country' => true, 'visa_status' => 'Tourist Visa', + 'main_profession' => 'Nanny', 'age' => 28, 'gender' => 'female', 'salary' => 1800, @@ -96,7 +98,7 @@ protected function setUp(): void 'file_path' => 'passports/test2.jpg', ]); - // Worker 3: Dubai, full-time, live-out, Kenyan, in_country=false, Cancelled Visa, male + // Worker 3: Dubai, full-time, live-out, Kenyan, in_country=false, Residence Visa, male $w3 = Worker::create([ 'name' => 'Worker Kenyan Out Country', 'email' => 'worker3@example.com', @@ -110,7 +112,8 @@ protected function setUp(): void 'preferred_job_type' => 'full-time', 'live_in_out' => 'live_out', 'in_country' => false, - 'visa_status' => 'Cancelled Visa', + 'visa_status' => 'Residence Visa', + 'main_profession' => 'Cook', 'age' => 30, 'gender' => 'male', 'salary' => 1200, @@ -201,17 +204,29 @@ public function test_get_workers_visa_status_if_in_country_filter() $this->assertCount(1, $workers); $this->assertEquals('Worker Filipino In Abu Dhabi', $workers[0]['name']); - // Cancelled Visa is for Worker 3, but Worker 3 is out country, so it should not match + // Residence Visa is for Worker 3 (Kenyan), but Worker 3 is out country, so it should not match // because of the "if in country next visa type" constraint. $response2 = $this->withHeaders([ 'Authorization' => 'Bearer ' . $this->token, - ])->getJson('/api/employers/workers?visa_status=Cancelled Visa'); + ])->getJson('/api/employers/workers?visa_status=Residence Visa&nationality=Kenyan'); $response2->assertStatus(200); $workers2 = $response2->json('data.workers'); $this->assertCount(0, $workers2); } + public function test_get_workers_main_profession_filter() + { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + ])->getJson('/api/employers/workers?main_profession=Housemaid'); + + $response->assertStatus(200); + $workers = $response->json('data.workers'); + $this->assertCount(1, $workers); + $this->assertEquals('Worker Indian In Dubai', $workers[0]['name']); + } + /** * Test getCandidates filtering. */ diff --git a/tests/Feature/EmployerWorkerWebFilterTest.php b/tests/Feature/EmployerWorkerWebFilterTest.php index a71e688..ee1517e 100644 --- a/tests/Feature/EmployerWorkerWebFilterTest.php +++ b/tests/Feature/EmployerWorkerWebFilterTest.php @@ -41,6 +41,7 @@ protected function setUp(): void 'live_in_out' => 'live_in', 'in_country' => true, 'visa_status' => 'Residence Visa', + 'main_profession' => 'Housemaid', 'age' => 25, 'salary' => 1500, 'experience' => '3 Years', @@ -70,6 +71,7 @@ protected function setUp(): void 'live_in_out' => 'live_out', 'in_country' => true, 'visa_status' => 'Tourist Visa', + 'main_profession' => 'Nanny', 'age' => 28, 'salary' => 1800, 'experience' => '2 Years', @@ -164,7 +166,8 @@ public function test_employer_can_view_candidates_index_and_apply_filters() 'preferred_job_type' => 'full-time', 'live_in_out' => 'live_out', 'in_country' => false, - 'visa_status' => 'Cancelled Visa', + 'visa_status' => 'Residence Visa', + 'main_profession' => 'Cook', 'age' => 30, 'salary' => 1200, 'experience' => 'None', @@ -227,4 +230,59 @@ public function test_employer_workers_web_gender_filtering() $response->assertSee('Worker Indian Dubai'); $response->assertDontSee('Worker Filipino Abu Dhabi'); } + + /** + * Test web workers index main_profession filtering. + */ + public function test_employer_workers_web_main_profession_filtering() + { + $response = $this->actingAs($this->employer) + ->withSession(['user' => $this->employer]) + ->get(route('employer.workers', ['main_profession' => 'Housemaid'])); + + $response->assertStatus(200); + $response->assertSee('Worker Indian Dubai'); + $response->assertDontSee('Worker Filipino Abu Dhabi'); + + // Test candidate filtering by main_profession + $w = Worker::create([ + 'name' => 'Worker Kenyan Hired', + 'email' => 'worker3@example.com', + 'phone' => '+971503333333', + 'password' => bcrypt('password'), + 'nationality' => 'Kenyan', + 'verified' => true, + 'status' => 'Hired', + 'preferred_location' => 'Dubai', + 'preferred_job_type' => 'full-time', + 'live_in_out' => 'live_out', + 'in_country' => false, + 'visa_status' => 'Residence Visa', + 'main_profession' => 'Cook', + 'age' => 30, + 'salary' => 1200, + 'experience' => 'None', + 'religion' => 'Christian', + 'language' => 'SW', + 'availability' => 'Immediate', + 'bio' => 'New helper', + ]); + + JobOffer::create([ + 'employer_id' => $this->employer->id, + 'worker_id' => $w->id, + 'work_date' => now()->format('Y-m-d'), + 'location' => 'Dubai', + 'salary' => 1200, + 'notes' => 'Hired', + 'status' => 'accepted', + ]); + + $response2 = $this->actingAs($this->employer) + ->withSession(['user' => $this->employer]) + ->get(route('employer.candidates', ['main_profession' => 'Cook'])); + + $response2->assertStatus(200); + $response2->assertSee('selectedWorkers'); + } } diff --git a/tests/Feature/NewRemindersAndSettingsTest.php b/tests/Feature/NewRemindersAndSettingsTest.php new file mode 100644 index 0000000..d143764 --- /dev/null +++ b/tests/Feature/NewRemindersAndSettingsTest.php @@ -0,0 +1,369 @@ + 'John Worker', + 'email' => 'worker@example.com', + 'password' => bcrypt('password'), + 'phone' => '971500000001', + 'nationality' => 'Indian', + 'status' => 'active', + 'api_token' => 'worker_api_token', + 'age' => 25, + 'salary' => 2000, + 'availability' => 'Immediate', + 'experience' => '2 Years', + 'religion' => 'Christian', + 'bio' => 'Helper info', + 'passport_status' => 'valid', + ], $overrides)); + } + + private function createTestEmployer(array $overrides = []): User + { + return User::create(array_merge([ + 'name' => 'Alice Employer', + 'email' => 'employer@example.com', + 'password' => bcrypt('password'), + 'role' => 'employer', + 'api_token' => 'employer_api_token', + 'subscription_status' => 'active', + ], $overrides)); + } + + public function test_worker_no_response_reminders() + { + Notification::fake(); + + $worker = $this->createTestWorker(); + $employer = $this->createTestEmployer(); + + // 1. Create a pending direct Job Offer created exactly 14 days ago + $offer = JobOffer::create([ + 'employer_id' => $employer->id, + 'worker_id' => $worker->id, + 'work_date' => now()->addDays(5)->toDateString(), + 'location' => 'Dubai Marina', + 'salary' => 3000, + 'status' => 'pending', + ]); + // Force created_at to 14 days ago + $offer->created_at = now()->subDays(14); + $offer->save(); + + // 2. Create a conversation where the last message is from employer, sent 14 days ago + $conversation = Conversation::create([ + 'employer_id' => $employer->id, + 'worker_id' => $worker->id, + ]); + $message = Message::create([ + 'conversation_id' => $conversation->id, + 'sender_type' => 'App\Models\User', + 'sender_id' => $employer->id, + 'text' => 'Hello worker, are you interested?', + ]); + $message->created_at = now()->subDays(14); + $message->save(); + + // Run scheduler + Artisan::call('app:send-reminders'); + + // Assert Worker got WorkerNoResponseNotification for both + Notification::assertSentTo( + $worker, + WorkerNoResponseNotification::class, + function ($notification) use ($offer) { + return $notification->type === 'worker_no_response_offer' && + $notification->daysElapsed === 14 && + $notification->entityId === $offer->id; + } + ); + + Notification::assertSentTo( + $worker, + WorkerNoResponseNotification::class, + function ($notification) use ($message) { + return $notification->type === 'worker_no_response_chat' && + $notification->daysElapsed === 14 && + $notification->entityId === $message->id; + } + ); + + // Assert reminder logs were created + $this->assertDatabaseHas('reminder_logs', [ + 'user_type' => get_class($worker), + 'user_id' => $worker->id, + 'event_type' => 'worker_no_response_offer', + 'reminder_level' => '14_days', + ]); + + $this->assertDatabaseHas('reminder_logs', [ + 'user_type' => get_class($worker), + 'user_id' => $worker->id, + 'event_type' => 'worker_no_response_chat', + 'reminder_level' => '14_days', + ]); + + // Run again to verify duplicate prevention + Notification::fake(); + Artisan::call('app:send-reminders'); + Notification::assertNothingSent(); + } + + public function test_review_reminders_after_joining_date() + { + Notification::fake(); + + $worker = $this->createTestWorker(); + $employer = $this->createTestEmployer(); + + $job = JobPost::create([ + 'employer_id' => $employer->id, + 'title' => 'Nanny', + 'location' => 'Dubai', + 'salary' => 2500, + 'workers_needed' => 1, + 'job_type' => 'Full Time', + 'start_date' => now()->subMonths(4)->toDateString(), + 'description' => 'Test job', + 'status' => 'active', + ]); + + // JobApplication with joining_confirmed_at exactly 3 months ago + $app = JobApplication::create([ + 'job_id' => $job->id, + 'worker_id' => $worker->id, + 'status' => 'hired', + 'joining_confirmed_at' => now()->subMonths(3)->toDateString(), + ]); + + // Run scheduler + Artisan::call('app:send-reminders'); + + // Assert Worker got review reminder for employer + Notification::assertSentTo( + $worker, + WorkerReviewReminderNotification::class, + function ($notification) use ($app) { + return $notification->employerName === 'Alice Employer' && + $notification->applicationId === $app->id && + $notification->reviewLevel === '3_months'; + } + ); + + // Assert Employer got review reminder for worker + Notification::assertSentTo( + $employer, + EmployerReviewReminderNotification::class, + function ($notification) use ($app) { + return $notification->workerName === 'John Worker' && + $notification->applicationId === $app->id && + $notification->reviewLevel === '3_months'; + } + ); + + // Assert reminder logs were created + $this->assertDatabaseHas('reminder_logs', [ + 'user_type' => get_class($worker), + 'user_id' => $worker->id, + 'event_type' => 'review_employer_reminder', + 'reminder_level' => '3_months', + ]); + + $this->assertDatabaseHas('reminder_logs', [ + 'user_type' => get_class($employer), + 'user_id' => $employer->id, + 'event_type' => 'review_worker_reminder', + 'reminder_level' => '3_months', + ]); + + // Run again to verify duplicate prevention + Notification::fake(); + Artisan::call('app:send-reminders'); + Notification::assertNothingSent(); + } + + public function test_worker_review_controller_eligibility_and_edit_window() + { + $worker = $this->createTestWorker(); + $employer = $this->createTestEmployer(); + + $job = JobPost::create([ + 'employer_id' => $employer->id, + 'title' => 'Test Job', + 'location' => 'Dubai', + 'salary' => 2500, + 'workers_needed' => 1, + 'job_type' => 'Full Time', + 'start_date' => now()->subMonths(4)->toDateString(), + 'description' => 'Test Description', + 'status' => 'active', + ]); + + // 1. Cannot review before eligibility period (set eligibility to 3 months) + $app = JobApplication::create([ + 'job_id' => $job->id, + 'worker_id' => $worker->id, + 'status' => 'hired', + 'joining_confirmed_at' => now()->subMonths(2)->toDateString(), + ]); + + $response = $this->postJson('/api/workers/reviews', [ + 'employer_id' => $employer->id, + 'job_id' => $job->id, + 'rating' => 5, + 'comment' => 'Great employer!', + ], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(403); + $response->assertJsonPath('success', false); + $response->assertJsonPath('message', 'You can write a review only after 3 months from the joining date.'); + + // 2. Can review after eligibility period + $app->joining_confirmed_at = now()->subMonths(3)->subDays(5)->toDateString(); + $app->save(); + + $response = $this->postJson('/api/workers/reviews', [ + 'employer_id' => $employer->id, + 'job_id' => $job->id, + 'rating' => 5, + 'comment' => 'Great employer!', + ], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(201); + $response->assertJsonPath('success', true); + $reviewId = $response->json('data.review.id'); + + // 3. Edit review within 7 days + $response = $this->putJson("/api/workers/reviews/{$reviewId}", [ + 'rating' => 4, + 'comment' => 'Good employer indeed.', + ], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + $response->assertStatus(200); + + // 4. Cannot edit review after 7 days + $review = EmployerReview::find($reviewId); + $review->created_at = now()->subDays(8); + $review->save(); + + $response = $this->putJson("/api/workers/reviews/{$reviewId}", [ + 'rating' => 3, + 'comment' => 'Okay employer.', + ], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(403); + $response->assertJsonPath('success', false); + $response->assertJsonPath('message', 'The 7-day edit window for this review has expired.'); + } + + public function test_employer_review_controller_eligibility_and_edit_window() + { + $worker = $this->createTestWorker(); + $employer = $this->createTestEmployer(); + + $job = JobPost::create([ + 'employer_id' => $employer->id, + 'title' => 'Test Job', + 'location' => 'Dubai', + 'salary' => 2500, + 'workers_needed' => 1, + 'job_type' => 'Full Time', + 'start_date' => now()->subMonths(4)->toDateString(), + 'description' => 'Test Description', + 'status' => 'active', + ]); + + // 1. Cannot review before eligibility period (set eligibility to 3 months) + $app = JobApplication::create([ + 'job_id' => $job->id, + 'worker_id' => $worker->id, + 'status' => 'hired', + 'joining_confirmed_at' => now()->subMonths(2)->toDateString(), + ]); + + $response = $this->postJson('/api/employers/reviews', [ + 'worker_id' => $worker->id, + 'rating' => 5, + 'comment' => 'Great worker!', + ], [ + 'Authorization' => 'Bearer employer_api_token' + ]); + + $response->assertStatus(403); + $response->assertJsonPath('success', false); + $response->assertJsonPath('message', 'You can write a review only after 3 months from the joining date.'); + + // 2. Can review after eligibility period + $app->joining_confirmed_at = now()->subMonths(3)->subDays(5)->toDateString(); + $app->save(); + + $response = $this->postJson('/api/employers/reviews', [ + 'worker_id' => $worker->id, + 'rating' => 5, + 'comment' => 'Great worker!', + ], [ + 'Authorization' => 'Bearer employer_api_token' + ]); + + $response->assertStatus(201); + $response->assertJsonPath('success', true); + $reviewId = $response->json('data.review.id'); + + // 3. Edit review within 7 days + $response = $this->putJson("/api/employers/reviews/{$reviewId}", [ + 'rating' => 4, + 'comment' => 'Good worker indeed.', + ], [ + 'Authorization' => 'Bearer employer_api_token' + ]); + $response->assertStatus(200); + + // 4. Cannot edit review after 7 days + $review = Review::find($reviewId); + $review->created_at = now()->subDays(8); + $review->save(); + + $response = $this->putJson("/api/employers/reviews/{$reviewId}", [ + 'rating' => 3, + 'comment' => 'Okay worker.', + ], [ + 'Authorization' => 'Bearer employer_api_token' + ]); + + $response->assertStatus(403); + $response->assertJsonPath('success', false); + $response->assertJsonPath('message', 'The 7-day edit window for this review has expired.'); + } +} diff --git a/tests/Feature/NotificationApiTest.php b/tests/Feature/NotificationApiTest.php new file mode 100644 index 0000000..507bdac --- /dev/null +++ b/tests/Feature/NotificationApiTest.php @@ -0,0 +1,248 @@ + 'John Worker', + 'email' => 'worker@example.com', + 'password' => bcrypt('password'), + 'phone' => '971500000001', + 'nationality' => 'Indian', + 'status' => 'active', + 'api_token' => 'worker_api_token', + 'age' => 25, + 'salary' => 2000, + 'availability' => 'Immediate', + 'experience' => '2 Years', + 'religion' => 'Christian', + 'bio' => 'Helper info', + 'passport_status' => 'valid', + ], $overrides)); + } + + private function createTestEmployer(array $overrides = []): User + { + return User::create(array_merge([ + 'name' => 'Alice Employer', + 'email' => 'employer@example.com', + 'password' => bcrypt('password'), + 'role' => 'employer', + 'api_token' => 'employer_api_token', + 'subscription_status' => 'active', + ], $overrides)); + } + + public function test_worker_can_list_notifications_with_filters() + { + $worker = $this->createTestWorker(); + + // Send two notifications + $worker->notify(new DocumentExpiryNotification('Passport', '2026-12-31', 30, '30_days')); + $worker->notify(new DocumentExpiryNotification('Visa', '2026-12-31', 7, '7_days')); + + // Mark the Passport notification as read deterministically + $passportNotification = $worker->unreadNotifications()->where('data->document_type', 'Passport')->first(); + $passportNotification->markAsRead(); + + // 1. Fetch all notifications + $response = $this->getJson('/api/workers/notifications', [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $response->assertJsonCount(2, 'data.notifications'); + + // 2. Fetch unread notifications + $response = $this->getJson('/api/workers/notifications?filter=unread', [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonCount(1, 'data.notifications'); + $response->assertJsonPath('data.notifications.0.data.document_type', 'Visa'); + + // 3. Fetch read notifications + $response = $this->getJson('/api/workers/notifications?filter=read', [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonCount(1, 'data.notifications'); + $response->assertJsonPath('data.notifications.0.data.document_type', 'Passport'); + } + + public function test_worker_can_mark_single_and_all_notifications_as_read() + { + $worker = $this->createTestWorker(); + + // Send two notifications + $worker->notify(new DocumentExpiryNotification('Passport', '2026-12-31', 30, '30_days')); + $worker->notify(new DocumentExpiryNotification('Visa', '2026-12-31', 7, '7_days')); + + $unread = $worker->unreadNotifications; + $this->assertEquals(2, $unread->count()); + + // 1. Mark single notification as read + $notificationId = $unread->first()->id; + $response = $this->putJson("/api/workers/notifications/{$notificationId}/read", [], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $this->assertEquals(1, $worker->fresh()->unreadNotifications->count()); + + // 2. Mark all notifications as read + $response = $this->putJson('/api/workers/notifications/read', [], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $this->assertEquals(0, $worker->fresh()->unreadNotifications->count()); + } + + public function test_worker_can_delete_single_and_all_notifications() + { + $worker = $this->createTestWorker(); + + // Send two notifications + $worker->notify(new DocumentExpiryNotification('Passport', '2026-12-31', 30, '30_days')); + $worker->notify(new DocumentExpiryNotification('Visa', '2026-12-31', 7, '7_days')); + + $this->assertEquals(2, $worker->notifications()->count()); + + // 1. Delete single notification + $notificationId = $worker->notifications->first()->id; + $response = $this->deleteJson("/api/workers/notifications/{$notificationId}", [], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $this->assertEquals(1, $worker->fresh()->notifications()->count()); + + // 2. Delete all notifications + $response = $this->deleteJson('/api/workers/notifications', [], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $this->assertEquals(0, $worker->fresh()->notifications()->count()); + } + + public function test_employer_can_list_notifications_with_filters() + { + $employer = $this->createTestEmployer(); + + // Send two notifications + $employer->notify(new DocumentExpiryNotification('Emirates ID', '2026-12-31', 30, '30_days')); + $employer->notify(new DocumentExpiryNotification('Trade License', '2026-12-31', 7, '7_days')); + + // Mark the Emirates ID notification as read deterministically + $emiratesIdNotification = $employer->unreadNotifications()->where('data->document_type', 'Emirates ID')->first(); + $emiratesIdNotification->markAsRead(); + + // 1. Fetch all notifications + $response = $this->getJson('/api/employers/notifications', [ + 'Authorization' => 'Bearer employer_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $response->assertJsonCount(2, 'data.notifications'); + + // 2. Fetch unread notifications + $response = $this->getJson('/api/employers/notifications?filter=unread', [ + 'Authorization' => 'Bearer employer_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonCount(1, 'data.notifications'); + $response->assertJsonPath('data.notifications.0.data.document_type', 'Trade License'); + + // 3. Fetch read notifications + $response = $this->getJson('/api/employers/notifications?filter=read', [ + 'Authorization' => 'Bearer employer_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonCount(1, 'data.notifications'); + $response->assertJsonPath('data.notifications.0.data.document_type', 'Emirates ID'); + } + + public function test_employer_can_mark_single_and_all_notifications_as_read() + { + $employer = $this->createTestEmployer(); + + // Send two notifications + $employer->notify(new DocumentExpiryNotification('Emirates ID', '2026-12-31', 30, '30_days')); + $employer->notify(new DocumentExpiryNotification('Trade License', '2026-12-31', 7, '7_days')); + + $unread = $employer->unreadNotifications; + $this->assertEquals(2, $unread->count()); + + // 1. Mark single notification as read + $notificationId = $unread->first()->id; + $response = $this->putJson("/api/employers/notifications/{$notificationId}/read", [], [ + 'Authorization' => 'Bearer employer_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $this->assertEquals(1, $employer->fresh()->unreadNotifications->count()); + + // 2. Mark all notifications as read + $response = $this->putJson('/api/employers/notifications/read', [], [ + 'Authorization' => 'Bearer employer_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $this->assertEquals(0, $employer->fresh()->unreadNotifications->count()); + } + + public function test_employer_can_delete_single_and_all_notifications() + { + $employer = $this->createTestEmployer(); + + // Send two notifications + $employer->notify(new DocumentExpiryNotification('Emirates ID', '2026-12-31', 30, '30_days')); + $employer->notify(new DocumentExpiryNotification('Trade License', '2026-12-31', 7, '7_days')); + + $this->assertEquals(2, $employer->notifications()->count()); + + // 1. Delete single notification + $notificationId = $employer->notifications->first()->id; + $response = $this->deleteJson("/api/employers/notifications/{$notificationId}", [], [ + 'Authorization' => 'Bearer employer_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $this->assertEquals(1, $employer->fresh()->notifications()->count()); + + // 2. Delete all notifications + $response = $this->deleteJson('/api/employers/notifications', [], [ + 'Authorization' => 'Bearer employer_api_token' + ]); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $this->assertEquals(0, $employer->fresh()->notifications()->count()); + } +} diff --git a/tests/Feature/SponsorAuthApiTest.php b/tests/Feature/SponsorAuthApiTest.php index ed2059c..be550c9 100644 --- a/tests/Feature/SponsorAuthApiTest.php +++ b/tests/Feature/SponsorAuthApiTest.php @@ -217,15 +217,18 @@ public function test_sponsor_can_post_charity_event() $response = $this->withHeaders([ 'Authorization' => 'Bearer sponsor_token_xyz', ])->postJson('/api/sponsors/charity-events', [ - 'title' => 'Community Ramadan Iftar', - 'body' => 'Join us for a free community Iftar gathering at the center.', - 'type' => 'charity', - 'event_date' => '2026-06-15', - 'start_time' => '09:00 AM', - 'end_time' => '04:00 PM', - 'provided_items' => 'Free Iftar meals, Water, Dates', - 'location_details' => 'Al Quoz Community Center, Dubai', - 'location_pin' => 'https://maps.app.goo.gl/xyz', + 'title' => 'Community Ramadan Iftar', + 'body' => 'Join us for a free community Iftar gathering at the center.', + 'type' => 'charity', + 'event_date' => '2026-06-15', + 'start_time' => '09:00 AM', + 'end_time' => '04:00 PM', + 'provided_items' => 'Free Iftar meals, Water, Dates', + 'location_details' => 'Al Quoz Community Center, Dubai', + 'location_pin' => 'https://maps.app.goo.gl/xyz', + 'contact_person_name' => 'Jane Doe', + 'contact_number' => '551234567', + 'country_code' => '+971', ]); $response->assertStatus(201) @@ -246,6 +249,9 @@ public function test_sponsor_can_post_charity_event() 'location_details' => 'Al Quoz Community Center, Dubai', 'location_pin' => 'https://maps.app.goo.gl/xyz', 'content' => 'Join us for a free community Iftar gathering at the center.', + 'contact_person_name' => 'Jane Doe', + 'contact_number' => '551234567', + 'country_code' => '+971', ] ] ]) @@ -266,6 +272,9 @@ public function test_sponsor_can_post_charity_event() 'location_details', 'location_pin', 'content', + 'contact_person_name', + 'contact_number', + 'country_code', ] ] ]); @@ -277,6 +286,82 @@ public function test_sponsor_can_post_charity_event() ]); } + /** + * Test validation rules for charity event posting. + */ + public function test_sponsor_charity_event_validation() + { + $sponsor = Sponsor::create([ + 'full_name' => 'Test Sponsor', + 'organization_name' => 'Save the Children', + 'email' => 'tsponsor@example.com', + 'mobile' => '+971509994444', + 'password' => Hash::make('password123'), + 'api_token' => 'sponsor_token_xyz', + 'status' => 'active', + ]); + + // 1. Missing fields + $response = $this->withHeaders([ + 'Authorization' => 'Bearer sponsor_token_xyz', + ])->postJson('/api/sponsors/charity-events', [ + 'title' => 'Community Ramadan Iftar', + 'body' => 'Join us for a free community Iftar gathering at the center.', + 'type' => 'charity', + 'event_date' => '2026-06-15', + 'start_time' => '09:00 AM', + 'end_time' => '04:00 PM', + 'provided_items' => 'Free Iftar meals, Water, Dates', + 'location_details' => 'Al Quoz Community Center, Dubai', + 'location_pin' => 'https://maps.app.goo.gl/xyz', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['contact_person_name', 'contact_number', 'country_code']); + + // 2. Invalid country code format (missing +) + $response = $this->withHeaders([ + 'Authorization' => 'Bearer sponsor_token_xyz', + ])->postJson('/api/sponsors/charity-events', [ + 'title' => 'Community Ramadan Iftar', + 'body' => 'Join us for a free community Iftar gathering at the center.', + 'type' => 'charity', + 'event_date' => '2026-06-15', + 'start_time' => '09:00 AM', + 'end_time' => '04:00 PM', + 'provided_items' => 'Free Iftar meals, Water, Dates', + 'location_details' => 'Al Quoz Community Center, Dubai', + 'location_pin' => 'https://maps.app.goo.gl/xyz', + 'contact_person_name' => 'Jane Doe', + 'contact_number' => '551234567', + 'country_code' => '971', // missing plus + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['country_code']); + + // 3. Invalid contact number format (non-digits) + $response = $this->withHeaders([ + 'Authorization' => 'Bearer sponsor_token_xyz', + ])->postJson('/api/sponsors/charity-events', [ + 'title' => 'Community Ramadan Iftar', + 'body' => 'Join us for a free community Iftar gathering at the center.', + 'type' => 'charity', + 'event_date' => '2026-06-15', + 'start_time' => '09:00 AM', + 'end_time' => '04:00 PM', + 'provided_items' => 'Free Iftar meals, Water, Dates', + 'location_details' => 'Al Quoz Community Center, Dubai', + 'location_pin' => 'https://maps.app.goo.gl/xyz', + 'contact_person_name' => 'Jane Doe', + 'contact_number' => '55-123-abc', // invalid format + 'country_code' => '+971', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['contact_number']); + } + /** * Test that a sponsor can list charity events showing both employer and sponsor events. */ diff --git a/tests/Feature/WorkerEmployersApiTest.php b/tests/Feature/WorkerEmployersApiTest.php new file mode 100644 index 0000000..69303b4 --- /dev/null +++ b/tests/Feature/WorkerEmployersApiTest.php @@ -0,0 +1,239 @@ + 'Rahul Sharma', + 'email' => 'rahul@example.com', + 'phone' => '+971501234567', + 'language' => 'HI', + 'password' => bcrypt('password'), + 'nationality' => 'Indian', + 'age' => 25, + 'salary' => 1500, + 'availability' => 'Immediate', + 'experience' => 'Not Specified', + 'religion' => 'Not Specified', + 'bio' => 'Test', + 'verified' => true, + 'status' => 'active', + 'api_token' => 'worker-test-token', + ]); + + // Create passport document to prevent pending 404 + WorkerDocument::create([ + 'worker_id' => $worker->id, + 'type' => 'passport', + 'number' => '123456', + 'file_path' => 'passports/test.pdf', + ]); + + // Create an employer + $employer = User::create([ + 'name' => 'Employer One', + 'email' => 'emp1@example.com', + 'password' => bcrypt('password'), + 'role' => 'employer', + ]); + + EmployerProfile::create([ + 'user_id' => $employer->id, + 'company_name' => 'Ahmad Tech Ltd', + 'nationality' => 'UAE', + 'city' => 'Dubai', + ]); + + // Create an accepted job offer (current employer) + JobOffer::create([ + 'employer_id' => $employer->id, + 'worker_id' => $worker->id, + 'work_date' => now()->subDays(10), + 'location' => 'Dubai', + 'salary' => 2500, + 'status' => 'accepted', + 'notes' => 'Current Active Job', + ]); + + // Hit the worker dashboard API + $response = $this->withHeaders([ + 'Authorization' => 'Bearer worker-test-token', + ])->getJson('/api/workers/dashboard'); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'data' => [ + 'current_employer' => [ + 'id' => $employer->id, + 'name' => 'Employer One', + 'company_name' => 'Ahmad Tech Ltd', + 'city' => 'Dubai', + 'nationality' => 'UAE', + ], + 'currently_working_sponsor' => [ + 'id' => $employer->id, + 'name' => 'Employer One', + 'company_name' => 'Ahmad Tech Ltd', + 'city' => 'Dubai', + 'nationality' => 'UAE', + ] + ] + ]); + } + + public function test_worker_employers_list_pagination_and_sorting() + { + // Create worker + $worker = Worker::create([ + 'name' => 'Rahul Sharma', + 'email' => 'rahul@example.com', + 'phone' => '+971501234567', + 'language' => 'HI', + 'password' => bcrypt('password'), + 'nationality' => 'Indian', + 'age' => 25, + 'salary' => 1500, + 'availability' => 'Immediate', + 'experience' => 'Not Specified', + 'religion' => 'Not Specified', + 'bio' => 'Test', + 'verified' => true, + 'status' => 'active', + 'api_token' => 'worker-test-token', + ]); + + WorkerDocument::create([ + 'worker_id' => $worker->id, + 'type' => 'passport', + 'number' => '123456', + 'file_path' => 'passports/test.pdf', + ]); + + // Create 3 employers + $emp1 = User::create([ + 'name' => 'Employer One', + 'email' => 'emp1@example.com', + 'password' => bcrypt('password'), + 'role' => 'employer' + ]); + EmployerProfile::create(['user_id' => $emp1->id, 'company_name' => 'Tech One']); + + $emp2 = User::create([ + 'name' => 'Employer Two', + 'email' => 'emp2@example.com', + 'password' => bcrypt('password'), + 'role' => 'employer' + ]); + EmployerProfile::create(['user_id' => $emp2->id, 'company_name' => 'Tech Two']); + + $emp3 = User::create([ + 'name' => 'Employer Three', + 'email' => 'emp3@example.com', + 'password' => bcrypt('password'), + 'role' => 'employer' + ]); + EmployerProfile::create(['user_id' => $emp3->id, 'company_name' => 'Tech Three']); + + // Create JobOffers: + // Offer 1: Completed status, joining 20 days ago + JobOffer::create([ + 'employer_id' => $emp1->id, + 'worker_id' => $worker->id, + 'work_date' => now()->subDays(20), + 'location' => 'Abu Dhabi', + 'salary' => 2000, + 'status' => 'completed', + 'notes' => 'Past job', + ]); + + // Offer 2: Cancelled status, joining 30 days ago + JobOffer::create([ + 'employer_id' => $emp2->id, + 'worker_id' => $worker->id, + 'work_date' => now()->subDays(30), + 'location' => 'Sharjah', + 'salary' => 1800, + 'status' => 'cancelled', + 'notes' => 'Cancelled job', + ]); + + // Offer 3: Accepted (Active) status, joining 10 days ago + JobOffer::create([ + 'employer_id' => $emp3->id, + 'worker_id' => $worker->id, + 'work_date' => now()->subDays(10), + 'location' => 'Dubai', + 'salary' => 3000, + 'status' => 'accepted', + 'notes' => 'Active job', + ]); + + // List all employers (should return 3) + // With Active employer at the top, then previous employers sorted by work_date desc: + // 1st: Emp 3 (Active) + // 2nd: Emp 1 (Completed, 20 days ago) + // 3rd: Emp 2 (Cancelled, 30 days ago) + $response = $this->withHeaders([ + 'Authorization' => 'Bearer worker-test-token', + ])->getJson('/api/workers/employers'); + + $response->assertStatus(200); + $data = $response->json('data.employers'); + + $this->assertCount(3, $data); + $this->assertEquals('Active', $data[0]['status']); + $this->assertEquals('Employer Three', $data[0]['employer']['name']); + $this->assertEquals('Completed', $data[1]['status']); + $this->assertEquals('Employer One', $data[1]['employer']['name']); + $this->assertEquals('Cancelled', $data[2]['status']); + $this->assertEquals('Employer Two', $data[2]['employer']['name']); + + // Test filtering by status Completed + $responseFiltered = $this->withHeaders([ + 'Authorization' => 'Bearer worker-test-token', + ])->getJson('/api/workers/employers?status=completed'); + + $responseFiltered->assertStatus(200); + $filteredData = $responseFiltered->json('data.employers'); + $this->assertCount(1, $filteredData); + $this->assertEquals('Completed', $filteredData[0]['status']); + $this->assertEquals('Employer One', $filteredData[0]['employer']['name']); + + // Test filtering by status Active + $responseActive = $this->withHeaders([ + 'Authorization' => 'Bearer worker-test-token', + ])->getJson('/api/workers/employers?status=active'); + + $responseActive->assertStatus(200); + $activeData = $responseActive->json('data.employers'); + $this->assertCount(1, $activeData); + $this->assertEquals('Active', $activeData[0]['status']); + $this->assertEquals('Employer Three', $activeData[0]['employer']['name']); + + // Test pagination + $responsePaginated = $this->withHeaders([ + 'Authorization' => 'Bearer worker-test-token', + ])->getJson('/api/workers/employers?per_page=2'); + + $responsePaginated->assertStatus(200); + $paginatedData = $responsePaginated->json('data.employers'); + $this->assertCount(2, $paginatedData); + $this->assertEquals(3, $responsePaginated->json('data.pagination.total')); + $this->assertEquals(2, $responsePaginated->json('data.pagination.per_page')); + } +} diff --git a/tests/Feature/WorkerJourneyApiTest.php b/tests/Feature/WorkerJourneyApiTest.php index 3cf25d2..f978614 100644 --- a/tests/Feature/WorkerJourneyApiTest.php +++ b/tests/Feature/WorkerJourneyApiTest.php @@ -507,7 +507,7 @@ public function test_register_passport_and_visa_with_direct_json_payload() 'nationality' => 'Filipino', 'age' => $expectedAge, 'verified' => true, - 'visa_status' => 'CLEANER', + 'visa_status' => 'Tourist Visa', ]); $this->assertDatabaseHas('worker_documents', [ @@ -636,7 +636,7 @@ public function test_register_visa_with_full_ocr_fields() $visaDoc = \App\Models\WorkerDocument::where('worker_id', $workerId)->where('type', 'visa')->first(); $this->assertNotNull($visaDoc->ocr_data); $this->assertEquals('uae_visa', $visaDoc->ocr_data['document_type']); - $this->assertEquals('ENTRY PERMIT', $visaDoc->ocr_data['visa_type']); + $this->assertEquals('Tourist Visa', $visaDoc->ocr_data['visa_type']); $this->assertEquals('Mr.MUHAMMAD NADEEM RASHEED', $visaDoc->ocr_data['full_name']); $this->assertEquals(97.4, $visaDoc->ocr_accuracy); } @@ -689,7 +689,7 @@ public function test_register_passport_and_visa_with_json_encoded_strings() 'name' => 'Johnny Test', 'phone' => '+971509999999', 'verified' => true, - 'visa_status' => 'HELPER', + 'visa_status' => 'Tourist Visa', ]); $this->assertDatabaseHas('worker_documents', [