d3ro-voice/apps/mobile-rn/__tests__/recording-lifecycle.local.integration.mjs
2026-08-29 18:33:45 +09:00

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`);