subhajit_work_22_07 #11
@ -393,12 +393,125 @@ public function blockCandidate(Request $request, $id)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve Storage File (Recordings & Public Assets) directly with Range Support for Video Streaming.
|
||||
*/
|
||||
public function serveStorageFile($path)
|
||||
{
|
||||
$sanitizedPath = ltrim(str_replace('..', '', $path), '/');
|
||||
|
||||
$candidatePaths = [
|
||||
storage_path('app/public/' . $sanitizedPath),
|
||||
storage_path('app/private/public/' . $sanitizedPath),
|
||||
storage_path('app/private/' . $sanitizedPath),
|
||||
storage_path('app/' . $sanitizedPath),
|
||||
public_path('storage/' . $sanitizedPath),
|
||||
public_path($sanitizedPath),
|
||||
];
|
||||
|
||||
// Also check with alternate extensions (.webm / .mp4) if requested file extension differs
|
||||
$baseNoExt = preg_replace('/\.(mp4|webm|m4v|mov)$/i', '', $sanitizedPath);
|
||||
if ($baseNoExt !== $sanitizedPath) {
|
||||
$candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.webm');
|
||||
$candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.mp4');
|
||||
$candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.webm');
|
||||
$candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.mp4');
|
||||
}
|
||||
|
||||
$filePath = null;
|
||||
foreach ($candidatePaths as $cp) {
|
||||
if (file_exists($cp) && is_file($cp)) {
|
||||
$filePath = $cp;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$filePath) {
|
||||
abort(404, 'Storage file not found.');
|
||||
}
|
||||
|
||||
// Read first 12 bytes to accurately detect container format (WebM vs MP4 vs Image)
|
||||
$handle = @fopen($filePath, 'rb');
|
||||
$header = $handle ? fread($handle, 12) : '';
|
||||
if ($handle) fclose($handle);
|
||||
|
||||
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
$isWebmHeader = str_starts_with($header, "\x1A\x45\xDF\xA3");
|
||||
|
||||
if ($isWebmHeader || $extension === 'webm') {
|
||||
$mimeType = 'video/webm';
|
||||
} elseif (str_contains($header, 'ftyp') || in_array($extension, ['mp4', 'm4v'])) {
|
||||
$mimeType = 'video/mp4';
|
||||
} elseif ($extension === 'png') {
|
||||
$mimeType = 'image/png';
|
||||
} elseif (in_array($extension, ['jpg', 'jpeg'])) {
|
||||
$mimeType = 'image/jpeg';
|
||||
} else {
|
||||
$mimeType = @mime_content_type($filePath) ?: 'application/octet-stream';
|
||||
}
|
||||
|
||||
return response()->file($filePath, [
|
||||
'Content-Type' => $mimeType,
|
||||
'Accept-Ranges' => 'bytes',
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get normalized recordings list sorted in descending order of creation timestamp (newest first).
|
||||
*/
|
||||
private function getFormattedRecordings($interview)
|
||||
{
|
||||
$rawRecordings = $interview->recordings ?? [];
|
||||
if (!is_array($rawRecordings) || empty($rawRecordings)) {
|
||||
if (!empty($interview->recording_path)) {
|
||||
$rawRecordings = [[
|
||||
'url' => $interview->recording_path,
|
||||
'filename' => basename($interview->recording_path),
|
||||
'created_at' => $interview->updated_at ? $interview->updated_at->toIso8601String() : now()->toIso8601String(),
|
||||
]];
|
||||
} else {
|
||||
$rawRecordings = [];
|
||||
}
|
||||
}
|
||||
|
||||
$formatted = [];
|
||||
foreach ($rawRecordings as $idx => $rec) {
|
||||
if (is_string($rec)) {
|
||||
$rec = [
|
||||
'url' => $rec,
|
||||
'filename' => basename($rec),
|
||||
'created_at' => $interview->updated_at ? $interview->updated_at->toIso8601String() : now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
$url = $rec['url'] ?? '';
|
||||
$urlPath = parse_url($url, PHP_URL_PATH) ?? $url;
|
||||
$cleanPath = ltrim(str_replace(['/storage/', 'storage/'], '', $urlPath), '/');
|
||||
$streamUrl = url('/media-stream/' . $cleanPath);
|
||||
|
||||
$rec['original_index'] = $idx;
|
||||
$rec['stream_url'] = $streamUrl;
|
||||
$rec['download_url'] = route('interview.download-recording', $interview->id) . '?index=' . $idx;
|
||||
$formatted[] = $rec;
|
||||
}
|
||||
|
||||
// Sort descending by created_at timestamp (newest first)
|
||||
usort($formatted, function ($a, $b) {
|
||||
$timeA = isset($a['created_at']) ? strtotime($a['created_at']) : 0;
|
||||
$timeB = isset($b['created_at']) ? strtotime($b['created_at']) : 0;
|
||||
return $timeB <=> $timeA;
|
||||
});
|
||||
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live Polling Endpoint for Candidate Warnings & HR Monitoring.
|
||||
*/
|
||||
public function getPollData($id)
|
||||
{
|
||||
$interview = Interview::with(['warnings.sender'])->findOrFail($id);
|
||||
$formattedRecordings = $this->getFormattedRecordings($interview);
|
||||
|
||||
return response()->json([
|
||||
'status' => $interview->status,
|
||||
@ -412,7 +525,7 @@ public function getPollData($id)
|
||||
'blocked_ip' => $interview->blocked_ip,
|
||||
'proctor_logs' => $interview->proctor_logs ?? [],
|
||||
'recording_path' => $interview->recording_path,
|
||||
'recordings' => $interview->recordings ?? [],
|
||||
'recordings' => $formattedRecordings,
|
||||
'warnings' => $interview->warnings,
|
||||
'is_expired' => $interview->isExpired(),
|
||||
]);
|
||||
@ -438,9 +551,14 @@ public function uploadRecording(Request $request, $id)
|
||||
|
||||
if ($request->hasFile('video')) {
|
||||
$file = $request->file('video');
|
||||
$filename = 'interview_' . $interview->submission_unique_id . '_' . time() . '.mp4';
|
||||
$path = $file->storeAs('public/recordings', $filename);
|
||||
$url = Storage::url($path);
|
||||
$ext = strtolower($file->getClientOriginalExtension() ?: 'webm');
|
||||
if (!in_array($ext, ['mp4', 'webm', 'm4v', 'mov'])) {
|
||||
$ext = 'webm';
|
||||
}
|
||||
|
||||
$filename = 'interview_' . $interview->submission_unique_id . '_' . time() . '.' . $ext;
|
||||
$path = $file->storeAs('recordings', $filename, 'public');
|
||||
$url = Storage::disk('public')->url($path);
|
||||
|
||||
$recordings = $interview->recordings ?? [];
|
||||
if (!is_array($recordings)) {
|
||||
@ -464,7 +582,7 @@ public function uploadRecording(Request $request, $id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Download Candidate Video Recording as MP4 File.
|
||||
* Download Candidate Video Recording as File.
|
||||
*/
|
||||
public function downloadRecording(Request $request, $id)
|
||||
{
|
||||
@ -486,11 +604,46 @@ public function downloadRecording(Request $request, $id)
|
||||
return back()->with('error', 'No video recording found for this candidate profile.');
|
||||
}
|
||||
|
||||
$relativePath = str_replace('/storage/', 'public/', $targetUrl);
|
||||
if (Storage::exists($relativePath)) {
|
||||
$downloadFilename = 'candidate_' . Str::slug($interview->candidate_name) . '_recording_' . ($index + 1) . '.mp4';
|
||||
return Storage::download($relativePath, $downloadFilename, [
|
||||
'Content-Type' => 'video/mp4',
|
||||
$urlPath = parse_url($targetUrl, PHP_URL_PATH) ?? $targetUrl;
|
||||
$sanitizedPath = ltrim(str_replace(['/storage/', 'storage/'], '', $urlPath), '/');
|
||||
|
||||
$candidatePaths = [
|
||||
storage_path('app/public/' . $sanitizedPath),
|
||||
storage_path('app/private/public/' . $sanitizedPath),
|
||||
storage_path('app/private/' . $sanitizedPath),
|
||||
storage_path('app/' . $sanitizedPath),
|
||||
public_path('storage/' . $sanitizedPath),
|
||||
public_path($sanitizedPath),
|
||||
];
|
||||
|
||||
$baseNoExt = preg_replace('/\.(mp4|webm|m4v|mov)$/i', '', $sanitizedPath);
|
||||
if ($baseNoExt !== $sanitizedPath) {
|
||||
$candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.webm');
|
||||
$candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.mp4');
|
||||
$candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.webm');
|
||||
$candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.mp4');
|
||||
}
|
||||
|
||||
$filePath = null;
|
||||
foreach ($candidatePaths as $cp) {
|
||||
if (file_exists($cp) && is_file($cp)) {
|
||||
$filePath = $cp;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($filePath && file_exists($filePath)) {
|
||||
$handle = @fopen($filePath, 'rb');
|
||||
$header = $handle ? fread($handle, 12) : '';
|
||||
if ($handle) fclose($handle);
|
||||
|
||||
$isWebm = str_starts_with($header, "\x1A\x45\xDF\xA3") || str_ends_with($filePath, '.webm');
|
||||
$ext = $isWebm ? 'webm' : 'mp4';
|
||||
$mimeType = $isWebm ? 'video/webm' : 'video/mp4';
|
||||
|
||||
$downloadFilename = 'candidate_' . Str::slug($interview->candidate_name) . '_recording_' . ($index + 1) . '.' . $ext;
|
||||
return response()->download($filePath, $downloadFilename, [
|
||||
'Content-Type' => $mimeType,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -556,7 +709,7 @@ public function generateReport($id)
|
||||
$codeScore += 10;
|
||||
}
|
||||
}
|
||||
$codeScore = min(100, $codeScore);
|
||||
$interview->formatted_recordings = $this->getFormattedRecordings($interview);
|
||||
|
||||
return view('interview.report', compact(
|
||||
'interview',
|
||||
|
||||
@ -621,8 +621,8 @@
|
||||
|
||||
<!-- Tab 4: Saved Candidate Profile Video Recordings & MP4 Downloads -->
|
||||
<div id="tab-content-recordings" style="display: none;">
|
||||
<div style="font-size: 0.75rem; color: #94a3b8; margin-bottom: 6px; font-weight: 600;">Saved Candidate Video Sessions (Downloadable in MP4 format):</div>
|
||||
<div id="modal-recordings-container" style="background: #050811; border: 1px solid var(--card-border); border-radius: 10px; padding: 12px; min-height: 250px; max-height: 350px; overflow-y: auto;">
|
||||
<div style="font-size: 0.75rem; color: #94a3b8; margin-bottom: 6px; font-weight: 600;">Saved Candidate Video Sessions (Sorted by Newest First):</div>
|
||||
<div id="modal-recordings-container" style="background: #050811; border: 1px solid var(--card-border); border-radius: 10px; padding: 12px; min-height: 250px; max-height: 420px; overflow-y: auto;">
|
||||
<div style="color: #94a3b8; font-size: 0.8rem; text-align: center; padding-top: 40px;">No video recordings saved for this candidate yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -840,27 +840,50 @@ function pollReviewData() {
|
||||
noDrawing.style.display = 'block';
|
||||
}
|
||||
|
||||
// Render Candidate Saved Video Recordings in Profile Modal
|
||||
// Render Candidate Saved Video Recordings in Profile Modal (Descending order by timestamp)
|
||||
const recContainer = document.getElementById('modal-recordings-container');
|
||||
if (recContainer) {
|
||||
const recPath = data.recording_path;
|
||||
const recList = data.recordings || [];
|
||||
if (recPath || recList.length > 0) {
|
||||
let recHtml = '';
|
||||
let allRecs = recList.length > 0 ? recList : [{ url: recPath, created_at: new Date().toISOString() }];
|
||||
allRecs.forEach((r, idx) => {
|
||||
const videoUrl = typeof r === 'string' ? r : (r.url || recPath);
|
||||
const dateStr = r.created_at ? new Date(r.created_at).toLocaleTimeString() : 'Session Recording';
|
||||
const downloadUrl = `{{ route('interview.download-recording', ':id') }}`.replace(':id', currentInterviewId) + `?index=${idx}`;
|
||||
let recList = data.recordings || [];
|
||||
|
||||
recHtml += `<div style="background: rgba(255,255,255,0.04); border: 1px solid var(--card-border); border-radius: 10px; padding: 12px; margin-bottom: 10px;">
|
||||
if (recList.length === 0 && recPath) {
|
||||
recList = [{ url: recPath, created_at: new Date().toISOString(), original_index: 0 }];
|
||||
}
|
||||
|
||||
if (recList.length > 0) {
|
||||
// Ensure descending order by created_at
|
||||
recList.sort((a, b) => {
|
||||
const timeA = a.created_at ? new Date(a.created_at).getTime() : 0;
|
||||
const timeB = b.created_at ? new Date(b.created_at).getTime() : 0;
|
||||
return timeB - timeA;
|
||||
});
|
||||
|
||||
let recHtml = `<div style="font-size: 0.75rem; color: #38bdf8; font-weight: 700; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>📹 Stored Video Sessions (${recList.length} recording${recList.length > 1 ? 's' : ''})</span>
|
||||
<span style="font-size: 0.65rem; color: #94a3b8;">📜 Scroll to view older recordings</span>
|
||||
</div>`;
|
||||
|
||||
recList.forEach((r, displayIdx) => {
|
||||
let videoUrl = typeof r === 'string' ? r : (r.stream_url || r.url || recPath);
|
||||
if (videoUrl && videoUrl.includes('/storage/')) {
|
||||
videoUrl = videoUrl.replace('/storage/', '/media-stream/');
|
||||
}
|
||||
const origIdx = (typeof r === 'object' && r.original_index !== undefined) ? r.original_index : displayIdx;
|
||||
const dateObj = (r && r.created_at) ? new Date(r.created_at) : new Date();
|
||||
const dateStr = dateObj.toLocaleDateString() + ' ' + dateObj.toLocaleTimeString();
|
||||
const downloadUrl = (typeof r === 'object' && r.download_url) ? r.download_url : (`{{ route('interview.download-recording', ':id') }}`.replace(':id', currentInterviewId) + `?index=${origIdx}`);
|
||||
|
||||
recHtml += `<div style="background: rgba(255,255,255,0.04); border: 1px solid var(--card-border); border-radius: 10px; padding: 12px; margin-bottom: 12px; transition: all 0.2s ease;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<strong style="color: white; font-size: 0.8rem;">📹 Video Session Recording #${idx + 1} (${dateStr})</strong>
|
||||
<a href="${downloadUrl}" target="_blank" download class="btn-nav" style="background: #10b981; color: white; border: none; font-size: 0.75rem; font-weight: 700; padding: 4px 12px; text-decoration: none;">
|
||||
📥 Download MP4 Video
|
||||
<div>
|
||||
<strong style="color: white; font-size: 0.8rem;">📹 Session Recording #${recList.length - displayIdx}</strong>
|
||||
<span style="font-size: 0.7rem; color: #94a3b8; margin-left: 8px;">📅 ${dateStr}</span>
|
||||
</div>
|
||||
<a href="${downloadUrl}" target="_blank" download class="btn-nav" style="background: #10b981; color: white; border: none; font-size: 0.75rem; font-weight: 700; padding: 4px 12px; text-decoration: none; border-radius: 6px; display: inline-flex; align-items: center; gap: 4px;">
|
||||
📥 Download Video
|
||||
</a>
|
||||
</div>
|
||||
<video src="${videoUrl}" controls style="width: 100%; max-height: 200px; border-radius: 8px; background: #000; outline: none;"></video>
|
||||
<video src="${videoUrl}" controls preload="metadata" style="width: 100%; max-height: 240px; border-radius: 8px; background: #000; outline: none; border: 1px solid rgba(255,255,255,0.1);"></video>
|
||||
</div>`;
|
||||
});
|
||||
recContainer.innerHTML = recHtml;
|
||||
@ -1394,10 +1417,16 @@ function startCallRecording() {
|
||||
|
||||
adminRecordedChunks = [];
|
||||
try {
|
||||
const options = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
|
||||
? { mimeType: 'video/webm;codecs=vp9' }
|
||||
: (MediaRecorder.isTypeSupported('video/webm') ? { mimeType: 'video/webm' } : {});
|
||||
let mime = 'video/webm';
|
||||
if (MediaRecorder.isTypeSupported('video/mp4')) {
|
||||
mime = 'video/mp4';
|
||||
} else if (MediaRecorder.isTypeSupported('video/webm;codecs=vp9')) {
|
||||
mime = 'video/webm;codecs=vp9';
|
||||
} else if (MediaRecorder.isTypeSupported('video/webm')) {
|
||||
mime = 'video/webm';
|
||||
}
|
||||
|
||||
const options = { mimeType: mime };
|
||||
adminMediaRecorder = new MediaRecorder(streamToRecord, options);
|
||||
|
||||
adminMediaRecorder.ondataavailable = function(e) {
|
||||
@ -1408,8 +1437,13 @@ function startCallRecording() {
|
||||
|
||||
adminMediaRecorder.onstop = function() {
|
||||
if (adminRecordedChunks.length === 0) return;
|
||||
const blob = new Blob(adminRecordedChunks, { type: 'video/webm' });
|
||||
uploadRecordedBlob(blob);
|
||||
const recMime = adminMediaRecorder.mimeType || mime;
|
||||
const isMp4 = recMime.includes('mp4');
|
||||
const ext = isMp4 ? 'mp4' : 'webm';
|
||||
const blobType = isMp4 ? 'video/mp4' : 'video/webm';
|
||||
|
||||
const blob = new Blob(adminRecordedChunks, { type: blobType });
|
||||
uploadRecordedBlob(blob, ext);
|
||||
};
|
||||
|
||||
adminMediaRecorder.start(1000);
|
||||
@ -1442,11 +1476,11 @@ function stopCallRecording() {
|
||||
document.getElementById('btn-stop-recording').style.display = 'none';
|
||||
}
|
||||
|
||||
function uploadRecordedBlob(blob) {
|
||||
function uploadRecordedBlob(blob, ext = 'webm') {
|
||||
if (!currentInterviewId) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('video', blob, `interview_${currentInterviewId}_${Date.now()}.mp4`);
|
||||
formData.append('video', blob, `interview_${currentInterviewId}_${Date.now()}.${ext}`);
|
||||
formData.append('_token', '{{ csrf_token() }}');
|
||||
|
||||
fetch(`{{ route('interview.upload-recording', ':id') }}`.replace(':id', currentInterviewId), {
|
||||
|
||||
@ -402,16 +402,25 @@
|
||||
@endif
|
||||
|
||||
@if(is_array($interview->formatted_recordings) && count($interview->formatted_recordings) > 0)
|
||||
<div style="font-size: 0.75rem; font-weight: 700; color: #6366f1; margin-bottom: 8px;">📹 STORED CALL SESSION RECORDINGS ({{ count($interview->formatted_recordings) }} RECORDINGS):</div>
|
||||
@foreach($interview->formatted_recordings as $idx => $rec)
|
||||
<div style="font-size: 0.75rem; font-weight: 700; color: #6366f1; margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>📹 STORED CALL SESSION RECORDINGS ({{ count($interview->formatted_recordings) }} RECORDINGS - NEWEST FIRST):</span>
|
||||
<span style="font-size: 0.65rem; color: #94a3b8;">📜 Scroll to view all</span>
|
||||
</div>
|
||||
<div style="max-height: 480px; overflow-y: auto; padding-right: 4px; border: 1px solid var(--border); border-radius: 8px; padding: 10px; background: #fafafa; margin-bottom: 12px;">
|
||||
@foreach($interview->formatted_recordings as $displayIdx => $rec)
|
||||
<div style="margin-bottom: 12px; background: white; border: 1px solid var(--border); border-radius: 8px; padding: 10px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<span style="font-size: 0.75rem; font-weight: 700; color: var(--primary);">Recording #{{ $idx + 1 }} ({{ $rec['created_at'] }})</span>
|
||||
<a href="{{ $rec['download_url'] }}" download style="font-size: 0.7rem; color: #6366f1; font-weight: 700; text-decoration: none;">📥 Download MP4 Video</a>
|
||||
<span style="font-size: 0.75rem; font-weight: 700; color: var(--primary);">Recording #{{ count($interview->formatted_recordings) - $displayIdx }} ({{ $rec['created_at'] }})</span>
|
||||
<a href="{{ $rec['download_url'] }}" download style="font-size: 0.7rem; color: #6366f1; font-weight: 700; text-decoration: none; padding: 3px 8px; background: rgba(99,102,241,0.1); border-radius: 4px;">📥 Download MP4 Video</a>
|
||||
</div>
|
||||
<video src="{{ $rec['stream_url'] }}" controls style="width: 100%; max-height: 220px; border-radius: 6px; background: #000; outline: none;"></video>
|
||||
<video controls preload="metadata" style="width: 100%; max-height: 220px; border-radius: 6px; background: #000; outline: none;">
|
||||
<source src="{{ $rec['stream_url'] }}" type="video/mp4">
|
||||
<source src="{{ $rec['stream_url'] }}" type="video/webm">
|
||||
Your browser does not support video playback.
|
||||
</video>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@ -38,6 +38,10 @@
|
||||
Route::post('/candidate/send-message/{id}', [InterviewController::class, 'candidateSendMessage'])->name('interview.candidate.send-message');
|
||||
Route::get('/candidate/poll/{id}', [InterviewController::class, 'getPollData'])->name('interview.candidate.poll');
|
||||
|
||||
// Public Storage & Media Stream Delivery (Recordings & Video Streaming)
|
||||
Route::get('/media-stream/{path}', [InterviewController::class, 'serveStorageFile'])->where('path', '.*')->name('media.stream');
|
||||
Route::get('/storage/{path}', [InterviewController::class, 'serveStorageFile'])->where('path', '.*')->name('storage.file');
|
||||
|
||||
// User Dashboard Portal (requires authenticated user)
|
||||
Route::middleware(['auth', 'role:user', 'verify-ms-session'])->group(function () {
|
||||
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
|
||||
|
||||
69
tests/Feature/RecordingStreamTest.php
Normal file
69
tests/Feature/RecordingStreamTest.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Interview;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RecordingStreamTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_media_stream_serves_recorded_video_with_range_and_headers()
|
||||
{
|
||||
$dir = storage_path('app/public/recordings');
|
||||
if (!File::exists($dir)) {
|
||||
File::makeDirectory($dir, 0755, true);
|
||||
}
|
||||
|
||||
$testFile = $dir . '/test_stream_video.webm';
|
||||
$fakeVideoContent = "\x1A\x45\xDF\xA3" . str_repeat('0', 100);
|
||||
File::put($testFile, $fakeVideoContent);
|
||||
|
||||
try {
|
||||
$response = $this->get('/media-stream/recordings/test_stream_video.webm');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertHeader('Content-Type', 'video/webm');
|
||||
$response->assertHeader('Accept-Ranges', 'bytes');
|
||||
} finally {
|
||||
if (File::exists($testFile)) {
|
||||
File::delete($testFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_poll_returns_media_stream_urls()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$interview = Interview::create([
|
||||
'candidate_name' => 'John Streamer',
|
||||
'candidate_email' => 'stream@example.com',
|
||||
'candidate_phone' => '1234567890',
|
||||
'submission_unique_id' => 'stream_test_123',
|
||||
'temp_password' => 'secret123',
|
||||
'language' => 'python',
|
||||
'status' => 'pending',
|
||||
'expires_at' => now()->addDays(7),
|
||||
'recording_path' => '/storage/recordings/stream_test_123.webm',
|
||||
'recordings' => [
|
||||
[
|
||||
'url' => '/storage/recordings/stream_test_123.webm',
|
||||
'filename' => 'stream_test_123.webm',
|
||||
'created_at' => now()->toIso8601String(),
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('interview.poll', $interview->id));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json();
|
||||
$this->assertNotEmpty($data['recordings']);
|
||||
$this->assertStringContainsString('/media-stream/recordings/stream_test_123.webm', $data['recordings'][0]['stream_url']);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user