plan = Plan::find('premium'); // Create employer with active subscription $this->employer = User::create([ 'name' => 'Employer User', 'email' => 'employer@example.com', 'password' => bcrypt('password'), 'role' => 'employer', 'subscription_status' => 'active', 'subscription_expires_at' => now()->addDays(30), 'api_token' => 'employer_api_token', ]); Subscription::create([ 'user_id' => $this->employer->id, 'plan_id' => 'premium', 'amount_aed' => 100.00, 'starts_at' => now(), 'expires_at' => now()->addDays(30), 'status' => 'active', ]); // Create worker $this->worker = Worker::create([ 'name' => 'Worker User', 'email' => 'worker@example.com', 'password' => bcrypt('password'), 'phone' => '971501234567', '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', 'main_profession' => 'Plumber', ]); $skill = \App\Models\Skill::firstOrCreate(['name' => 'Piping']); $this->worker->skills()->sync([$skill->id]); } public function test_employer_job_crud_web() { // 1. Create Job (POST /employer/jobs/create) $response = $this->actingAs($this->employer) ->withSession(['user' => $this->employer]) ->post('/employer/jobs/create', [ 'title' => 'Test Mason Job', 'main_profession' => 'Plumber', 'skills' => 'Piping', 'workers_needed' => 3, 'location' => 'Dubai', 'salary' => 2000, 'job_type' => 'Full Time', 'start_date' => now()->addDays(5)->format('Y-m-d'), 'description' => 'Test mason job description', 'requirements' => 'Must have experience', ]); $response->assertRedirect('/employer/jobs'); $this->assertDatabaseHas('job_posts', [ 'employer_id' => $this->employer->id, 'title' => 'Test Mason Job', 'location' => 'Dubai', 'status' => 'active', ]); $job = JobPost::first(); // 2. View Job Details (GET /employer/jobs/{id}) $response = $this->actingAs($this->employer) ->withSession(['user' => $this->employer]) ->get("/employer/jobs/{$job->id}"); $response->assertStatus(200); // 3. Edit Job (POST /employer/jobs/{id}/edit) $response = $this->actingAs($this->employer) ->withSession(['user' => $this->employer]) ->post("/employer/jobs/{$job->id}/edit", [ 'title' => 'Updated Mason Job', 'main_profession' => 'Plumber', 'skills' => 'Piping', 'workers_needed' => 2, 'location' => 'Abu Dhabi', 'salary' => 2500, 'job_type' => 'Part Time', 'start_date' => now()->addDays(10)->format('Y-m-d'), 'description' => 'Updated description', 'requirements' => 'Must speak English', 'status' => 'closed', ]); $response->assertRedirect('/employer/jobs'); $this->assertDatabaseHas('job_posts', [ 'id' => $job->id, 'title' => 'Updated Mason Job', 'status' => 'closed', ]); // 4. Delete Job (DELETE /employer/jobs/{id}) $response = $this->actingAs($this->employer) ->withSession(['user' => $this->employer]) ->delete("/employer/jobs/{$job->id}"); $response->assertRedirect('/employer/jobs'); $this->assertSoftDeleted('job_posts', [ 'id' => $job->id, ]); } public function test_worker_job_apis() { // Setup an active job post $job = JobPost::create([ 'employer_id' => $this->employer->id, 'title' => 'Available Plumber Job', 'main_profession' => 'Plumber', 'location' => 'Dubai Marina', 'salary' => 3000, 'workers_needed' => 1, 'job_type' => 'Contract', 'start_date' => now()->addDays(2), 'description' => 'Plumber description', 'status' => 'active', ]); $skill = \App\Models\Skill::firstOrCreate(['name' => 'Piping']); $job->skills()->sync([$skill->id]); // 1. List available jobs $response = $this->getJson('/api/workers/jobs', [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(200); $response->assertJsonPath('success', true); $response->assertJsonCount(1, 'data.jobs'); $response->assertJsonPath('data.jobs.0.title', 'Available Plumber Job'); $response->assertJsonPath('data.jobs.0.posted_by', 'Employer User'); $response->assertJsonPath('data.jobs.0.status', 'new'); // 2. Retrieve job details $response = $this->getJson("/api/workers/jobs/{$job->id}", [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(200); $response->assertJsonPath('success', true); $response->assertJsonPath('data.job.title', 'Available Plumber Job'); $response->assertJsonPath('data.job.posted_by', 'Employer User'); $response->assertJsonPath('data.job.status', 'new'); // 3. Apply for job $response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(201); $response->assertJsonPath('success', true); $this->assertDatabaseHas('job_applications', [ 'worker_id' => $this->worker->id, 'job_id' => $job->id, 'status' => 'applied', ]); // Verify status becomes 'applied' in list available jobs $response = $this->getJson('/api/workers/jobs', [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(200); $response->assertJsonPath('data.jobs.0.status', 'applied'); // 4. Prevent duplicate job applications $response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(409); $response->assertJsonPath('success', false); $response->assertJsonPath('message', 'You have already applied for this job.'); // 5. View applied jobs $response = $this->getJson('/api/workers/applied-jobs', [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(200); $response->assertJsonPath('success', true); $response->assertJsonCount(1, 'data.applications'); $response->assertJsonPath('data.applications.0.job.title', 'Available Plumber Job'); $response->assertJsonPath('data.applications.0.job.posted_by', 'Employer User'); $application = JobApplication::first(); // 6. Employer view applicants for job $response = $this->getJson("/api/employers/jobs/{$job->id}/applicants", [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(200); $response->assertJsonPath('success', true); $response->assertJsonCount(1, 'data.applicants'); $response->assertJsonPath('data.applicants.0.worker.name', 'Worker User'); // 7. Employer update application status $response = $this->postJson("/api/employers/applications/{$application->id}/status", [ 'status' => 'shortlisted' ], [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(200); $response->assertJsonPath('success', true); $this->assertDatabaseHas('job_applications', [ 'id' => $application->id, 'status' => 'shortlisted', ]); } public function test_employer_view_applicants_filtering() { // 1. Create a job post $job = JobPost::create([ 'employer_id' => $this->employer->id, 'title' => 'Filter Test Job', 'main_profession' => 'Plumber', 'location' => 'Dubai', 'salary' => 2000, 'workers_needed' => 5, 'job_type' => 'Full Time', 'start_date' => now()->addDays(5)->format('Y-m-d'), 'description' => 'Filter test job description', 'status' => 'active', ]); // 2. Create worker A: Male, Indian, Salary 2000, Age 25, Experience 2 Years $workerA = Worker::create([ 'name' => 'Worker A', 'email' => 'workera@example.com', 'password' => bcrypt('password'), 'phone' => '971500000010', 'nationality' => 'Indian', 'gender' => 'male', 'age' => 25, 'salary' => 2000, 'experience' => '2 Years', 'status' => 'active', 'api_token' => 'worker_a_token', 'availability' => 'Immediate', 'religion' => 'Christian', 'bio' => 'Bio info', 'passport_status' => 'valid', 'main_profession' => 'Plumber', ]); // 3. Create worker B: Female, Filipino, Salary 3000, Age 30, Experience 5 Years $workerB = Worker::create([ 'name' => 'Worker B', 'email' => 'workerb@example.com', 'password' => bcrypt('password'), 'phone' => '971500000020', 'nationality' => 'Filipino', 'gender' => 'female', 'age' => 30, 'salary' => 3000, 'experience' => '5 Years', 'status' => 'active', 'api_token' => 'worker_b_token', 'availability' => 'Immediate', 'religion' => 'Christian', 'bio' => 'Bio info', 'passport_status' => 'valid', 'main_profession' => 'Plumber', ]); // 4. Submit applications JobApplication::create([ 'job_id' => $job->id, 'worker_id' => $workerA->id, 'status' => 'applied' ]); JobApplication::create([ 'job_id' => $job->id, 'worker_id' => $workerB->id, 'status' => 'applied' ]); // Test filter by gender=female (should return only Worker B) $response = $this->getJson("/api/employers/jobs/{$job->id}/applicants?gender=female", [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(200); $response->assertJsonCount(1, 'data.applicants'); $response->assertJsonPath('data.applicants.0.worker.name', 'Worker B'); // Test filter by nationality=Indian (should return only Worker A) $response = $this->getJson("/api/employers/jobs/{$job->id}/applicants?nationality=Indian", [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(200); $response->assertJsonCount(1, 'data.applicants'); $response->assertJsonPath('data.applicants.0.worker.name', 'Worker A'); // Test filter by salary_max=2500 (should return only Worker A) $response = $this->getJson("/api/employers/jobs/{$job->id}/applicants?salary_max=2500", [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(200); $response->assertJsonCount(1, 'data.applicants'); $response->assertJsonPath('data.applicants.0.worker.name', 'Worker A'); // Test filter by age range "28-35" (should return only Worker B) $response = $this->getJson("/api/employers/jobs/{$job->id}/applicants?age=28-35", [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(200); $response->assertJsonCount(1, 'data.applicants'); $response->assertJsonPath('data.applicants.0.worker.name', 'Worker B'); // Test filter by experience="5 Years" (should return only Worker B) $response = $this->getJson("/api/employers/jobs/{$job->id}/applicants?experience=5%20Years", [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(200); $response->assertJsonCount(1, 'data.applicants'); $response->assertJsonPath('data.applicants.0.worker.name', 'Worker B'); } public function test_employer_job_apis_and_access_restrictions() { // 1. List jobs via API (premium plan by default) $response = $this->getJson('/api/employers/jobs', [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(200); $response->assertJsonPath('success', true); $response->assertJsonCount(0, 'data.jobs'); // 2. Create job via API $response = $this->postJson('/api/employers/jobs', [ 'title' => 'API Clean Job', 'main_profession' => 'Plumber', 'skills' => 'Piping', 'workers_needed' => 3, 'location' => 'Dubai JLT', 'salary' => 2200, 'job_type' => 'Full Time', 'start_date' => now()->addDays(3)->format('Y-m-d'), 'description' => 'Clean job description', 'requirements' => 'Requirements content' ], [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(201); $response->assertJsonPath('success', true); $jobId = $response->json('data.job.id'); $this->assertDatabaseHas('job_posts', [ 'id' => $jobId, 'title' => 'API Clean Job', 'location' => 'Dubai JLT', ]); // 3. Get job detail via API $response = $this->getJson("/api/employers/jobs/{$jobId}", [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(200); $response->assertJsonPath('data.job.title', 'API Clean Job'); // 4. Update job via API $response = $this->postJson("/api/employers/jobs/{$jobId}/update", [ 'title' => 'Updated API Clean Job', 'main_profession' => 'Plumber', 'skills' => 'Piping', 'workers_needed' => 4, 'location' => 'Dubai Marina', 'salary' => 2500, 'job_type' => 'Contract', 'start_date' => now()->addDays(4)->format('Y-m-d'), 'description' => 'Updated clean job description', 'requirements' => 'Updated requirements', 'status' => 'draft' ], [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(200); $response->assertJsonPath('data.job.title', 'Updated API Clean Job'); $response->assertJsonPath('data.job.status', 'draft'); // 5. Delete job via API $response = $this->deleteJson("/api/employers/jobs/{$jobId}", [], [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(200); $response->assertJsonPath('success', true); $this->assertSoftDeleted('job_posts', [ 'id' => $jobId ]); // 6. Test basic plan restrictions (no job access) Subscription::where('user_id', $this->employer->id)->update(['plan_id' => 'basic']); // Check List Jobs block $response = $this->getJson('/api/employers/jobs', [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(403); $response->assertJsonPath('success', false); $response->assertJsonPath('message', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'); // Check Create Job block $response = $this->postJson('/api/employers/jobs', [ 'title' => 'Blocked Job' ], [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(403); // Check Get Detail block $response = $this->getJson("/api/employers/jobs/{$jobId}", [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(403); // Check Update block $response = $this->postJson("/api/employers/jobs/{$jobId}/update", [ 'title' => 'Blocked Job' ], [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(403); // Check Delete block $response = $this->deleteJson("/api/employers/jobs/{$jobId}", [], [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(403); // Check Applicants block $response = $this->getJson("/api/employers/jobs/{$jobId}/applicants", [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(403); // Check Update status block $response = $this->postJson("/api/employers/applications/1/status", [ 'status' => 'shortlisted' ], [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(403); // 7. Test contact limits enforcement (both Web and API) // Set plan limits to max 1 contacted worker Subscription::where('user_id', $this->employer->id)->update(['plan_id' => 'premium']); $premiumPlan = Plan::find('premium'); $premiumPlan->update([ 'unlimited_contacts' => false, 'max_contact_people' => 1 ]); // Create 2 workers $workerA = Worker::create([ 'name' => 'Worker A', 'email' => 'workera@example.com', 'phone' => '971501234568', 'nationality' => 'Indian', 'status' => 'active', 'age' => 25, 'salary' => 2000, 'availability' => 'Immediate', 'experience' => '2 Years', 'religion' => 'Christian', 'bio' => 'Helper A', ]); $workerB = Worker::create([ 'name' => 'Worker B', 'email' => 'workerb@example.com', 'phone' => '971501234569', 'nationality' => 'Indian', 'status' => 'active', 'age' => 25, 'salary' => 2000, 'availability' => 'Immediate', 'experience' => '2 Years', 'religion' => 'Christian', 'bio' => 'Helper B', ]); // Create an active job for testing applications status updates $activeJob = JobPost::create([ 'employer_id' => $this->employer->id, 'title' => 'Active Job Limit Test', 'location' => 'Dubai', 'salary' => 2000, 'workers_needed' => 5, 'job_type' => 'Full Time', 'start_date' => now()->addDays(5), 'description' => 'Test description', 'status' => 'active', ]); $appA = JobApplication::create([ 'job_id' => $activeJob->id, 'worker_id' => $workerA->id, 'status' => 'applied' ]); $appB = JobApplication::create([ 'job_id' => $activeJob->id, 'worker_id' => $workerB->id, 'status' => 'applied' ]); // First contact (Worker A) -> Should succeed $response = $this->postJson("/api/employers/applications/{$appA->id}/status", [ 'status' => 'shortlisted' ], [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(200); $this->assertEquals(1, User::contactedWorkersCount($this->employer->id)); // Second contact (Worker B) -> Should fail with 403 (contact limit reached) $response = $this->postJson("/api/employers/applications/{$appB->id}/status", [ 'status' => 'shortlisted' ], [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(403); $response->assertJsonPath('success', false); $response->assertJsonFragment([ 'message' => 'You have reached your contacted workers limit of 1 under your active plan. Please upgrade or renew your subscription to contact more workers.' ]); // Web status update: first check web login and update status // Web: contact limit block redirect $response = $this->actingAs($this->employer) ->withSession(['user' => $this->employer]) ->post("/employer/candidates/{$appB->id}/status", [ 'status' => 'Offer Sent' ]); $response->assertRedirect('/employer/subscription'); $response->assertSessionHas('error', 'You have reached your contacted workers limit of 1 under your active plan. Please upgrade or renew your subscription to contact more workers.'); } public function test_job_hiring_workflow_enhancements() { // 1. Create a job $job = JobPost::create([ 'employer_id' => $this->employer->id, 'title' => 'Workflow Developer Job', 'location' => 'Dubai Marina', 'salary' => 5000, 'workers_needed' => 1, 'job_type' => 'Full Time', 'start_date' => now()->addDays(2), 'description' => 'Developer description', 'status' => 'active', ]); // 2. Worker applies $response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(201); $app = JobApplication::first(); // 3. Worker views details of application $response = $this->getJson("/api/workers/applied-jobs/{$app->id}", [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(200); $response->assertJsonPath('success', true); $response->assertJsonPath('data.application.status', 'applied'); $response->assertJsonPath('data.application.job.posted_by', 'Employer User'); $response->assertJsonCount(0, 'data.application.status_history'); // 4. Employer updates status to shortlisted with notes $response = $this->postJson("/api/employers/applications/{$app->id}/status", [ 'status' => 'shortlisted', 'notes' => 'Very qualified candidate.' ], [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(200); $response->assertJsonPath('success', true); $response->assertJsonPath('data.application.status', 'shortlisted'); $response->assertJsonPath('data.application.notes', 'Very qualified candidate.'); $response->assertJsonCount(1, 'data.application.status_history'); $response->assertJsonPath('data.application.status_history.0.status', 'shortlisted'); $response->assertJsonPath('data.application.status_history.0.notes', 'Very qualified candidate.'); // 5. Verify saved workers sync: worker is now saved (shortlisted) $this->assertDatabaseHas('shortlists', [ 'employer_id' => $this->employer->id, 'worker_id' => $this->worker->id, ]); // 6. Worker withdraws application -> should fail because status is shortlisted (not applied) $response = $this->postJson("/api/workers/applied-jobs/{$app->id}/withdraw", [], [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(400); // 7. Reset application status to applied to test withdraw $app->refresh(); $app->update(['status' => 'applied']); $response = $this->postJson("/api/workers/applied-jobs/{$app->id}/withdraw", [], [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(200); $this->assertDatabaseMissing('job_applications', [ 'id' => $app->id ]); } public function test_hired_worker_visibility_and_profile_access() { // Create EmployerProfile with a phone number \App\Models\EmployerProfile::create([ 'user_id' => $this->employer->id, 'company_name' => 'Premium Sponsoring Group', 'phone' => '971501234567', 'city' => 'Dubai', 'nationality' => 'UAE', ]); // 1. Create a job post $job = JobPost::create([ 'employer_id' => $this->employer->id, 'title' => 'Special Hired Job', 'main_profession' => 'Plumber', 'location' => 'Dubai Jumeirah', 'salary' => 4500, 'workers_needed' => 1, 'job_type' => 'Full Time', 'start_date' => now()->addDays(5)->format('Y-m-d'), 'description' => 'A special job details description', 'status' => 'active', ]); $skill = \App\Models\Skill::firstOrCreate(['name' => 'Piping']); $job->skills()->sync([$skill->id]); // 2. Worker applies for the job $response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(201); $app = JobApplication::latest()->first(); // 3. Employer hires the worker by setting status to 'hired' (lowercase, as in the database) $response = $this->postJson("/api/employers/applications/{$app->id}/status", [ 'status' => 'hired', 'notes' => 'Hired through job flow' ], [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(200); // Under two-way confirmation, it should be in hire_requested status $this->assertDatabaseHas('job_applications', [ 'id' => $app->id, 'status' => 'hire_requested', ]); // Worker confirms the hiring $response = $this->postJson("/api/workers/applied-jobs/{$app->id}/confirm-hire", [], [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(200); // Verify status updates $this->assertDatabaseHas('job_applications', [ 'id' => $app->id, 'status' => 'hired', ]); $this->assertDatabaseHas('workers', [ 'id' => $this->worker->id, 'status' => 'Hired', ]); // 4. Verify candidate is visible in Hired list (web: CandidateController::index) $response = $this->actingAs($this->employer) ->withSession(['user' => $this->employer]) ->get('/employer/candidates'); $response->assertStatus(200); // Extract the Inertia prop 'selectedWorkers' to check $inertiaData = $response->original->getData(); $selectedWorkers = $inertiaData['page']['props']['selectedWorkers'] ?? []; $hiredList = array_values(array_filter($selectedWorkers, function ($w) { return $w['worker_id'] === $this->worker->id; })); $this->assertCount(1, $hiredList); $this->assertEquals('Hired', $hiredList[0]['status']); // 5. Verify candidate is visible in Hired list (API: EmployerWorkerController::getCandidates) $response = $this->getJson('/api/employers/candidates', [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(200); $response->assertJsonPath('success', true); // Find the worker in candidates array $candidates = $response->json('data.candidates'); $hiredApiList = array_values(array_filter($candidates, function ($c) { return $c['worker_id'] === $this->worker->id; })); $this->assertCount(1, $hiredApiList); $this->assertEquals('Hired', $hiredApiList[0]['status']); // 6. Verify hired worker can view employer profile details bypassing subscription limits (WorkerProfileController::getEmployerProfile) $response = $this->getJson("/api/workers/employers/{$this->employer->id}", [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(200); $response->assertJsonPath('success', true); $response->assertJsonPath('data.employer.email', 'employer@example.com'); $response->assertJsonPath('data.employer.phone', '971501234567'); // should be exposed/visible because worker is hired // 7. Verify hired worker dashboard shows correct currently working sponsor $response = $this->getJson('/api/workers/dashboard', [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(200); $response->assertJsonPath('success', true); $response->assertJsonPath('data.current_employer.id', $this->employer->id); $response->assertJsonPath('data.currently_working_sponsor.id', $this->employer->id); } public function test_worker_can_decline_hire() { // 1. Create a job posting matching the worker's profession/skills $job = JobPost::create([ 'employer_id' => $this->employer->id, 'title' => 'Housekeeper/Nanny', 'main_profession' => 'Housekeeper', 'location' => 'Dubai Marina', 'salary' => 2500, 'workers_needed' => 1, 'job_type' => 'Full Time', 'start_date' => now()->addDays(5)->format('Y-m-d'), 'description' => 'Need clean house and nanny care.', 'status' => 'active', ]); $skill = \App\Models\Skill::firstOrCreate(['name' => 'Piping']); $job->skills()->sync([$skill->id]); // 2. Worker applies for the job $response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(201); $app = JobApplication::latest()->first(); // 3. Employer hires the worker by setting status to 'hired' $response = $this->postJson("/api/employers/applications/{$app->id}/status", [ 'status' => 'hired', 'notes' => 'Declining test offer' ], [ 'Authorization' => 'Bearer employer_api_token' ]); $response->assertStatus(200); // Under two-way confirmation, it should be in hire_requested status $this->assertDatabaseHas('job_applications', [ 'id' => $app->id, 'status' => 'hire_requested', ]); // Worker declines the hiring $response = $this->postJson("/api/workers/applied-jobs/{$app->id}/decline-hire", [], [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(200); // Verify status updates $this->assertDatabaseHas('job_applications', [ 'id' => $app->id, 'status' => 'declined', ]); $this->assertDatabaseHas('workers', [ 'id' => $this->worker->id, 'status' => 'active', // Should still be active ]); } public function test_worker_can_view_job_matching_only_skills() { // 1. Create a job where main_profession is different from worker's (Plumber) // Let's set it to 'Electrician' $job = JobPost::create([ 'employer_id' => $this->employer->id, 'title' => 'Electrician Needed', 'main_profession' => 'Electrician', 'location' => 'Dubai Marina', 'salary' => 3000, 'workers_needed' => 1, 'job_type' => 'Contract', 'start_date' => now()->addDays(2), 'description' => 'Electrician description', 'status' => 'active', ]); // Sync the same skill 'Piping' (which the worker has) $skill = \App\Models\Skill::firstOrCreate(['name' => 'Piping']); $job->skills()->sync([$skill->id]); // 2. Request list jobs $response = $this->getJson('/api/workers/jobs', [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(200); $response->assertJsonPath('success', true); // The worker's main profession is 'Plumber', but the job's main profession is 'Electrician'. // However, the worker has the 'Piping' skill, which matches the job. // Therefore, the job should show up. $response->assertJsonFragment([ 'title' => 'Electrician Needed' ]); } public function test_worker_can_filter_and_sort_jobs() { // 1. Create jobs with different contractTypes and salaries $job1 = JobPost::create([ 'employer_id' => $this->employer->id, 'title' => 'Contract Plumber Job', 'main_profession' => 'Plumber', 'location' => 'Dubai Marina', 'salary' => 1000, 'workers_needed' => 1, 'job_type' => 'Contract', 'start_date' => now()->addDays(2), 'description' => 'Fix pipes description', 'status' => 'active', ]); $skill = \App\Models\Skill::firstOrCreate(['name' => 'Piping']); $job1->skills()->sync([$skill->id]); $job2 = JobPost::create([ 'employer_id' => $this->employer->id, 'title' => 'Full Time Plumber Job', 'main_profession' => 'Plumber', 'location' => 'Dubai Marina', 'salary' => 5000, 'workers_needed' => 1, 'job_type' => 'Full Time', 'start_date' => now()->addDays(2), 'description' => 'Manage plumbing description', 'status' => 'active', ]); $job2->skills()->sync([$skill->id]); // 2. Filter by job_type = Contract $response = $this->getJson('/api/workers/jobs?job_type=Contract', [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(200); $response->assertJsonFragment(['title' => 'Contract Plumber Job']); $response->assertJsonMissingExact(['title' => 'Full Time Plumber Job']); // 3. Search for "Manage" $response = $this->getJson('/api/workers/jobs?search=Manage', [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(200); $response->assertJsonFragment(['title' => 'Full Time Plumber Job']); $response->assertJsonMissingExact(['title' => 'Contract Plumber Job']); // 4. Sort by PRICE: HIGH TO LOW $response = $this->getJson('/api/workers/jobs?sortBy=PRICE:%20HIGH%20TO%20LOW', [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(200); // The first job in list should be the one with salary 5000 (Job 2) $this->assertEquals('Full Time Plumber Job', $response->json('data.jobs.0.title')); // 5. Filter by job_type directly $response = $this->getJson('/api/workers/jobs?job_type=Full%20Time', [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(200); $response->assertJsonFragment(['title' => 'Full Time Plumber Job']); $response->assertJsonMissingExact(['title' => 'Contract Plumber Job']); // 6. Filter by skills $cookingSkill = \App\Models\Skill::firstOrCreate(['name' => 'Cooking']); $job2->skills()->sync([$skill->id, $cookingSkill->id]); $response = $this->getJson('/api/workers/jobs?skills=Cooking', [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(200); $response->assertJsonFragment(['title' => 'Full Time Plumber Job']); $response->assertJsonMissingExact(['title' => 'Contract Plumber Job']); // 7. Filter by salary range $response = $this->getJson('/api/workers/jobs?salary_min=2000&salary_max=6000', [ 'Authorization' => 'Bearer worker_api_token' ]); $response->assertStatus(200); $response->assertJsonFragment(['title' => 'Full Time Plumber Job']); $response->assertJsonMissingExact(['title' => 'Contract Plumber Job']); } public function test_candidate_status_update_sends_correct_notifications_to_worker() { // Fake Google OAuth and FCM endpoints \Illuminate\Support\Facades\Http::fake([ 'https://oauth2.googleapis.com/token' => \Illuminate\Support\Facades\Http::response(['access_token' => 'fake_token'], 200), 'https://fcm.googleapis.com/v1/projects/*' => \Illuminate\Support\Facades\Http::response(['name' => 'projects/migrant-fe5a7/messages/1234'], 200), ]); // Create standard credentials file dummy for FCMService if not exists $credentialsPath = base_path('migrant-fe5a7-firebase-adminsdk-fbsvc-f8b365ecd0.json'); $createdFile = false; if (!file_exists($credentialsPath)) { file_put_contents($credentialsPath, json_encode([ 'private_key' => "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC3\n-----END PRIVATE KEY-----", 'client_email' => 'fake-fcm@migrant.iam.gserviceaccount.com', 'project_id' => 'migrant-fe5a7' ])); $createdFile = true; } // Give the worker an FCM token so FcmChannel is used $this->worker->update(['fcm_token' => 'worker_test_fcm_token']); // Create a job post $job = JobPost::create([ 'employer_id' => $this->employer->id, 'title' => 'Test Notification Job', 'location' => 'Dubai Marina', 'salary' => 3000, 'workers_needed' => 1, 'job_type' => 'Full Time', 'start_date' => now()->addDays(2), 'description' => 'Description here', 'status' => 'active', ]); // Apply for the job $application = JobApplication::create([ 'worker_id' => $this->worker->id, 'job_id' => $job->id, 'status' => 'applied' ]); // 1. Update status to 'shortlisted' via Web endpoint $response = $this->actingAs($this->employer) ->withSession(['user' => $this->employer]) ->post("/employer/candidates/{$application->id}/status", [ 'status' => 'Shortlisted', 'notes' => 'Shortlist note' ]); $response->assertSessionHasNoErrors(); // Assert that the database notification was created for worker $this->assertDatabaseHas('notifications', [ 'notifiable_id' => $this->worker->id, 'notifiable_type' => get_class($this->worker), ]); // Verify FCM request was triggered \Illuminate\Support\Facades\Http::assertSent(function ($request) { return str_contains($request->url(), 'messages:send') && str_contains($request['message']['token'], 'worker_test_fcm_token') && str_contains($request['message']['notification']['title'], 'Job Application Update') && str_contains($request['message']['data']['type'], 'shortlisted'); }); // 2. Update status to 'applied' (Review) and assert notification type is 'review' $response = $this->actingAs($this->employer) ->withSession(['user' => $this->employer]) ->post("/employer/candidates/{$application->id}/status", [ 'status' => 'Applied', 'notes' => 'Reviewing again' ]); $response->assertSessionHasNoErrors(); // Verify FCM request for 'review' was triggered \Illuminate\Support\Facades\Http::assertSent(function ($request) { return str_contains($request->url(), 'messages:send') && str_contains($request['message']['token'], 'worker_test_fcm_token') && str_contains($request['message']['data']['type'], 'review'); }); // Clean up temporary credentials file if we created it if ($createdFile && file_exists($credentialsPath)) { unlink($credentialsPath); } } public function test_direct_hiring_sends_correct_notifications_to_worker() { // Fake Google OAuth and FCM endpoints \Illuminate\Support\Facades\Http::fake([ 'https://oauth2.googleapis.com/token' => \Illuminate\Support\Facades\Http::response(['access_token' => 'fake_token'], 200), 'https://fcm.googleapis.com/v1/projects/*' => \Illuminate\Support\Facades\Http::response(['name' => 'projects/migrant-fe5a7/messages/1234'], 200), ]); $credentialsPath = base_path('migrant-fe5a7-firebase-adminsdk-fbsvc-f8b365ecd0.json'); $createdFile = false; if (!file_exists($credentialsPath)) { file_put_contents($credentialsPath, json_encode([ 'private_key' => "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC3\n-----END PRIVATE KEY-----", 'client_email' => 'fake-fcm@migrant.iam.gserviceaccount.com', 'project_id' => 'migrant-fe5a7' ])); $createdFile = true; } $this->worker->update(['fcm_token' => 'worker_test_fcm_token']); // 1. Create a direct Job Offer for the worker $offer = \App\Models\JobOffer::create([ 'employer_id' => $this->employer->id, 'worker_id' => $this->worker->id, 'work_date' => now()->format('Y-m-d'), 'location' => 'Dubai', 'salary' => 3000, 'notes' => 'Direct sponsoring', 'status' => 'pending', ]); // Send a status update to 'Hired' via CandidateController (maps to pending for direct offers) $response = $this->actingAs($this->employer) ->withSession(['user' => $this->employer]) ->post("/employer/candidates/offer_{$offer->id}/status", [ 'status' => 'Hired', 'notes' => 'Re-initiating direct hire offer' ]); $response->assertSessionHasNoErrors(); // Verify FCM request for 'direct_hire_request' was triggered with correct text \Illuminate\Support\Facades\Http::assertSent(function ($request) { return str_contains($request->url(), 'messages:send') && str_contains($request['message']['token'], 'worker_test_fcm_token') && str_contains($request['message']['notification']['title'], 'Direct Hiring Offer') && str_contains($request['message']['notification']['body'], 'wants to hire you. Do you want to accept this job offer?') && str_contains($request['message']['data']['type'], 'direct_hire_request'); }); if ($createdFile && file_exists($credentialsPath)) { unlink($credentialsPath); } } }