paperclip-bridges.test.mjs
526 lines 19.7 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Mocked API tests for the 3 SaaS bridges that Paperclip uses to build the video factory:
3 * - HeyGen render bridge (Custom Digital Twin avatar speaks the script)
4 * - ElevenLabs TTS bridge (Pro Voice Clone backup audio)
5 * - Descript import bridge (auto-edit + caption + 5-clip slicing)
6 *
7 * Per Aaron's Rule #0: every bridge has a test before it ships to AWS.
8 * Per Aaron's Rule #5: tests cover happy path AND error paths AND timeout paths
9 * AND validation boundaries.
10 *
11 * These tests use injected fetch + sleep so no real API calls are made and the
12 * polling loops complete in microseconds rather than the 10-second real interval.
13 *
14 * Run: node --test test/paperclip-bridges.test.mjs
15 */
16
17 import { describe, it } from 'node:test';
18 import assert from 'node:assert/strict';
19
20 import { createHeyGenClient } from '../deploy/paperclip/skills/heygen-render.mjs';
21 import { createElevenLabsClient } from '../deploy/paperclip/skills/elevenlabs-tts.mjs';
22 import { createDescriptClient } from '../deploy/paperclip/skills/descript-import.mjs';
23
24 /**
25 * Build a fake fetch that consumes scripted responses in order.
26 * @param {Array<{ status?: number, body?: any, contentType?: string, audioBytes?: number }>} responses
27 */
28 function makeFakeFetch(responses) {
29 const calls = [];
30 let i = 0;
31 const fetchImpl = async (url, init) => {
32 const r = responses[Math.min(i, responses.length - 1)];
33 i += 1;
34 calls.push({ url: String(url), init });
35 return {
36 ok: (r.status ?? 200) >= 200 && (r.status ?? 200) < 300,
37 status: r.status ?? 200,
38 statusText: r.statusText ?? 'OK',
39 headers: {
40 get: (name) => {
41 if (String(name).toLowerCase() === 'content-type')
42 return r.contentType ?? 'application/json';
43 return null;
44 },
45 },
46 json: async () => r.body ?? {},
47 text: async () => JSON.stringify(r.body ?? {}),
48 arrayBuffer: async () => new ArrayBuffer(r.audioBytes ?? 0),
49 };
50 };
51 return { fetchImpl, calls };
52 }
53
54 const noSleep = async () => {}; // make poll loops instant
55
56 // ============================================================
57 // HeyGen — render
58 // ============================================================
59
60 describe('createHeyGenClient — required options', () => {
61 it('throws if apiKey missing', () => {
62 assert.throws(() => createHeyGenClient({ fetch: () => {} }), /apiKey required/);
63 });
64 it('throws if fetch missing', () => {
65 assert.throws(() => createHeyGenClient({ apiKey: 'k', fetch: null }), /fetch required/);
66 });
67 });
68
69 describe('HeyGen submitRender — request shape', () => {
70 it('POSTs to /v2/video/generate with correct body, headers, and X-Api-Key', async () => {
71 const { fetchImpl, calls } = makeFakeFetch([
72 { status: 200, body: { data: { video_id: 'vid_123' } } },
73 ]);
74 const client = createHeyGenClient({ apiKey: 'sk_test', fetch: fetchImpl, sleep: noSleep });
75
76 const r = await client.submitRender({
77 script: 'Hello world.',
78 avatarId: 'avatar_abc',
79 voiceId: 'voice_xyz',
80 });
81
82 assert.equal(r.video_id, 'vid_123');
83 assert.equal(calls[0].init.method, 'POST');
84 assert.equal(calls[0].init.headers['X-Api-Key'], 'sk_test');
85 assert.match(calls[0].url, /\/v2\/video\/generate$/);
86
87 const sent = JSON.parse(calls[0].init.body);
88 assert.equal(sent.video_inputs[0].character.avatar_id, 'avatar_abc');
89 assert.equal(sent.video_inputs[0].voice.voice_id, 'voice_xyz');
90 assert.equal(sent.video_inputs[0].voice.input_text, 'Hello world.');
91 assert.deepEqual(sent.dimension, { width: 1920, height: 1080 });
92 });
93
94 it('uses 720p dimension when quality=720p', async () => {
95 const { fetchImpl, calls } = makeFakeFetch([
96 { status: 200, body: { data: { video_id: 'v' } } },
97 ]);
98 const client = createHeyGenClient({ apiKey: 'k', fetch: fetchImpl, sleep: noSleep });
99 await client.submitRender({
100 script: 'x',
101 avatarId: 'a',
102 voiceId: 'v',
103 quality: '720p',
104 });
105 const sent = JSON.parse(calls[0].init.body);
106 assert.deepEqual(sent.dimension, { width: 1280, height: 720 });
107 });
108
109 it('throws when API returns non-2xx', async () => {
110 const { fetchImpl } = makeFakeFetch([
111 { status: 401, body: { message: 'invalid api key' } },
112 ]);
113 const client = createHeyGenClient({ apiKey: 'wrong', fetch: fetchImpl, sleep: noSleep });
114 await assert.rejects(
115 client.submitRender({ script: 'x', avatarId: 'a', voiceId: 'v' }),
116 (err) => err.status === 401 && /heygen_submit_401/.test(err.message)
117 );
118 });
119
120 it('throws HEYGEN_MISSING_VIDEO_ID when API returns 200 but no video_id', async () => {
121 const { fetchImpl } = makeFakeFetch([{ status: 200, body: { data: {} } }]);
122 const client = createHeyGenClient({ apiKey: 'k', fetch: fetchImpl, sleep: noSleep });
123 await assert.rejects(
124 client.submitRender({ script: 'x', avatarId: 'a', voiceId: 'v' }),
125 (err) => err.code === 'HEYGEN_MISSING_VIDEO_ID'
126 );
127 });
128 });
129
130 describe('HeyGen submitRender — input validation', () => {
131 const client = createHeyGenClient({ apiKey: 'k', fetch: () => {}, sleep: noSleep });
132 it('rejects empty script', async () => {
133 await assert.rejects(
134 client.submitRender({ script: '', avatarId: 'a', voiceId: 'v' }),
135 (err) => err.code === 'HEYGEN_INVALID_SCRIPT'
136 );
137 });
138 it('rejects script > 30000 chars', async () => {
139 await assert.rejects(
140 client.submitRender({ script: 'x'.repeat(30_001), avatarId: 'a', voiceId: 'v' }),
141 (err) => err.code === 'HEYGEN_SCRIPT_TOO_LONG'
142 );
143 });
144 it('rejects empty avatarId', async () => {
145 await assert.rejects(
146 client.submitRender({ script: 's', avatarId: '', voiceId: 'v' }),
147 (err) => err.code === 'HEYGEN_INVALID_AVATAR_ID'
148 );
149 });
150 it('rejects empty voiceId', async () => {
151 await assert.rejects(
152 client.submitRender({ script: 's', avatarId: 'a', voiceId: '' }),
153 (err) => err.code === 'HEYGEN_INVALID_VOICE_ID'
154 );
155 });
156 });
157
158 describe('HeyGen pollStatus — completion paths', () => {
159 it('returns video_url when status=completed', async () => {
160 const { fetchImpl, calls } = makeFakeFetch([
161 {
162 status: 200,
163 body: {
164 data: {
165 status: 'completed',
166 video_url: 'https://heygen.cdn/abc.mp4',
167 duration: 240,
168 },
169 },
170 },
171 ]);
172 const client = createHeyGenClient({ apiKey: 'k', fetch: fetchImpl, sleep: noSleep });
173 const r = await client.pollStatus('vid_1', { intervalMs: 1, timeoutMs: 1000 });
174 assert.equal(r.status, 'completed');
175 assert.equal(r.video_url, 'https://heygen.cdn/abc.mp4');
176 assert.equal(r.duration_seconds, 240);
177 assert.match(calls[0].url, /video_status\.get\?video_id=vid_1/);
178 });
179
180 it('polls through processing -> completed', async () => {
181 const { fetchImpl, calls } = makeFakeFetch([
182 { status: 200, body: { data: { status: 'pending' } } },
183 { status: 200, body: { data: { status: 'processing' } } },
184 {
185 status: 200,
186 body: { data: { status: 'completed', video_url: 'https://heygen.cdn/x.mp4', duration: 60 } },
187 },
188 ]);
189 const client = createHeyGenClient({ apiKey: 'k', fetch: fetchImpl, sleep: noSleep });
190 const r = await client.pollStatus('vid_1', { intervalMs: 1, timeoutMs: 10_000 });
191 assert.equal(r.video_url, 'https://heygen.cdn/x.mp4');
192 assert.equal(calls.length, 3);
193 });
194
195 it('throws HEYGEN_RENDER_FAILED when status=failed', async () => {
196 const { fetchImpl } = makeFakeFetch([
197 { status: 200, body: { data: { status: 'failed', error: 'avatar not found' } } },
198 ]);
199 const client = createHeyGenClient({ apiKey: 'k', fetch: fetchImpl, sleep: noSleep });
200 await assert.rejects(
201 client.pollStatus('vid_1', { intervalMs: 1, timeoutMs: 1000 }),
202 (err) => err.code === 'HEYGEN_RENDER_FAILED' && /avatar not found/.test(err.message)
203 );
204 });
205
206 it('throws HEYGEN_COMPLETED_BUT_NO_URL when API returns completed without URL', async () => {
207 const { fetchImpl } = makeFakeFetch([
208 { status: 200, body: { data: { status: 'completed' } } },
209 ]);
210 const client = createHeyGenClient({ apiKey: 'k', fetch: fetchImpl, sleep: noSleep });
211 await assert.rejects(
212 client.pollStatus('vid_1', { intervalMs: 1, timeoutMs: 1000 }),
213 (err) => err.code === 'HEYGEN_COMPLETED_BUT_NO_URL'
214 );
215 });
216
217 it('throws HEYGEN_POLL_TIMEOUT when poll exceeds timeoutMs', async () => {
218 const { fetchImpl } = makeFakeFetch([
219 { status: 200, body: { data: { status: 'processing' } } },
220 ]);
221 // Use a real (tiny) sleep so the timeout actually elapses.
222 const client = createHeyGenClient({
223 apiKey: 'k',
224 fetch: fetchImpl,
225 sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
226 });
227 await assert.rejects(
228 client.pollStatus('vid_1', { intervalMs: 5, timeoutMs: 25 }),
229 (err) => err.code === 'HEYGEN_POLL_TIMEOUT' && err.videoId === 'vid_1'
230 );
231 });
232 });
233
234 describe('HeyGen render — end-to-end (submit + poll)', () => {
235 it('submits, polls processing, returns completed result', async () => {
236 const { fetchImpl, calls } = makeFakeFetch([
237 { status: 200, body: { data: { video_id: 'v_e2e' } } },
238 { status: 200, body: { data: { status: 'processing' } } },
239 {
240 status: 200,
241 body: { data: { status: 'completed', video_url: 'https://heygen.cdn/e2e.mp4', duration: 90 } },
242 },
243 ]);
244 const client = createHeyGenClient({ apiKey: 'k', fetch: fetchImpl, sleep: noSleep });
245 const r = await client.render({
246 script: 'Hello, factory.',
247 avatarId: 'a1',
248 voiceId: 'v1',
249 pollIntervalMs: 1,
250 pollTimeoutMs: 5000,
251 });
252 assert.equal(r.video_id, 'v_e2e');
253 assert.equal(r.video_url, 'https://heygen.cdn/e2e.mp4');
254 assert.equal(r.duration_seconds, 90);
255 assert.equal(calls[0].init.method, 'POST');
256 assert.match(calls[1].url, /video_status\.get/);
257 assert.match(calls[2].url, /video_status\.get/);
258 });
259 });
260
261 // ============================================================
262 // ElevenLabs — TTS + quota
263 // ============================================================
264
265 describe('createElevenLabsClient', () => {
266 it('throws if apiKey missing', () => {
267 assert.throws(() => createElevenLabsClient({ fetch: () => {} }), /apiKey required/);
268 });
269 });
270
271 describe('ElevenLabs tts', () => {
272 it('POSTs to /v1/text-to-speech/{voiceId} with correct body, xi-api-key header, accepts audio/mpeg', async () => {
273 const { fetchImpl, calls } = makeFakeFetch([
274 { status: 200, audioBytes: 1234, contentType: 'audio/mpeg' },
275 ]);
276 const client = createElevenLabsClient({ apiKey: 'xi_test', fetch: fetchImpl });
277 const r = await client.tts({
278 text: 'Hello',
279 voiceId: 'voice_aaa',
280 stability: 0.6,
281 similarityBoost: 0.8,
282 });
283
284 assert.ok(r.audio instanceof ArrayBuffer);
285 assert.equal(r.audio.byteLength, 1234);
286 assert.equal(r.contentType, 'audio/mpeg');
287 assert.equal(r.voiceId, 'voice_aaa');
288 assert.equal(r.modelId, 'eleven_multilingual_v2');
289
290 assert.match(calls[0].url, /\/v1\/text-to-speech\/voice_aaa$/);
291 assert.equal(calls[0].init.headers['xi-api-key'], 'xi_test');
292 assert.equal(calls[0].init.headers.Accept, 'audio/mpeg');
293
294 const sent = JSON.parse(calls[0].init.body);
295 assert.equal(sent.text, 'Hello');
296 assert.equal(sent.model_id, 'eleven_multilingual_v2');
297 assert.equal(sent.voice_settings.stability, 0.6);
298 assert.equal(sent.voice_settings.similarity_boost, 0.8);
299 });
300
301 it('clamps voice settings to 0..1', async () => {
302 const { fetchImpl, calls } = makeFakeFetch([
303 { status: 200, audioBytes: 100 },
304 ]);
305 const client = createElevenLabsClient({ apiKey: 'k', fetch: fetchImpl });
306 await client.tts({
307 text: 'x',
308 voiceId: 'v',
309 stability: 5, // out of range
310 similarityBoost: -2, // out of range
311 });
312 const sent = JSON.parse(calls[0].init.body);
313 assert.equal(sent.voice_settings.stability, 1);
314 assert.equal(sent.voice_settings.similarity_boost, 0);
315 });
316
317 it('rejects empty text', async () => {
318 const client = createElevenLabsClient({ apiKey: 'k', fetch: () => {} });
319 await assert.rejects(
320 client.tts({ text: '', voiceId: 'v' }),
321 (err) => err.code === 'ELEVENLABS_INVALID_TEXT'
322 );
323 });
324
325 it('rejects text > 5000 chars (forces caller to chunk)', async () => {
326 const client = createElevenLabsClient({ apiKey: 'k', fetch: () => {} });
327 await assert.rejects(
328 client.tts({ text: 'x'.repeat(5001), voiceId: 'v' }),
329 (err) => err.code === 'ELEVENLABS_TEXT_TOO_LONG'
330 );
331 });
332
333 it('rejects empty voiceId', async () => {
334 const client = createElevenLabsClient({ apiKey: 'k', fetch: () => {} });
335 await assert.rejects(
336 client.tts({ text: 'x', voiceId: '' }),
337 (err) => err.code === 'ELEVENLABS_INVALID_VOICE_ID'
338 );
339 });
340
341 it('throws structured error on 401', async () => {
342 const { fetchImpl } = makeFakeFetch([
343 { status: 401, body: { detail: { message: 'invalid api key' } } },
344 ]);
345 const client = createElevenLabsClient({ apiKey: 'wrong', fetch: fetchImpl });
346 await assert.rejects(
347 client.tts({ text: 'x', voiceId: 'v' }),
348 (err) => err.status === 401 && /elevenlabs_401/.test(err.message)
349 );
350 });
351 });
352
353 describe('ElevenLabs getQuota', () => {
354 it('returns characterCount, characterLimit, remaining', async () => {
355 const { fetchImpl } = makeFakeFetch([
356 { status: 200, body: { character_count: 25_000, character_limit: 100_000 } },
357 ]);
358 const client = createElevenLabsClient({ apiKey: 'k', fetch: fetchImpl });
359 const q = await client.getQuota();
360 assert.deepEqual(q, { characterCount: 25_000, characterLimit: 100_000, remaining: 75_000 });
361 });
362
363 it('clamps remaining to 0 when overage', async () => {
364 const { fetchImpl } = makeFakeFetch([
365 { status: 200, body: { character_count: 110_000, character_limit: 100_000 } },
366 ]);
367 const client = createElevenLabsClient({ apiKey: 'k', fetch: fetchImpl });
368 const q = await client.getQuota();
369 assert.equal(q.remaining, 0);
370 });
371 });
372
373 // ============================================================
374 // Descript — import
375 // ============================================================
376
377 describe('createDescriptClient', () => {
378 it('throws if apiKey missing', () => {
379 assert.throws(() => createDescriptClient({ fetch: () => {} }), /apiKey required/);
380 });
381 });
382
383 describe('Descript submitImport', () => {
384 it('POSTs to /v1/projects/{id}/imports with auth, body shape', async () => {
385 const { fetchImpl, calls } = makeFakeFetch([
386 { status: 200, body: { import_id: 'imp_1' } },
387 ]);
388 const client = createDescriptClient({ apiKey: 'ds_test', fetch: fetchImpl, sleep: noSleep });
389 const r = await client.submitImport({
390 projectId: 'proj_bornfree',
391 videoUrl: 'https://heygen.cdn/x.mp4',
392 title: 'Born Free Episode 1',
393 });
394 assert.equal(r.import_id, 'imp_1');
395 assert.equal(calls[0].init.method, 'POST');
396 assert.equal(calls[0].init.headers.Authorization, 'Bearer ds_test');
397 assert.match(calls[0].url, /\/v1\/projects\/proj_bornfree\/imports$/);
398
399 const sent = JSON.parse(calls[0].init.body);
400 assert.deepEqual(sent.source, { type: 'url', url: 'https://heygen.cdn/x.mp4' });
401 assert.equal(sent.auto_transcribe, true);
402 assert.equal(sent.auto_generate_shorts, true);
403 assert.equal(sent.title, 'Born Free Episode 1');
404 });
405
406 it('rejects invalid videoUrl (not http/https)', async () => {
407 const client = createDescriptClient({ apiKey: 'k', fetch: () => {}, sleep: noSleep });
408 await assert.rejects(
409 client.submitImport({ projectId: 'p', videoUrl: 'file:///etc/passwd' }),
410 (err) => err.code === 'DESCRIPT_INVALID_VIDEO_URL'
411 );
412 });
413
414 it('rejects empty projectId', async () => {
415 const client = createDescriptClient({ apiKey: 'k', fetch: () => {}, sleep: noSleep });
416 await assert.rejects(
417 client.submitImport({ projectId: '', videoUrl: 'https://x.com/y.mp4' }),
418 (err) => err.code === 'DESCRIPT_INVALID_PROJECT_ID'
419 );
420 });
421
422 it('throws DESCRIPT_MISSING_IMPORT_ID when API returns 200 with no id', async () => {
423 const { fetchImpl } = makeFakeFetch([{ status: 200, body: {} }]);
424 const client = createDescriptClient({ apiKey: 'k', fetch: fetchImpl, sleep: noSleep });
425 await assert.rejects(
426 client.submitImport({ projectId: 'p', videoUrl: 'https://x.com/y.mp4' }),
427 (err) => err.code === 'DESCRIPT_MISSING_IMPORT_ID'
428 );
429 });
430
431 it('throws structured error on 403', async () => {
432 const { fetchImpl } = makeFakeFetch([
433 { status: 403, body: { error: 'project not found' } },
434 ]);
435 const client = createDescriptClient({ apiKey: 'k', fetch: fetchImpl, sleep: noSleep });
436 await assert.rejects(
437 client.submitImport({ projectId: 'p', videoUrl: 'https://x.com/y.mp4' }),
438 (err) => err.status === 403
439 );
440 });
441 });
442
443 describe('Descript pollImport', () => {
444 it('returns ready status with shorts array', async () => {
445 const { fetchImpl, calls } = makeFakeFetch([
446 {
447 status: 200,
448 body: {
449 status: 'ready',
450 project_id: 'proj_x',
451 media_id: 'media_y',
452 transcript_url: 'https://d.cdn/t.txt',
453 shorts: [
454 { id: 's1', start: 0, end: 60, suggested_title: 'Hook 1' },
455 { id: 's2', start: 90, end: 150, suggested_title: 'Hook 2' },
456 ],
457 },
458 },
459 ]);
460 const client = createDescriptClient({ apiKey: 'k', fetch: fetchImpl, sleep: noSleep });
461 const r = await client.pollImport('imp_1', { intervalMs: 1, timeoutMs: 1000 });
462 assert.equal(r.status, 'ready');
463 assert.equal(r.shorts.length, 2);
464 assert.match(calls[0].url, /\/v1\/imports\/imp_1$/);
465 });
466
467 it('polls processing -> ready', async () => {
468 const { fetchImpl } = makeFakeFetch([
469 { status: 200, body: { status: 'processing' } },
470 { status: 200, body: { status: 'processing' } },
471 { status: 200, body: { status: 'ready', project_id: 'p', media_id: 'm' } },
472 ]);
473 const client = createDescriptClient({ apiKey: 'k', fetch: fetchImpl, sleep: noSleep });
474 const r = await client.pollImport('imp_1', { intervalMs: 1, timeoutMs: 5000 });
475 assert.equal(r.status, 'ready');
476 });
477
478 it('throws DESCRIPT_IMPORT_FAILED on status=failed', async () => {
479 const { fetchImpl } = makeFakeFetch([
480 { status: 200, body: { status: 'failed', error: 'codec unsupported' } },
481 ]);
482 const client = createDescriptClient({ apiKey: 'k', fetch: fetchImpl, sleep: noSleep });
483 await assert.rejects(
484 client.pollImport('imp_1', { intervalMs: 1, timeoutMs: 1000 }),
485 (err) => err.code === 'DESCRIPT_IMPORT_FAILED'
486 );
487 });
488
489 it('throws DESCRIPT_POLL_TIMEOUT', async () => {
490 const { fetchImpl } = makeFakeFetch([
491 { status: 200, body: { status: 'processing' } },
492 ]);
493 const client = createDescriptClient({
494 apiKey: 'k',
495 fetch: fetchImpl,
496 sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
497 });
498 await assert.rejects(
499 client.pollImport('imp_1', { intervalMs: 5, timeoutMs: 25 }),
500 (err) => err.code === 'DESCRIPT_POLL_TIMEOUT'
501 );
502 });
503 });
504
505 describe('Descript end-to-end import', () => {
506 it('submits then polls to ready', async () => {
507 const { fetchImpl, calls } = makeFakeFetch([
508 { status: 200, body: { import_id: 'imp_e2e' } },
509 { status: 200, body: { status: 'processing' } },
510 {
511 status: 200,
512 body: { status: 'ready', project_id: 'p', media_id: 'm', shorts: [] },
513 },
514 ]);
515 const client = createDescriptClient({ apiKey: 'k', fetch: fetchImpl, sleep: noSleep });
516 const r = await client.import({
517 projectId: 'p',
518 videoUrl: 'https://heygen.cdn/abc.mp4',
519 pollIntervalMs: 1,
520 pollTimeoutMs: 5000,
521 });
522 assert.equal(r.status, 'ready');
523 assert.equal(calls[0].init.method, 'POST');
524 assert.match(calls[1].url, /\/v1\/imports\//);
525 });
526 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago