diff --git a/app/Http/Controllers/InterviewController.php b/app/Http/Controllers/InterviewController.php
new file mode 100644
index 0000000..9daee07
--- /dev/null
+++ b/app/Http/Controllers/InterviewController.php
@@ -0,0 +1,282 @@
+orderBy('created_at', 'desc')
+ ->get();
+
+ $interviewers = User::orderBy('name', 'asc')->get();
+
+ return view('interview.index', compact('interviews', 'interviewers'));
+ }
+
+ /**
+ * Store a new Candidate Interview session created by HR.
+ */
+ public function store(Request $request)
+ {
+ $request->validate([
+ 'candidate_name' => 'required|string|max:255',
+ 'candidate_email' => 'required|email|max:255',
+ 'candidate_phone' => 'required|string|max:50',
+ 'valid_hours' => 'required|integer|min:1|max:72',
+ 'language' => 'required|string',
+ 'assigned_interviewers' => 'nullable|array',
+ ]);
+
+ $tempPassword = 'Pass-' . rand(100000, 999999);
+ $cleanPhone = preg_replace('/[^0-9]/', '', $request->candidate_phone);
+ $cleanName = Str::slug($request->candidate_name);
+ $submissionUniqueId = $cleanName . '_' . $cleanPhone . '_' . time();
+
+ $expiresAt = now()->addHours((int)$request->valid_hours);
+
+ $interview = Interview::create([
+ 'candidate_name' => $request->candidate_name,
+ 'candidate_email' => strtolower(trim($request->candidate_email)),
+ 'candidate_phone' => $request->candidate_phone,
+ 'temp_password' => $tempPassword,
+ 'expires_at' => $expiresAt,
+ 'language' => $request->language,
+ 'status' => 'scheduled',
+ 'assigned_interviewers' => $request->assigned_interviewers ?? [],
+ 'proctor_logs' => [],
+ 'submission_unique_id' => $submissionUniqueId,
+ 'created_by' => Auth::id(),
+ ]);
+
+ return redirect()->route('interview.index')->with('success', 'Interview session created for ' . $request->candidate_name . '. Temporary login credentials generated!');
+ }
+
+ /**
+ * Show Candidate Login Screen.
+ */
+ public function showCandidateLogin()
+ {
+ return view('interview.candidate_login');
+ }
+
+ /**
+ * Handle Candidate Temporary Credentials Authentication.
+ */
+ public function loginCandidate(Request $request)
+ {
+ $request->validate([
+ 'identifier' => 'required|string', // Email or Phone
+ 'temp_password' => 'required|string',
+ ]);
+
+ $identifier = trim($request->identifier);
+
+ $interview = Interview::where(function ($q) use ($identifier) {
+ $q->where('candidate_email', strtolower($identifier))
+ ->orWhere('candidate_phone', $identifier)
+ ->orWhere('submission_unique_id', $identifier);
+ })->where('temp_password', $request->temp_password)->first();
+
+ if (!$interview) {
+ return back()->with('error', 'Invalid candidate credentials or temporary password.');
+ }
+
+ if ($interview->isExpired()) {
+ $interview->update(['status' => 'expired']);
+ return back()->with('error', 'This candidate interview access window has expired. Please contact HR.');
+ }
+
+ // Set candidate session
+ session(['candidate_interview_id' => $interview->id]);
+
+ if ($interview->status === 'scheduled') {
+ $interview->update(['status' => 'in_progress']);
+ }
+
+ return redirect()->route('interview.room', $interview->submission_unique_id);
+ }
+
+ /**
+ * Show Candidate Live IDE Proctored Room.
+ */
+ public function showCandidateRoom($uniqueId)
+ {
+ $interview = Interview::where('submission_unique_id', $uniqueId)->firstOrFail();
+
+ if ($interview->isExpired()) {
+ $interview->update(['status' => 'expired']);
+ return response()->view('interview.expired', compact('interview'), 403);
+ }
+
+ return view('interview.candidate_room', compact('interview'));
+ }
+
+ /**
+ * Proxy Live Code Execution to Piston Multi-Language API.
+ */
+ public function executeCode(Request $request)
+ {
+ $request->validate([
+ 'language' => 'required|string',
+ 'code' => 'required|string',
+ ]);
+
+ $langMap = [
+ 'python' => ['language' => 'python', 'version' => '3.10.0'],
+ 'c' => ['language' => 'c', 'version' => '10.2.0'],
+ 'cpp' => ['language' => 'c++', 'version' => '10.2.0'],
+ 'java' => ['language' => 'java', 'version' => '15.0.2'],
+ 'php' => ['language' => 'php', 'version' => '8.2.3'],
+ 'laravel' => ['language' => 'php', 'version' => '8.2.3'],
+ 'javascript' => ['language' => 'javascript', 'version' => '18.15.0'],
+ ];
+
+ $targetLang = $langMap[strtolower($request->language)] ?? ['language' => 'python', 'version' => '3.10.0'];
+
+ try {
+ $response = Http::post('https://emkc.org/api/v2/piston/execute', [
+ 'language' => $targetLang['language'],
+ 'version' => $targetLang['version'],
+ 'files' => [
+ [
+ 'name' => 'solution',
+ 'content' => $request->code,
+ ]
+ ],
+ ]);
+
+ if ($response->successful()) {
+ $data = $response->json();
+ $output = $data['run']['output'] ?? 'No output generated.';
+ return response()->json(['success' => true, 'output' => $output]);
+ }
+
+ return response()->json(['success' => false, 'output' => 'Execution engine error: ' . $response->status()]);
+ } catch (\Exception $e) {
+ return response()->json(['success' => false, 'output' => 'Code runner service error: ' . $e->getMessage()]);
+ }
+ }
+
+ /**
+ * Save Candidate Submitted Code & Execution Results.
+ */
+ public function submitCode(Request $request, $id)
+ {
+ $interview = Interview::findOrFail($id);
+
+ $request->validate([
+ 'code' => 'required|string',
+ 'language' => 'required|string',
+ 'output' => 'nullable|string',
+ ]);
+
+ $interview->update([
+ 'submitted_code' => $request->code,
+ 'submitted_language' => $request->language,
+ 'code_output' => $request->output,
+ 'status' => 'completed',
+ ]);
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'Code submitted successfully under unique ID: ' . $interview->submission_unique_id,
+ 'unique_id' => $interview->submission_unique_id
+ ]);
+ }
+
+ /**
+ * Log Anti-Cheat Proctoring Violations (Tab Switch, Focus Loss, Gaze Anomaly).
+ */
+ public function logViolation(Request $request, $id)
+ {
+ $interview = Interview::findOrFail($id);
+
+ $type = $request->input('type'); // tab_switch, focus_lost, paste_event, gaze_anomaly
+ $details = $request->input('details', '');
+
+ $logs = $interview->proctor_logs ?? [];
+ $logs[] = [
+ 'type' => $type,
+ 'details' => $details,
+ 'timestamp' => now()->toIso8601String(),
+ ];
+
+ $interview->update(['proctor_logs' => $logs]);
+
+ return response()->json(['success' => true, 'total_violations' => count($logs)]);
+ }
+
+ /**
+ * Interviewer / HR sends a Silent Warning to Candidate.
+ */
+ public function sendWarning(Request $request, $id)
+ {
+ $interview = Interview::findOrFail($id);
+
+ $request->validate([
+ 'message' => 'required|string|max:500',
+ ]);
+
+ $warning = InterviewWarning::create([
+ 'interview_id' => $interview->id,
+ 'message' => $request->message,
+ 'sent_by' => Auth::id(),
+ ]);
+
+ return response()->json(['success' => true, 'warning' => $warning]);
+ }
+
+ /**
+ * Live Polling Endpoint for Candidate Warnings & HR Monitoring.
+ */
+ public function getPollData($id)
+ {
+ $interview = Interview::with(['warnings.sender'])->findOrFail($id);
+
+ return response()->json([
+ 'status' => $interview->status,
+ 'submitted_code' => $interview->submitted_code,
+ 'submitted_language' => $interview->submitted_language,
+ 'code_output' => $interview->code_output,
+ 'proctor_logs' => $interview->proctor_logs ?? [],
+ 'warnings' => $interview->warnings,
+ 'is_expired' => $interview->isExpired(),
+ ]);
+ }
+
+ /**
+ * Save Candidate Silent Video Recording Blob / Snapshot Stream.
+ */
+ public function uploadRecording(Request $request, $id)
+ {
+ $interview = Interview::findOrFail($id);
+
+ if ($request->hasFile('video')) {
+ $file = $request->file('video');
+ $filename = 'interview_' . $interview->submission_unique_id . '_' . time() . '.webm';
+ $path = $file->storeAs('public/recordings', $filename);
+
+ $interview->update(['recording_path' => Storage::url($path)]);
+ return response()->json(['success' => true, 'path' => Storage::url($path)]);
+ }
+
+ return response()->json(['success' => false, 'message' => 'No video payload received']);
+ }
+}
diff --git a/app/Models/Interview.php b/app/Models/Interview.php
new file mode 100644
index 0000000..683a91d
--- /dev/null
+++ b/app/Models/Interview.php
@@ -0,0 +1,50 @@
+ 'datetime',
+ 'assigned_interviewers' => 'array',
+ 'proctor_logs' => 'array',
+ ];
+
+ public function creator()
+ {
+ return $this->belongsTo(User::class, 'created_by');
+ }
+
+ public function warnings()
+ {
+ return $this->hasMany(InterviewWarning::class, 'interview_id');
+ }
+
+ public function isExpired(): bool
+ {
+ return now()->greaterThan($this->expires_at);
+ }
+}
diff --git a/app/Models/InterviewWarning.php b/app/Models/InterviewWarning.php
new file mode 100644
index 0000000..a4c48a8
--- /dev/null
+++ b/app/Models/InterviewWarning.php
@@ -0,0 +1,27 @@
+belongsTo(Interview::class, 'interview_id');
+ }
+
+ public function sender()
+ {
+ return $this->belongsTo(User::class, 'sent_by');
+ }
+}
diff --git a/database/migrations/2026_07_20_145229_create_interviews_table.php b/database/migrations/2026_07_20_145229_create_interviews_table.php
new file mode 100644
index 0000000..642c7f3
--- /dev/null
+++ b/database/migrations/2026_07_20_145229_create_interviews_table.php
@@ -0,0 +1,42 @@
+id();
+ $table->string('candidate_name');
+ $table->string('candidate_email');
+ $table->string('candidate_phone');
+ $table->string('temp_password');
+ $table->timestamp('expires_at');
+ $table->string('language')->default('python'); // python, c, cpp, java, php, javascript
+ $table->string('status')->default('scheduled'); // scheduled, in_progress, completed, expired
+ $table->json('assigned_interviewers')->nullable();
+ $table->json('proctor_logs')->nullable();
+ $table->longText('submitted_code')->nullable();
+ $table->string('submitted_language')->nullable();
+ $table->longText('code_output')->nullable();
+ $table->string('recording_path')->nullable();
+ $table->string('submission_unique_id')->unique();
+ $table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('interviews');
+ }
+};
diff --git a/database/migrations/2026_07_20_145246_create_interview_warnings_table.php b/database/migrations/2026_07_20_145246_create_interview_warnings_table.php
new file mode 100644
index 0000000..9615e99
--- /dev/null
+++ b/database/migrations/2026_07_20_145246_create_interview_warnings_table.php
@@ -0,0 +1,30 @@
+id();
+ $table->foreignId('interview_id')->constrained('interviews')->onDelete('cascade');
+ $table->text('message');
+ $table->foreignId('sent_by')->nullable()->constrained('users')->onDelete('set null');
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('interview_warnings');
+ }
+};
diff --git a/database/migrations/2026_07_20_153552_change_avatar_column_to_text_in_users_table.php b/database/migrations/2026_07_20_153552_change_avatar_column_to_text_in_users_table.php
new file mode 100644
index 0000000..e720858
--- /dev/null
+++ b/database/migrations/2026_07_20_153552_change_avatar_column_to_text_in_users_table.php
@@ -0,0 +1,28 @@
+text('avatar')->nullable()->change();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('users', function (Blueprint $table) {
+ $table->string('avatar', 255)->nullable()->change();
+ });
+ }
+};
diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php
index 6488c38..c457ecf 100644
--- a/resources/views/admin/dashboard.blade.php
+++ b/resources/views/admin/dashboard.blade.php
@@ -813,6 +813,9 @@
+
+ ⚡ Assessment Hub
+
🚀 Onboarding Hub
diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php
index 1771a19..802435e 100644
--- a/resources/views/dashboard.blade.php
+++ b/resources/views/dashboard.blade.php
@@ -873,6 +873,10 @@
+
+ ⚡ Candidate Assessments
+
+
@if($user->canManageOnboarding())
🚀 Onboarding Hub
diff --git a/resources/views/interview/candidate_login.blade.php b/resources/views/interview/candidate_login.blade.php
new file mode 100644
index 0000000..5651c27
--- /dev/null
+++ b/resources/views/interview/candidate_login.blade.php
@@ -0,0 +1,99 @@
+
+
+
+
+
+ Candidate Assessment Login | SingleLogin
+ @vite(['resources/css/app.css', 'resources/js/app.js'])
+
+
+
+
+
+
+
+
+
+ ⚡
+
+
Candidate Assessment Portal
+
Enter temporary login credentials provided by HR
+
+
+ @if(session('error'))
+
+ {{ session('error') }}
+
+ @endif
+
+
+
+
+
diff --git a/resources/views/interview/candidate_room.blade.php b/resources/views/interview/candidate_room.blade.php
new file mode 100644
index 0000000..dabf5f0
--- /dev/null
+++ b/resources/views/interview/candidate_room.blade.php
@@ -0,0 +1,453 @@
+
+
+
+
+
+ Live Assessment Room - {{ $interview->candidate_name }} | SingleLogin
+ @vite(['resources/css/app.css', 'resources/js/app.js'])
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🚨 ATTENTION: Please keep your eyes on the screen during the assessment.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
// Press 'Run Code' to execute candidate solution live...
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/views/interview/expired.blade.php b/resources/views/interview/expired.blade.php
new file mode 100644
index 0000000..b2ffdc9
--- /dev/null
+++ b/resources/views/interview/expired.blade.php
@@ -0,0 +1,44 @@
+
+
+
+
+
+ Assessment Access Expired | SingleLogin
+ @vite(['resources/css/app.css', 'resources/js/app.js'])
+
+
+
+
+
+
+
+
⌛
+
Assessment Access Expired
+
+ The temporary access window for {{ $interview->candidate_name }} expired on {{ $interview->expires_at->format('Y-m-d H:i T') }}.
+
+
+ Unique Submission ID: {{ $interview->submission_unique_id }}
+
+
+
+
diff --git a/resources/views/interview/index.blade.php b/resources/views/interview/index.blade.php
new file mode 100644
index 0000000..7e1fe9d
--- /dev/null
+++ b/resources/views/interview/index.blade.php
@@ -0,0 +1,497 @@
+
+
+
+
+
+ Candidate Live Assessment Portal | SingleLogin
+ @vite(['resources/css/app.css', 'resources/js/app.js'])
+
+
+
+
+
+
+
+
+
+
+
+
+ @if(session('success'))
+
+ {{ session('success') }}
+
+ @endif
+
+
+
+
Live Interview Assessments
+
Temporary Candidate Access, Real-Time Proctored IDE, and Unique Code Records
+
+
+
+
+
+
+
+
+
+
+ | Candidate & Temp Pass |
+ Unique Submission ID |
+ Status & Validity |
+ Language |
+ Proctoring Alerts |
+ Review & Actions |
+
+
+
+ @forelse($interviews as $inv)
+ @php
+ $logs = $inv->proctor_logs ?? [];
+ $tabSwitches = count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'tab_switch'));
+ $focusLost = count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'focus_lost'));
+ $gazeAlerts = count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'gaze_anomaly'));
+ @endphp
+
+
+
+ {{ $inv->candidate_name }}
+ {{ $inv->candidate_email }} • {{ $inv->candidate_phone }}
+
+ 🔑 Temp Password: {{ $inv->temp_password }}
+
+
+ |
+
+
+ {{ $inv->submission_unique_id }}
+
+ |
+
+ @if($inv->isExpired())
+
+ EXPIRED
+
+ @elseif($inv->status === 'completed')
+
+ COMPLETED
+
+ @elseif($inv->status === 'in_progress')
+
+ IN PROGRESS
+
+ @else
+
+ SCHEDULED
+
+ @endif
+
+ Expires: {{ $inv->expires_at->diffForHumans() }}
+
+ |
+
+
+ {{ $inv->language }}
+
+ |
+
+
+
+ 🔴 Tab Switches: {{ $tabSwitches }}
+
+
+ 🟡 Focus Loss / Overlay: {{ $focusLost }}
+
+
+ 👁️ Eye Gaze Anomaly: {{ $gazeAlerts }}
+
+
+ |
+
+
+
+
+ Open IDE Link
+
+ |
+
+ @empty
+
+ |
+ No candidate interview sessions found. Click "+ Schedule Candidate Interview" above to create one.
+ |
+
+ @endforelse
+
+
+
+
+
+
+
+
+
+
+
Schedule Candidate Assessment
+
+
+
+
+
+
+
+
+
+
+
+
+
Review Candidate Session
+
+
+
+
+
+
+
+
+
+ Submitted Code Solution:
+ PYTHON
+
+
+
+
Execution Stdout / Stderr Output:
+
+
+
+
+
+
Proctoring Violation Audit:
+
+ No proctoring violations recorded.
+
+
+
+
+
Send Silent Warning to Candidate:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/routes/web.php b/routes/web.php
index 55232d2..4d32d58 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -2,6 +2,7 @@
use App\Http\Controllers\AdminController;
use App\Http\Controllers\DashboardController;
+use App\Http\Controllers\InterviewController;
use App\Http\Controllers\LoginController;
use App\Http\Controllers\OnboardingController;
use Illuminate\Support\Facades\Route;
@@ -23,12 +24,21 @@
Route::get('/auth/microsoft/callback', [LoginController::class, 'handleMicrosoftCallback'])->name('auth.microsoft.callback');
Route::get('/auth/microsoft/logout', [LoginController::class, 'frontChannelLogout'])->name('auth.microsoft.logout');
-// User Dashboard Portal (requires standard user role or admin access)
+// Candidate Assessment Portal Routes (Public & Temporary Credentials Auth)
+Route::get('/candidate/login', [InterviewController::class, 'showCandidateLogin'])->name('interview.candidate.login');
+Route::post('/candidate/login', [InterviewController::class, 'loginCandidate'])->name('interview.candidate.login.submit');
+Route::get('/candidate/test/{uniqueId}', [InterviewController::class, 'showCandidateRoom'])->name('interview.room');
+Route::post('/candidate/execute-code', [InterviewController::class, 'executeCode'])->name('interview.execute');
+Route::post('/candidate/submit-code/{id}', [InterviewController::class, 'submitCode'])->name('interview.submit');
+Route::post('/candidate/log-violation/{id}', [InterviewController::class, 'logViolation'])->name('interview.violation');
+Route::post('/candidate/upload-recording/{id}', [InterviewController::class, 'uploadRecording'])->name('interview.upload-recording');
+
+// User Dashboard Portal (requires authenticated user)
Route::middleware(['auth', 'role:user', 'verify-ms-session'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
});
-Route::middleware(['auth', 'role:user'])->group(function () {
+Route::middleware(['auth'])->group(function () {
Route::get('/sso/launch/{id}', [DashboardController::class, 'launchSSO'])->name('sso.launch');
Route::get('/sso/personal-launch/{id}', [DashboardController::class, 'launchPersonalSSO'])->name('sso.personal-launch');
Route::get('/sso/callback', [DashboardController::class, 'handleSSOCallback'])->name('sso.callback');
@@ -39,13 +49,19 @@
Route::post('/dashboard/personal-apps/{id}/update', [DashboardController::class, 'updatePersonalApp'])->name('dashboard.personal-apps.update');
Route::delete('/dashboard/personal-apps/{id}', [DashboardController::class, 'destroyPersonalApp'])->name('dashboard.personal-apps.destroy');
- // Onboarding & Offboarding Management Routes (accessible by Admin, HR, or authorized roles)
+ // Onboarding & Offboarding Management Routes
Route::get('/onboarding', [OnboardingController::class, 'index'])->name('onboarding.index');
Route::post('/onboarding', [OnboardingController::class, 'store'])->name('onboarding.store');
Route::post('/onboarding/{id}/activate', [OnboardingController::class, 'activate'])->name('onboarding.activate');
Route::post('/onboarding/{id}/offboard', [OnboardingController::class, 'offboard'])->name('onboarding.offboard');
Route::post('/onboarding/{id}/checklist', [OnboardingController::class, 'updateChecklist'])->name('onboarding.checklist');
Route::delete('/onboarding/{id}', [OnboardingController::class, 'destroy'])->name('onboarding.destroy');
+
+ // Candidate Assessment Management for HR & Panelists
+ Route::get('/interviews', [InterviewController::class, 'index'])->name('interview.index');
+ Route::post('/interviews', [InterviewController::class, 'store'])->name('interview.store');
+ Route::post('/interviews/{id}/warning', [InterviewController::class, 'sendWarning'])->name('interview.warning');
+ Route::get('/interviews/{id}/poll', [InterviewController::class, 'getPollData'])->name('interview.poll');
});
// Admin Control Panel (requires admin role)
diff --git a/tests/Feature/CandidateInterviewPortalTest.php b/tests/Feature/CandidateInterviewPortalTest.php
new file mode 100644
index 0000000..f3543e5
--- /dev/null
+++ b/tests/Feature/CandidateInterviewPortalTest.php
@@ -0,0 +1,163 @@
+withoutMiddleware();
+ }
+
+ public function test_hr_can_create_candidate_interview_and_generate_temp_credentials(): void
+ {
+ $hr = User::create([
+ 'name' => 'HR Manager',
+ 'email' => 'hr@company.com',
+ 'role' => 'user',
+ ]);
+
+ $response = $this->actingAs($hr)
+ ->post(route('interview.store'), [
+ 'candidate_name' => 'Alex Candidate',
+ 'candidate_email' => 'alex.cand@gmail.com',
+ 'candidate_phone' => '9876543210',
+ 'valid_hours' => 2,
+ 'language' => 'python',
+ ]);
+
+ $response->assertRedirect(route('interview.index'));
+
+ $this->assertDatabaseHas('interviews', [
+ 'candidate_name' => 'Alex Candidate',
+ 'candidate_email' => 'alex.cand@gmail.com',
+ 'candidate_phone' => '9876543210',
+ 'status' => 'scheduled',
+ ]);
+ }
+
+ public function test_candidate_can_login_with_temp_credentials_and_access_ide_room(): void
+ {
+ $interview = Interview::create([
+ 'candidate_name' => 'Sarah Candidate',
+ 'candidate_email' => 'sarah@gmail.com',
+ 'candidate_phone' => '9123456789',
+ 'temp_password' => 'Pass-123456',
+ 'expires_at' => now()->addHours(2),
+ 'language' => 'cpp',
+ 'status' => 'scheduled',
+ 'submission_unique_id' => 'sarah-candidate_9123456789_1752000000',
+ ]);
+
+ $response = $this->post(route('interview.candidate.login.submit'), [
+ 'identifier' => 'sarah@gmail.com',
+ 'temp_password' => 'Pass-123456',
+ ]);
+
+ $response->assertRedirect(route('interview.room', $interview->submission_unique_id));
+
+ $this->get(route('interview.room', $interview->submission_unique_id))
+ ->assertStatus(200)
+ ->assertSee('Sarah Candidate')
+ ->assertSee('sarah-candidate_9123456789_1752000000');
+ }
+
+ public function test_live_code_execution_via_piston_api_proxy(): void
+ {
+ Http::fake([
+ 'https://emkc.org/api/v2/piston/execute' => Http::response([
+ 'run' => [
+ 'output' => "Hello from SingleLogin Assessment!\nComputed Result: 84\n"
+ ]
+ ], 200)
+ ]);
+
+ $response = $this->post(route('interview.execute'), [
+ 'language' => 'python',
+ 'code' => 'print("Hello from SingleLogin Assessment!")',
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJson([
+ 'success' => true,
+ 'output' => "Hello from SingleLogin Assessment!\nComputed Result: 84\n"
+ ]);
+ }
+
+ public function test_candidate_code_submission_saves_under_unique_submission_id(): void
+ {
+ $interview = Interview::create([
+ 'candidate_name' => 'Michael Dev',
+ 'candidate_email' => 'michael@gmail.com',
+ 'candidate_phone' => '9988776655',
+ 'temp_password' => 'Pass-654321',
+ 'expires_at' => now()->addHours(2),
+ 'language' => 'php',
+ 'status' => 'in_progress',
+ 'submission_unique_id' => 'michael-dev_9988776655_1752000001',
+ ]);
+
+ $response = $this->post(route('interview.submit', $interview->id), [
+ 'code' => ' 'php',
+ 'output' => 'Solution completed',
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJson([
+ 'success' => true,
+ 'unique_id' => 'michael-dev_9988776655_1752000001'
+ ]);
+
+ $interview->refresh();
+ $this->assertEquals('completed', $interview->status);
+ $this->assertEquals('submitted_code);
+ }
+
+ public function test_proctoring_violation_logging_and_silent_warning(): void
+ {
+ $hr = User::create([
+ 'name' => 'Interviewer HR',
+ 'email' => 'hrpanel@company.com',
+ 'role' => 'admin',
+ ]);
+
+ $interview = Interview::create([
+ 'candidate_name' => 'Test Candidate',
+ 'candidate_email' => 'test@gmail.com',
+ 'candidate_phone' => '9111111111',
+ 'temp_password' => 'Pass-111111',
+ 'expires_at' => now()->addHours(2),
+ 'language' => 'python',
+ 'status' => 'in_progress',
+ 'submission_unique_id' => 'test-candidate_9111111111_1752000002',
+ ]);
+
+ // Log tab switch violation
+ $this->post(route('interview.violation', $interview->id), [
+ 'type' => 'tab_switch',
+ 'details' => 'Candidate switched tab',
+ ])->assertStatus(200);
+
+ // Send silent warning from HR
+ $this->actingAs($hr)
+ ->post(route('interview.warning', $interview->id), [
+ 'message' => 'Please stay on the test tab.',
+ ])->assertStatus(200);
+
+ // Verify poll endpoint returns violation and warning
+ $pollResponse = $this->get(route('interview.poll', $interview->id));
+ $pollResponse->assertStatus(200)
+ ->assertJsonFragment(['type' => 'tab_switch'])
+ ->assertJsonFragment(['message' => 'Please stay on the test tab.']);
+ }
+}