sls/tests/Feature/CandidateInterviewPortalTest.php
kushal.saha e4fabef1d3 feat(interview): add icalendar invites, white date pickers, and drawer validation feedback
- integrate spatie/icalendar-generator for candidate & panelist .ics invitations (RSVP & 15m alerts)
    - add StoreInterviewRequest, ScheduleInterviewDto, and invitation/reminder queued actions
    - add migration for job_title, round, description, and reminder_sent_at on interviews table
    - style datetime-local picker indicators to pure white on dark input backgrounds
    - support button loading spinners with :showLoader="true" and loadingText
    - auto-open schedule drawer on validation failure with inline @error alerts and old() bindings
2026-08-27 10:16:46 +00:00

466 lines
19 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Interview;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class CandidateInterviewPortalTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->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',
'job_title' => 'Software Engineer',
'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_judge0_api_proxy(): void
{
Http::fake([
'https://ce.judge0.com/submissions*' => Http::response([
'stdout' => "Hello from SingleLogin Assessment!\nComputed Result: 84\n",
'stderr' => null,
'compile_output' => null,
'status' => [
'id' => 3,
'description' => 'Accepted',
],
], 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 echo "Solution completed";',
'language' => '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('<?php echo "Solution completed";', $interview->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.']);
}
public function test_candidate_login_page_renders_dedicated_route(): void
{
$response = $this->get(route('interview.candidate.login'));
$response->assertStatus(200)
->assertSee('Candidate Assessment Portal')
->assertSee('Enter temporary login credentials provided by HR');
}
public function test_interview_zone_tab_visibility_for_assigned_interviewer_during_active_timeline(): void
{
$interviewer = User::create([
'name' => 'Jane Interviewer',
'email' => 'jane@company.com',
'role' => 'user',
]);
$unassignedUser = User::create([
'name' => 'Bob Developer',
'email' => 'bob@company.com',
'role' => 'user',
]);
// Before creating an assigned interview, neither has active interview zone
$this->assertFalse($interviewer->hasActiveInterviewZone());
$this->assertFalse($unassignedUser->hasActiveInterviewZone());
// Create an interview with timeline scheduled now + 2 hours valid, assigning Jane Interviewer
$interview = Interview::create([
'candidate_name' => 'John Candidate',
'candidate_email' => 'john.cand@gmail.com',
'candidate_phone' => '9888877777',
'temp_password' => 'Pass-999888',
'scheduled_at' => now(),
'expires_at' => now()->addHours(2),
'language' => 'python',
'status' => 'scheduled',
'assigned_interviewers' => [$interviewer->id],
'submission_unique_id' => 'john-candidate_9888877777_1752000003',
]);
// Assigned interviewer should have active interview zone during active timeline
$this->assertTrue($interviewer->hasActiveInterviewZone());
$this->assertFalse($unassignedUser->hasActiveInterviewZone());
// After timeline expires, the temporary Interview Zone tab is no longer active
$interview->update(['expires_at' => now()->subMinute()]);
$this->assertFalse($interviewer->fresh()->hasActiveInterviewZone());
}
public function test_candidate_notes_drawing_saving_and_interviewer_password_regen_blocking(): void
{
$hr = User::create([
'name' => 'HR Admin',
'email' => 'admin@company.com',
'role' => 'admin',
]);
$interview = Interview::create([
'candidate_name' => 'David Candidate',
'candidate_email' => 'david@gmail.com',
'candidate_phone' => '9777766666',
'temp_password' => 'Pass-777666',
'expires_at' => now()->addHours(2),
'language' => 'python',
'status' => 'in_progress',
'submission_unique_id' => 'david-candidate_9777766666_1752000004',
]);
// 1. Candidate saves notepad notes
$this->post(route('interview.candidate.notes', $interview->id), [
'notes' => 'Scratchpad logic: O(N log N) sorting approach',
])->assertStatus(200);
// 2. Candidate saves canvas drawing data
$this->post(route('interview.candidate.drawing', $interview->id), [
'drawing' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
])->assertStatus(200);
$interview->refresh();
$this->assertEquals('Scratchpad logic: O(N log N) sorting approach', $interview->candidate_notes);
$this->assertNotNull($interview->candidate_drawing);
// 3. Interviewer regenerates password
$regenResp = $this->actingAs($hr)
->post(route('interview.regenerate-password', $interview->id));
$regenResp->assertStatus(200)->assertJson(['success' => true]);
$interview->refresh();
$this->assertNotEquals('Pass-777666', $interview->temp_password);
// 4. Interviewer blocks candidate and IP
$blockResp = $this->actingAs($hr)
->post(route('interview.block-candidate', $interview->id));
$blockResp->assertStatus(200)->assertJson(['success' => true]);
$interview->refresh();
$this->assertEquals('blocked', $interview->status);
// Blocked candidate login should be denied
$this->post(route('interview.candidate.login.submit'), [
'identifier' => 'david@gmail.com',
'temp_password' => $interview->temp_password,
])->assertSessionHas('error');
}
public function test_executive_candidate_evaluation_pdf_report_generation(): void
{
$hr = User::create([
'name' => 'Report Viewer HR',
'email' => 'hrreport@company.com',
'role' => 'admin',
]);
$interview = Interview::create([
'candidate_name' => 'Rachel Candidate',
'candidate_email' => 'rachel@gmail.com',
'candidate_phone' => '9666655555',
'temp_password' => 'Pass-666555',
'expires_at' => now()->addHours(2),
'language' => 'python',
'status' => 'completed',
'submission_unique_id' => 'rachel-candidate_9666655555_1752000005',
'submitted_code' => 'def solve(): return 42',
'code_output' => 'Result: 42',
'candidate_notes' => 'Complexity analysis: O(1)',
]);
$response = $this->actingAs($hr)->get(route('interview.report', $interview->id));
$response->assertStatus(200)
->assertSee('Rachel Candidate')
->assertSee('rachel@gmail.com')
->assertSee('Executive Summary')
->assertSee('Behavioral Integrity Rating')
->assertSee('Technical Code Competency')
->assertSee('Complexity analysis: O(1)');
}
public function test_silent_proctoring_violations_and_multi_party_active_call_status(): void
{
$hr = User::create([
'name' => 'Interviewer One',
'email' => 'interviewer1@company.com',
'role' => 'admin',
]);
$interview = Interview::create([
'candidate_name' => 'Proctor Test Candidate',
'candidate_email' => 'proctortest@gmail.com',
'candidate_phone' => '9555544444',
'temp_password' => 'Pass-555444',
'expires_at' => now()->addHours(2),
'language' => 'python',
'status' => 'in_progress',
'submission_unique_id' => 'proctor-test-candidate_9555544444_1752000006',
]);
// 1. Silent Lower Screen Gaze Stare violation
$this->post(route('interview.violation', $interview->id), [
'type' => 'gaze_lower_device',
'details' => 'Candidate continuously looking at lower portion of screen for 10s (suspected reading from mobile device or cheat sheet)',
])->assertStatus(200);
// 2. Silent Question Repetition violation
$this->post(route('interview.violation', $interview->id), [
'type' => 'question_repetition',
'details' => 'Candidate reading/repeating question out loud: "Write a function to reverse a linked list" (suspected Parakeet AI speech prompt)',
])->assertStatus(200);
// 3. Silent Talking to Secondary Person violation
$this->post(route('interview.violation', $interview->id), [
'type' => 'talking_secondary_person',
'details' => 'Candidate speaking out loud to secondary person in room: "Can you search the solution on your laptop?"',
])->assertStatus(200);
// 4. Silent External AI Overlay Hotkey violation
$this->post(route('interview.violation', $interview->id), [
'type' => 'external_ai_detected',
'details' => 'Parakeet AI / External AI Hotkey Intercepted (KeyA): Candidate activated external AI assistant overlay shortcut',
])->assertStatus(200);
// Verify poll endpoint returns all 4 silent proctoring violations to interviewer dashboard
$pollResp = $this->get(route('interview.poll', $interview->id));
$pollResp->assertStatus(200)
->assertJsonFragment(['type' => 'gaze_lower_device'])
->assertJsonFragment(['type' => 'question_repetition'])
->assertJsonFragment(['type' => 'talking_secondary_person'])
->assertJsonFragment(['type' => 'external_ai_detected']);
// 5. Update call status for multi-party call test
$callResp = $this->actingAs($hr)->post(route('interview.call-status', $interview->id), [
'call_status' => 'active',
]);
$callResp->assertStatus(200)->assertJson(['success' => true, 'call_status' => 'active']);
// Active calls endpoint should list the active session for multiple interviewers to join
$activeCallsResp = $this->actingAs($hr)->get(route('interview.active-calls'));
$activeCallsResp->assertStatus(200)
->assertJsonFragment(['submission_unique_id' => 'proctor-test-candidate_9555544444_1752000006']);
}
public function test_tab_switch_logging_and_external_ai_detection_behavior(): void
{
$interview = Interview::create([
'candidate_name' => 'Tab Switch Candidate',
'candidate_email' => 'tabswitch@gmail.com',
'candidate_phone' => '9998887776',
'temp_password' => 'Pass-999888',
'expires_at' => now()->addHours(2),
'language' => 'python',
'status' => 'in_progress',
'submission_unique_id' => 'tab-switch-candidate_9998887776_1752000099',
]);
// 1. Tab switch log violation should log type 'tab_switch'
$resp = $this->post(route('interview.violation', $interview->id), [
'type' => 'tab_switch',
'details' => 'Candidate switched browser tab or window',
]);
$resp->assertStatus(200);
$poll = $this->get(route('interview.poll', $interview->id));
$poll->assertStatus(200)
->assertJsonFragment(['type' => 'tab_switch', 'details' => 'Candidate switched browser tab or window']);
// 2. External AI hotkey violation should log type 'external_ai_detected' with meaningful message
$aiResp = $this->post(route('interview.violation', $interview->id), [
'type' => 'external_ai_detected',
'details' => 'candidate might use external ai: Intercepted AI assistant shortcut hotkey (KeyA)',
]);
$aiResp->assertStatus(200);
// 3. Talking on phone violation
$phoneResp = $this->post(route('interview.violation', $interview->id), [
'type' => 'talking_on_phone',
'details' => 'Candidate detected talking on phone / phone call during assessment: "hello hold on calling you back"',
]);
$phoneResp->assertStatus(200);
// 4. Reading from external device violation
$extDevResp = $this->post(route('interview.violation', $interview->id), [
'type' => 'reading_external_device',
'details' => 'Candidate detected reading from external device, mobile screen, or secondary monitor',
]);
$extDevResp->assertStatus(200);
$poll3 = $this->get(route('interview.poll', $interview->id));
$poll3->assertStatus(200)
->assertJsonFragment(['type' => 'talking_on_phone'])
->assertJsonFragment(['type' => 'reading_external_device']);
}
public function test_expired_interview_automatically_ends_call_status_when_rendering_view(): void
{
$admin = User::create([
'name' => 'Admin Test',
'email' => 'admintest@example.com',
'role' => 'admin',
]);
// Create an expired interview that had active call_status in DB
$expiredInterview = Interview::create([
'candidate_name' => 'Expired Candidate',
'candidate_email' => 'expired@example.com',
'candidate_phone' => '1231231234',
'temp_password' => 'Pass-111222',
'expires_at' => now()->subHour(),
'language' => 'python',
'status' => 'scheduled',
'call_status' => 'active',
'active_peers' => [['peer_id' => 'peer_1', 'last_seen' => now()->subHour()->timestamp]],
'submission_unique_id' => 'expired-cand_1231231234_1752000100',
]);
$this->assertTrue($expiredInterview->isExpired());
$this->assertFalse($expiredInterview->isCallActive());
// 1. Loading index page view should automatically end the call status
$response = $this->actingAs($admin)->get(route('interview.index'));
$response->assertStatus(200);
$response->assertSee('EXPIRED');
$response->assertDontSee('Join Active Call');
$expiredInterview->refresh();
$this->assertEquals('ended', $expiredInterview->call_status);
$this->assertEmpty($expiredInterview->active_peers);
// 2. Reset back to active and test show page view
$expiredInterview->update(['call_status' => 'active']);
$showResp = $this->actingAs($admin)->get(route('interview.show', $expiredInterview->id));
$showResp->assertStatus(200);
$showResp->assertSee('Call Ended');
$expiredInterview->refresh();
$this->assertEquals('ended', $expiredInterview->call_status);
}
}