feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -0,0 +1,267 @@
|
|||
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`);
|
||||
Loading…
Add table
Add a link
Reference in a new issue