Fix stuck recording processing and add invoice service API test.
Deploy Ladill Meet / deploy (push) Successful in 52s
Deploy Ladill Meet / deploy (push) Successful in 52s
Capture stage spotlight video for browser recordings, fail incomplete uploads, allow post-session host uploads, and expire stale processing recordings automatically. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Meet\RecordingService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class FailStaleMeetRecordingsCommand extends Command
|
||||
{
|
||||
protected $signature = 'meet:fail-stale-recordings';
|
||||
|
||||
protected $description = 'Mark Meet recordings stuck in processing as failed';
|
||||
|
||||
public function handle(RecordingService $recordings): int
|
||||
{
|
||||
$failed = $recordings->failStaleProcessingRecordings();
|
||||
|
||||
$this->info("Marked {$failed} stale recording(s) as failed.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -431,11 +431,25 @@ class MeetingRoomController extends Controller
|
||||
|
||||
public function stopRecording(Request $request, Session $session): JsonResponse
|
||||
{
|
||||
$participant = $this->currentParticipant($request, $session);
|
||||
abort_unless($participant->isHost(), 403);
|
||||
$participant = $this->recordingHost($request, $session);
|
||||
|
||||
$recording = $session->recordings()->where('status', 'recording')->firstOrFail();
|
||||
$recording = $this->recordings->stop($recording, $participant->user_ref ?? $participant->uuid);
|
||||
$actorRef = $participant->user_ref ?? $participant->uuid;
|
||||
|
||||
if ($request->boolean('failed')) {
|
||||
$recording = $this->recordings->fail(
|
||||
$recording,
|
||||
(string) $request->input('reason', 'Recording capture did not complete.'),
|
||||
$actorRef,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'failed',
|
||||
'recording_uuid' => $recording->uuid,
|
||||
]);
|
||||
}
|
||||
|
||||
$recording = $this->recordings->stop($recording, $actorRef);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'processing',
|
||||
@@ -443,10 +457,27 @@ class MeetingRoomController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function failRecording(Request $request, Session $session, Recording $recording): JsonResponse
|
||||
{
|
||||
$participant = $this->recordingHost($request, $session);
|
||||
abort_unless($recording->session_id === $session->id, 404);
|
||||
abort_if(! in_array($recording->status, ['recording', 'processing'], true), 422);
|
||||
|
||||
$recording = $this->recordings->fail(
|
||||
$recording,
|
||||
(string) $request->input('reason', 'Recording capture did not complete.'),
|
||||
$participant->user_ref ?? $participant->uuid,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'status' => $recording->status,
|
||||
'recording_uuid' => $recording->uuid,
|
||||
]);
|
||||
}
|
||||
|
||||
public function uploadRecording(Request $request, Session $session, Recording $recording): JsonResponse
|
||||
{
|
||||
$participant = $this->currentParticipant($request, $session);
|
||||
abort_unless($participant->isHost(), 403);
|
||||
$participant = $this->recordingHost($request, $session);
|
||||
abort_unless($recording->session_id === $session->id, 404);
|
||||
|
||||
$maxKb = max(1, (int) config('meet.recordings.max_mb', 512)) * 1024;
|
||||
@@ -529,4 +560,33 @@ class MeetingRoomController extends Controller
|
||||
->firstOrFail()
|
||||
);
|
||||
}
|
||||
|
||||
/** Host/co-host in the room session, or the room host after the meeting ended. */
|
||||
protected function recordingHost(Request $request, Session $session): Participant
|
||||
{
|
||||
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
|
||||
if ($participantUuid) {
|
||||
$participant = Participant::query()
|
||||
->where('uuid', $participantUuid)
|
||||
->where('session_id', $session->id)
|
||||
->first();
|
||||
|
||||
if ($participant?->isHost()) {
|
||||
return $this->sessions->syncEventsSpeakerRole($request, $participant);
|
||||
}
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
abort_unless($user, 403);
|
||||
|
||||
$hostParticipant = $session->participants()
|
||||
->where('user_ref', $user->ownerRef())
|
||||
->whereIn('role', ['host', 'co_host'])
|
||||
->orderByRaw("case when role = 'host' then 0 else 1 end")
|
||||
->first();
|
||||
|
||||
abort_unless($hostParticipant, 403);
|
||||
|
||||
return $hostParticipant;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ class RecordingController extends Controller
|
||||
$this->authorizeAbility($request, 'meetings.view');
|
||||
$this->authorizeOwner($request, $recording);
|
||||
$recording->load(['session.room', 'session.aiSummaries', 'session.participants']);
|
||||
$recording = $this->recordings->ensureStorageValid($recording);
|
||||
$recording = $this->recordings->resolveProcessingState($recording);
|
||||
|
||||
$canManage = app(\App\Services\Meet\MeetPermissions::class)
|
||||
->can($this->member($request), 'meetings.manage');
|
||||
@@ -52,7 +52,7 @@ class RecordingController extends Controller
|
||||
{
|
||||
$this->authorizeAbility($request, 'meetings.view');
|
||||
$this->authorizeOwner($request, $recording);
|
||||
$recording = $this->recordings->ensureStorageValid($recording);
|
||||
$recording = $this->recordings->resolveProcessingState($recording);
|
||||
abort_unless($recording->hasPlayableFile(), 404, 'Recording file is not available.');
|
||||
abort_unless(app(\App\Services\Meet\MeetBillingService::class)->storageIsAccessible($recording), 402, 'Recording storage billing is overdue. Top up your Ladill wallet to download this recording.');
|
||||
|
||||
|
||||
@@ -121,6 +121,80 @@ class RecordingService
|
||||
return $recording->fresh();
|
||||
}
|
||||
|
||||
public function fail(Recording $recording, string $reason, ?string $actorRef = null): Recording
|
||||
{
|
||||
if (! in_array($recording->status, ['recording', 'processing'], true)) {
|
||||
return $recording;
|
||||
}
|
||||
|
||||
$recording->update([
|
||||
'status' => 'failed',
|
||||
'failure_reason' => $reason,
|
||||
'ended_at' => $recording->ended_at ?? now(),
|
||||
'duration_seconds' => $recording->duration_seconds
|
||||
?? ($recording->started_at ? (int) $recording->started_at->diffInSeconds(now()) : null),
|
||||
]);
|
||||
|
||||
if ($actorRef) {
|
||||
AuditLogger::record(
|
||||
$recording->owner_ref,
|
||||
'recording.failed',
|
||||
$recording->session->room->organization_id,
|
||||
$actorRef,
|
||||
Recording::class,
|
||||
$recording->id,
|
||||
);
|
||||
}
|
||||
|
||||
return $recording->fresh();
|
||||
}
|
||||
|
||||
/** Fail recordings left in processing long after the meeting ended. */
|
||||
public function resolveProcessingState(Recording $recording): Recording
|
||||
{
|
||||
if ($recording->status !== 'processing') {
|
||||
return $this->ensureStorageValid($recording);
|
||||
}
|
||||
|
||||
$timeoutMinutes = max(5, (int) config('meet.recordings.processing_timeout_minutes', 15));
|
||||
$staleAt = $recording->ended_at ?? $recording->updated_at;
|
||||
|
||||
if ($staleAt && $staleAt->lt(now()->subMinutes($timeoutMinutes))) {
|
||||
return $this->fail(
|
||||
$recording,
|
||||
'Recording upload did not complete. Keep the host browser open briefly after ending the meeting so the file can upload.',
|
||||
);
|
||||
}
|
||||
|
||||
return $recording;
|
||||
}
|
||||
|
||||
public function failStaleProcessingRecordings(?int $timeoutMinutes = null): int
|
||||
{
|
||||
$timeoutMinutes = max(5, $timeoutMinutes ?? (int) config('meet.recordings.processing_timeout_minutes', 15));
|
||||
$cutoff = now()->subMinutes($timeoutMinutes);
|
||||
$failed = 0;
|
||||
|
||||
Recording::query()
|
||||
->where('status', 'processing')
|
||||
->where(function ($query) use ($cutoff) {
|
||||
$query->where('ended_at', '<', $cutoff)
|
||||
->orWhere(function ($query) use ($cutoff) {
|
||||
$query->whereNull('ended_at')->where('updated_at', '<', $cutoff);
|
||||
});
|
||||
})
|
||||
->orderBy('id')
|
||||
->each(function (Recording $recording) use (&$failed) {
|
||||
$this->fail(
|
||||
$recording,
|
||||
'Recording upload did not complete. Keep the host browser open briefly after ending the meeting so the file can upload.',
|
||||
);
|
||||
$failed++;
|
||||
});
|
||||
|
||||
return $failed;
|
||||
}
|
||||
|
||||
/** Mark ready recordings with missing/empty files as failed. */
|
||||
public function ensureStorageValid(Recording $recording): Recording
|
||||
{
|
||||
|
||||
@@ -113,6 +113,7 @@ return [
|
||||
'recordings' => [
|
||||
'disk' => env('MEET_RECORDINGS_DISK', 'local'),
|
||||
'max_mb' => (int) env('MEET_RECORDING_MAX_MB', 512),
|
||||
'processing_timeout_minutes' => (int) env('MEET_RECORDING_PROCESSING_TIMEOUT_MINUTES', 15),
|
||||
],
|
||||
|
||||
'ai' => [
|
||||
|
||||
+70
-10
@@ -1486,8 +1486,10 @@ function meetRoom() {
|
||||
const data = await res.json();
|
||||
this.activeRecordingUuid = data.recording_uuid;
|
||||
if (! await this.tryStartClientRecordingWithRetry()) {
|
||||
await this.abortServerRecording('Could not capture meeting video in this browser.');
|
||||
this.isRecording = false;
|
||||
this.activeRecordingUuid = null;
|
||||
window.alert(`Recording could not start because this browser could not capture the ${this.sessionNoun()} video. Try Chrome on desktop, or disable stage-only layouts temporarily.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1530,7 +1532,11 @@ function meetRoom() {
|
||||
const screenVideo = screenEl && !screenEl.classList.contains('hidden')
|
||||
? screenEl.querySelector('video')
|
||||
: null;
|
||||
const videoSource = screenVideo || document.getElementById('video-grid');
|
||||
|
||||
let videoSource = screenVideo;
|
||||
if (!videoSource) {
|
||||
videoSource = this.recordingVideoCaptureElement();
|
||||
}
|
||||
|
||||
if (!videoSource?.captureStream) {
|
||||
return null;
|
||||
@@ -1542,6 +1548,26 @@ function meetRoom() {
|
||||
return tracks.length ? new MediaStream(tracks) : null;
|
||||
},
|
||||
|
||||
recordingVideoCaptureElement() {
|
||||
if (!this.usesStageLayout) {
|
||||
return document.getElementById('video-grid');
|
||||
}
|
||||
|
||||
const spotlight = document.getElementById('meet-stage-spotlight');
|
||||
const grid = document.getElementById('video-grid');
|
||||
const stage = document.getElementById('meet-stage');
|
||||
|
||||
if (spotlight?.querySelector('video')) {
|
||||
return spotlight;
|
||||
}
|
||||
|
||||
if (grid?.querySelector('video')) {
|
||||
return grid;
|
||||
}
|
||||
|
||||
return stage || spotlight || grid;
|
||||
},
|
||||
|
||||
collectMeetingAudioTracks() {
|
||||
const tracks = [];
|
||||
const addFromParticipant = (participant) => {
|
||||
@@ -1564,30 +1590,56 @@ function meetRoom() {
|
||||
|
||||
async stopServerAndUploadRecording() {
|
||||
const hadClientRecorder = !!recordingMediaRecorder;
|
||||
const res = await fetch(this.config.recordingStopUrl, { method: 'POST', headers: this.headers() });
|
||||
const blob = await this.stopClientRecording();
|
||||
this.isRecording = false;
|
||||
|
||||
if (!res.ok) {
|
||||
let recordingUuid = this.activeRecordingUuid;
|
||||
const res = await fetch(this.config.recordingStopUrl, { method: 'POST', headers: this.headers() });
|
||||
if (res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
recordingUuid = data.recording_uuid || recordingUuid;
|
||||
} else if (res.status !== 404 || !recordingUuid) {
|
||||
this.activeRecordingUuid = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const recordingUuid = data.recording_uuid || this.activeRecordingUuid;
|
||||
|
||||
if (blob && recordingUuid) {
|
||||
const uploaded = await this.uploadRecording(blob, recordingUuid);
|
||||
if (!uploaded) {
|
||||
window.alert(`Recording upload failed. Open Recordings and try again, or re-record the ${this.sessionNoun()}.`);
|
||||
window.alert(`Recording upload failed. Open Recordings and refresh shortly, or re-record the ${this.sessionNoun()}.`);
|
||||
}
|
||||
} else if (recordingUuid) {
|
||||
await this.failServerRecording(
|
||||
recordingUuid,
|
||||
hadClientRecorder
|
||||
? `No recording was captured from this ${this.sessionNoun()}.`
|
||||
: 'Recording capture stopped before any video was saved.',
|
||||
);
|
||||
if (hadClientRecorder) {
|
||||
window.alert(`No recording was captured from this ${this.sessionNoun()}. Start recording again while the ${this.sessionNoun()} is live.`);
|
||||
}
|
||||
} else if (recordingUuid && hadClientRecorder) {
|
||||
window.alert(`No recording was captured from this ${this.sessionNoun()}. Start recording again while the ${this.sessionNoun()} is live.`);
|
||||
}
|
||||
|
||||
this.activeRecordingUuid = null;
|
||||
},
|
||||
|
||||
async abortServerRecording(reason) {
|
||||
await fetch(this.config.recordingStopUrl, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ failed: true, reason }),
|
||||
});
|
||||
},
|
||||
|
||||
async failServerRecording(recordingUuid, reason) {
|
||||
const url = `/room/${this.config.sessionUuid}/recordings/${recordingUuid}/fail`;
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
},
|
||||
|
||||
stopClientRecording() {
|
||||
return new Promise((resolve) => {
|
||||
const recorder = recordingMediaRecorder;
|
||||
@@ -1826,7 +1878,7 @@ function meetRoom() {
|
||||
},
|
||||
|
||||
submitEndMeeting() {
|
||||
if (this.isRecording) {
|
||||
if (this.isRecording || this.activeRecordingUuid || recordingMediaRecorder) {
|
||||
this.stopServerAndUploadRecording().finally(() => {
|
||||
document.getElementById('meet-end-form')?.requestSubmit();
|
||||
});
|
||||
@@ -1836,6 +1888,14 @@ function meetRoom() {
|
||||
this.endMeetingConfirmOpen = false;
|
||||
},
|
||||
|
||||
async submitLeave() {
|
||||
if (this.isHost && (this.isRecording || this.activeRecordingUuid || recordingMediaRecorder)) {
|
||||
await this.stopServerAndUploadRecording();
|
||||
}
|
||||
|
||||
document.getElementById('meet-leave-form')?.submit();
|
||||
},
|
||||
|
||||
async uploadFile(event) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file || !this.config.filesUploadUrl) return;
|
||||
|
||||
@@ -427,7 +427,7 @@
|
||||
<div class="meet-toolbar__divider sm:hidden" aria-hidden="true"></div>
|
||||
|
||||
<div class="meet-toolbar__actions sm:contents">
|
||||
<form method="POST" action="{{ route('meet.room.leave', $session) }}" class="inline">
|
||||
<form id="meet-leave-form" method="POST" action="{{ route('meet.room.leave', $session) }}" class="inline" @submit.prevent="submitLeave()">
|
||||
@csrf
|
||||
<button type="submit"
|
||||
class="rounded-full bg-red-600 p-3 font-medium hover:bg-red-500 sm:px-5 sm:py-3 sm:text-sm"
|
||||
|
||||
@@ -4,3 +4,4 @@ use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::command('meet:send-reminders')->everyFiveMinutes();
|
||||
Schedule::command('meet:process-billing')->daily()->withoutOverlapping();
|
||||
Schedule::command('meet:fail-stale-recordings')->everyFifteenMinutes()->withoutOverlapping();
|
||||
|
||||
@@ -78,6 +78,7 @@ Route::get('/room/{session}/media-token', [MeetingRoomController::class, 'mediaT
|
||||
Route::post('/room/{session}/recording/start', [MeetingRoomController::class, 'startRecording'])->name('meet.room.recording.start');
|
||||
Route::post('/room/{session}/recording/stop', [MeetingRoomController::class, 'stopRecording'])->name('meet.room.recording.stop');
|
||||
Route::post('/room/{session}/recordings/{recording}/upload', [MeetingRoomController::class, 'uploadRecording'])->name('meet.room.recording.upload');
|
||||
Route::post('/room/{session}/recordings/{recording}/fail', [MeetingRoomController::class, 'failRecording'])->name('meet.room.recording.fail');
|
||||
Route::post('/room/{session}/lock', [MeetingRoomController::class, 'lock'])->name('meet.room.lock');
|
||||
Route::post('/room/{session}/unlock', [MeetingRoomController::class, 'unlock'])->name('meet.room.unlock');
|
||||
Route::post('/room/{session}/caption', [MeetingRoomController::class, 'caption'])->name('meet.room.caption');
|
||||
|
||||
@@ -113,6 +113,148 @@ class MeetRecordingUploadTest extends TestCase
|
||||
Storage::disk('local')->assertExists($recording->storage_path);
|
||||
}
|
||||
|
||||
public function test_host_can_upload_after_session_ended_without_participant_cookie(): void
|
||||
{
|
||||
$room = Room::create([
|
||||
'organization_id' => $this->organization->id,
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'host_user_ref' => $this->user->public_id,
|
||||
'title' => 'Ended meeting recording',
|
||||
'type' => 'instant',
|
||||
'status' => 'ended',
|
||||
'timezone' => 'UTC',
|
||||
]);
|
||||
|
||||
$session = Session::create([
|
||||
'room_id' => $room->id,
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'media_room_name' => 'room-'.$room->uuid,
|
||||
'status' => 'ended',
|
||||
'started_at' => now()->subMinutes(10),
|
||||
'ended_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
Participant::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'session_id' => $session->id,
|
||||
'user_ref' => $this->user->public_id,
|
||||
'role' => 'host',
|
||||
'display_name' => 'Test User',
|
||||
'status' => 'left',
|
||||
'joined_at' => now()->subMinutes(10),
|
||||
'left_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$recording = Recording::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'session_id' => $session->id,
|
||||
'status' => 'processing',
|
||||
'layout' => 'gallery',
|
||||
'started_by_ref' => $this->user->public_id,
|
||||
'started_at' => now()->subMinutes(5),
|
||||
'ended_at' => now()->subMinute(),
|
||||
'duration_seconds' => 240,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->post("/room/{$session->uuid}/recordings/{$recording->uuid}/upload", [
|
||||
'recording' => UploadedFile::fake()->create('meet.webm', 128, 'video/webm'),
|
||||
])
|
||||
->assertOk()
|
||||
->assertJson(['status' => 'ready']);
|
||||
}
|
||||
|
||||
public function test_host_can_mark_processing_recording_failed(): void
|
||||
{
|
||||
$room = Room::create([
|
||||
'organization_id' => $this->organization->id,
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'host_user_ref' => $this->user->public_id,
|
||||
'title' => 'Failed capture',
|
||||
'type' => 'instant',
|
||||
'status' => 'ended',
|
||||
'timezone' => 'UTC',
|
||||
]);
|
||||
|
||||
$session = Session::create([
|
||||
'room_id' => $room->id,
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'media_room_name' => 'room-'.$room->uuid,
|
||||
'status' => 'ended',
|
||||
'started_at' => now()->subMinutes(10),
|
||||
'ended_at' => now(),
|
||||
]);
|
||||
|
||||
Participant::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'session_id' => $session->id,
|
||||
'user_ref' => $this->user->public_id,
|
||||
'role' => 'host',
|
||||
'display_name' => 'Test User',
|
||||
'status' => 'left',
|
||||
'joined_at' => now()->subMinutes(10),
|
||||
'left_at' => now(),
|
||||
]);
|
||||
|
||||
$recording = Recording::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'session_id' => $session->id,
|
||||
'status' => 'processing',
|
||||
'layout' => 'gallery',
|
||||
'started_by_ref' => $this->user->public_id,
|
||||
'started_at' => now()->subMinutes(5),
|
||||
'ended_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson("/room/{$session->uuid}/recordings/{$recording->uuid}/fail", [
|
||||
'reason' => 'Browser capture failed.',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJson(['status' => 'failed']);
|
||||
|
||||
$recording->refresh();
|
||||
$this->assertSame('failed', $recording->status);
|
||||
$this->assertSame('Browser capture failed.', $recording->failure_reason);
|
||||
}
|
||||
|
||||
public function test_stale_processing_recording_is_marked_failed(): void
|
||||
{
|
||||
$room = Room::create([
|
||||
'organization_id' => $this->organization->id,
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'host_user_ref' => $this->user->public_id,
|
||||
'title' => 'Stale processing',
|
||||
'type' => 'instant',
|
||||
'status' => 'ended',
|
||||
'timezone' => 'UTC',
|
||||
]);
|
||||
|
||||
$session = Session::create([
|
||||
'room_id' => $room->id,
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'media_room_name' => 'room-'.$room->uuid,
|
||||
'status' => 'ended',
|
||||
'started_at' => now()->subHour(),
|
||||
'ended_at' => now()->subMinutes(30),
|
||||
]);
|
||||
|
||||
$recording = Recording::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'session_id' => $session->id,
|
||||
'status' => 'processing',
|
||||
'layout' => 'gallery',
|
||||
'started_by_ref' => $this->user->public_id,
|
||||
'started_at' => now()->subHour(),
|
||||
'ended_at' => now()->subMinutes(30),
|
||||
]);
|
||||
|
||||
$recording = app(\App\Services\Meet\RecordingService::class)->resolveProcessingState($recording);
|
||||
|
||||
$this->assertSame('failed', $recording->status);
|
||||
$this->assertNotNull($recording->failure_reason);
|
||||
}
|
||||
|
||||
public function test_empty_ready_recording_is_marked_failed(): void
|
||||
{
|
||||
$room = Room::create([
|
||||
|
||||
@@ -497,6 +497,22 @@ class MeetWebTest extends TestCase
|
||||
->assertJsonPath('room.organization_id', $this->organization->id);
|
||||
}
|
||||
|
||||
public function test_service_api_can_create_room_for_invoice_app(): void
|
||||
{
|
||||
\Illuminate\Support\Facades\Http::fake();
|
||||
config(['meet.service_api_keys.invoice' => 'test-invoice-key']);
|
||||
|
||||
$this->postJson('/api/service/v1/rooms', [
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'host_user_ref' => $this->user->public_id,
|
||||
'title' => 'Invoice review',
|
||||
'scheduled_at' => now()->addDay()->toIso8601String(),
|
||||
'source' => ['app' => 'invoice', 'entity_type' => 'invoice', 'entity_id' => '99'],
|
||||
], ['Authorization' => 'Bearer test-invoice-key'])
|
||||
->assertCreated()
|
||||
->assertJsonPath('room.title', 'Invoice review');
|
||||
}
|
||||
|
||||
public function test_service_api_can_create_town_hall_room(): void
|
||||
{
|
||||
\Illuminate\Support\Facades\Http::fake();
|
||||
|
||||
Reference in New Issue
Block a user