fix/call-status-and-video-recording #22
@ -32,6 +32,15 @@ public function index()
|
||||
}
|
||||
|
||||
$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();
|
||||
|
||||
return view('interview.index', compact('interviews', 'interviewers'));
|
||||
@ -49,6 +58,13 @@ public function show($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();
|
||||
|
||||
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
|
||||
if ($interview->isExpired()) {
|
||||
if ($interview->call_status === 'active') {
|
||||
$interview->update(['call_status' => 'ended']);
|
||||
$interview->update(['call_status' => 'ended', 'active_peers' => []]);
|
||||
$interview->call_status = 'ended';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -70,6 +70,18 @@ public function isExpired(): bool
|
||||
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.
|
||||
*/
|
||||
|
||||
@ -2505,7 +2505,7 @@ export function startCallRecording() {
|
||||
recordingCompositor = new RecordingCompositor({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
fps: 24,
|
||||
fps: 30,
|
||||
getParticipantsData: () => {
|
||||
const candStream = (candVid && candVid.srcObject) ? candVid.srcObject : null;
|
||||
const candCamIcon = document.getElementById('cand-cam-status');
|
||||
|
||||
@ -16,8 +16,8 @@ export class RecordingCompositor {
|
||||
* @param {{width:number, height:number, fps: number, getParticipantsData: () => {}}} options
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.width = options.width || 1920;
|
||||
this.height = options.height || 1080;
|
||||
this.width = options.width || 1280;
|
||||
this.height = options.height || (options.width ? Math.round((options.width * 9) / 16) : 720);
|
||||
this.fps = options.fps || 30;
|
||||
|
||||
this.canvas = document.createElement("canvas");
|
||||
@ -257,17 +257,29 @@ export class RecordingCompositor {
|
||||
secondaryFeeds = [selfFeed, ...panelistFeeds];
|
||||
}
|
||||
|
||||
// Compute geometry with strict 16:9 aspect ratio preservation for every tile
|
||||
const gap = 16;
|
||||
const padX = 24;
|
||||
// Compute dynamic geometry with strict 16:9 aspect ratio preservation for every tile
|
||||
const padX = Math.max(8, Math.round(W * 0.015));
|
||||
const padY = Math.max(8, Math.round(H * 0.02));
|
||||
const gap = Math.max(6, Math.round(W * 0.01));
|
||||
const totalSecondary = secondaryFeeds.length;
|
||||
|
||||
if (totalSecondary === 0) {
|
||||
// Case 0: Only Main Feed (Full Canvas 16:9)
|
||||
const mainH = 1032;
|
||||
const mainW = Math.round(mainH * (16 / 9)); // 1835px
|
||||
// Case 0: Only Main Feed (Full Canvas 16:9 with padding)
|
||||
const maxAvailW = W - 2 * padX;
|
||||
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 mainY = Math.round((H - mainH) / 2);
|
||||
|
||||
this.renderTile(
|
||||
ctx,
|
||||
mainFeed,
|
||||
@ -276,10 +288,20 @@ export class RecordingCompositor {
|
||||
);
|
||||
} else if (totalSecondary === 1) {
|
||||
// Case 1: Main + 1 Sidebar Tile (Both 16:9)
|
||||
const sideW = 480;
|
||||
const sideH = Math.round(sideW * (9 / 16)); // 270px
|
||||
const mainW = 1376;
|
||||
const mainH = Math.round(mainW * (9 / 16)); // 774px
|
||||
const availW = W - 2 * padX - gap;
|
||||
const availH = H - 2 * padY;
|
||||
|
||||
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 mainY = Math.round((H - mainH) / 2);
|
||||
@ -299,17 +321,30 @@ export class RecordingCompositor {
|
||||
false,
|
||||
);
|
||||
} else if (totalSecondary === 2) {
|
||||
// Case 2: Main + 2 Sidebar Tiles (All 16:9)
|
||||
const sideW = 480;
|
||||
const sideH = Math.round(sideW * (9 / 16)); // 270px
|
||||
const mainW = 1376;
|
||||
const mainH = Math.round(mainW * (9 / 16)); // 774px
|
||||
// Case 2: Main + 2 Sidebar Tiles stacked vertically (All 16:9)
|
||||
const availW = W - 2 * padX - gap;
|
||||
const availH = H - 2 * padY;
|
||||
|
||||
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 mainY = Math.round((H - mainH) / 2);
|
||||
const sideX = padX + mainW + gap;
|
||||
|
||||
const totalSideH = 2 * sideH + gap; // 556px
|
||||
const sideX = W - padX - sideW;
|
||||
const sideYStart = Math.round((H - totalSideH) / 2);
|
||||
|
||||
this.renderTile(
|
||||
@ -331,18 +366,24 @@ export class RecordingCompositor {
|
||||
false,
|
||||
);
|
||||
} else if (totalSecondary === 3) {
|
||||
// Case 3: Main + 3 Sidebar Tiles (All 16:9)
|
||||
const sideH = 330;
|
||||
const sideW = Math.round(sideH * (16 / 9)); // 587px
|
||||
const totalSideH = 3 * sideH + 2 * gap; // 1022px
|
||||
const topY = Math.round((H - totalSideH) / 2); // 29px
|
||||
// Case 3: Main + 3 Sidebar Tiles stacked vertically (All 16:9)
|
||||
const availH = H - 2 * padY;
|
||||
const sideH = Math.floor((availH - 2 * gap) / 3);
|
||||
const sideW = Math.round(sideH * (16 / 9));
|
||||
const totalSideH = 3 * sideH + 2 * gap;
|
||||
const topY = Math.round((H - totalSideH) / 2);
|
||||
|
||||
const sideX = W - padX - sideW;
|
||||
const mainW = sideX - gap - padX; // 1270px
|
||||
const mainH = Math.round(mainW * (9 / 16)); // 714px
|
||||
let mainW = sideX - gap - padX;
|
||||
let mainH = Math.round(mainW * (9 / 16));
|
||||
|
||||
if (mainH > totalSideH) {
|
||||
mainH = totalSideH;
|
||||
mainW = Math.round(mainH * (16 / 9));
|
||||
}
|
||||
|
||||
const mainX = padX;
|
||||
const mainY = topY;
|
||||
const mainY = Math.round((H - mainH) / 2);
|
||||
|
||||
this.renderTile(
|
||||
ctx,
|
||||
@ -363,16 +404,25 @@ export class RecordingCompositor {
|
||||
}
|
||||
} else {
|
||||
// Case 4: Main + 3 Sidebar Tiles + Bottom Overflow Row (All 16:9)
|
||||
const sideH = 330;
|
||||
const sideW = Math.round(sideH * (16 / 9)); // 587px
|
||||
const totalSideH = 3 * sideH + 2 * gap; // 1022px
|
||||
const topY = Math.round((H - totalSideH) / 2); // 29px
|
||||
const availH = H - 2 * padY;
|
||||
const sideH = Math.floor((availH - 2 * gap) / 3);
|
||||
const sideW = Math.round(sideH * (16 / 9));
|
||||
const totalSideH = 3 * sideH + 2 * gap;
|
||||
const topY = Math.round((H - totalSideH) / 2);
|
||||
|
||||
const sideX = W - padX - sideW;
|
||||
const mainW = sideX - gap - padX; // 1270px
|
||||
const mainH = Math.round(mainW * (9 / 16)); // 714px
|
||||
const mainAreaW = sideX - gap - padX;
|
||||
|
||||
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;
|
||||
|
||||
// 1. Render Main
|
||||
@ -398,12 +448,11 @@ export class RecordingCompositor {
|
||||
// 3. Render Bottom Overflow Tiles (each 16:9)
|
||||
const overflowFeeds = secondaryFeeds.slice(3);
|
||||
const numOverflow = overflowFeeds.length;
|
||||
const botMaxH = totalSideH - mainH - gap; // 292px
|
||||
const botYBase = topY + mainH + gap; // 759px
|
||||
const botMaxH = totalSideH - mainH - gap;
|
||||
const botYBase = topY + mainH + gap;
|
||||
|
||||
// Calculate width per tile to fit in mainW
|
||||
const candBotW = Math.floor(
|
||||
(mainW - (numOverflow - 1) * gap) / numOverflow,
|
||||
(mainAreaW - (numOverflow - 1) * gap) / numOverflow,
|
||||
);
|
||||
const candBotH = Math.round(candBotW * (9 / 16));
|
||||
|
||||
@ -417,7 +466,7 @@ export class RecordingCompositor {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
overflowFeeds.forEach((feed, j) => {
|
||||
@ -434,7 +483,7 @@ export class RecordingCompositor {
|
||||
|
||||
renderTile(ctx, feed, rect, isMain = false) {
|
||||
const { x, y, w, h } = rect;
|
||||
const radius = 12;
|
||||
const radius = Math.max(6, Math.round(Math.min(w, h) * 0.035));
|
||||
|
||||
ctx.save();
|
||||
this.drawRoundedClip(ctx, x, y, w, h, radius);
|
||||
@ -465,7 +514,7 @@ export class RecordingCompositor {
|
||||
ctx.restore();
|
||||
|
||||
// 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) {
|
||||
@ -524,10 +573,10 @@ export class RecordingCompositor {
|
||||
|
||||
drawPlaceholder(ctx, feed, x, y, w, h, isMain = false) {
|
||||
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
|
||||
? Math.min(54, h * 0.18)
|
||||
: Math.min(32, h * 0.22);
|
||||
? Math.min(Math.round(h * 0.18), Math.round(w * 0.12), 54)
|
||||
: Math.min(Math.round(h * 0.2), Math.round(w * 0.14), 36);
|
||||
|
||||
// Circular gradient avatar
|
||||
const gradient = ctx.createLinearGradient(
|
||||
@ -546,54 +595,60 @@ export class RecordingCompositor {
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
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();
|
||||
|
||||
// Avatar Initials Text
|
||||
const initialsFont = Math.max(10, Math.round(circleRadius * 0.85));
|
||||
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.textBaseline = "middle";
|
||||
ctx.fillText(feed.initials || "IV", cx, cy);
|
||||
|
||||
// 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.font = `600 ${isMain ? 18 : 13}px sans-serif`;
|
||||
ctx.font = `600 ${nameFont}px sans-serif`;
|
||||
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
|
||||
const subFont = Math.max(9, Math.round(isMain ? Math.min(14, h * 0.032) : Math.min(11, h * 0.045)));
|
||||
ctx.fillStyle = "#94a3b8";
|
||||
ctx.font = `400 ${isMain ? 13 : 10.5}px sans-serif`;
|
||||
ctx.font = `400 ${subFont}px sans-serif`;
|
||||
ctx.fillText(
|
||||
feed.isCamOn ? "Connecting video..." : "Camera turned off",
|
||||
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 fontSize = 11;
|
||||
const fontSize = Math.max(9, Math.min(12, Math.round(tileH * 0.045)));
|
||||
ctx.font = `600 ${fontSize}px sans-serif`;
|
||||
|
||||
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 tagH = 20;
|
||||
const tagH = Math.max(16, Math.round(fontSize * 1.8));
|
||||
|
||||
const tagX = x;
|
||||
const tagY = y - tagH;
|
||||
const marginX = Math.max(6, Math.round(tileW * 0.02));
|
||||
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();
|
||||
// Background
|
||||
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
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.12)";
|
||||
ctx.lineWidth = 1;
|
||||
this.drawRoundedStroke(ctx, tagX, tagY, tagW, tagH, 5);
|
||||
this.drawRoundedStroke(ctx, tagX, tagY, tagW, tagH, tagRadius);
|
||||
|
||||
// Name text
|
||||
ctx.fillStyle = "#e2e8f0";
|
||||
|
||||
@ -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>
|
||||
|
||||
<!-- 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">
|
||||
Call Ended
|
||||
</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">
|
||||
Join Call
|
||||
</x-button>
|
||||
|
||||
@ -76,7 +76,7 @@
|
||||
<x-pill id="timeline-status-pill" variant="danger" size="sm" icon="fa-solid fa-circle">Expired</x-pill>
|
||||
@elseif($interview->status === 'completed')
|
||||
<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>
|
||||
@elseif($interview->isActiveTimeline())
|
||||
<x-pill id="timeline-status-pill" variant="info" size="sm" icon="fa-solid fa-bolt">Timeline live</x-pill>
|
||||
|
||||
@ -4,6 +4,10 @@
|
||||
|
||||
@php
|
||||
$metrics = $interview->proctor_metrics;
|
||||
if ($interview->isExpired() && $interview->call_status === 'active') {
|
||||
$interview->update(['call_status' => 'ended', 'active_peers' => []]);
|
||||
$interview->call_status = 'ended';
|
||||
}
|
||||
@endphp
|
||||
|
||||
<tr class="hover:bg-white/[0.02] transition-colors">
|
||||
@ -36,7 +40,7 @@
|
||||
<x-pill variant="danger" size="sm">EXPIRED</x-pill>
|
||||
@elseif($interview->status === 'completed')
|
||||
<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>
|
||||
@elseif($interview->isActiveTimeline())
|
||||
<x-pill variant="info" size="sm" icon="fa-solid fa-bolt">TIMELINE LIVE</x-pill>
|
||||
@ -71,7 +75,7 @@
|
||||
</a>
|
||||
|
||||
<!-- 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">
|
||||
<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
|
||||
|
||||
@ -416,4 +416,49 @@ public function test_tab_switch_logging_and_external_ai_detection_behavior(): vo
|
||||
->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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user