diff --git a/resources/js/lang/en.json b/resources/js/lang/en.json
index 628a553..7506baf 100644
--- a/resources/js/lang/en.json
+++ b/resources/js/lang/en.json
@@ -3,6 +3,7 @@
"find_workers": "Find Workers",
"shortlist": "Shortlist",
"candidates": "Hired Workers",
+ "my_jobs": "My Jobs",
"messages": "Messages",
"charity_events": "Charity Events",
"subscription": "Subscription",
@@ -425,5 +426,13 @@
"share_experience_placeholder": "Tell other sponsors about childcare skills, cleaning, cooking style, driving safety, etc...",
"post_trust_review_action": "Post Trust Review",
"no_charity_events_title": "No Charity Events",
- "no_charity_events_desc": "You haven't posted any charity events yet"
+ "no_charity_events_desc": "You haven't posted any charity events yet",
+ "workers_group": "Workers",
+ "workers_list": "Workers List",
+ "hired_workers": "Hired Workers",
+ "jobs_group": "Jobs",
+ "applicants": "Applicants",
+ "shortlisted_workers": "Shortlisted Workers",
+ "help_support": "Help & Support",
+ "payment_history": "Payment History"
}
\ No newline at end of file
diff --git a/resources/js/lang/hi.json b/resources/js/lang/hi.json
index 5588066..0fc3267 100644
--- a/resources/js/lang/hi.json
+++ b/resources/js/lang/hi.json
@@ -3,6 +3,7 @@
"find_workers": "कामगार खोजें",
"shortlist": "शॉर्टलिस्ट",
"candidates": "नियुक्त कर्मचारी",
+ "my_jobs": "मेरी नौकरियाँ",
"messages": "संदेश",
"charity_events": "दान कार्यक्रम",
"subscription": "सदस्यता",
@@ -424,5 +425,13 @@
"share_experience_placeholder": "अन्य प्रायोजकों को बाल देखभाल कौशल, सफाई, खाना पकाने की शैली, ड्राइविंग सुरक्षा आदि के बारे में बताएं...",
"post_trust_review_action": "समीक्षा पोस्ट करें",
"no_charity_events_title": "कोई चैरिटी कार्यक्रम नहीं",
- "no_charity_events_desc": "आपने अभी तक कोई चैरिटी कार्यक्रम पोस्ट नहीं किया है"
+ "no_charity_events_desc": "आपने अभी तक कोई चैरिटी कार्यक्रम पोस्ट नहीं किया है",
+ "workers_group": "कामगार",
+ "workers_list": "कामगारों की सूची",
+ "hired_workers": "नियुक्त कर्मचारी",
+ "jobs_group": "नौकरियाँ",
+ "applicants": "आवेदक",
+ "shortlisted_workers": "शॉर्टलिस्ट किए गए कामगार",
+ "help_support": "सहायता और समर्थन",
+ "payment_history": "भुगतान इतिहास"
}
\ No newline at end of file
diff --git a/routes/api.php b/routes/api.php
index 06b9ba6..3bd494d 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,8 @@
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::get('/workers/employers/{id}', [WorkerProfileController::class, 'getEmployerProfile']);
Route::post('/workers/change-password', [WorkerProfileController::class, 'changePassword']);
@@ -103,6 +107,27 @@
Route::post('/workers/tickets', [\App\Http\Controllers\Api\SupportTicketController::class, 'createTicketFromWorker']);
Route::post('/workers/tickets/{id}/reply', [\App\Http\Controllers\Api\SupportTicketController::class, 'replyToTicketFromWorker']);
Route::get('/workers/tickets/{id}', [\App\Http\Controllers\Api\SupportTicketController::class, 'getTicketDetail']);
+
+ // Job Management (Worker)
+ Route::get('/workers/jobs', [\App\Http\Controllers\Api\WorkerJobController::class, 'listJobs']);
+ Route::get('/workers/jobs/{id}', [\App\Http\Controllers\Api\WorkerJobController::class, 'jobDetails']);
+ Route::post('/workers/jobs/{id}/apply', [\App\Http\Controllers\Api\WorkerJobController::class, 'applyJob']);
+ Route::get('/workers/applied-jobs', [\App\Http\Controllers\Api\WorkerJobController::class, 'appliedJobs']);
+ Route::get('/workers/applied-jobs/{id}', [\App\Http\Controllers\Api\WorkerJobController::class, 'appliedJobDetail']);
+ Route::post('/workers/applied-jobs/{id}/withdraw', [\App\Http\Controllers\Api\WorkerJobController::class, 'withdrawApplication']);
+
+ // Review Management (Worker reviewing Employer)
+ Route::get('/workers/reviews', [\App\Http\Controllers\Api\WorkerReviewController::class, 'getReviews']);
+ 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)
@@ -153,6 +178,22 @@
Route::post('/employers/tickets', [\App\Http\Controllers\Api\SupportTicketController::class, 'createTicketFromEmployer']);
Route::post('/employers/tickets/{id}/reply', [\App\Http\Controllers\Api\SupportTicketController::class, 'replyToTicketFromEmployer']);
Route::get('/employers/tickets/{id}', [\App\Http\Controllers\Api\SupportTicketController::class, 'getTicketDetail']);
+
+ // Job & Applicant Management (Employer)
+ Route::get('/employers/jobs', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerListJobs']);
+ Route::post('/employers/jobs', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerCreateJob']);
+ Route::get('/employers/jobs/{id}', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerJobDetail']);
+ Route::post('/employers/jobs/{id}/update', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerUpdateJob']);
+ 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/routes/console.php b/routes/console.php
index 3c9adf1..a86dc65 100644
--- a/routes/console.php
+++ b/routes/console.php
@@ -2,7 +2,10 @@
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
+use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
+
+Schedule::command('app:send-reminders')->daily();
diff --git a/routes/web.php b/routes/web.php
index ccaef98..48484b5 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -76,9 +76,97 @@
Route::delete('/charity-organizations/{id}', [\App\Http\Controllers\Admin\SponsorController::class, 'delete'])->name('admin.charity-organizations.delete');
Route::get('/subscriptions', function () {
- return Inertia::render('Admin/Subscriptions/Index');
+ $plans = \App\Models\Plan::all()->map(function ($plan) {
+ $plan->usage_count = \App\Models\Sponsor::where('subscription_plan', $plan->id)
+ ->where('subscription_status', 'active')
+ ->count();
+ return $plan;
+ });
+ return Inertia::render('Admin/Subscriptions/Index', [
+ 'plans' => $plans
+ ]);
})->name('admin.subscriptions');
+ Route::post('/subscriptions', function (\Illuminate\Http\Request $request) {
+ $data = $request->validate([
+ 'id' => 'required|string|unique:plans,id',
+ 'name' => 'required|string|max:255',
+ 'price' => 'required|numeric|min:0',
+ 'duration' => 'required|string',
+ 'features' => 'required|array',
+ 'max_contact_people' => 'nullable|integer',
+ 'unlimited_contacts' => 'required|boolean',
+ 'enable_job_apply' => 'required|boolean',
+ 'status' => 'nullable|string|in:Active,Disabled',
+ ]);
+
+ \App\Models\Plan::create([
+ 'id' => $data['id'],
+ 'name' => $data['name'],
+ 'price' => $data['price'],
+ 'duration' => $data['duration'],
+ 'features' => $data['features'],
+ 'status' => $data['status'] ?? 'Active',
+ 'max_contact_people' => $data['unlimited_contacts'] ? null : $data['max_contact_people'],
+ 'unlimited_contacts' => $data['unlimited_contacts'],
+ 'enable_job_apply' => $data['enable_job_apply'],
+ ]);
+
+ return redirect()->back();
+ })->name('admin.subscriptions.store');
+
+ Route::post('/subscriptions/{id}/update', function (\Illuminate\Http\Request $request, $id) {
+ $plan = \App\Models\Plan::findOrFail($id);
+ $data = $request->validate([
+ 'name' => 'required|string|max:255',
+ 'price' => 'required|numeric|min:0',
+ 'duration' => 'required|string',
+ 'features' => 'required|array',
+ 'max_contact_people' => 'nullable|integer',
+ 'unlimited_contacts' => 'required|boolean',
+ 'enable_job_apply' => 'required|boolean',
+ 'status' => 'required|string|in:Active,Disabled',
+ ]);
+
+ if ($data['status'] === 'Disabled') {
+ $usageCount = \App\Models\Sponsor::where('subscription_plan', $plan->id)
+ ->where('subscription_status', 'active')
+ ->count();
+ if ($usageCount > 0) {
+ return redirect()->back()->withErrors([
+ 'status' => "Cannot disable plan. {$usageCount} " . ($usageCount === 1 ? 'person is' : 'people are') . " using this plan."
+ ]);
+ }
+ }
+
+ $plan->update([
+ 'name' => $data['name'],
+ 'price' => $data['price'],
+ 'duration' => $data['duration'],
+ 'features' => $data['features'],
+ 'status' => $data['status'],
+ 'max_contact_people' => $data['unlimited_contacts'] ? null : $data['max_contact_people'],
+ 'unlimited_contacts' => $data['unlimited_contacts'],
+ 'enable_job_apply' => $data['enable_job_apply'],
+ ]);
+
+ return redirect()->back();
+ })->name('admin.subscriptions.update');
+
+ Route::delete('/subscriptions/{id}', function ($id) {
+ $plan = \App\Models\Plan::findOrFail($id);
+ $usageCount = \App\Models\Sponsor::where('subscription_plan', $plan->id)
+ ->where('subscription_status', 'active')
+ ->count();
+ if ($usageCount > 0) {
+ return redirect()->back()->withErrors([
+ 'status' => "Cannot delete plan. {$usageCount} " . ($usageCount === 1 ? 'person is' : 'people are') . " using this plan."
+ ]);
+ }
+ $plan->delete();
+ return redirect()->back();
+ })->name('admin.subscriptions.delete');
+
Route::get('/payments', function () {
$payments = \Illuminate\Support\Facades\DB::table('subscriptions')
->leftJoin('users', 'subscriptions.user_id', '=', 'users.id')
@@ -282,6 +370,7 @@
Route::post('/employer/resend-otp', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'resendOtp'])->name('employer.resend-otp')->middleware('throttle:3,1');
Route::get('/employer/upload-emirates-id', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showUploadEmiratesId'])->name('employer.upload-emirates-id');
Route::post('/employer/upload-emirates-id', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'uploadEmiratesId'])->name('employer.upload-emirates-id.submit');
+Route::post('/employer/confirm-emirates-id', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'confirmEmiratesId'])->name('employer.confirm-emirates-id.submit');
Route::get('/employer/register-payment', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showRegisterPayment'])->name('employer.register-payment');
Route::post('/employer/register-payment', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'storeRegisterPayment'])->name('employer.register-payment.submit');
Route::get('/employer/create-password', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showCreatePassword'])->name('employer.create-password');
@@ -324,6 +413,13 @@
Route::get('/jobs', [\App\Http\Controllers\Employer\JobController::class, 'index'])->name('employer.jobs');
Route::get('/jobs/create', [\App\Http\Controllers\Employer\JobController::class, 'create'])->name('employer.jobs.create');
Route::post('/jobs/create', [\App\Http\Controllers\Employer\JobController::class, 'store'])->name('employer.jobs.store');
+ Route::get('/jobs/all-applicants', [\App\Http\Controllers\Employer\JobController::class, 'allApplicants'])->name('employer.jobs.all-applicants');
+ Route::get('/jobs/shortlisted', [\App\Http\Controllers\Employer\JobController::class, 'shortlistedApplicants'])->name('employer.jobs.shortlisted');
+ Route::get('/jobs/{id}/edit', [\App\Http\Controllers\Employer\JobController::class, 'edit'])->name('employer.jobs.edit');
+ Route::post('/jobs/{id}/edit', [\App\Http\Controllers\Employer\JobController::class, 'update'])->name('employer.jobs.update');
+ Route::delete('/jobs/{id}', [\App\Http\Controllers\Employer\JobController::class, 'destroy'])->name('employer.jobs.destroy');
+ Route::get('/jobs/{id}', [\App\Http\Controllers\Employer\JobController::class, 'show'])->name('employer.jobs.show');
+ Route::post('/jobs/{id}/close', [\App\Http\Controllers\Employer\JobController::class, 'close'])->name('employer.jobs.close');
Route::get('/jobs/{id}/applicants', [\App\Http\Controllers\Employer\JobController::class, 'applicants'])->name('employer.jobs.applicants');
Route::get('/subscription', function () {
$sess = session('user');
@@ -331,13 +427,10 @@
$user = $sessId ? \App\Models\User::find($sessId) : \App\Models\User::where('role', 'employer')->first();
$sub = $user ? \Illuminate\Support\Facades\DB::table('subscriptions')->where('user_id', $user->id)->where('status', 'active')->latest('id')->first() : null;
- $expiresAt = $sub && $sub->expires_at ? date('Y-m-d', strtotime($sub->expires_at)) : '2026-12-31';
- $planName = $sub ? (ucfirst($sub->plan_id) . ' Pass') : 'Premium Employer Pass';
- return Inertia::render('Employer/Subscription', [
- 'currentPlan' => $planName,
- 'expiresAt' => $expiresAt,
- 'plans' => [
+ $dbPlans = \App\Models\Plan::where('status', 'Active')->get();
+ if ($dbPlans->isEmpty()) {
+ $plans = [
[
'id' => 'basic',
'name' => 'Basic Search',
@@ -355,17 +448,74 @@
'popular' => true,
],
[
- 'id' => 'enterprise',
+ 'id' => 'vip',
'name' => 'VIP Concierge',
'price' => '499 AED',
'period' => 'month',
'features' => ['All Premium features', 'Assigned recruitment manager', 'Background medical verification guarantee', 'Free replacement within 30 days'],
'popular' => false,
],
- ]
+ ];
+ } else {
+ $plans = $dbPlans->map(function ($p) {
+ $features = is_string($p->features) ? json_decode($p->features, true) : $p->features;
+ return [
+ 'id' => $p->id,
+ 'name' => $p->name,
+ 'price' => round($p->price) . ' AED',
+ 'period' => strtolower($p->duration) === 'monthly' ? 'month' : (strtolower($p->duration) === 'yearly' ? 'year' : 'period'),
+ 'features' => $features ?: [],
+ 'popular' => $p->id === 'premium',
+ ];
+ })->toArray();
+ }
+
+ $planName = 'Premium Employer Pass';
+ $expiresAt = '2026-12-31';
+
+ if ($sub) {
+ $expiresAt = date('Y-m-d', strtotime($sub->expires_at));
+ $associatedPlan = \App\Models\Plan::find($sub->plan_id);
+ $planName = $associatedPlan ? $associatedPlan->name : (ucfirst($sub->plan_id) . ' Pass');
+ }
+
+ $invoices = [];
+ if ($user) {
+ $invoices = \Illuminate\Support\Facades\DB::table('subscriptions')
+ ->leftJoin('plans', 'subscriptions.plan_id', '=', 'plans.id')
+ ->where('subscriptions.user_id', $user->id)
+ ->orderBy('subscriptions.created_at', 'desc')
+ ->select('subscriptions.*', 'plans.name as plan_name')
+ ->get()
+ ->map(function($s) {
+ $planLabel = $s->plan_name ?: (ucfirst($s->plan_id) . ' Pass');
+ $subStatus = strtolower($s->status);
+ if ($subStatus !== 'active' && $subStatus !== 'pending' && $subStatus !== 'expired') {
+ $subStatus = 'expired';
+ }
+ return [
+ 'id' => 'INV-' . date('Y', strtotime($s->created_at)) . '-' . str_pad($s->id, 3, '0', STR_PAD_LEFT),
+ 'plan_name' => $planLabel,
+ 'purchase_date' => date('M d, Y', strtotime($s->created_at)),
+ 'activation_date' => $s->starts_at ? date('M d, Y', strtotime($s->starts_at)) : 'N/A',
+ 'expiry_date' => $s->expires_at ? date('M d, Y', strtotime($s->expires_at)) : 'N/A',
+ 'amount' => round($s->amount_aed) . ' AED',
+ 'payment_status' => 'Paid',
+ 'status' => ucfirst($subStatus),
+ ];
+ })->toArray();
+ }
+
+ return Inertia::render('Employer/Subscription', [
+ 'currentPlan' => $planName,
+ 'expiresAt' => $expiresAt,
+ 'plans' => $plans,
+ 'invoices' => $invoices
]);
})->name('employer.subscription');
+ Route::post('/subscription/purchase', [\App\Http\Controllers\Employer\PaymentController::class, 'purchase'])->name('employer.subscription.purchase');
+
Route::get('/messages', [\App\Http\Controllers\Employer\MessageController::class, 'index'])->name('employer.messages');
Route::get('/messages/{id}', [\App\Http\Controllers\Employer\MessageController::class, 'show'])->name('employer.messages.show');
Route::post('/messages/{id}/send', [\App\Http\Controllers\Employer\MessageController::class, 'send'])->name('employer.messages.send');
diff --git a/tests/Feature/AdminSubscriptionPlansTest.php b/tests/Feature/AdminSubscriptionPlansTest.php
new file mode 100644
index 0000000..b8fab2b
--- /dev/null
+++ b/tests/Feature/AdminSubscriptionPlansTest.php
@@ -0,0 +1,142 @@
+admin = User::create([
+ 'name' => 'Admin User',
+ 'email' => 'admin@example.com',
+ 'password' => bcrypt('password'),
+ 'role' => 'admin',
+ ]);
+ }
+
+ public function test_admin_can_view_subscription_plans()
+ {
+ // Seed default plans are loaded from the migration, let's verify
+ $this->assertDatabaseHas('plans', ['id' => 'basic']);
+
+ $response = $this->actingAs($this->admin)->get('/admin/subscriptions');
+
+ $response->assertStatus(200);
+ }
+
+ public function test_admin_can_create_new_plan()
+ {
+ $response = $this->actingAs($this->admin)->post('/admin/subscriptions', [
+ 'id' => 'super-vip',
+ 'name' => 'Super VIP Plan',
+ 'price' => 999.00,
+ 'duration' => 'Monthly',
+ 'features' => ['Super feature 1', 'Super feature 2'],
+ 'max_contact_people' => null,
+ 'unlimited_contacts' => true,
+ 'enable_job_apply' => true,
+ 'status' => 'Active',
+ ]);
+
+ $response->assertStatus(302);
+ $this->assertDatabaseHas('plans', [
+ 'id' => 'super-vip',
+ 'name' => 'Super VIP Plan',
+ 'price' => 999.00,
+ 'duration' => 'Monthly',
+ 'unlimited_contacts' => true,
+ 'enable_job_apply' => true,
+ 'status' => 'Active',
+ ]);
+ }
+
+ public function test_admin_can_update_existing_plan()
+ {
+ $response = $this->actingAs($this->admin)->post('/admin/subscriptions/basic/update', [
+ 'name' => 'Basic Search Updated',
+ 'price' => 120.00,
+ 'duration' => 'Monthly',
+ 'features' => ['Browse 500+ workers', '10 Shortlists', 'New feature'],
+ 'max_contact_people' => 15,
+ 'unlimited_contacts' => false,
+ 'enable_job_apply' => false,
+ 'status' => 'Active',
+ ]);
+
+ $response->assertStatus(302);
+ $this->assertDatabaseHas('plans', [
+ 'id' => 'basic',
+ 'name' => 'Basic Search Updated',
+ 'price' => 120.00,
+ 'max_contact_people' => 15,
+ 'unlimited_contacts' => false,
+ 'status' => 'Active',
+ ]);
+ }
+
+ public function test_admin_can_delete_plan()
+ {
+ $response = $this->actingAs($this->admin)->delete('/admin/subscriptions/basic');
+
+ $response->assertStatus(302);
+ $this->assertDatabaseMissing('plans', [
+ 'id' => 'basic',
+ ]);
+ }
+
+ public function test_admin_cannot_disable_plan_if_in_use()
+ {
+ // Associate a sponsor with 'basic' plan
+ \App\Models\Sponsor::create([
+ 'full_name' => 'Test Sponsor',
+ 'email' => 'sponsor@example.com',
+ 'mobile' => '+971501112233',
+ 'password' => bcrypt('password'),
+ 'subscription_status' => 'active',
+ 'subscription_plan' => 'basic',
+ ]);
+
+ $response = $this->actingAs($this->admin)->post('/admin/subscriptions/basic/update', [
+ 'name' => 'Basic Search',
+ 'price' => 99.00,
+ 'duration' => 'Monthly',
+ 'features' => ['Browse 500+ workers'],
+ 'max_contact_people' => 10,
+ 'unlimited_contacts' => false,
+ 'enable_job_apply' => false,
+ 'status' => 'Disabled',
+ ]);
+
+ $response->assertSessionHasErrors(['status']);
+ $this->assertEquals('Active', Plan::find('basic')->status);
+ }
+
+ public function test_admin_cannot_delete_plan_if_in_use()
+ {
+ // Associate a sponsor with 'basic' plan
+ \App\Models\Sponsor::create([
+ 'full_name' => 'Test Sponsor',
+ 'email' => 'sponsor@example.com',
+ 'mobile' => '+971501112233',
+ 'password' => bcrypt('password'),
+ 'subscription_status' => 'active',
+ 'subscription_plan' => 'basic',
+ ]);
+
+ $response = $this->actingAs($this->admin)->delete('/admin/subscriptions/basic');
+
+ $response->assertSessionHasErrors(['status']);
+ $this->assertDatabaseHas('plans', ['id' => 'basic']);
+ }
+}
diff --git a/tests/Feature/AutomatedReminderTest.php b/tests/Feature/AutomatedReminderTest.php
new file mode 100644
index 0000000..f61180d
--- /dev/null
+++ b/tests/Feature/AutomatedReminderTest.php
@@ -0,0 +1,281 @@
+ 'John Worker',
+ 'email' => 'worker@example.com',
+ 'password' => bcrypt('password'),
+ 'phone' => '971500000001',
+ 'nationality' => 'Indian',
+ 'status' => 'active',
+ 'age' => 25,
+ 'salary' => 2000,
+ 'availability' => 'Immediate',
+ 'experience' => '2 Years',
+ 'religion' => 'Christian',
+ 'bio' => 'Helper info',
+ 'passport_status' => 'valid',
+ ], $overrides));
+ }
+
+ public function test_document_expiry_reminders()
+ {
+ Notification::fake();
+
+ // 1. Create a Worker with a document expiring in exactly 7 days
+ $worker = $this->createTestWorker();
+
+ $doc = WorkerDocument::create([
+ 'worker_id' => $worker->id,
+ 'type' => 'passport',
+ 'number' => 'PASS123',
+ 'expiry_date' => now()->addDays(7)->toDateString(),
+ ]);
+
+ // 2. Create an Employer (User) with Emirates ID expiring in exactly 3 days
+ $employer = User::create([
+ 'name' => 'Alice Employer',
+ 'email' => 'employer@example.com',
+ 'password' => bcrypt('password'),
+ 'role' => 'employer',
+ ]);
+
+ $employerProfile = EmployerProfile::create([
+ 'user_id' => $employer->id,
+ 'phone' => '971500000002',
+ 'address' => 'Dubai',
+ 'emirates_id_expiry' => now()->addDays(3)->toDateString(),
+ ]);
+
+ // 3. Create a Sponsor with Emirates ID expiring in exactly 1 day
+ $sponsor = Sponsor::create([
+ 'full_name' => 'Sponsor Bob',
+ 'email' => 'sponsor@example.com',
+ 'mobile' => '971500000003',
+ 'password' => bcrypt('password'),
+ 'emirates_id_expiry' => now()->addDays(1)->toDateString(),
+ ]);
+
+ // Run the command
+ Artisan::call('app:send-reminders');
+
+ // Assert Worker got Passport expiry 7 days notification
+ Notification::assertSentTo(
+ $worker,
+ DocumentExpiryNotification::class,
+ function ($notification) {
+ return $notification->documentType === 'Passport' &&
+ $notification->daysRemaining === 7 &&
+ $notification->reminderLevel === '7_days';
+ }
+ );
+
+ // Assert Employer got Emirates ID expiry 3 days notification
+ Notification::assertSentTo(
+ $employer,
+ DocumentExpiryNotification::class,
+ function ($notification) {
+ return $notification->documentType === 'Emirates ID' &&
+ $notification->daysRemaining === 3 &&
+ $notification->reminderLevel === '3_days';
+ }
+ );
+
+ // Assert Sponsor got Emirates ID expiry 1 day notification
+ Notification::assertSentTo(
+ $sponsor,
+ DocumentExpiryNotification::class,
+ function ($notification) {
+ return $notification->documentType === 'Emirates ID' &&
+ $notification->daysRemaining === 1 &&
+ $notification->reminderLevel === '1_day';
+ }
+ );
+
+ // Assert reminder logs were created
+ $this->assertDatabaseHas('reminder_logs', [
+ 'user_type' => get_class($worker),
+ 'user_id' => $worker->id,
+ 'event_type' => 'document_expiry',
+ 'reminder_level' => '7_days',
+ ]);
+
+ $this->assertDatabaseHas('reminder_logs', [
+ 'user_type' => get_class($employer),
+ 'user_id' => $employer->id,
+ 'event_type' => 'document_expiry',
+ 'reminder_level' => '3_days',
+ ]);
+
+ $this->assertDatabaseHas('reminder_logs', [
+ 'user_type' => get_class($sponsor),
+ 'user_id' => $sponsor->id,
+ 'event_type' => 'document_expiry_emirates',
+ 'reminder_level' => '1_day',
+ ]);
+
+ // Run the command again to ensure duplicate is prevented
+ Notification::fake();
+ Artisan::call('app:send-reminders');
+
+ Notification::assertNothingSent();
+ }
+
+ public function test_hire_and_joining_confirmations_reminders()
+ {
+ Notification::fake();
+
+ // Create Employer
+ $employer = User::create([
+ 'name' => 'Alice Employer',
+ 'email' => 'employer@example.com',
+ 'password' => bcrypt('password'),
+ 'role' => 'employer',
+ ]);
+
+ // Create Worker
+ $worker = $this->createTestWorker();
+
+ // Create a Job Post starting in 7 days
+ $job = JobPost::create([
+ 'employer_id' => $employer->id,
+ 'title' => 'Housekeeper',
+ 'location' => 'Dubai',
+ 'salary' => 2000,
+ 'workers_needed' => 1,
+ 'job_type' => 'Full Time',
+ 'start_date' => now()->addDays(7)->toDateString(),
+ 'description' => 'Test job',
+ 'status' => 'active',
+ ]);
+
+ // Create a Job Application with status 'selected' (needs Hire Confirmation)
+ $appSelected = JobApplication::create([
+ 'job_id' => $job->id,
+ 'worker_id' => $worker->id,
+ 'status' => 'selected',
+ ]);
+
+ // Create another Job Post starting in 3 days
+ $jobHired = JobPost::create([
+ 'employer_id' => $employer->id,
+ 'title' => 'Nanny',
+ 'location' => 'Dubai',
+ 'salary' => 2500,
+ 'workers_needed' => 1,
+ 'job_type' => 'Full Time',
+ 'start_date' => now()->addDays(3)->toDateString(),
+ 'description' => 'Test job hired',
+ 'status' => 'active',
+ ]);
+
+ // Create a Job Application with status 'hired' (needs Joining Confirmation)
+ $appHired = JobApplication::create([
+ 'job_id' => $jobHired->id,
+ 'worker_id' => $worker->id,
+ 'status' => 'hired',
+ ]);
+
+ // Run the command
+ Artisan::call('app:send-reminders');
+
+ // Assert Employer got Hire Confirmation 7 days notification
+ Notification::assertSentTo(
+ $employer,
+ HireConfirmationNotification::class,
+ function ($notification) use ($appSelected) {
+ return $notification->jobTitle === 'Housekeeper' &&
+ $notification->workerName === 'John Worker' &&
+ $notification->daysRemaining === 7 &&
+ $notification->applicationId === $appSelected->id;
+ }
+ );
+
+ // Assert Worker got Joining Confirmation 3 days notification
+ Notification::assertSentTo(
+ $worker,
+ JoiningConfirmationNotification::class,
+ function ($notification) use ($appHired) {
+ return $notification->jobTitle === 'Nanny' &&
+ $notification->employerName === 'Alice Employer' &&
+ $notification->daysRemaining === 3 &&
+ $notification->applicationId === $appHired->id;
+ }
+ );
+
+ // Verify duplicate prevention
+ Notification::fake();
+ Artisan::call('app:send-reminders');
+ Notification::assertNothingSent();
+ }
+
+ public function test_pending_direct_job_offer_reminders()
+ {
+ Notification::fake();
+
+ // Create Employer
+ $employer = User::create([
+ 'name' => 'Alice Employer',
+ 'email' => 'employer@example.com',
+ 'password' => bcrypt('password'),
+ 'role' => 'employer',
+ ]);
+
+ // Create Worker
+ $worker = $this->createTestWorker();
+
+ // Create pending direct Job Offer starting in 1 day
+ $offer = JobOffer::create([
+ 'employer_id' => $employer->id,
+ 'worker_id' => $worker->id,
+ 'work_date' => now()->addDays(1)->toDateString(),
+ 'location' => 'Dubai Marina',
+ 'salary' => 3000,
+ 'status' => 'pending',
+ ]);
+
+ // Run the command
+ Artisan::call('app:send-reminders');
+
+ // Assert Worker got Pending Direct Job Offer 1 day notification
+ Notification::assertSentTo(
+ $worker,
+ PendingConfirmationNotification::class,
+ function ($notification) use ($offer) {
+ return $notification->actionType === 'accept_offer' &&
+ $notification->daysRemaining === 1 &&
+ $notification->entityId === $offer->id;
+ }
+ );
+
+ // Verify duplicate prevention
+ Notification::fake();
+ Artisan::call('app:send-reminders');
+ Notification::assertNothingSent();
+ }
+}
diff --git a/tests/Feature/EmployerJobTest.php b/tests/Feature/EmployerJobTest.php
new file mode 100644
index 0000000..b4534a3
--- /dev/null
+++ b/tests/Feature/EmployerJobTest.php
@@ -0,0 +1,295 @@
+employer = User::create([
+ 'name' => 'Jane Sponsor',
+ 'email' => 'sponsor@example.com',
+ 'password' => bcrypt('password'),
+ 'role' => 'employer',
+ ]);
+
+ // Create an active premium subscription so Jane can post/view jobs
+ \Illuminate\Support\Facades\DB::table('subscriptions')->insert([
+ 'user_id' => $this->employer->id,
+ 'plan_id' => 'premium',
+ 'amount_aed' => 199.00,
+ 'starts_at' => now(),
+ 'expires_at' => now()->addDays(30),
+ 'paytabs_transaction_id' => 'TEST_TXN_ID',
+ 'status' => 'active',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ /**
+ * Test displaying the employer jobs list with basic plan.
+ */
+ public function test_employer_with_basic_plan_cannot_view_jobs_list()
+ {
+ // Update subscription to basic
+ \Illuminate\Support\Facades\DB::table('subscriptions')
+ ->where('user_id', $this->employer->id)
+ ->update(['plan_id' => 'basic']);
+
+ $response = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->get('/employer/jobs');
+
+ $response->assertRedirect('/employer/subscription');
+ $response->assertSessionHas('error');
+ }
+
+ /**
+ * Test displaying the employer jobs list with no subscription.
+ */
+ public function test_employer_with_no_subscription_cannot_view_jobs_list()
+ {
+ // Delete subscription
+ \Illuminate\Support\Facades\DB::table('subscriptions')
+ ->where('user_id', $this->employer->id)
+ ->delete();
+
+ $response = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->get('/employer/jobs');
+
+ $response->assertRedirect('/employer/subscription');
+ $response->assertSessionHas('error');
+ }
+
+ /**
+ * Test displaying the employer jobs list.
+ */
+ public function test_employer_can_view_jobs_list()
+ {
+ $job = JobPost::create([
+ 'employer_id' => $this->employer->id,
+ 'title' => 'Experienced Mason',
+ 'workers_needed' => 5,
+ 'job_type' => 'Full Time',
+ 'location' => 'Dubai Marina',
+ 'salary' => 2800,
+ 'start_date' => '2026-07-01',
+ 'description' => 'Looking for experienced masons for a residential project.',
+ 'requirements' => '5+ years experience, basic English.',
+ 'status' => 'active',
+ ]);
+
+ $response = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->get('/employer/jobs');
+
+ $response->assertStatus(200);
+ $response->assertSee('Experienced Mason');
+ }
+
+ /**
+ * Test displaying the create job form.
+ */
+ public function test_employer_can_view_create_job_form()
+ {
+ $response = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->get('/employer/jobs/create');
+
+ $response->assertStatus(200);
+ }
+
+ /**
+ * Test storing a new job posting.
+ */
+ public function test_employer_can_post_job()
+ {
+ $response = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->post('/employer/jobs/create', [
+ 'title' => 'Villa Cleaner',
+ 'workers_needed' => 2,
+ 'job_type' => 'Part Time',
+ 'location' => 'Al Barsha',
+ 'salary' => 1800,
+ 'start_date' => '2026-07-10',
+ 'description' => 'Need part-time villa cleaners for daily housekeeping tasks.',
+ 'requirements' => 'Housekeeping experience is a plus.',
+ ]);
+
+ $response->assertRedirect('/employer/jobs');
+ $response->assertSessionHas('success');
+
+ $this->assertDatabaseHas('job_posts', [
+ 'employer_id' => $this->employer->id,
+ 'title' => 'Villa Cleaner',
+ 'workers_needed' => 2,
+ 'job_type' => 'Part Time',
+ 'location' => 'Al Barsha',
+ 'salary' => 1800,
+ ]);
+ }
+
+ /**
+ * Test validation during job posting.
+ */
+ public function test_job_posting_requires_fields()
+ {
+ $response = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->post('/employer/jobs/create', [
+ 'title' => '',
+ 'workers_needed' => 0,
+ 'salary' => -100,
+ ]);
+
+ $response->assertSessionHasErrors(['title', 'workers_needed', 'location', 'salary', 'job_type', 'start_date', 'description']);
+ }
+
+ /**
+ * Test viewing job applicants.
+ */
+ public function test_employer_can_view_applicants()
+ {
+ $job = JobPost::create([
+ 'employer_id' => $this->employer->id,
+ 'title' => 'Mason for Project',
+ 'workers_needed' => 3,
+ 'job_type' => 'Contract',
+ 'location' => 'Sharjah',
+ 'salary' => 3000,
+ 'start_date' => '2026-07-15',
+ 'description' => 'Contract mason project.',
+ 'status' => 'active',
+ ]);
+
+ $worker = Worker::create([
+ 'name' => 'John Worker',
+ 'email' => 'worker@example.com',
+ 'phone' => '+971500000001',
+ 'nationality' => 'Indian',
+ 'age' => 30,
+ 'salary' => 2500,
+ 'availability' => 'Available',
+ 'experience' => '4 Years',
+ 'religion' => 'Christian',
+ 'bio' => 'Vetted domestic worker with great experience.',
+ ]);
+
+ $application = JobApplication::create([
+ 'job_id' => $job->id,
+ 'worker_id' => $worker->id,
+ 'status' => 'applied',
+ ]);
+
+ $response = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->get("/employer/jobs/{$job->id}/applicants");
+
+ $response->assertStatus(200);
+ $response->assertSee('John Worker');
+ }
+
+ /**
+ * Test viewing all job applicants.
+ */
+ public function test_employer_can_view_all_applicants()
+ {
+ $job = JobPost::create([
+ 'employer_id' => $this->employer->id,
+ 'title' => 'Mason for Project',
+ 'workers_needed' => 3,
+ 'job_type' => 'Contract',
+ 'location' => 'Sharjah',
+ 'salary' => 3000,
+ 'start_date' => '2026-07-15',
+ 'description' => 'Contract mason project.',
+ 'status' => 'active',
+ ]);
+
+ $worker = Worker::create([
+ 'name' => 'John Worker',
+ 'email' => 'worker@example.com',
+ 'phone' => '+971500000001',
+ 'nationality' => 'Indian',
+ 'age' => 30,
+ 'salary' => 2500,
+ 'availability' => 'Available',
+ 'experience' => '4 Years',
+ 'religion' => 'Christian',
+ 'bio' => 'Vetted domestic worker with great experience.',
+ ]);
+
+ $application = JobApplication::create([
+ 'job_id' => $job->id,
+ 'worker_id' => $worker->id,
+ 'status' => 'applied',
+ ]);
+
+ $response = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->get("/employer/jobs/all-applicants");
+
+ $response->assertStatus(200);
+ $response->assertSee('John Worker');
+ }
+
+ /**
+ * Test viewing shortlisted applicants.
+ */
+ public function test_employer_can_view_shortlisted_applicants()
+ {
+ $job = JobPost::create([
+ 'employer_id' => $this->employer->id,
+ 'title' => 'Mason for Project',
+ 'workers_needed' => 3,
+ 'job_type' => 'Contract',
+ 'location' => 'Sharjah',
+ 'salary' => 3000,
+ 'start_date' => '2026-07-15',
+ 'description' => 'Contract mason project.',
+ 'status' => 'active',
+ ]);
+
+ $worker = Worker::create([
+ 'name' => 'Shortlisted John',
+ 'email' => 'worker_shortlisted@example.com',
+ 'phone' => '+971500000002',
+ 'nationality' => 'Indian',
+ 'age' => 30,
+ 'salary' => 2500,
+ 'availability' => 'Available',
+ 'experience' => '4 Years',
+ 'religion' => 'Christian',
+ 'bio' => 'Vetted domestic worker with great experience.',
+ ]);
+
+ $application = JobApplication::create([
+ 'job_id' => $job->id,
+ 'worker_id' => $worker->id,
+ 'status' => 'shortlisted',
+ ]);
+
+ $response = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->get("/employer/jobs/shortlisted");
+
+ $response->assertStatus(200);
+ $response->assertSee('Shortlisted John');
+ }
+}
diff --git a/tests/Feature/EmployerProfileApiTest.php b/tests/Feature/EmployerProfileApiTest.php
index e7d3bc6..edd043a 100644
--- a/tests/Feature/EmployerProfileApiTest.php
+++ b/tests/Feature/EmployerProfileApiTest.php
@@ -152,10 +152,10 @@ public function test_employer_can_update_profile_with_emirates_id_fields()
'emirates_id' => [
'emirates_id_number' => '784-1987-5493842-5',
'name' => 'Mohammad Jobaier Mohammad Abul Kalam',
- 'date_of_birth' => '14/04/1987',
+ 'date_of_birth' => '1987-04-14',
'nationality' => 'Bangladesh',
- 'issue_date' => '12/12/2025',
- 'expiry_date' => '11/12/2027',
+ 'issue_date' => '2025-12-12',
+ 'expiry_date' => '2027-12-11',
'employer' => 'Msj International Technical Services L.L.C UAE',
'issue_place' => 'Dubai',
'occupation' => 'Electrician',
@@ -371,4 +371,226 @@ public function test_employer_can_change_password()
$this->assertTrue(\Illuminate\Support\Facades\Hash::check('NewPassword@123', $this->employer->fresh()->password));
$this->assertTrue(\Illuminate\Support\Facades\Hash::check('NewPassword@123', $sponsor->fresh()->password));
}
+
+ /**
+ * Test registering an employer with DD/MM/YYYY dates normalizes them to Y-m-d.
+ */
+ public function test_employer_registration_normalizes_dates()
+ {
+ $response = $this->postJson('/api/employers/register', [
+ 'name' => 'Sandeep Vishwakarma',
+ 'email' => 'sandeep@example.com',
+ 'phone' => '+971509990002',
+ 'address' => 'Dubai, UAE',
+ 'emirates_id' => [
+ 'emirates_id_number' => '784-2002-4977006-4',
+ 'name' => 'Sandeep Vishwakarma',
+ 'date_of_birth' => '05/07/2002',
+ 'issue_date' => '07/06/2023',
+ 'expiry_date' => '06/06/2025',
+ ],
+ ]);
+
+ $response->assertStatus(201)
+ ->assertJson([
+ 'success' => true,
+ ]);
+
+ $this->assertDatabaseHas('employer_profiles', [
+ 'phone' => '+971509990002',
+ 'emirates_id_dob' => '2002-07-05',
+ 'emirates_id_issue_date' => '2023-06-07',
+ 'emirates_id_expiry' => '2025-06-06',
+ ]);
+
+ $this->assertDatabaseHas('sponsors', [
+ 'mobile' => '+971509990002',
+ 'emirates_id_dob' => '2002-07-05',
+ 'emirates_id_issue_date' => '2023-06-07',
+ 'emirates_id_expiry' => '2025-06-06',
+ ]);
+ }
+
+ /**
+ * Test employer dashboard returns subscription plan details and access rights.
+ */
+ public function test_employer_dashboard_returns_plan_details()
+ {
+ // 1. First, request dashboard without any subscription in DB.
+ // It should fallback to the default 'premium' plan.
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer ' . $this->token,
+ ])->getJson('/api/employers/dashboard');
+
+ $response->assertStatus(200)
+ ->assertJsonStructure([
+ 'success',
+ 'data' => [
+ 'current_plan' => [
+ 'plan_id',
+ 'name',
+ 'status',
+ 'starts_at',
+ 'expires_at',
+ 'max_contact_people',
+ 'unlimited_contacts',
+ 'enable_job_apply',
+ 'features',
+ 'price',
+ 'duration',
+ ]
+ ]
+ ]);
+
+ $this->assertEquals(2, $response->json('data.current_plan.plan_id'));
+ $this->assertEquals(50, $response->json('data.current_plan.max_contact_people'));
+ $this->assertFalse($response->json('data.current_plan.unlimited_contacts'));
+ $this->assertTrue($response->json('data.current_plan.enable_job_apply'));
+
+ // 2. Now update/prepare the plan and create an active subscription for the employer
+ \App\Models\Plan::updateOrCreate(
+ ['id' => 'basic'],
+ [
+ 'name' => 'Basic Plan',
+ 'price' => 99.00,
+ 'duration' => 'Monthly',
+ 'features' => ['Feature 1', 'Feature 2'],
+ 'status' => 'Active',
+ 'max_contact_people' => 10,
+ 'unlimited_contacts' => false,
+ 'enable_job_apply' => false,
+ ]
+ );
+
+ \Illuminate\Support\Facades\DB::table('subscriptions')->insert([
+ 'user_id' => $this->employer->id,
+ 'plan_id' => 'basic',
+ 'amount_aed' => 99.00,
+ 'starts_at' => now(),
+ 'expires_at' => now()->addDays(30),
+ 'paytabs_transaction_id' => 'tx_test123',
+ 'status' => 'active',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $responseSub = $this->withHeaders([
+ 'Authorization' => 'Bearer ' . $this->token,
+ ])->getJson('/api/employers/dashboard');
+
+ $responseSub->assertStatus(200);
+ $this->assertEquals(1, $responseSub->json('data.current_plan.plan_id'));
+ $this->assertEquals('Basic Plan', $responseSub->json('data.current_plan.name'));
+ $this->assertEquals(10, $responseSub->json('data.current_plan.max_contact_people'));
+ $this->assertFalse($responseSub->json('data.current_plan.unlimited_contacts'));
+ $this->assertFalse($responseSub->json('data.current_plan.enable_job_apply'));
+ $this->assertEquals(99.00, $responseSub->json('data.current_plan.price'));
+
+ // 3. Test that a subscription with plan_id 'enterprise' resolves correctly to 'vip' in the response.
+ \App\Models\Plan::updateOrCreate(
+ ['id' => 'vip'],
+ [
+ 'name' => 'VIP Concierge',
+ 'price' => 499.00,
+ 'duration' => 'Monthly',
+ 'features' => ['VIP Feature 1'],
+ 'status' => 'Active',
+ 'max_contact_people' => null,
+ 'unlimited_contacts' => true,
+ 'enable_job_apply' => true,
+ ]
+ );
+
+ \Illuminate\Support\Facades\DB::table('subscriptions')->where('user_id', $this->employer->id)->delete();
+ \Illuminate\Support\Facades\DB::table('subscriptions')->insert([
+ 'user_id' => $this->employer->id,
+ 'plan_id' => 'enterprise',
+ 'amount_aed' => 499.00,
+ 'starts_at' => now(),
+ 'expires_at' => now()->addDays(30),
+ 'paytabs_transaction_id' => 'tx_test456',
+ 'status' => 'active',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $responseVip = $this->withHeaders([
+ 'Authorization' => 'Bearer ' . $this->token,
+ ])->getJson('/api/employers/dashboard');
+
+ $responseVip->assertStatus(200);
+ $this->assertEquals(3, $responseVip->json('data.current_plan.plan_id'));
+ $this->assertEquals('VIP Concierge', $responseVip->json('data.current_plan.name'));
+ $this->assertNull($responseVip->json('data.current_plan.max_contact_people'));
+ $this->assertTrue($responseVip->json('data.current_plan.unlimited_contacts'));
+ }
+
+ /**
+ * Test employer can fetch plans and pay with numeric plan ID.
+ */
+ public function test_employer_can_get_plans_and_pay_with_numeric_id()
+ {
+ // 1. Fetch plans
+ $responsePlans = $this->getJson('/api/employers/plans');
+ $responsePlans->assertStatus(200)
+ ->assertJsonStructure([
+ 'success',
+ 'data' => [
+ 'plans' => [
+ '*' => [
+ 'id',
+ 'name',
+ 'price',
+ 'currency',
+ 'period',
+ 'features',
+ 'popular',
+ 'max_contact_people',
+ 'unlimited_contacts',
+ 'enable_job_apply',
+ 'status',
+ 'duration',
+ ]
+ ]
+ ]
+ ]);
+
+ $plans = $responsePlans->json('data.plans');
+ $this->assertNotEmpty($plans);
+ foreach ($plans as $plan) {
+ $this->assertIsInt($plan['id']);
+ }
+
+ // Create sponsor record for payment verification
+ \App\Models\Sponsor::create([
+ 'email' => $this->employer->email,
+ 'full_name' => $this->employer->name,
+ 'mobile' => '+971501112222',
+ 'password' => $this->employer->password,
+ 'is_verified' => true,
+ 'status' => 'active',
+ ]);
+
+ // 2. Submit payment with a numeric plan ID (e.g. 3)
+ $responsePayment = $this->postJson('/api/employers/payment', [
+ 'email' => $this->employer->email,
+ 'plan_id' => 3,
+ 'amount_aed' => 499.00,
+ 'paytabs_transaction_id' => 'tx_numeric123',
+ ]);
+
+ $responsePayment->assertStatus(200)
+ ->assertJson([
+ 'success' => true,
+ ]);
+
+ // Assert database subscription resolved to 'vip' string
+ $this->assertDatabaseHas('subscriptions', [
+ 'user_id' => $this->employer->id,
+ 'plan_id' => 'vip',
+ 'amount_aed' => 499.00,
+ 'paytabs_transaction_id' => 'tx_numeric123',
+ 'status' => 'active',
+ ]);
+ }
}
diff --git a/tests/Feature/EmployerProfileWebTest.php b/tests/Feature/EmployerProfileWebTest.php
index de46962..ac8d788 100644
--- a/tests/Feature/EmployerProfileWebTest.php
+++ b/tests/Feature/EmployerProfileWebTest.php
@@ -85,4 +85,31 @@ public function test_employer_upload_emirates_id_ocr_and_purge()
$this->assertNotNull($profile->emirates_id_expiry);
$this->assertEquals('approved', $profile->verification_status);
}
+
+ /**
+ * Test that a stale session with an invalid user ID does not cause a redirect loop.
+ */
+ public function test_stale_session_does_not_cause_redirect_loop()
+ {
+ // 1. Visit with a session user object containing a non-existent ID
+ $staleUser = (object)[
+ 'id' => 999999, // Doesn't exist
+ 'name' => 'Stale User',
+ 'email' => 'stale@example.com',
+ 'role' => 'employer',
+ 'subscription_status' => 'active',
+ 'verification_status' => 'approved',
+ ];
+
+ // Accessing dashboard should fail auth, clear session, and redirect to login
+ $response = $this->withSession(['user' => $staleUser])
+ ->get('/employer/dashboard');
+
+ $response->assertRedirect(route('employer.login'));
+ $this->assertNull(session('user')); // Session must be cleared
+
+ // Subsequent access to login page should render successfully (no redirect)
+ $loginResponse = $this->get('/employer/login');
+ $loginResponse->assertStatus(200);
+ }
}
diff --git a/tests/Feature/EmployerShortlistWebFilterTest.php b/tests/Feature/EmployerShortlistWebFilterTest.php
new file mode 100644
index 0000000..cc64eeb
--- /dev/null
+++ b/tests/Feature/EmployerShortlistWebFilterTest.php
@@ -0,0 +1,192 @@
+employer = User::create([
+ 'name' => 'Test Employer',
+ 'email' => 'employer@example.com',
+ 'password' => bcrypt('password'),
+ 'role' => 'employer',
+ ]);
+
+ $this->worker1 = Worker::create([
+ 'name' => 'Worker Indian Dubai',
+ 'email' => 'worker1@example.com',
+ 'phone' => '+971501111111',
+ 'password' => bcrypt('password'),
+ 'nationality' => 'Indian',
+ 'verified' => true,
+ 'status' => 'active',
+ 'preferred_location' => 'Dubai',
+ 'preferred_job_type' => 'full-time',
+ 'live_in_out' => 'live_in',
+ 'in_country' => true,
+ 'visa_status' => 'Residence Visa',
+ 'main_profession' => 'Housemaid',
+ 'age' => 25,
+ 'salary' => 1500,
+ 'experience' => '3 Years',
+ 'religion' => 'Christian',
+ 'language' => 'EN',
+ 'availability' => 'Immediate',
+ 'bio' => 'Experienced helper',
+ 'gender' => 'female',
+ ]);
+
+ WorkerDocument::create([
+ 'worker_id' => $this->worker1->id,
+ 'type' => 'passport',
+ 'number' => 'P111111',
+ 'file_path' => 'passports/test1.jpg',
+ ]);
+
+ $this->worker2 = Worker::create([
+ 'name' => 'Worker Filipino Abu Dhabi',
+ 'email' => 'worker2@example.com',
+ 'phone' => '+971502222222',
+ 'password' => bcrypt('password'),
+ 'nationality' => 'Filipino',
+ 'verified' => true,
+ 'status' => 'active',
+ 'preferred_location' => 'Abu Dhabi',
+ 'preferred_job_type' => 'part-time',
+ 'live_in_out' => 'live_out',
+ 'in_country' => true,
+ 'visa_status' => 'Tourist Visa',
+ 'main_profession' => 'Nanny',
+ 'age' => 28,
+ 'salary' => 1800,
+ 'experience' => '2 Years',
+ 'religion' => 'Christian',
+ 'language' => 'TL',
+ 'availability' => 'Immediate',
+ 'bio' => 'Ready to work',
+ 'gender' => 'male',
+ ]);
+
+ WorkerDocument::create([
+ 'worker_id' => $this->worker2->id,
+ 'type' => 'passport',
+ 'number' => 'P222222',
+ 'file_path' => 'passports/test2.jpg',
+ ]);
+
+ // Shortlist both workers for this employer
+ Shortlist::create([
+ 'employer_id' => $this->employer->id,
+ 'worker_id' => $this->worker1->id,
+ ]);
+
+ Shortlist::create([
+ 'employer_id' => $this->employer->id,
+ 'worker_id' => $this->worker2->id,
+ ]);
+ }
+
+ /**
+ * Test shortlist index page loads correct properties.
+ */
+ public function test_employer_can_view_shortlist_with_metadata()
+ {
+ $response = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->get(route('employer.shortlist'));
+
+ $response->assertStatus(200);
+ $response->assertSee('shortlistedWorkers');
+ $response->assertSee('filtersMetadata');
+
+ // Check if both workers are returned when no filters are applied
+ $shortlisted = $response->viewData('page')['props']['shortlistedWorkers'];
+ $this->assertCount(2, $shortlisted);
+ }
+
+ /**
+ * Test shortlist filtering by location.
+ */
+ public function test_employer_shortlist_filtering_by_location()
+ {
+ $response = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->get(route('employer.shortlist', ['preferred_location' => 'Abu Dhabi']));
+
+ $response->assertStatus(200);
+ $shortlisted = $response->viewData('page')['props']['shortlistedWorkers'];
+
+ $this->assertCount(1, $shortlisted);
+ $this->assertEquals('Worker Filipino Abu Dhabi', $shortlisted[0]['name']);
+ }
+
+ /**
+ * Test shortlist filtering by nationality.
+ */
+ public function test_employer_shortlist_filtering_by_nationality()
+ {
+ $response = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->get(route('employer.shortlist', ['nationality' => 'Indian']));
+
+ $response->assertStatus(200);
+ $shortlisted = $response->viewData('page')['props']['shortlistedWorkers'];
+
+ $this->assertCount(1, $shortlisted);
+ $this->assertEquals('Worker Indian Dubai', $shortlisted[0]['name']);
+ }
+
+ /**
+ * Test shortlist filtering by job type and accommodation.
+ */
+ public function test_employer_shortlist_filtering_by_job_type_and_accommodation()
+ {
+ $response = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->get(route('employer.shortlist', [
+ 'job_type' => 'part-time',
+ 'accommodation_type' => 'live_out'
+ ]));
+
+ $response->assertStatus(200);
+ $shortlisted = $response->viewData('page')['props']['shortlistedWorkers'];
+
+ $this->assertCount(1, $shortlisted);
+ $this->assertEquals('Worker Filipino Abu Dhabi', $shortlisted[0]['name']);
+ }
+
+ /**
+ * Test shortlist filtering by visa status and gender.
+ */
+ public function test_employer_shortlist_filtering_by_visa_status_and_gender()
+ {
+ $response = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->get(route('employer.shortlist', [
+ 'visa_status' => 'Residence Visa',
+ 'gender' => 'female'
+ ]));
+
+ $response->assertStatus(200);
+ $shortlisted = $response->viewData('page')['props']['shortlistedWorkers'];
+
+ $this->assertCount(1, $shortlisted);
+ $this->assertEquals('Worker Indian Dubai', $shortlisted[0]['name']);
+ }
+}
diff --git a/tests/Feature/EmployerSubscriptionUpgradeTest.php b/tests/Feature/EmployerSubscriptionUpgradeTest.php
new file mode 100644
index 0000000..6ca614c
--- /dev/null
+++ b/tests/Feature/EmployerSubscriptionUpgradeTest.php
@@ -0,0 +1,259 @@
+employer = User::create([
+ 'name' => 'Employer User',
+ 'email' => 'employer@example.com',
+ 'password' => bcrypt('password'),
+ 'role' => 'employer',
+ 'subscription_status' => 'expired',
+ 'subscription_expires_at' => null,
+ ]);
+ }
+
+ public function test_purchase_when_no_active_subscription_activates_immediately()
+ {
+ $response = $this->actingAs($this->employer)->postJson(route('employer.subscription.purchase'), [
+ 'plan_id' => 'premium',
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJsonPath('success', true);
+
+ // Assert subscription is active
+ $this->assertDatabaseHas('subscriptions', [
+ 'user_id' => $this->employer->id,
+ 'plan_id' => 'premium',
+ 'status' => 'active',
+ ]);
+
+ $this->employer->refresh();
+ $this->assertEquals('active', $this->employer->subscription_status);
+ $this->assertNotNull($this->employer->subscription_expires_at);
+ }
+
+ public function test_purchase_when_active_subscription_exists_queues_pending()
+ {
+ // 1. Create active subscription
+ $activeSub = Subscription::create([
+ 'user_id' => $this->employer->id,
+ 'plan_id' => 'basic',
+ 'amount_aed' => 99.00,
+ 'starts_at' => now(),
+ 'expires_at' => now()->addDays(30),
+ 'status' => 'active',
+ ]);
+
+ $this->employer->update([
+ 'subscription_status' => 'active',
+ 'subscription_expires_at' => $activeSub->expires_at,
+ ]);
+
+ // 2. Purchase another subscription
+ $response = $this->actingAs($this->employer)->postJson(route('employer.subscription.purchase'), [
+ 'plan_id' => 'premium',
+ ]);
+
+ $response->assertStatus(200);
+
+ // Assert new subscription is pending and queued chronologically
+ $this->assertDatabaseHas('subscriptions', [
+ 'user_id' => $this->employer->id,
+ 'plan_id' => 'premium',
+ 'status' => 'pending',
+ 'starts_at' => $activeSub->expires_at,
+ ]);
+
+ // User should still have active status with previous expiry
+ $this->employer->refresh();
+ $this->assertEquals('active', $this->employer->subscription_status);
+ $this->assertEquals($activeSub->expires_at->toDateTimeString(), $this->employer->subscription_expires_at->toDateTimeString());
+ }
+
+ public function test_pending_subscription_activates_when_active_expires()
+ {
+ // 1. Create active subscription expiring in the past
+ $expiredSub = Subscription::create([
+ 'user_id' => $this->employer->id,
+ 'plan_id' => 'basic',
+ 'amount_aed' => 99.00,
+ 'starts_at' => now()->subDays(40),
+ 'expires_at' => now()->subDays(10), // expired 10 days ago
+ 'status' => 'active',
+ ]);
+
+ // 2. Create pending subscription queued to start when expired sub ended
+ $pendingSub = Subscription::create([
+ 'user_id' => $this->employer->id,
+ 'plan_id' => 'premium',
+ 'amount_aed' => 199.00,
+ 'starts_at' => $expiredSub->expires_at,
+ 'expires_at' => $expiredSub->expires_at->copy()->addDays(30),
+ 'status' => 'pending',
+ ]);
+
+ $this->employer->update([
+ 'subscription_status' => 'active',
+ 'subscription_expires_at' => $expiredSub->expires_at,
+ ]);
+
+ // 3. Trigger check and activate routine
+ User::checkAndActivatePendingSubscriptions($this->employer->id);
+
+ // 4. Assert expired sub is set to expired
+ $expiredSub->refresh();
+ $this->assertEquals('expired', $expiredSub->status);
+
+ // 5. Assert pending sub is now active
+ $pendingSub->refresh();
+ $this->assertEquals('active', $pendingSub->status);
+
+ // 6. Assert user is active and has new expires_at set starting from now
+ $this->employer->refresh();
+ $this->assertEquals('active', $this->employer->subscription_status);
+ $this->assertTrue($this->employer->subscription_expires_at > now());
+ }
+
+ public function test_expired_subscription_restricts_access_and_redirects_to_subscription_plans()
+ {
+ // 1. Create expired subscription
+ Subscription::create([
+ 'user_id' => $this->employer->id,
+ 'plan_id' => 'basic',
+ 'amount_aed' => 99.00,
+ 'starts_at' => now()->subDays(40),
+ 'expires_at' => now()->subDays(10), // expired 10 days ago
+ 'status' => 'expired',
+ ]);
+
+ $this->employer->update([
+ 'subscription_status' => 'expired',
+ 'subscription_expires_at' => now()->subDays(10),
+ ]);
+
+ // 2. Try to view dashboard - should redirect to subscription
+ $response = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->get('/employer/dashboard');
+
+ $response->assertRedirect(route('employer.subscription'));
+
+ // 3. Accessing subscription page itself is allowed
+ $subResponse = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->get(route('employer.subscription'));
+
+ $subResponse->assertStatus(200);
+ }
+
+ public function test_contact_limit_blocks_new_conversations_but_allows_existing_ones()
+ {
+ // 1. Create a custom plan with max_contact_people = 2
+ Plan::create([
+ 'id' => 'test-plan-2',
+ 'name' => 'Test Plan 2',
+ 'price' => 10.00,
+ 'duration' => 'Monthly',
+ 'features' => ['Feature 1'],
+ 'max_contact_people' => 2,
+ 'unlimited_contacts' => false,
+ 'enable_job_apply' => true,
+ 'status' => 'Active',
+ ]);
+
+ Subscription::create([
+ 'user_id' => $this->employer->id,
+ 'plan_id' => 'test-plan-2',
+ 'amount_aed' => 10.00,
+ 'starts_at' => now(),
+ 'expires_at' => now()->addDays(30),
+ 'status' => 'active',
+ ]);
+
+ $this->employer->update([
+ 'subscription_status' => 'active',
+ 'subscription_expires_at' => now()->addDays(30),
+ ]);
+
+ // Create 3 workers
+ $worker1 = \App\Models\Worker::create(['name' => 'Worker One', 'email' => 'w1@ex.com', 'phone' => '1111111', 'nationality' => 'Indian', 'status' => 'active', 'passport_status' => 'valid', 'age' => 25, 'salary' => 1500, 'availability' => 'Immediate', 'experience' => '2 Years', 'religion' => 'Christian', 'bio' => 'Helper']);
+ $worker2 = \App\Models\Worker::create(['name' => 'Worker Two', 'email' => 'w2@ex.com', 'phone' => '2222222', 'nationality' => 'Indian', 'status' => 'active', 'passport_status' => 'valid', 'age' => 26, 'salary' => 1600, 'availability' => 'Immediate', 'experience' => '2 Years', 'religion' => 'Christian', 'bio' => 'Helper']);
+ $worker3 = \App\Models\Worker::create(['name' => 'Worker Three', 'email' => 'w3@ex.com', 'phone' => '3333333', 'nationality' => 'Indian', 'status' => 'active', 'passport_status' => 'valid', 'age' => 27, 'salary' => 1700, 'availability' => 'Immediate', 'experience' => '2 Years', 'religion' => 'Christian', 'bio' => 'Helper']);
+
+ // 2. Contact worker 1 -> Allowed
+ $response1 = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->get(route('employer.messages.start', ['workerId' => $worker1->id]));
+
+ $response1->assertRedirect();
+ $this->assertDatabaseHas('conversations', [
+ 'employer_id' => $this->employer->id,
+ 'worker_id' => $worker1->id,
+ ]);
+
+ // 3. Contact worker 2 -> Allowed
+ $response2 = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->get(route('employer.messages.start', ['workerId' => $worker2->id]));
+
+ $response2->assertRedirect();
+ $this->assertDatabaseHas('conversations', [
+ 'employer_id' => $this->employer->id,
+ 'worker_id' => $worker2->id,
+ ]);
+
+ // 4. Contact worker 3 -> Blocked (limit reached)
+ $response3 = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->get(route('employer.messages.start', ['workerId' => $worker3->id]));
+
+ $response3->assertRedirect(route('employer.subscription'));
+ $response3->assertSessionHas('error');
+ $this->assertDatabaseMissing('conversations', [
+ 'employer_id' => $this->employer->id,
+ 'worker_id' => $worker3->id,
+ ]);
+
+ // 5. Contact worker 1 again -> Allowed (already contacted)
+ $response4 = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->get(route('employer.messages.start', ['workerId' => $worker1->id]));
+
+ $response4->assertRedirect();
+ }
+
+ public function test_api_expired_subscription_blocks_access()
+ {
+ $token = 'test_api_token_123';
+ $this->employer->update([
+ 'api_token' => $token,
+ 'subscription_status' => 'expired',
+ ]);
+
+ // Access API dashboard -> Should get 403 Forbidden
+ $response = $this->getJson('/api/employers/dashboard', [
+ 'Authorization' => 'Bearer ' . $token,
+ ]);
+
+ $response->assertStatus(403);
+ $response->assertJsonPath('success', false);
+ }
+}
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 bc88625..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.
*/
@@ -605,6 +690,40 @@ public function test_sponsor_forgot_password_user_not_found()
]);
}
+ /**
+ * Test registering a sponsor with DD/MM/YYYY dates normalizes them to Y-m-d.
+ */
+ public function test_sponsor_registration_normalizes_dates()
+ {
+ $response = $this->postJson('/api/sponsors/register', [
+ 'full_name' => 'Test Sponsor Date Normalization',
+ 'mobile' => '+971509990999',
+ 'password' => 'securepassword',
+ 'organization_name' => 'Charity Date Norm',
+ 'email' => 'test.sponsor.datenorm@example.com',
+ 'nationality' => 'Emirati',
+ 'city' => 'Abu Dhabi',
+ 'address' => 'Villa 45, Street 12',
+ 'country_code' => '+971',
+ 'emirates_id' => [
+ 'emirates_id_number' => '784-1988-5310327-2',
+ 'name' => 'Test Sponsor Date Normalization',
+ 'date_of_birth' => '05/07/2002',
+ 'issue_date' => '07/06/2023',
+ 'expiry_date' => '06/06/2025',
+ ],
+ ]);
+
+ $response->assertStatus(201);
+
+ $this->assertDatabaseHas('sponsors', [
+ 'email' => 'test.sponsor.datenorm@example.com',
+ 'emirates_id_dob' => '2002-07-05',
+ 'emirates_id_issue_date' => '2023-06-07',
+ 'emirates_id_expiry' => '2025-06-06',
+ ]);
+ }
+
/**
* Test sponsor can change password successfully.
*/
diff --git a/tests/Feature/WorkerEmployersApiTest.php b/tests/Feature/WorkerEmployersApiTest.php
new file mode 100644
index 0000000..85b130e
--- /dev/null
+++ b/tests/Feature/WorkerEmployersApiTest.php
@@ -0,0 +1,407 @@
+ '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'));
+ }
+
+ public function test_worker_can_view_employer_profile_with_permissions_and_reviews()
+ {
+ // 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 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',
+ 'phone' => '+971500000001',
+ 'nationality' => 'UAE',
+ 'city' => 'Dubai',
+ 'accommodation' => 'Provided',
+ 'language' => 'English',
+ ]);
+
+ // Create active job post
+ \App\Models\JobPost::create([
+ 'employer_id' => $employer->id,
+ 'title' => 'Housekeeper Needed',
+ 'location' => 'Dubai Marina',
+ 'salary' => 2500,
+ 'workers_needed' => 1,
+ 'job_type' => 'Full Time',
+ 'start_date' => now()->addDays(5)->toDateString(),
+ 'description' => 'Clean house',
+ 'status' => 'active',
+ ]);
+
+ // Create an accepted job offer (connection exists)
+ JobOffer::create([
+ 'employer_id' => $employer->id,
+ 'worker_id' => $worker->id,
+ 'work_date' => now()->subDays(10),
+ 'location' => 'Dubai',
+ 'salary' => 2500,
+ 'status' => 'accepted',
+ ]);
+
+ // Create a review submitted by another worker
+ $otherWorker = Worker::create([
+ 'name' => 'John Doe',
+ 'email' => 'john@example.com',
+ 'phone' => '+971501234568',
+ 'password' => bcrypt('password'),
+ 'nationality' => 'Filipino',
+ 'age' => 25,
+ 'salary' => 1500,
+ 'availability' => 'Immediate',
+ 'experience' => 'Not Specified',
+ 'religion' => 'Not Specified',
+ 'bio' => 'Test',
+ 'passport_status' => 'valid',
+ ]);
+
+ \App\Models\EmployerReview::create([
+ 'worker_id' => $otherWorker->id,
+ 'employer_id' => $employer->id,
+ 'rating' => 5,
+ 'title' => 'Excellent sponsor',
+ 'comment' => 'Very polite and pays on time.',
+ ]);
+
+ // Hit the new API endpoint
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer worker-test-token',
+ ])->getJson("/api/workers/employers/{$employer->id}");
+
+ $response->assertStatus(200);
+ $response->assertJsonPath('success', true);
+ $response->assertJsonPath('data.employer.name', 'Employer One');
+ $response->assertJsonPath('data.employer.email', 'emp1@example.com'); // Contact info visible because of JobOffer connection
+ $response->assertJsonPath('data.employer.phone', '+971500000001');
+ $response->assertJsonPath('data.employer.company_name', 'Ahmad Tech Ltd');
+ $response->assertJsonPath('data.employer.rating', 5);
+ $response->assertJsonPath('data.employer.reviews_count', 1);
+ $response->assertJsonPath('data.employer.review_summary.stars.5', 1);
+ $response->assertJsonCount(1, 'data.employer.active_jobs');
+ $response->assertJsonPath('data.employer.active_jobs.0.title', 'Housekeeper Needed');
+ $response->assertJsonCount(1, 'data.reviews.data');
+ $response->assertJsonPath('data.reviews.data.0.worker.name', 'John Doe');
+ $response->assertJsonPath('data.reviews.data.0.title', 'Excellent sponsor');
+ }
+
+ public function test_worker_can_view_employer_profile_without_permissions_masks_contact()
+ {
+ // 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 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',
+ 'phone' => '+971500000001',
+ ]);
+
+ // Hit the API (no connection exists)
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer worker-test-token',
+ ])->getJson("/api/workers/employers/{$employer->id}");
+
+ $response->assertStatus(200);
+ $response->assertJsonPath('success', true);
+ $response->assertJsonPath('data.employer.name', 'Employer One');
+ $response->assertJsonPath('data.employer.email', null); // Masked/null since no connection exists
+ $response->assertJsonPath('data.employer.phone', null);
+ }
+}
diff --git a/tests/Feature/WorkerJobApiTest.php b/tests/Feature/WorkerJobApiTest.php
new file mode 100644
index 0000000..5268371
--- /dev/null
+++ b/tests/Feature/WorkerJobApiTest.php
@@ -0,0 +1,510 @@
+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',
+ ]);
+ }
+
+ 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',
+ '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',
+ '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',
+ 'location' => 'Dubai Marina',
+ 'salary' => 3000,
+ 'workers_needed' => 1,
+ 'job_type' => 'Contract',
+ 'start_date' => now()->addDays(2),
+ 'description' => 'Plumber description',
+ 'status' => 'active',
+ ]);
+
+ // 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');
+
+ // 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');
+
+ // 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',
+ ]);
+
+ // 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_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',
+ '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',
+ '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
+ ]);
+ }
+}
diff --git a/tests/Feature/WorkerJourneyApiTest.php b/tests/Feature/WorkerJourneyApiTest.php
index 47e9aa0..f978614 100644
--- a/tests/Feature/WorkerJourneyApiTest.php
+++ b/tests/Feature/WorkerJourneyApiTest.php
@@ -154,6 +154,7 @@ public function test_s1_complete_basic_profile_setup()
'nationality' => 'Indian',
'language' => 'HI',
'gender' => 'male',
+ 'main_profession' => 'Nanny',
'skills' => [],
]);
@@ -180,6 +181,7 @@ public function test_s1_complete_basic_profile_setup()
'phone' => $phone,
'language' => 'HI',
'gender' => 'male',
+ 'main_profession' => 'Nanny',
'verified' => false,
'status' => 'active',
]);
@@ -386,6 +388,7 @@ public function test_register_with_new_api_fields()
'in_country' => 'true',
'visa_status' => 'Tourist Visa',
'preferred_job_type' => 'full-time',
+ 'main_profession' => 'Housemaid',
'gender' => 'male',
'live_in_out' => 'live_out',
'preferred_location' => 'Dubai Marina',
@@ -399,6 +402,7 @@ public function test_register_with_new_api_fields()
'in_country' => true,
'visa_status' => 'Tourist Visa',
'preferred_job_type' => 'full-time',
+ 'main_profession' => 'Housemaid',
'gender' => 'male',
'live_in_out' => 'live_out',
'preferred_location' => 'Dubai Marina',
@@ -503,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', [
@@ -632,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);
}
@@ -685,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', [
diff --git a/tests/Feature/WorkerLoginReviewDataTest.php b/tests/Feature/WorkerLoginReviewDataTest.php
new file mode 100644
index 0000000..86161e9
--- /dev/null
+++ b/tests/Feature/WorkerLoginReviewDataTest.php
@@ -0,0 +1,211 @@
+worker = Worker::create([
+ 'name' => 'Rahul Sharma',
+ 'email' => 'rahul@example.com',
+ 'phone' => '+971501234567',
+ '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' => $this->worker->id,
+ 'type' => 'passport',
+ 'number' => '123456',
+ 'file_path' => 'passports/test.pdf',
+ ]);
+
+ // 2. Create an employer
+ $this->employer = User::create([
+ 'name' => 'Employer One',
+ 'email' => 'emp1@example.com',
+ 'password' => bcrypt('password'),
+ 'role' => 'employer',
+ 'api_token' => 'employer-test-token',
+ ]);
+
+ EmployerProfile::create([
+ 'user_id' => $this->employer->id,
+ 'company_name' => 'Ahmad Tech Ltd',
+ 'nationality' => 'UAE',
+ 'city' => 'Dubai',
+ 'phone' => '+971 50 123 4567',
+ 'address' => 'Dubai, UAE',
+ ]);
+
+ // 3. Create job post
+ $this->job = JobPost::create([
+ 'employer_id' => $this->employer->id,
+ 'title' => 'Test Job',
+ 'location' => 'Dubai',
+ 'salary' => 2500,
+ 'workers_needed' => 1,
+ 'job_type' => 'Full Time',
+ 'start_date' => now()->subMonths(4)->toDateString(),
+ 'description' => 'Test Job Description',
+ 'status' => 'active',
+ ]);
+ }
+
+ public function test_worker_login_and_profile_returns_review_data()
+ {
+ // Create an Employer review of this Worker
+ Review::create([
+ 'employer_id' => $this->employer->id,
+ 'worker_id' => $this->worker->id,
+ 'rating' => 4,
+ 'comment' => 'Good worker',
+ ]);
+
+ // Create a Worker review of this Employer
+ EmployerReview::create([
+ 'worker_id' => $this->worker->id,
+ 'employer_id' => $this->employer->id,
+ 'job_id' => $this->job->id,
+ 'rating' => 5,
+ 'title' => 'Nice place',
+ 'comment' => 'Great experience',
+ ]);
+
+ // Act: Worker login
+ $response = $this->postJson('/api/workers/login', [
+ 'phone' => '+971501234567',
+ 'password' => 'password'
+ ]);
+
+ $response->assertStatus(200);
+ $workerData = $response->json('data.worker');
+
+ $this->assertEquals(4.0, $workerData['rating']);
+ $this->assertEquals(1, $workerData['reviews_count']);
+ $this->assertCount(1, $workerData['reviews']);
+ $this->assertEquals('Good worker', $workerData['reviews'][0]['comment']);
+ $this->assertEquals('Employer One', $workerData['reviews'][0]['employer']['name']);
+
+ $this->assertCount(1, $workerData['employer_reviews']);
+ $this->assertEquals('Great experience', $workerData['employer_reviews'][0]['comment']);
+ $this->assertEquals('Employer One', $workerData['employer_reviews'][0]['employer']['name']);
+
+ // Act: Get worker profile
+ $token = $response->json('data.token');
+ $profileResponse = $this->withHeaders([
+ 'Authorization' => 'Bearer ' . $token,
+ ])->getJson('/api/workers/profile');
+
+ $profileResponse->assertStatus(200);
+ $profileWorker = $profileResponse->json('data.worker');
+ $this->assertEquals(4.0, $profileWorker['rating']);
+ $this->assertEquals(1, $profileWorker['reviews_count']);
+ }
+
+ public function test_worker_dashboard_and_employers_list_includes_employer_review_stats()
+ {
+ // Hired job offer to establish relationship
+ JobOffer::create([
+ 'employer_id' => $this->employer->id,
+ 'worker_id' => $this->worker->id,
+ 'work_date' => now()->subDays(10),
+ 'location' => 'Dubai',
+ 'salary' => 2500,
+ 'status' => 'accepted',
+ 'notes' => 'Current Active Job',
+ ]);
+
+ // Submit worker review of employer
+ EmployerReview::create([
+ 'worker_id' => $this->worker->id,
+ 'employer_id' => $this->employer->id,
+ 'job_id' => $this->job->id,
+ 'rating' => 5,
+ 'title' => 'Fantastic',
+ 'comment' => 'Very good',
+ ]);
+
+ // Act: Get dashboard
+ $dashboardResponse = $this->withHeaders([
+ 'Authorization' => 'Bearer worker-test-token',
+ ])->getJson('/api/workers/dashboard');
+
+ $dashboardResponse->assertStatus(200);
+ $currentEmp = $dashboardResponse->json('data.current_employer');
+ $this->assertEquals(5.0, $currentEmp['rating']);
+ $this->assertEquals(1, $currentEmp['reviews_count']);
+ $this->assertCount(1, $currentEmp['reviews']);
+ $this->assertEquals('Very good', $currentEmp['reviews'][0]['comment']);
+
+ // Act: Get employers list
+ $employersResponse = $this->withHeaders([
+ 'Authorization' => 'Bearer worker-test-token',
+ ])->getJson('/api/workers/employers');
+
+ $employersResponse->assertStatus(200);
+ $listEmp = $employersResponse->json('data.employers.0.employer');
+ $this->assertEquals(5.0, $listEmp['rating']);
+ $this->assertEquals(1, $listEmp['reviews_count']);
+ $this->assertCount(1, $listEmp['reviews']);
+ }
+
+ public function test_employer_profile_returns_review_data()
+ {
+ // Submit review of employer
+ EmployerReview::create([
+ 'worker_id' => $this->worker->id,
+ 'employer_id' => $this->employer->id,
+ 'job_id' => $this->job->id,
+ 'rating' => 5,
+ 'title' => 'Nice place',
+ 'comment' => 'Great experience',
+ ]);
+
+ // Act: Get employer profile
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer employer-test-token',
+ ])->getJson('/api/employers/profile');
+
+ $response->assertStatus(200);
+ $profile = $response->json('data.profile');
+
+ $this->assertEquals(5.0, $profile['rating']);
+ $this->assertEquals(1, $profile['reviews_count']);
+ $this->assertCount(1, $profile['reviews']);
+ $this->assertEquals('Great experience', $profile['reviews'][0]['comment']);
+ $this->assertEquals('Rahul Sharma', $profile['reviews'][0]['worker']['name']);
+ }
+}
diff --git a/tests/Feature/WorkerReviewTest.php b/tests/Feature/WorkerReviewTest.php
new file mode 100644
index 0000000..2574366
--- /dev/null
+++ b/tests/Feature/WorkerReviewTest.php
@@ -0,0 +1,304 @@
+employer = User::create([
+ 'name' => 'Test Employer',
+ 'email' => 'employer@example.com',
+ 'password' => bcrypt('password'),
+ 'role' => 'employer',
+ ]);
+
+ // 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',
+ ]);
+
+ // Create job post
+ $this->job = JobPost::create([
+ 'employer_id' => $this->employer->id,
+ 'title' => 'Test Job',
+ 'location' => 'Dubai',
+ 'salary' => 2500,
+ 'workers_needed' => 1,
+ 'job_type' => 'Full Time',
+ 'start_date' => now()->subMonths(4)->toDateString(),
+ 'description' => 'Test Job Description',
+ 'status' => 'active',
+ ]);
+ }
+
+ public function test_cannot_review_without_confirmed_hire_and_joining()
+ {
+ // Act: Try to review before any job application is made
+ $response = $this->postJson('/api/workers/reviews', [
+ 'employer_id' => $this->employer->id,
+ 'job_id' => $this->job->id,
+ 'rating' => 5,
+ 'comment' => 'Great employer!',
+ ], [
+ 'Authorization' => 'Bearer worker_api_token'
+ ]);
+
+ $response->assertStatus(403);
+ $response->assertJsonPath('success', false);
+ $response->assertJsonPath('message', 'You can only review employers with a confirmed hire and completed joining.');
+ }
+
+ public function test_cannot_review_before_3_months_since_joining()
+ {
+ // Setup job application with hired status but joining_confirmed_at is only 2 months ago
+ JobApplication::create([
+ 'job_id' => $this->job->id,
+ 'worker_id' => $this->worker->id,
+ 'status' => 'hired',
+ 'joining_confirmed_at' => now()->subMonths(2)->toDateString(),
+ ]);
+
+ // Act: Try to review
+ $response = $this->postJson('/api/workers/reviews', [
+ 'employer_id' => $this->employer->id,
+ 'job_id' => $this->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.');
+ }
+
+ public function test_can_review_after_3_months_since_joining()
+ {
+ // Setup job application with hired status and joining_confirmed_at 3.5 months ago
+ JobApplication::create([
+ 'job_id' => $this->job->id,
+ 'worker_id' => $this->worker->id,
+ 'status' => 'hired',
+ 'joining_confirmed_at' => now()->subMonths(3)->subDays(15)->toDateString(),
+ ]);
+
+ // Act: Post review
+ $response = $this->postJson('/api/workers/reviews', [
+ 'employer_id' => $this->employer->id,
+ 'job_id' => $this->job->id,
+ 'rating' => 4,
+ 'title' => 'Fair Employer',
+ 'comment' => 'Very good experience overall.',
+ ], [
+ 'Authorization' => 'Bearer worker_api_token'
+ ]);
+
+ $response->assertStatus(201);
+ $response->assertJsonPath('success', true);
+ $response->assertJsonPath('data.review.rating', 4);
+ $response->assertJsonPath('data.review.title', 'Fair Employer');
+ $response->assertJsonPath('data.review.comment', 'Very good experience overall.');
+
+ $this->assertDatabaseHas('employer_reviews', [
+ 'worker_id' => $this->worker->id,
+ 'employer_id' => $this->employer->id,
+ 'job_id' => $this->job->id,
+ 'rating' => 4,
+ 'title' => 'Fair Employer',
+ ]);
+ }
+
+ public function test_cannot_duplicate_reviews()
+ {
+ // Setup job application
+ JobApplication::create([
+ 'job_id' => $this->job->id,
+ 'worker_id' => $this->worker->id,
+ 'status' => 'hired',
+ 'joining_confirmed_at' => now()->subMonths(4)->toDateString(),
+ ]);
+
+ // Submit first review
+ EmployerReview::create([
+ 'worker_id' => $this->worker->id,
+ 'employer_id' => $this->employer->id,
+ 'job_id' => $this->job->id,
+ 'rating' => 5,
+ 'comment' => 'Nice',
+ ]);
+
+ // Act: Try to submit duplicate review
+ $response = $this->postJson('/api/workers/reviews', [
+ 'employer_id' => $this->employer->id,
+ 'job_id' => $this->job->id,
+ 'rating' => 4,
+ 'comment' => 'Another review',
+ ], [
+ 'Authorization' => 'Bearer worker_api_token'
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonPath('success', false);
+ $response->assertJsonPath('message', 'You have already submitted a review for this job/employer.');
+ }
+
+ public function test_edit_review_within_7_days()
+ {
+ // Setup existing review created now
+ $review = EmployerReview::create([
+ 'worker_id' => $this->worker->id,
+ 'employer_id' => $this->employer->id,
+ 'job_id' => $this->job->id,
+ 'rating' => 3,
+ 'title' => 'Initial Title',
+ 'comment' => 'Initial Comment',
+ ]);
+
+ // Act: Edit the review
+ $response = $this->putJson("/api/workers/reviews/{$review->id}", [
+ 'rating' => 5,
+ 'title' => 'Updated Title',
+ 'comment' => 'Updated Comment Details',
+ ], [
+ 'Authorization' => 'Bearer worker_api_token'
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJsonPath('success', true);
+ $response->assertJsonPath('data.review.rating', 5);
+ $response->assertJsonPath('data.review.title', 'Updated Title');
+ $response->assertJsonPath('data.review.comment', 'Updated Comment Details');
+
+ $this->assertDatabaseHas('employer_reviews', [
+ 'id' => $review->id,
+ 'rating' => 5,
+ 'title' => 'Updated Title',
+ ]);
+ }
+
+ public function test_cannot_edit_review_after_7_days()
+ {
+ // Setup existing review created 8 days ago
+ $review = EmployerReview::create([
+ 'worker_id' => $this->worker->id,
+ 'employer_id' => $this->employer->id,
+ 'job_id' => $this->job->id,
+ 'rating' => 3,
+ 'comment' => 'Initial Comment',
+ ]);
+
+ // Force creation date to be 8 days ago
+ $review->created_at = now()->subDays(8);
+ $review->save();
+
+ // Act: Attempt to edit
+ $response = $this->putJson("/api/workers/reviews/{$review->id}", [
+ 'rating' => 5,
+ 'comment' => 'Attempted Edit',
+ ], [
+ '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_view_and_list_reviews()
+ {
+ // Create a review
+ $review = EmployerReview::create([
+ 'worker_id' => $this->worker->id,
+ 'employer_id' => $this->employer->id,
+ 'job_id' => $this->job->id,
+ 'rating' => 5,
+ 'title' => 'Awesome place',
+ 'comment' => 'Had a wonderful time.',
+ ]);
+
+ // 1. Get Single Review
+ $response = $this->getJson("/api/workers/reviews/{$review->id}", [
+ 'Authorization' => 'Bearer worker_api_token'
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJsonPath('success', true);
+ $response->assertJsonPath('data.review.title', 'Awesome place');
+ $response->assertJsonPath('data.review.rating', 5);
+
+ // 2. List Reviews
+ $response = $this->getJson('/api/workers/reviews', [
+ 'Authorization' => 'Bearer worker_api_token'
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJsonPath('success', true);
+ $response->assertJsonCount(1, 'data.reviews');
+ $response->assertJsonPath('data.reviews.0.title', 'Awesome place');
+ }
+
+ public function test_review_with_invalid_or_missing_job_id_passes_and_saves_null()
+ {
+ // Setup job application with hired status and joining_confirmed_at 3.5 months ago
+ JobApplication::create([
+ 'job_id' => $this->job->id,
+ 'worker_id' => $this->worker->id,
+ 'status' => 'hired',
+ 'joining_confirmed_at' => now()->subMonths(3)->subDays(15)->toDateString(),
+ ]);
+
+ // Act: Post review with an invalid job_id (e.g. 'invalid-id-or-empty')
+ $response = $this->postJson('/api/workers/reviews', [
+ 'employer_id' => $this->employer->id,
+ 'job_id' => 'invalid-id-or-empty',
+ 'rating' => 4,
+ 'title' => 'Fair Employer',
+ 'comment' => 'Very good experience overall.',
+ ], [
+ 'Authorization' => 'Bearer worker_api_token'
+ ]);
+
+ $response->assertStatus(201);
+ $response->assertJsonPath('success', true);
+ $response->assertJsonPath('data.review.job_id', null); // Resolved to null
+
+ $this->assertDatabaseHas('employer_reviews', [
+ 'worker_id' => $this->worker->id,
+ 'employer_id' => $this->employer->id,
+ 'job_id' => null,
+ 'rating' => 4,
+ ]);
+ }
+}