// The editor's blob side on F (remoteBlobs), on a fetch the test answers: a // blob made here is kept at once and taken to F with the cookie, tried // again once, then told as failed (also when F names it otherwise); the // blobs a document names are fetched once each and told as they come; one F // has not got is not asked for again, one the network lost is. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as Y from 'yjs'; import { attachBlobs, BLOB_THRESHOLD_BYTES, blobSha, changeFiles, emptyProject, memoryBlobSide, missingBlobs, ROOTS, writeProject } from '@mdg-filius/doc'; import { BLOB_ASK_AGAIN_MILLISECONDS, BLOB_ASKS, remoteBlobs, UPLOAD_RETRY_MILLISECONDS } from '../src/remote/remoteBlobs.js'; const picture = (seed: number) => Uint8Array.from({ length: BLOB_THRESHOLD_BYTES }, (_, index) => (index + seed) % 256); interface Call { readonly url: string; readonly init: RequestInit | undefined; } function fakeFetch(answer: (call: Call) => Response | Promise) { const calls: Call[] = []; const fetch = (async (url: string, init?: RequestInit) => { const call = { url, init }; calls.push(call); return answer(call); }) as typeof globalThis.fetch; return { calls, fetch }; } /** A document naming these pictures as blobs on a computer, made with a side of its own. */ function docNaming(...pictures: Uint8Array[]): Y.Doc { const made = new Y.Doc(); attachBlobs(made, memoryBlobSide()); writeProject(made, emptyProject()); made.transact(() => made.getMap>(ROOTS.nodes).set('pc', new Y.Map())); changeFiles(made, 'pc', 'person', (files) => pictures.forEach((each, index) => files.writeImageFile(`/p${index}.png`, each, 'image/png'))); const copy = new Y.Doc(); Y.applyUpdate(copy, Y.encodeStateAsUpdate(made)); return copy; } beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); describe("the editor's blobs on F", () => { it('keeps a blob at once and takes it to F with the cookie', async () => { const bytes = picture(1); const { calls, fetch } = fakeFetch(() => Response.json({ sha: blobSha(bytes) }, { status: 201 })); const failed: string[] = []; const side = remoteBlobs({ server: 'https://f.test/', project: 'p 1', fetch, onFailure: (sha) => failed.push(sha) }); side.keep(blobSha(bytes), bytes); expect(side.bytes(blobSha(bytes))).toBe(bytes); await vi.runAllTimersAsync(); expect(calls.map(({ url, init }) => [url, init?.method, init?.credentials, init?.body])).toEqual([['https://f.test/blobs?project=p%201', 'POST', 'include', bytes]]); expect(failed).toEqual([]); // Kept once: the same again goes nowhere. side.keep(blobSha(bytes), bytes); await vi.runAllTimersAsync(); expect(calls).toHaveLength(1); }); it('tries again once after a while, then says it failed — also when F names the bytes otherwise', async () => { for (const answer of [() => new Response('', { status: 500 }), () => Response.json({ sha: 'other' }), () => Promise.reject(new Error('offline'))]) { const bytes = picture(2); const { calls, fetch } = fakeFetch(answer); const failed: string[] = []; remoteBlobs({ server: 'https://f.test', project: 'p', fetch, onFailure: (sha) => failed.push(sha) }).keep(blobSha(bytes), bytes); await vi.advanceTimersByTimeAsync(UPLOAD_RETRY_MILLISECONDS - 1); expect(calls).toHaveLength(1); await vi.advanceTimersByTimeAsync(1); await vi.runAllTimersAsync(); expect([calls.length, failed]).toEqual([2, [blobSha(bytes)]]); } // A second try that works says nothing. const bytes = picture(3); let tries = 0; const { fetch } = fakeFetch(() => (++tries === 1 ? new Response('', { status: 503 }) : Response.json({ sha: blobSha(bytes) }))); const failed: string[] = []; remoteBlobs({ server: 'https://f.test', project: 'p', fetch, onFailure: (sha) => failed.push(sha) }).keep(blobSha(bytes), bytes); await vi.runAllTimersAsync(); expect([tries, failed]).toEqual([2, []]); }); it('fetches the blobs a document names once each, and tells when they came', async () => { const [first, second] = [picture(4), picture(5)]; const doc = docNaming(first, second); const { calls, fetch } = fakeFetch(({ url }) => new Response(url.includes(blobSha(first)) ? first : second)); const side = remoteBlobs({ server: 'https://f.test', project: 'p', fetch }); attachBlobs(doc, side); let heard = 0; side.onArrive(() => (heard += 1)); expect(missingBlobs(doc).sort()).toEqual([blobSha(first), blobSha(second)].sort()); await Promise.all([side.fetchMissing(doc), side.fetchMissing(doc)]); expect(calls.map(({ url, init }) => [url, init?.credentials]).sort()).toEqual( [ [`https://f.test/blobs/${blobSha(first)}?project=p`, 'include'], [`https://f.test/blobs/${blobSha(second)}?project=p`, 'include'], ].sort(), ); expect([missingBlobs(doc), heard]).toEqual([[], 1]); expect(side.bytes(blobSha(first))).toEqual(first); }); it('asks again a few times, a while apart, for a blob F has not got yet; for one the network lost with the next change', async () => { const [late, gone, lost] = [picture(6), picture(7), picture(8)]; const doc = docNaming(late, gone, lost); let offline = true; let lateAsked = 0; const { calls, fetch } = fakeFetch(({ url }) => { if (url.includes(blobSha(late))) return ++lateAsked < 3 ? new Response('', { status: 404 }) : new Response(late); if (url.includes(blobSha(gone))) return new Response('', { status: 404 }); if (offline) return Promise.reject(new Error('offline')); return new Response(lost); }); const side = remoteBlobs({ server: 'https://f.test', project: 'p', fetch }); attachBlobs(doc, side); await side.fetchMissing(doc); offline = false; // Without a change, only what F has not got is asked for again. await vi.advanceTimersByTimeAsync(BLOB_ASK_AGAIN_MILLISECONDS - 1); expect(calls).toHaveLength(3); await vi.advanceTimersByTimeAsync(1); await vi.runAllTimersAsync(); expect(side.bytes(blobSha(late))).toEqual(late); expect(calls.filter(({ url }) => url.includes(blobSha(late)))).toHaveLength(3); expect(calls.filter(({ url }) => url.includes(blobSha(gone)))).toHaveLength(BLOB_ASKS); // The one the network lost comes with the next change (its fetch then asks for what is still missing). await side.fetchMissing(doc); expect(side.bytes(blobSha(lost))).toEqual(lost); expect(missingBlobs(doc)).toEqual([blobSha(gone)]); // Given up on: not asked for again. await side.fetchMissing(doc); await vi.runAllTimersAsync(); expect(calls.filter(({ url }) => url.includes(blobSha(gone)))).toHaveLength(BLOB_ASKS); }); });