267 lines
10 KiB
JavaScript
267 lines
10 KiB
JavaScript
import { createHash, randomUUID } from 'node:crypto';
|
|
|
|
const baseUrl = process.env.D3RO_E2E_SUPABASE_URL;
|
|
const anonKey = process.env.D3RO_E2E_ANON_KEY;
|
|
const serviceKey = process.env.D3RO_E2E_SERVICE_KEY;
|
|
if (!baseUrl || !anonKey || !serviceKey) {
|
|
throw new Error('D3RO_E2E_SUPABASE_URL, D3RO_E2E_ANON_KEY and D3RO_E2E_SERVICE_KEY are required');
|
|
}
|
|
|
|
let assertions = 0;
|
|
function assert(condition, message) {
|
|
if (!condition) throw new Error(`assertion_failed: ${message}`);
|
|
assertions += 1;
|
|
}
|
|
|
|
async function jsonRequest(path, init = {}, expected = [200]) {
|
|
const response = await fetch(`${baseUrl}${path}`, init);
|
|
const text = await response.text();
|
|
if (!expected.includes(response.status)) {
|
|
throw new Error(`${init.method ?? 'GET'} ${path} -> ${response.status}: ${text.slice(0, 500)}`);
|
|
}
|
|
return text === '' ? null : JSON.parse(text);
|
|
}
|
|
|
|
function authHeaders(token, extra = {}) {
|
|
return {
|
|
apikey: anonKey,
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
async function createUser(email, password) {
|
|
return jsonRequest('/auth/v1/admin/users', {
|
|
method: 'POST',
|
|
headers: {
|
|
apikey: serviceKey,
|
|
Authorization: `Bearer ${serviceKey}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ email, password, email_confirm: true }),
|
|
});
|
|
}
|
|
|
|
async function login(email, password) {
|
|
return jsonRequest('/auth/v1/token?grant_type=password', {
|
|
method: 'POST',
|
|
headers: { apikey: anonKey, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password }),
|
|
});
|
|
}
|
|
|
|
function wavFixture(dataBytes) {
|
|
const output = Buffer.alloc(44 + dataBytes);
|
|
output.write('RIFF', 0);
|
|
output.writeUInt32LE(36 + dataBytes, 4);
|
|
output.write('WAVEfmt ', 8);
|
|
output.writeUInt32LE(16, 16);
|
|
output.writeUInt16LE(1, 20);
|
|
output.writeUInt16LE(1, 22);
|
|
output.writeUInt32LE(16_000, 24);
|
|
output.writeUInt32LE(32_000, 28);
|
|
output.writeUInt16LE(2, 32);
|
|
output.writeUInt16LE(16, 34);
|
|
output.write('data', 36);
|
|
output.writeUInt32LE(dataBytes, 40);
|
|
for (let index = 44; index < output.length; index += 2) {
|
|
output.writeInt16LE(Math.round(Math.sin(index / 30) * 1_500), index);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
function metadata(name, value) {
|
|
return `${name} ${Buffer.from(value, 'utf8').toString('base64')}`;
|
|
}
|
|
|
|
const password = `Local-${randomUUID()}-A1!`;
|
|
const ownerEmail = `recording-owner-${randomUUID()}@example.invalid`;
|
|
const otherEmail = `recording-other-${randomUUID()}@example.invalid`;
|
|
let owner = null;
|
|
let other = null;
|
|
let ownerToken = null;
|
|
let storageKey = null;
|
|
|
|
try {
|
|
owner = await createUser(ownerEmail, password);
|
|
other = await createUser(otherEmail, password);
|
|
const ownerSession = await login(ownerEmail, password);
|
|
const otherSession = await login(otherEmail, password);
|
|
ownerToken = ownerSession.access_token;
|
|
assert(ownerSession.user.id === owner.id, 'owner login returns the created account');
|
|
|
|
const [meeting] = await jsonRequest('/rest/v1/meetings', {
|
|
method: 'POST',
|
|
headers: authHeaders(ownerToken, { Prefer: 'return=representation' }),
|
|
body: JSON.stringify({ user_id: owner.id, title: 'Resumable mobile meeting', status: 'recording' }),
|
|
}, [201]);
|
|
assert(meeting.status === 'recording', 'meeting begins in recording state');
|
|
|
|
const anonymous = await fetch(`${baseUrl}/rest/v1/rpc/mobile_begin_meeting_recording`, {
|
|
method: 'POST',
|
|
headers: { apikey: anonKey, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ p_meeting_id: meeting.id }),
|
|
});
|
|
assert(anonymous.status === 401 || anonymous.status === 403, 'anonymous recording mutation fails closed');
|
|
|
|
const forbidden = await fetch(`${baseUrl}/rest/v1/rpc/mobile_begin_meeting_recording`, {
|
|
method: 'POST',
|
|
headers: authHeaders(otherSession.access_token),
|
|
body: JSON.stringify({ p_meeting_id: meeting.id }),
|
|
});
|
|
assert(!forbidden.ok, 'another authenticated user cannot start the owner meeting');
|
|
|
|
const begun = await jsonRequest('/rest/v1/rpc/mobile_begin_meeting_recording', {
|
|
method: 'POST',
|
|
headers: authHeaders(ownerToken),
|
|
body: JSON.stringify({ p_meeting_id: meeting.id }),
|
|
});
|
|
assert(begun.id === meeting.id && begun.status === 'recording', 'owner recording RPC is confirmed');
|
|
|
|
const audio = wavFixture(6 * 1024 * 1024 + 32_000);
|
|
const sha256 = createHash('sha256').update(audio).digest('hex');
|
|
storageKey = `${owner.id}/imports/${sha256}/resumable.wav`;
|
|
const [audioRow] = await jsonRequest('/rest/v1/audio_files', {
|
|
method: 'POST',
|
|
headers: authHeaders(ownerToken, { Prefer: 'return=representation' }),
|
|
body: JSON.stringify({
|
|
user_id: owner.id,
|
|
meeting_id: meeting.id,
|
|
source: 'recording',
|
|
original_name: 'resumable.wav',
|
|
storage_key: storageKey,
|
|
mime_type: 'audio/wav',
|
|
size_bytes: audio.length,
|
|
duration_ms: Math.floor((audio.length - 44) / 32),
|
|
sha256,
|
|
upload_status: 'uploading',
|
|
}),
|
|
}, [201]);
|
|
|
|
const createUpload = await fetch(`${baseUrl}/storage/v1/upload/resumable`, {
|
|
method: 'POST',
|
|
headers: {
|
|
apikey: anonKey,
|
|
Authorization: `Bearer ${ownerToken}`,
|
|
'Tus-Resumable': '1.0.0',
|
|
'Upload-Length': String(audio.length),
|
|
'Upload-Metadata': [
|
|
metadata('bucketName', 'audio'),
|
|
metadata('objectName', storageKey),
|
|
metadata('contentType', 'audio/wav'),
|
|
metadata('cacheControl', '3600'),
|
|
].join(','),
|
|
'x-upsert': 'true',
|
|
},
|
|
});
|
|
assert(createUpload.status === 201, `TUS upload is created (${createUpload.status})`);
|
|
const location = createUpload.headers.get('location');
|
|
assert(typeof location === 'string' && location.includes('/storage/v1/upload/resumable/'), 'TUS location is returned');
|
|
const uploadUrl = new URL(location, baseUrl).toString();
|
|
const firstChunkSize = 6 * 1024 * 1024;
|
|
const firstPatch = await fetch(uploadUrl, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
apikey: anonKey,
|
|
Authorization: `Bearer ${ownerToken}`,
|
|
'Tus-Resumable': '1.0.0',
|
|
'Upload-Offset': '0',
|
|
'Content-Type': 'application/offset+octet-stream',
|
|
},
|
|
body: audio.subarray(0, firstChunkSize),
|
|
});
|
|
assert(firstPatch.status === 204, `first TUS chunk succeeds (${firstPatch.status})`);
|
|
|
|
const head = await fetch(uploadUrl, {
|
|
method: 'HEAD',
|
|
headers: {
|
|
apikey: anonKey,
|
|
Authorization: `Bearer ${ownerToken}`,
|
|
'Tus-Resumable': '1.0.0',
|
|
},
|
|
});
|
|
assert(head.status === 200 && Number(head.headers.get('upload-offset')) === firstChunkSize, 'HEAD restores the exact upload offset');
|
|
|
|
const finalPatch = await fetch(uploadUrl, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
apikey: anonKey,
|
|
Authorization: `Bearer ${ownerToken}`,
|
|
'Tus-Resumable': '1.0.0',
|
|
'Upload-Offset': String(firstChunkSize),
|
|
'Content-Type': 'application/offset+octet-stream',
|
|
},
|
|
body: audio.subarray(firstChunkSize),
|
|
});
|
|
assert(finalPatch.status === 204, `resumed final TUS chunk succeeds (${finalPatch.status})`);
|
|
assert(Number(finalPatch.headers.get('upload-offset')) === audio.length, 'final TUS offset equals file size');
|
|
|
|
const [uploadedAudio] = await jsonRequest(`/rest/v1/audio_files?id=eq.${audioRow.id}`, {
|
|
method: 'PATCH',
|
|
headers: authHeaders(ownerToken, { Prefer: 'return=representation' }),
|
|
body: JSON.stringify({ upload_status: 'uploaded' }),
|
|
});
|
|
assert(uploadedAudio.upload_status === 'uploaded', 'audio metadata confirms upload only after TUS completion');
|
|
|
|
const queuedMeeting = await jsonRequest('/rest/v1/rpc/mobile_queue_meeting_recording', {
|
|
method: 'POST',
|
|
headers: authHeaders(ownerToken),
|
|
body: JSON.stringify({ p_meeting_id: meeting.id, p_duration_ms: 197_608 }),
|
|
});
|
|
assert(queuedMeeting.status === 'processing', 'meeting becomes processing while the durable job runs');
|
|
|
|
const idempotencyKey = `mobile-meeting:${meeting.id}:${sha256}`;
|
|
const processingJob = await jsonRequest('/rest/v1/rpc/mobile_begin_meeting_processing', {
|
|
method: 'POST',
|
|
headers: authHeaders(ownerToken),
|
|
body: JSON.stringify({
|
|
p_meeting_id: meeting.id,
|
|
p_audio_file_id: audioRow.id,
|
|
p_idempotency_key: idempotencyKey,
|
|
}),
|
|
});
|
|
assert(processingJob.status === 'running' && processingJob.audio_file_id === audioRow.id, 'server-owned processing job is linked');
|
|
|
|
const completed = await jsonRequest('/rest/v1/rpc/mobile_complete_meeting_processing', {
|
|
method: 'POST',
|
|
headers: authHeaders(ownerToken),
|
|
body: JSON.stringify({
|
|
p_meeting_id: meeting.id,
|
|
p_audio_file_id: audioRow.id,
|
|
p_idempotency_key: idempotencyKey,
|
|
p_transcript: 'local resumable lifecycle verified',
|
|
p_language: 'en',
|
|
p_provider: 'integration-fixture',
|
|
p_duration_ms: 197_608,
|
|
p_stt_latency_ms: 50,
|
|
}),
|
|
});
|
|
assert(completed.status === 'completed' && completed.raw_transcript === 'local resumable lifecycle verified', 'atomic meeting completion is returned');
|
|
assert(completed.audio_storage_key === storageKey, 'meeting links the uploaded audio key');
|
|
|
|
const jobs = await jsonRequest(`/rest/v1/processing_jobs?id=eq.${processingJob.id}&select=*`, {
|
|
headers: authHeaders(ownerToken),
|
|
});
|
|
assert(jobs.length === 1 && jobs[0].status === 'succeeded' && jobs[0].progress === 100, 'job readback is succeeded at 100 percent');
|
|
const transcripts = await jsonRequest(`/rest/v1/transcripts?meeting_id=eq.${meeting.id}&select=*`, {
|
|
headers: authHeaders(ownerToken),
|
|
});
|
|
assert(transcripts.length === 1 && transcripts[0].text === 'local resumable lifecycle verified', 'transcript readback is linked to the meeting');
|
|
} finally {
|
|
if (storageKey && ownerToken) {
|
|
await fetch(`${baseUrl}/storage/v1/object/audio/${storageKey.split('/').map(encodeURIComponent).join('/')}`, {
|
|
method: 'DELETE',
|
|
headers: { apikey: anonKey, Authorization: `Bearer ${ownerToken}` },
|
|
}).catch(() => undefined);
|
|
}
|
|
for (const user of [owner, other]) {
|
|
if (!user?.id) continue;
|
|
await fetch(`${baseUrl}/auth/v1/admin/users/${user.id}`, {
|
|
method: 'DELETE',
|
|
headers: { apikey: serviceKey, Authorization: `Bearer ${serviceKey}` },
|
|
}).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
console.log(`recording lifecycle local integration: ${assertions} assertions passed`);
|