Merge pull request 'fix/call-status-and-video-recording' (#22) from fix/call-status-and-video-recording into main

Reviewed-on: #22
This commit is contained in:
subhajit.pal 2026-08-24 07:38:06 +00:00
commit 0aec99f575
8 changed files with 199 additions and 66 deletions

View File

@ -32,6 +32,15 @@ public function index()
} }
$interviews = $user->getAccessibleInterviews(); $interviews = $user->getAccessibleInterviews();
// Auto-terminate call session if interview timeline has expired
foreach ($interviews as $interview) {
if ($interview->isExpired() && $interview->call_status === 'active') {
$interview->update(['call_status' => 'ended', 'active_peers' => []]);
$interview->call_status = 'ended';
}
}
$interviewers = User::orderBy('name', 'asc')->get(); $interviewers = User::orderBy('name', 'asc')->get();
return view('interview.index', compact('interviews', 'interviewers')); return view('interview.index', compact('interviews', 'interviewers'));
@ -49,6 +58,13 @@ public function show($id)
} }
$interview = Interview::findOrFail($id); $interview = Interview::findOrFail($id);
// Auto-terminate call session if interview timeline has expired
if ($interview->isExpired() && $interview->call_status === 'active') {
$interview->update(['call_status' => 'ended', 'active_peers' => []]);
$interview->call_status = 'ended';
}
$interviewers = User::orderBy('name', 'asc')->get(); $interviewers = User::orderBy('name', 'asc')->get();
return view('interview.show', compact('interview', 'interviewers')); return view('interview.show', compact('interview', 'interviewers'));
@ -625,7 +641,8 @@ public function getActiveCalls(Request $request)
// Auto-terminate call session if interview timeline has expired // Auto-terminate call session if interview timeline has expired
if ($interview->isExpired()) { if ($interview->isExpired()) {
if ($interview->call_status === 'active') { if ($interview->call_status === 'active') {
$interview->update(['call_status' => 'ended']); $interview->update(['call_status' => 'ended', 'active_peers' => []]);
$interview->call_status = 'ended';
} }
continue; continue;
} }

View File

@ -70,6 +70,18 @@ public function isExpired(): bool
return now()->greaterThan($this->expires_at); return now()->greaterThan($this->expires_at);
} }
/**
* Check if the interview call is active and unexpired.
*/
public function isCallActive(): bool
{
if ($this->isExpired() || in_array($this->status, ['completed', 'expired'])) {
return false;
}
return $this->call_status === 'active';
}
/** /**
* Check if the current time is within the interview timeline. * Check if the current time is within the interview timeline.
*/ */

View File

@ -2505,7 +2505,7 @@ export function startCallRecording() {
recordingCompositor = new RecordingCompositor({ recordingCompositor = new RecordingCompositor({
width: 1280, width: 1280,
height: 720, height: 720,
fps: 24, fps: 30,
getParticipantsData: () => { getParticipantsData: () => {
const candStream = (candVid && candVid.srcObject) ? candVid.srcObject : null; const candStream = (candVid && candVid.srcObject) ? candVid.srcObject : null;
const candCamIcon = document.getElementById('cand-cam-status'); const candCamIcon = document.getElementById('cand-cam-status');

View File

@ -16,8 +16,8 @@ export class RecordingCompositor {
* @param {{width:number, height:number, fps: number, getParticipantsData: () => {}}} options * @param {{width:number, height:number, fps: number, getParticipantsData: () => {}}} options
*/ */
constructor(options = {}) { constructor(options = {}) {
this.width = options.width || 1920; this.width = options.width || 1280;
this.height = options.height || 1080; this.height = options.height || (options.width ? Math.round((options.width * 9) / 16) : 720);
this.fps = options.fps || 30; this.fps = options.fps || 30;
this.canvas = document.createElement("canvas"); this.canvas = document.createElement("canvas");
@ -257,17 +257,29 @@ export class RecordingCompositor {
secondaryFeeds = [selfFeed, ...panelistFeeds]; secondaryFeeds = [selfFeed, ...panelistFeeds];
} }
// Compute geometry with strict 16:9 aspect ratio preservation for every tile // Compute dynamic geometry with strict 16:9 aspect ratio preservation for every tile
const gap = 16; const padX = Math.max(8, Math.round(W * 0.015));
const padX = 24; const padY = Math.max(8, Math.round(H * 0.02));
const gap = Math.max(6, Math.round(W * 0.01));
const totalSecondary = secondaryFeeds.length; const totalSecondary = secondaryFeeds.length;
if (totalSecondary === 0) { if (totalSecondary === 0) {
// Case 0: Only Main Feed (Full Canvas 16:9) // Case 0: Only Main Feed (Full Canvas 16:9 with padding)
const mainH = 1032; const maxAvailW = W - 2 * padX;
const mainW = Math.round(mainH * (16 / 9)); // 1835px const maxAvailH = H - 2 * padY;
let mainW, mainH;
if (maxAvailW / maxAvailH > 16 / 9) {
mainH = maxAvailH;
mainW = Math.round(mainH * (16 / 9));
} else {
mainW = maxAvailW;
mainH = Math.round(mainW * (9 / 16));
}
const mainX = Math.round((W - mainW) / 2); const mainX = Math.round((W - mainW) / 2);
const mainY = Math.round((H - mainH) / 2); const mainY = Math.round((H - mainH) / 2);
this.renderTile( this.renderTile(
ctx, ctx,
mainFeed, mainFeed,
@ -276,10 +288,20 @@ export class RecordingCompositor {
); );
} else if (totalSecondary === 1) { } else if (totalSecondary === 1) {
// Case 1: Main + 1 Sidebar Tile (Both 16:9) // Case 1: Main + 1 Sidebar Tile (Both 16:9)
const sideW = 480; const availW = W - 2 * padX - gap;
const sideH = Math.round(sideW * (9 / 16)); // 270px const availH = H - 2 * padY;
const mainW = 1376;
const mainH = Math.round(mainW * (9 / 16)); // 774px let sideW = Math.round(availW * 0.26);
let sideH = Math.round(sideW * (9 / 16));
let mainW = availW - sideW;
let mainH = Math.round(mainW * (9 / 16));
if (mainH > availH) {
mainH = availH;
mainW = Math.round(mainH * (16 / 9));
sideW = Math.min(availW - mainW, Math.round(availH * (16 / 9)));
sideH = Math.round(sideW * (9 / 16));
}
const mainX = padX; const mainX = padX;
const mainY = Math.round((H - mainH) / 2); const mainY = Math.round((H - mainH) / 2);
@ -299,17 +321,30 @@ export class RecordingCompositor {
false, false,
); );
} else if (totalSecondary === 2) { } else if (totalSecondary === 2) {
// Case 2: Main + 2 Sidebar Tiles (All 16:9) // Case 2: Main + 2 Sidebar Tiles stacked vertically (All 16:9)
const sideW = 480; const availW = W - 2 * padX - gap;
const sideH = Math.round(sideW * (9 / 16)); // 270px const availH = H - 2 * padY;
const mainW = 1376;
const mainH = Math.round(mainW * (9 / 16)); // 774px let sideW = Math.round(availW * 0.28);
let sideH = Math.round(sideW * (9 / 16));
if (2 * sideH + gap > availH) {
sideH = Math.floor((availH - gap) / 2);
sideW = Math.round(sideH * (16 / 9));
}
const totalSideH = 2 * sideH + gap;
let mainW = availW - sideW;
let mainH = Math.round(mainW * (9 / 16));
if (mainH > availH) {
mainH = availH;
mainW = Math.round(mainH * (16 / 9));
}
const mainX = padX; const mainX = padX;
const mainY = Math.round((H - mainH) / 2); const mainY = Math.round((H - mainH) / 2);
const sideX = padX + mainW + gap; const sideX = W - padX - sideW;
const totalSideH = 2 * sideH + gap; // 556px
const sideYStart = Math.round((H - totalSideH) / 2); const sideYStart = Math.round((H - totalSideH) / 2);
this.renderTile( this.renderTile(
@ -331,18 +366,24 @@ export class RecordingCompositor {
false, false,
); );
} else if (totalSecondary === 3) { } else if (totalSecondary === 3) {
// Case 3: Main + 3 Sidebar Tiles (All 16:9) // Case 3: Main + 3 Sidebar Tiles stacked vertically (All 16:9)
const sideH = 330; const availH = H - 2 * padY;
const sideW = Math.round(sideH * (16 / 9)); // 587px const sideH = Math.floor((availH - 2 * gap) / 3);
const totalSideH = 3 * sideH + 2 * gap; // 1022px const sideW = Math.round(sideH * (16 / 9));
const topY = Math.round((H - totalSideH) / 2); // 29px const totalSideH = 3 * sideH + 2 * gap;
const topY = Math.round((H - totalSideH) / 2);
const sideX = W - padX - sideW; const sideX = W - padX - sideW;
const mainW = sideX - gap - padX; // 1270px let mainW = sideX - gap - padX;
const mainH = Math.round(mainW * (9 / 16)); // 714px let mainH = Math.round(mainW * (9 / 16));
if (mainH > totalSideH) {
mainH = totalSideH;
mainW = Math.round(mainH * (16 / 9));
}
const mainX = padX; const mainX = padX;
const mainY = topY; const mainY = Math.round((H - mainH) / 2);
this.renderTile( this.renderTile(
ctx, ctx,
@ -363,16 +404,25 @@ export class RecordingCompositor {
} }
} else { } else {
// Case 4: Main + 3 Sidebar Tiles + Bottom Overflow Row (All 16:9) // Case 4: Main + 3 Sidebar Tiles + Bottom Overflow Row (All 16:9)
const sideH = 330; const availH = H - 2 * padY;
const sideW = Math.round(sideH * (16 / 9)); // 587px const sideH = Math.floor((availH - 2 * gap) / 3);
const totalSideH = 3 * sideH + 2 * gap; // 1022px const sideW = Math.round(sideH * (16 / 9));
const topY = Math.round((H - totalSideH) / 2); // 29px const totalSideH = 3 * sideH + 2 * gap;
const topY = Math.round((H - totalSideH) / 2);
const sideX = W - padX - sideW; const sideX = W - padX - sideW;
const mainW = sideX - gap - padX; // 1270px const mainAreaW = sideX - gap - padX;
const mainH = Math.round(mainW * (9 / 16)); // 714px
const mainX = padX; const targetMainH = Math.round((totalSideH - gap) * 0.68);
let mainW = Math.round(targetMainH * (16 / 9));
let mainH = targetMainH;
if (mainW > mainAreaW) {
mainW = mainAreaW;
mainH = Math.round(mainW * (9 / 16));
}
const mainX = padX + Math.round((mainAreaW - mainW) / 2);
const mainY = topY; const mainY = topY;
// 1. Render Main // 1. Render Main
@ -398,12 +448,11 @@ export class RecordingCompositor {
// 3. Render Bottom Overflow Tiles (each 16:9) // 3. Render Bottom Overflow Tiles (each 16:9)
const overflowFeeds = secondaryFeeds.slice(3); const overflowFeeds = secondaryFeeds.slice(3);
const numOverflow = overflowFeeds.length; const numOverflow = overflowFeeds.length;
const botMaxH = totalSideH - mainH - gap; // 292px const botMaxH = totalSideH - mainH - gap;
const botYBase = topY + mainH + gap; // 759px const botYBase = topY + mainH + gap;
// Calculate width per tile to fit in mainW
const candBotW = Math.floor( const candBotW = Math.floor(
(mainW - (numOverflow - 1) * gap) / numOverflow, (mainAreaW - (numOverflow - 1) * gap) / numOverflow,
); );
const candBotH = Math.round(candBotW * (9 / 16)); const candBotH = Math.round(candBotW * (9 / 16));
@ -417,7 +466,7 @@ export class RecordingCompositor {
} }
const totalBotW = numOverflow * botW + (numOverflow - 1) * gap; const totalBotW = numOverflow * botW + (numOverflow - 1) * gap;
const botXStart = mainX + Math.round((mainW - totalBotW) / 2); const botXStart = padX + Math.round((mainAreaW - totalBotW) / 2);
const botY = botYBase + Math.round((botMaxH - botH) / 2); const botY = botYBase + Math.round((botMaxH - botH) / 2);
overflowFeeds.forEach((feed, j) => { overflowFeeds.forEach((feed, j) => {
@ -434,7 +483,7 @@ export class RecordingCompositor {
renderTile(ctx, feed, rect, isMain = false) { renderTile(ctx, feed, rect, isMain = false) {
const { x, y, w, h } = rect; const { x, y, w, h } = rect;
const radius = 12; const radius = Math.max(6, Math.round(Math.min(w, h) * 0.035));
ctx.save(); ctx.save();
this.drawRoundedClip(ctx, x, y, w, h, radius); this.drawRoundedClip(ctx, x, y, w, h, radius);
@ -465,7 +514,7 @@ export class RecordingCompositor {
ctx.restore(); ctx.restore();
// Overlay Pill Tag (Bottom-Left) // Overlay Pill Tag (Bottom-Left)
this.drawPillTag(ctx, feed, x + 10, y + h - 12); this.drawPillTag(ctx, feed, x, y, w, h);
} }
drawVideoCover(ctx, video, x, y, w, h) { drawVideoCover(ctx, video, x, y, w, h) {
@ -524,10 +573,10 @@ export class RecordingCompositor {
drawPlaceholder(ctx, feed, x, y, w, h, isMain = false) { drawPlaceholder(ctx, feed, x, y, w, h, isMain = false) {
const cx = x + w / 2; const cx = x + w / 2;
const cy = y + h / 2 - (isMain ? 15 : 8); const cy = y + h / 2 - (isMain ? Math.round(h * 0.03) : Math.round(h * 0.02));
const circleRadius = isMain const circleRadius = isMain
? Math.min(54, h * 0.18) ? Math.min(Math.round(h * 0.18), Math.round(w * 0.12), 54)
: Math.min(32, h * 0.22); : Math.min(Math.round(h * 0.2), Math.round(w * 0.14), 36);
// Circular gradient avatar // Circular gradient avatar
const gradient = ctx.createLinearGradient( const gradient = ctx.createLinearGradient(
@ -546,54 +595,60 @@ export class RecordingCompositor {
ctx.fillStyle = gradient; ctx.fillStyle = gradient;
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, circleRadius, 0, Math.PI * 2); ctx.arc(cx, cy, Math.max(1, circleRadius), 0, Math.PI * 2);
ctx.fill(); ctx.fill();
// Avatar Initials Text // Avatar Initials Text
const initialsFont = Math.max(10, Math.round(circleRadius * 0.85));
ctx.fillStyle = "#ffffff"; ctx.fillStyle = "#ffffff";
ctx.font = `bold ${Math.round(circleRadius * 0.85)}px sans-serif`; ctx.font = `bold ${initialsFont}px sans-serif`;
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
ctx.fillText(feed.initials || "IV", cx, cy); ctx.fillText(feed.initials || "IV", cx, cy);
// Participant Name // Participant Name
const nameFont = Math.max(10, Math.round(isMain ? Math.min(20, h * 0.045) : Math.min(14, h * 0.06)));
ctx.fillStyle = "#ffffff"; ctx.fillStyle = "#ffffff";
ctx.font = `600 ${isMain ? 18 : 13}px sans-serif`; ctx.font = `600 ${nameFont}px sans-serif`;
ctx.textBaseline = "top"; ctx.textBaseline = "top";
ctx.fillText(feed.name, cx, cy + circleRadius + (isMain ? 14 : 8)); ctx.fillText(feed.name, cx, cy + circleRadius + Math.max(6, Math.round(h * 0.02)));
// Subtitle status // Subtitle status
const subFont = Math.max(9, Math.round(isMain ? Math.min(14, h * 0.032) : Math.min(11, h * 0.045)));
ctx.fillStyle = "#94a3b8"; ctx.fillStyle = "#94a3b8";
ctx.font = `400 ${isMain ? 13 : 10.5}px sans-serif`; ctx.font = `400 ${subFont}px sans-serif`;
ctx.fillText( ctx.fillText(
feed.isCamOn ? "Connecting video..." : "Camera turned off", feed.isCamOn ? "Connecting video..." : "Camera turned off",
cx, cx,
cy + circleRadius + (isMain ? 36 : 24), cy + circleRadius + Math.max(6, Math.round(h * 0.02)) + nameFont + Math.max(4, Math.round(h * 0.01)),
); );
} }
drawPillTag(ctx, feed, x, y) { drawPillTag(ctx, feed, tileX, tileY, tileW, tileH) {
const text = feed.name; const text = feed.name;
const fontSize = 11; const fontSize = Math.max(9, Math.min(12, Math.round(tileH * 0.045)));
ctx.font = `600 ${fontSize}px sans-serif`; ctx.font = `600 ${fontSize}px sans-serif`;
const textMetrics = ctx.measureText(text); const textMetrics = ctx.measureText(text);
const tagPaddingX = 8; const tagPaddingX = Math.max(6, Math.round(fontSize * 0.7));
const tagW = textMetrics.width + tagPaddingX * 2; const tagW = textMetrics.width + tagPaddingX * 2;
const tagH = 20; const tagH = Math.max(16, Math.round(fontSize * 1.8));
const tagX = x; const marginX = Math.max(6, Math.round(tileW * 0.02));
const tagY = y - tagH; const marginY = Math.max(6, Math.round(tileH * 0.03));
const tagX = tileX + marginX;
const tagY = tileY + tileH - marginY - tagH;
const tagRadius = Math.max(3, Math.round(tagH * 0.25));
ctx.save(); ctx.save();
// Background // Background
ctx.fillStyle = "rgba(9, 13, 22, 0.8)"; ctx.fillStyle = "rgba(9, 13, 22, 0.8)";
this.drawRoundedFill(ctx, tagX, tagY, tagW, tagH, 5); this.drawRoundedFill(ctx, tagX, tagY, tagW, tagH, tagRadius);
// Border // Border
ctx.strokeStyle = "rgba(255, 255, 255, 0.12)"; ctx.strokeStyle = "rgba(255, 255, 255, 0.12)";
ctx.lineWidth = 1; ctx.lineWidth = 1;
this.drawRoundedStroke(ctx, tagX, tagY, tagW, tagH, 5); this.drawRoundedStroke(ctx, tagX, tagY, tagW, tagH, tagRadius);
// Name text // Name text
ctx.fillStyle = "#e2e8f0"; ctx.fillStyle = "#e2e8f0";

View File

@ -10,11 +10,11 @@
<x-button id="btn-toggle-interviewer-cam" onclick="toggleInterviewerCam()" variant="ghost" icon="fa-solid fa-video" class="hidden" title="Cam On / Off"></x-button> <x-button id="btn-toggle-interviewer-cam" onclick="toggleInterviewerCam()" variant="ghost" icon="fa-solid fa-video" class="hidden" title="Cam On / Off"></x-button>
<!-- 3. Start / Join / Restart Call --> <!-- 3. Start / Join / Restart Call -->
@if($interview->call_status === 'ended') @if($interview->isExpired() || $interview->call_status === 'ended')
<x-button id="btn-start-call" disabled variant="primary" icon="fa-solid fa-phone-slash" class="opacity-50 cursor-not-allowed pointer-events-none"> <x-button id="btn-start-call" disabled variant="primary" icon="fa-solid fa-phone-slash" class="opacity-50 cursor-not-allowed pointer-events-none">
Call Ended Call Ended
</x-button> </x-button>
@elseif($interview->call_status === 'active') @elseif(!$interview->isExpired() && $interview->call_status === 'active')
<x-button id="btn-start-call" onclick="startInterviewerCall()" variant="primary" icon="fa-solid fa-phone"> <x-button id="btn-start-call" onclick="startInterviewerCall()" variant="primary" icon="fa-solid fa-phone">
Join Call Join Call
</x-button> </x-button>

View File

@ -76,7 +76,7 @@
<x-pill id="timeline-status-pill" variant="danger" size="sm" icon="fa-solid fa-circle">Expired</x-pill> <x-pill id="timeline-status-pill" variant="danger" size="sm" icon="fa-solid fa-circle">Expired</x-pill>
@elseif($interview->status === 'completed') @elseif($interview->status === 'completed')
<x-pill id="timeline-status-pill" variant="success" size="sm" icon="fa-solid fa-circle">Completed</x-pill> <x-pill id="timeline-status-pill" variant="success" size="sm" icon="fa-solid fa-circle">Completed</x-pill>
@elseif($interview->call_status === 'active') @elseif(!$interview->isExpired() && $interview->call_status === 'active')
<x-pill id="timeline-status-pill" variant="info" size="sm" class="animate-pulse" icon="fa-solid fa-circle">Call in progress</x-pill> <x-pill id="timeline-status-pill" variant="info" size="sm" class="animate-pulse" icon="fa-solid fa-circle">Call in progress</x-pill>
@elseif($interview->isActiveTimeline()) @elseif($interview->isActiveTimeline())
<x-pill id="timeline-status-pill" variant="info" size="sm" icon="fa-solid fa-bolt">Timeline live</x-pill> <x-pill id="timeline-status-pill" variant="info" size="sm" icon="fa-solid fa-bolt">Timeline live</x-pill>

View File

@ -4,6 +4,10 @@
@php @php
$metrics = $interview->proctor_metrics; $metrics = $interview->proctor_metrics;
if ($interview->isExpired() && $interview->call_status === 'active') {
$interview->update(['call_status' => 'ended', 'active_peers' => []]);
$interview->call_status = 'ended';
}
@endphp @endphp
<tr class="hover:bg-white/[0.02] transition-colors"> <tr class="hover:bg-white/[0.02] transition-colors">
@ -36,7 +40,7 @@
<x-pill variant="danger" size="sm">EXPIRED</x-pill> <x-pill variant="danger" size="sm">EXPIRED</x-pill>
@elseif($interview->status === 'completed') @elseif($interview->status === 'completed')
<x-pill variant="success" size="sm">COMPLETED</x-pill> <x-pill variant="success" size="sm">COMPLETED</x-pill>
@elseif($interview->call_status === 'active') @elseif(!$interview->isExpired() && $interview->call_status === 'active')
<x-pill variant="success" size="sm" icon="fa-solid fa-phone" class="animate-pulse font-extrabold">CALL IN PROGRESS</x-pill> <x-pill variant="success" size="sm" icon="fa-solid fa-phone" class="animate-pulse font-extrabold">CALL IN PROGRESS</x-pill>
@elseif($interview->isActiveTimeline()) @elseif($interview->isActiveTimeline())
<x-pill variant="info" size="sm" icon="fa-solid fa-bolt">TIMELINE LIVE</x-pill> <x-pill variant="info" size="sm" icon="fa-solid fa-bolt">TIMELINE LIVE</x-pill>
@ -71,7 +75,7 @@
</a> </a>
<!-- Button 2: Join Call --> <!-- Button 2: Join Call -->
@if($interview->call_status === 'active') @if(!$interview->isExpired() && $interview->call_status === 'active')
<a href="{{ route('interview.show', $interview->id) }}?join_call=1"> <a href="{{ route('interview.show', $interview->id) }}?join_call=1">
<x-button variant="success" size="sm" icon="fa-solid fa-phone-volume" class="animate-pulse font-extrabold shadow-md shadow-emerald-500/50"> <x-button variant="success" size="sm" icon="fa-solid fa-phone-volume" class="animate-pulse font-extrabold shadow-md shadow-emerald-500/50">
Join Active Call Join Active Call

View File

@ -416,4 +416,49 @@ public function test_tab_switch_logging_and_external_ai_detection_behavior(): vo
->assertJsonFragment(['type' => 'talking_on_phone']) ->assertJsonFragment(['type' => 'talking_on_phone'])
->assertJsonFragment(['type' => 'reading_external_device']); ->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);
}
} }