/** * Tests for the `quak backup` logic, now built on the library API (issue #51). * * `lib.backup({ downloadDirectory })` refreshes the library, fetches each * pending file's original through the content cache/pools, and materialises the * unchanged on-disk layout: * * / * originals/ * . the decrypted bytes ("present means complete") * .json per-file metadata sidecar (rebuilt each run) * collections/ * / symlink into ../originals (rebuilt each run) * <name>.json per-collection metadata (rebuilt each run) * failures.json durable ledger of unresolved failures * * The properties that distinguish backup from a naive download loop, and that * these tests lock down: * * 1. Present-means-complete: an original already on disk is not re-fetched, so * runs are idempotent and interrupted runs resume. * 2. Per-file resilience: a download failure or a symlink failure is recorded * and the run continues (issue #8); the derived symlink/JSON views are * rebuilt from the model every run. * 3. A durable `failures.json` records each unresolved failure's classification, * attempt count, and last-tried time; the exit code (result.failed) is * non-zero while any failure remains and clears once every one is resolved. * * The cache and download layers are covered elsewhere (content.test.ts, * download tests); here a mock library client and a stand-in content source * drive the backup logic with no crypto and no network. */ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync, } from "node:fs"; import { spawnSync } from "node:child_process"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { runBackup, type BackupLibrary } from "../../src/backup.js"; import { Library } from "../../src/library/index.js"; import type { ContentSource } from "../../src/library/content.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; import { asLivePhoto, cdnSource, IMAGE, livePhotoZip, VIDEO, } from "../live-photo.js"; // `open` and `rename` are wrapped to record, in order, every fsync and rename, // so a test can pin the sequence "fsync the temp file, rename, fsync the // directory" that makes a copied original survive a power cut. `vi.hoisted` // because `vi.mock` factories run before module-level constants exist. const fsEvents = vi.hoisted(() => [] as string[]); vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal<typeof import("node:fs/promises")>(); return { ...actual, open: async ( ...args: Parameters<typeof actual.open> ): Promise<Awaited<ReturnType<typeof actual.open>>> => { const handle = await actual.open(...args); const realSync = handle.sync.bind(handle); handle.sync = async (): Promise<void> => { fsEvents.push(`sync:${String(args[0])}`); await realSync(); }; return handle; }, rename: async (from: string, to: string): Promise<void> => { fsEvents.push(`rename:${to}`); await actual.rename(from, to); }, }; }); const USER_ID = 42; // Decrypted-byte length each stub original writes, keyed by fileID. const SIZE_BY_ID: Record<number, number> = { 100: 3000, 101: 2000, 200: 1500 }; const collection = (id: number, name: string): Collection => ({ id, ownerID: USER_ID, key: new Uint8Array([id & 0xff]), name, type: "album", updationTime: 1, isShared: false, }); const file = (id: number, collectionID: number, title: string): EnteFile => ({ id, collectionID, ownerID: USER_ID, key: new Uint8Array([id & 0xff]), metadata: { title, fileType: "image", creationTime: 1, modificationTime: 1, }, file: { decryptionHeader: "aGVhZGVy" }, thumbnail: { decryptionHeader: "dGh1bWI=" }, updationTime: 1, }); // A metadata-only client: two albums, three files, served once. No ML. class MockClient { private served = false; whoami(): { email: string; userID: number } { return { email: "backup@example.com", userID: USER_ID }; } async collectionsSince(): Promise<CollectionsPage> { if (this.served) return { collections: [], deleted: [], cursor: 1 }; this.served = true; return { collections: [collection(1, "Vacation"), collection(2, "Work")], deleted: [], cursor: 1, }; } async filesSince(args: { collectionID: number }): Promise<FilesPage> { const files = args.collectionID === 1 ? [file(100, 1, "beach.jpg"), file(101, 1, "sunset.jpg")] : args.collectionID === 2 ? [file(200, 2, "diagram.png")] : []; return { files, deleted: [], cursor: 1 }; } } // A server that names an album and a file so as to climb out of the backup // directory. class HostileClient extends MockClient { override async collectionsSince(): Promise<CollectionsPage> { const page = await super.collectionsSince(); return { ...page, collections: page.collections.length ? [collection(3, "../escape")] : [], }; } override async filesSince(args: { collectionID: number; }): Promise<FilesPage> { const files = args.collectionID === 3 ? [file(300, 3, "../../.ssh/authorized_keys")] : []; return { files, deleted: [], cursor: 1 }; } } // A content source that writes byte buffers of the expected length and can be // told to fail one fileID's original, to exercise per-file resilience. interface StubSource extends ContentSource { failID?: number; failThumbID?: number; originalCalls: number; } const stubSource = (): StubSource => { const s: StubSource = { originalCalls: 0, original: async ({ file: f, destination }) => { s.originalCalls++; if (s.failID === f.id) throw new Error("HTTP 500 from server"); const size = SIZE_BY_ID[f.id] ?? 10; writeFileSync(destination, Buffer.alloc(size)); return { bytesWritten: size }; }, thumbnail: async ({ file: f, destination }) => { if (s.failThumbID === f.id) throw new Error("HTTP 500 from server"); writeFileSync(destination, Buffer.alloc(5)); return { bytesWritten: 5 }; }, }; return s; }; let root: string; const openLibrary = ( source: ContentSource, client: MockClient = new MockClient(), ): Promise<Library> => Library.open({ client, cacheDirectory: join(root, "cache"), contentSource: source, refreshIntervalSeconds: 3600, // These tests count exact fetches; the background precache (#48) would // add its own, so it is off here (it is covered in precache.test.ts). precacheThumbnails: false, precacheOriginals: false, }); const readLedger = ( outDir: string, ): { files: Record<string, Record<string, unknown>> } => JSON.parse(readFileSync(join(outDir, "failures.json"), "utf-8")); // Write a durable ledger holding one prior failure, to exercise pruning of // entries the current run cannot resolve. const seedLedger = (outDir: string, fileID: number, title: string): void => { mkdirSync(outDir, { recursive: true }); writeFileSync( join(outDir, "failures.json"), JSON.stringify({ version: 1, files: { [String(fileID)]: { fileID, title, classification: "transient", attempts: 1, lastTriedAt: Date.now(), error: "HTTP 500 from server", }, }, }), ); }; beforeEach(() => { root = mkdtempSync(join(tmpdir(), "quak-backup-test-")); }); afterEach(() => { if (root && existsSync(root)) rmSync(root, { recursive: true, force: true }); }); describe("lib.backup", () => { it("throws before any network when no downloadDirectory is given", async () => { const source = stubSource(); const lib = await openLibrary(source); await expect(lib.backup()).rejects.toThrow(/downloadDirectory/i); expect(source.originalCalls).toBe(0); lib.close(); }); it("writes the expected on-disk layout for every file", async () => { const source = stubSource(); const lib = await openLibrary(source); const outDir = join(root, "backup"); const result = await lib.backup({ downloadDirectory: outDir }); expect(result.totalFiles).toBe(3); expect(result.downloaded).toBe(3); expect(result.skipped).toBe(0); expect(result.failed).toBe(0); expect(result.errors).toEqual([]); // Originals under originals/<fileID>.<ext>. expect(readFileSync(join(outDir, "originals", "100.jpg")).length).toBe( 3000, ); expect(readFileSync(join(outDir, "originals", "101.jpg")).length).toBe( 2000, ); expect(readFileSync(join(outDir, "originals", "200.png")).length).toBe( 1500, ); // Per-file metadata sidecar. const sidecar = JSON.parse( readFileSync(join(outDir, "originals", "100.json"), "utf-8"), ); expect(sidecar.id).toBe(100); expect(sidecar.metadata.title).toBe("beach.jpg"); // Collection dirs contain symlinks into ../originals. const beach = join(outDir, "collections", "Vacation", "beach.jpg"); expect(lstatSync(beach).isSymbolicLink()).toBe(true); expect(readlinkSync(beach)).toContain("originals"); expect(readFileSync(beach).length).toBe(3000); // Per-collection metadata JSON. const vacation = JSON.parse( readFileSync(join(outDir, "collections", "Vacation.json"), "utf-8"), ); expect(vacation.name).toBe("Vacation"); expect(vacation.files.length).toBe(2); expect(vacation.files[0].metadata.title).toBeDefined(); // A clean run leaves no failure ledger behind. expect(existsSync(join(outDir, "failures.json"))).toBe(false); lib.close(); }); it("keeps server-supplied album and file names inside the backup", async () => { const lib = await openLibrary(stubSource(), new HostileClient()); const outDir = join(root, "backup"); const result = await lib.backup({ downloadDirectory: outDir }); expect(result.failed).toBe(0); // The title has no usable extension, so the original is `.bin`. expect(existsSync(join(outDir, "originals", "300.bin"))).toBe(true); const link = join( outDir, "collections", "__escape", "__.._.ssh_authorized_keys", ); expect(lstatSync(link).isSymbolicLink()).toBe(true); expect(existsSync(join(outDir, "collections", "__escape.json"))).toBe( true, ); // Nothing landed beside or above the backup directory. expect(readdirSync(root).sort()).toEqual(["backup", "cache"]); lib.close(); }); it("is an idempotent no-op when every original is already present", async () => { const source = stubSource(); const lib = await openLibrary(source); const outDir = join(root, "backup"); const first = await lib.backup({ downloadDirectory: outDir }); expect(first.downloaded).toBe(3); const callsAfterFirst = source.originalCalls; const second = await lib.backup({ downloadDirectory: outDir }); expect(second.downloaded).toBe(0); expect(second.skipped).toBe(3); expect(second.failed).toBe(0); // A present original is neither fetched nor copied again. expect(source.originalCalls).toBe(callsAfterFirst); lib.close(); }); it("continues past a download failure and records it in failures.json", async () => { const source = stubSource(); source.failID = 101; const lib = await openLibrary(source); const outDir = join(root, "backup"); const result = await lib.backup({ downloadDirectory: outDir }); expect(result.totalFiles).toBe(3); expect(result.downloaded).toBe(2); expect(result.failed).toBe(1); expect(result.errors.length).toBe(1); expect(result.errors[0]!.fileID).toBe(101); expect(result.errors[0]!.title).toBe("sunset.jpg"); // The two good files are on disk; the failed one is not. expect(existsSync(join(outDir, "originals", "100.jpg"))).toBe(true); expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true); expect(existsSync(join(outDir, "originals", "101.jpg"))).toBe(false); expect( existsSync(join(outDir, "collections", "Vacation", "sunset.jpg")), ).toBe(false); // Durable ledger with classification, attempts, last-tried. const ledger = readLedger(outDir); const entry = ledger.files["101"]!; expect(entry.attempts).toBe(1); expect(entry.classification).toBeDefined(); expect(typeof entry.lastTriedAt).toBe("number"); lib.close(); }); it("increments the attempt count across runs and clears the ledger once resolved", async () => { const source = stubSource(); source.failID = 101; const lib = await openLibrary(source); const outDir = join(root, "backup"); const r1 = await lib.backup({ downloadDirectory: outDir }); expect(r1.failed).toBe(1); expect(readLedger(outDir).files["101"]!.attempts).toBe(1); // Second run: the two good files are present, only 101 is retried. const r2 = await lib.backup({ downloadDirectory: outDir }); expect(r2.failed).toBe(1); expect(r2.skipped).toBe(2); expect(readLedger(outDir).files["101"]!.attempts).toBe(2); // Resume with a healthy source: 101 downloads, the rest are skipped. source.failID = undefined; const r3 = await lib.backup({ downloadDirectory: outDir }); expect(r3.failed).toBe(0); expect(r3.skipped).toBe(2); expect(existsSync(join(outDir, "originals", "101.jpg"))).toBe(true); // A ledger with no remaining failures is removed. expect(existsSync(join(outDir, "failures.json"))).toBe(false); lib.close(); }); it("does not abort when a symlink cannot be created (issue #8)", async () => { const source = stubSource(); const lib = await openLibrary(source); const outDir = join(root, "backup"); // Occupy beach.jpg's symlink path with a directory so symlink creation // fails for that one file. mkdirSync(join(outDir, "collections", "Vacation", "beach.jpg"), { recursive: true, }); const result = await lib.backup({ downloadDirectory: outDir }); // Every original still downloads despite the symlink failure. expect(existsSync(join(outDir, "originals", "100.jpg"))).toBe(true); expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true); // The other symlinks are still built. expect( lstatSync( join(outDir, "collections", "Vacation", "sunset.jpg"), ).isSymbolicLink(), ).toBe(true); expect( lstatSync( join(outDir, "collections", "Work", "diagram.png"), ).isSymbolicLink(), ).toBe(true); // The symlink failure is recorded, not thrown. expect(result.failed).toBeGreaterThanOrEqual(1); const err = result.errors.find((e) => e.fileID === 100); expect(err).toBeDefined(); expect(err!.collection).toBe("Vacation"); expect(readLedger(outDir).files["100"]).toBeDefined(); lib.close(); }); it("rebuilds a stale sidecar and a missing symlink on a later run", async () => { const source = stubSource(); const lib = await openLibrary(source); const outDir = join(root, "backup"); await lib.backup({ downloadDirectory: outDir }); // Corrupt a sidecar and delete a symlink between runs. writeFileSync(join(outDir, "originals", "100.json"), "not json"); rmSync(join(outDir, "collections", "Vacation", "beach.jpg")); const result = await lib.backup({ downloadDirectory: outDir }); expect(result.failed).toBe(0); // The derived views are repaired from the model. const sidecar = JSON.parse( readFileSync(join(outDir, "originals", "100.json"), "utf-8"), ); expect(sidecar.metadata.title).toBe("beach.jpg"); expect( lstatSync( join(outDir, "collections", "Vacation", "beach.jpg"), ).isSymbolicLink(), ).toBe(true); lib.close(); }); it("backs up only the named albums when onlyAlbumNames is given", async () => { const source = stubSource(); const lib = await openLibrary(source); const outDir = join(root, "backup"); const result = await lib.backup({ downloadDirectory: outDir, onlyAlbumNames: ["Work"], }); expect(result.totalFiles).toBe(1); expect(result.downloaded).toBe(1); expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true); expect(existsSync(join(outDir, "originals", "100.jpg"))).toBe(false); expect(existsSync(join(outDir, "collections", "Work.json"))).toBe(true); expect(existsSync(join(outDir, "collections", "Vacation.json"))).toBe( false, ); lib.close(); }); it("prunes a ledger entry for a file no longer in the library and exits zero", async () => { const source = stubSource(); const lib = await openLibrary(source); const outDir = join(root, "backup"); // A prior failure for a file that has since left the library (deleted // from the account). This run has no way to resolve it, so it must not // keep the exit code non-zero forever. seedLedger(outDir, 999, "gone.jpg"); const result = await lib.backup({ downloadDirectory: outDir }); // Everything still present is backed up cleanly, and the stale entry is // dropped rather than counted. expect(result.downloaded).toBe(3); expect(result.failed).toBe(0); expect(existsSync(join(outDir, "failures.json"))).toBe(false); lib.close(); }); it("prunes an out-of-scope ledger entry on a scoped run and exits zero", async () => { const source = stubSource(); const lib = await openLibrary(source); const outDir = join(root, "backup"); // A prior failure for a Vacation file; this run is scoped to Work and // never attempts it, so it must not poison the scoped run's exit code. seedLedger(outDir, 100, "beach.jpg"); const result = await lib.backup({ downloadDirectory: outDir, onlyAlbumNames: ["Work"], }); expect(result.totalFiles).toBe(1); expect(result.failed).toBe(0); expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true); expect(existsSync(join(outDir, "failures.json"))).toBe(false); lib.close(); }); it("also stores thumbnails when includeThumbnails is set", async () => { const source = stubSource(); const lib = await openLibrary(source); const outDir = join(root, "backup"); await lib.backup({ downloadDirectory: outDir, includeThumbnails: true, }); expect(existsSync(join(outDir, "thumbnails", "100.jpg"))).toBe(true); expect(existsSync(join(outDir, "thumbnails", "200.jpg"))).toBe(true); lib.close(); }); it("counts one attempt when a file fails both its original and thumbnail in a run", async () => { const source = stubSource(); source.failID = 101; source.failThumbID = 101; const lib = await openLibrary(source); const outDir = join(root, "backup"); const result = await lib.backup({ downloadDirectory: outDir, includeThumbnails: true, }); // Both kinds fail for 101, but the run counts it once. const errs = result.errors.filter((e) => e.fileID === 101); expect(errs.length).toBe(1); expect(readLedger(outDir).files["101"]!.attempts).toBe(1); lib.close(); }); it("fetches each original once and writes it only into the backup", async () => { // A backup of a 500 GB account must write 500 GB, not a copy in the // cache as well: an original fetched for the backup goes straight // into its originals/, and the cache records it there. const source = stubSource(); const lib = await openLibrary(source); const outDir = join(root, "backup"); const result = await lib.backup({ downloadDirectory: outDir }); expect(result.downloaded).toBe(3); expect(source.originalCalls).toBe(3); expect(readdirSync(join(root, "cache", "originals"))).toEqual([]); const stored = readdirSync(join(outDir, "originals")).filter( (name) => !name.endsWith(".json"), ); expect(stored.sort()).toEqual(["100.jpg", "101.jpg", "200.png"]); // The cache counts the backup's copy as present: reading the // original afterwards fetches nothing and answers with that copy. const read = await lib.photos.byID({ fileID: 100 })!.original(); expect(read.path).toBe(join(outDir, "originals", "100.jpg")); expect(source.originalCalls).toBe(3); await lib.close(); }); it("fsyncs an original copied from the cache before the rename and its directory after", async () => { const lib = await openLibrary(stubSource()); const outDir = join(root, "backup"); const originals = join(outDir, "originals"); const dest = join(originals, "100.jpg"); // Only an original already in the cache is copied into the backup; // one fetched for the backup is written there by the download writer. await lib.photos.byID({ fileID: 100 })!.original(); fsEvents.length = 0; await lib.backup({ downloadDirectory: outDir }); const at = fsEvents.indexOf(`rename:${dest}`); expect(at).toBeGreaterThan(0); expect(fsEvents[at - 1]).toMatch( /^sync:.*\/\.quak-backup-100\.jpg-\d+-[0-9a-z]*\.tmp$/, ); expect(fsEvents[at + 1]).toBe(`sync:${originals}`); lib.close(); }); it("removes temp files left by a killed backup but not those of one still running", async () => { const outDir = join(root, "backup"); const originals = join(outDir, "originals"); mkdirSync(originals, { recursive: true }); // A child that has already exited: its process ID is not running. const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid; const leftover = `.quak-backup-100.jpg-${exitedPID}-abc123.tmp`; // This test's own process stands in for a backup running at the same // time. const inProgress = `.quak-backup-101.jpg-${process.pid}-def456.tmp`; writeFileSync(join(originals, leftover), "partial"); writeFileSync(join(originals, inProgress), "partial"); const lib = await openLibrary(stubSource()); await lib.backup({ downloadDirectory: outDir }); const names = readdirSync(originals); expect(names).not.toContain(leftover); expect(names).toContain(inProgress); lib.close(); }); it("removes leftover temp files in thumbnails/ but not those of a backup still running", async () => { const outDir = join(root, "backup"); const thumbnails = join(outDir, "thumbnails"); mkdirSync(thumbnails, { recursive: true }); const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid; const leftover = `.quak-backup-100.jpg-${exitedPID}-abc123.tmp`; const inProgress = `.quak-backup-101.jpg-${process.pid}-def456.tmp`; writeFileSync(join(thumbnails, leftover), "partial"); writeFileSync(join(thumbnails, inProgress), "partial"); const lib = await openLibrary(stubSource()); await lib.backup({ downloadDirectory: outDir, includeThumbnails: true, }); const names = readdirSync(thumbnails); expect(names).not.toContain(leftover); expect(names).toContain(inProgress); lib.close(); }); }); // Every refresh fails, as with an expired session or no network. class FailingClient extends MockClient { override async collectionsSince(): Promise<CollectionsPage> { throw new Error("HTTP 401 from server"); } } // Holds its refresh open until `release()` is called, then reports a third // album, so a backup can be started while that refresh is still running. class HeldClient extends MockClient { release!: () => void; private held = new Promise<void>((resolve) => { this.release = resolve; }); override async collectionsSince(): Promise<CollectionsPage> { await this.held; return { collections: [collection(3, "Later")], deleted: [], cursor: 2, }; } override async filesSince(args: { collectionID: number; }): Promise<FilesPage> { if (args.collectionID !== 3) return super.filesSince(args); return { files: [file(300, 3, "late.jpg")], deleted: [], cursor: 2 }; } } // Fill the library cache on disk, so the next open starts its refresh in the // background instead of waiting for it. const fillCache = async (): Promise<void> => { const lib = await openLibrary(stubSource()); await lib.close(); }; describe("the refresh before a backup", () => { it("waits for a refresh already running and backs up what it found", async () => { await fillCache(); const client = new HeldClient(); const lib = await openLibrary(stubSource(), client); const outDir = join(root, "backup"); const backup = lib.backup({ downloadDirectory: outDir }); client.release(); const result = await backup; expect(result.totalFiles).toBe(4); expect(existsSync(join(outDir, "originals", "300.jpg"))).toBe(true); await lib.close(); }); it("fails before any download when the refresh fails, leaving failures.json as it was", async () => { await fillCache(); const outDir = join(root, "backup"); seedLedger(outDir, 100, "beach.jpg"); const ledgerPath = join(outDir, "failures.json"); const ledgerBefore = readFileSync(ledgerPath, "utf-8"); const source = stubSource(); const lib = await openLibrary(source, new FailingClient()); await expect(lib.backup({ downloadDirectory: outDir })).rejects.toThrow( "HTTP 401 from server", ); expect(source.originalCalls).toBe(0); expect(readFileSync(ledgerPath, "utf-8")).toBe(ledgerBefore); expect(existsSync(join(outDir, "originals"))).toBe(false); await lib.close(); }); it("fails when the refresh fails on an empty cache, instead of backing up nothing", async () => { const source = stubSource(); const lib = await openLibrary(source, new FailingClient()); const outDir = join(root, "backup"); await expect(lib.backup({ downloadDirectory: outDir })).rejects.toThrow( "HTTP 401 from server", ); expect(source.originalCalls).toBe(0); expect(existsSync(outDir)).toBe(false); await lib.close(); }); }); // Every entry under collections/, one level of directories deep, with each // symlink's target. const tree = (outDir: string): string[] => { const lines: string[] = []; const list = (dir: string, prefix: string): void => { for (const name of readdirSync(dir).sort()) { const path = join(dir, name); const st = lstatSync(path); if (st.isSymbolicLink()) { lines.push(`${prefix}${name} -> ${readlinkSync(path)}`); } else if (st.isDirectory() && prefix === "") { lines.push(`${name}/`); list(path, `${name}/`); } else { lines.push(`${prefix}${name}`); } } }; list(join(outDir, "collections"), ""); return lines; }; // The album folders under collections/, driven through `runBackup` with a // stand-in library whose albums a test changes between runs. describe("backup album folders", () => { interface Album { collection: Collection; files: EnteFile[]; } const libraryOf = (albums: Album[]): BackupLibrary => ({ refresh: async () => {}, listCollections: () => albums.map((a) => a.collection), listFiles: (id) => albums.find((a) => a.collection.id === id)?.files ?? [], original: async (fileID) => { const path = join(root, `source-${fileID}`); writeFileSync(path, `original ${fileID}`); return { path }; }, thumbnail: async () => { throw new Error("no thumbnails in this stand-in"); }, }); const albumID = (outDir: string, jsonName: string): number => JSON.parse(readFileSync(join(outDir, "collections", jsonName), "utf-8")) .id; it("gives every file and every album its own name when names repeat", async () => { const outDir = join(root, "backup"); const lib = libraryOf([ { collection: collection(10, "Trip"), files: [ file(1, 10, "IMG_0001.JPG"), file(2, 10, "IMG_0001.JPG"), file(4, 10, "img_0001.jpg"), file(3, 10, "other.jpg"), ], }, { collection: collection(11, "Trip"), files: [file(3, 11, "other.jpg")], }, ]); const result = await runBackup(lib, { downloadDirectory: outDir }); expect(result.failed).toBe(0); expect(tree(outDir)).toEqual([ "Trip (10)/", "Trip (10)/IMG_0001 (1).JPG -> ../../originals/1.JPG", "Trip (10)/IMG_0001 (2).JPG -> ../../originals/2.JPG", "Trip (10)/img_0001 (4).jpg -> ../../originals/4.jpg", "Trip (10)/other.jpg -> ../../originals/3.jpg", "Trip (10).json", "Trip (11)/", "Trip (11)/other.jpg -> ../../originals/3.jpg", "Trip (11).json", ]); expect(albumID(outDir, "Trip (10).json")).toBe(10); expect(albumID(outDir, "Trip (11).json")).toBe(11); }); it("keeps names unique when a name with an ID added is another entry's own name", async () => { const outDir = join(root, "backup"); const lib = libraryOf([ { collection: collection(10, "Trip"), files: [ file(5, 10, "IMG (6).JPG"), file(6, 10, "IMG.JPG"), file(7, 10, "IMG.JPG"), ], }, { collection: collection(11, "Trip"), files: [file(8, 11, "a.jpg")], }, { collection: collection(12, "Trip (11)"), files: [file(9, 12, "b.jpg")], }, ]); const result = await runBackup(lib, { downloadDirectory: outDir }); expect(result.failed).toBe(0); expect(tree(outDir)).toEqual([ "Trip (10)/", "Trip (10)/IMG (6) (5).JPG -> ../../originals/5.JPG", "Trip (10)/IMG (6).JPG -> ../../originals/6.JPG", "Trip (10)/IMG (7).JPG -> ../../originals/7.JPG", "Trip (10).json", "Trip (11)/", "Trip (11)/a.jpg -> ../../originals/8.jpg", "Trip (11) (12)/", "Trip (11) (12)/b.jpg -> ../../originals/9.jpg", "Trip (11) (12).json", "Trip (11).json", ]); expect(albumID(outDir, "Trip (10).json")).toBe(10); expect(albumID(outDir, "Trip (11).json")).toBe(11); expect(albumID(outDir, "Trip (11) (12).json")).toBe(12); }); it("changes nothing on a second run over an unchanged account", async () => { const outDir = join(root, "backup"); const lib = libraryOf([ { collection: collection(10, "Trip"), files: [ file(1, 10, "IMG_0001.JPG"), file(2, 10, "IMG_0001.JPG"), ], }, { collection: collection(11, "Trip"), files: [file(3, 11, "other.jpg")], }, ]); await runBackup(lib, { downloadDirectory: outDir }); const before = tree(outDir); const second = await runBackup(lib, { downloadDirectory: outDir }); expect(second.downloaded).toBe(0); expect(second.failed).toBe(0); expect(tree(outDir)).toEqual(before); }); it("leaves the albums an onlyAlbumNames run skips as they were", async () => { const outDir = join(root, "backup"); // "trip" is skipped by the scoped run but its name clashes with the // in-scope "Trip", so "Trip" must keep its ID suffix. const lib = libraryOf([ { collection: collection(10, "Trip"), files: [file(1, 10, "a.jpg")], }, { collection: collection(11, "trip"), files: [file(2, 11, "b.jpg")], }, { collection: collection(12, "Work"), files: [file(3, 12, "c.jpg")], }, ]); const json = (name: string): string => readFileSync(join(outDir, "collections", name), "utf-8"); await runBackup(lib, { downloadDirectory: outDir }); const before = tree(outDir); const skippedJSON = [json("trip (11).json"), json("Work.json")]; const scoped = await runBackup(lib, { downloadDirectory: outDir, onlyAlbumNames: ["Trip"], }); expect(scoped.failed).toBe(0); expect(before).toEqual([ "Trip (10)/", "Trip (10)/a.jpg -> ../../originals/1.jpg", "Trip (10).json", "Work/", "Work/c.jpg -> ../../originals/3.jpg", "Work.json", "trip (11)/", "trip (11)/b.jpg -> ../../originals/2.jpg", "trip (11).json", ]); expect(tree(outDir)).toEqual(before); expect([json("trip (11).json"), json("Work.json")]).toEqual( skippedJSON, ); }); it("removes links and album folders that are gone, and nothing the user added", async () => { const outDir = join(root, "backup"); const albums: Album[] = [ { collection: collection(10, "Trip"), files: [ file(1, 10, "IMG_0001.JPG"), file(2, 10, "IMG_0001.JPG"), file(3, 10, "other.jpg"), ], }, { collection: collection(12, "Work"), files: [file(5, 12, "a.jpg")], }, { collection: collection(13, "Old"), files: [file(5, 13, "a.jpg")], }, ]; const lib = libraryOf(albums); await runBackup(lib, { downloadDirectory: outDir }); // What the user put in the tree: a note and a symlink of their own in // an album, a note in an album about to be renamed, and a folder quak // did not create. const collectionsDir = join(outDir, "collections"); writeFileSync(join(collectionsDir, "Trip", "notes.txt"), "mine"); symlinkSync("../elsewhere", join(collectionsDir, "Trip", "mine")); writeFileSync(join(collectionsDir, "Work", "keep.txt"), "mine"); mkdirSync(join(collectionsDir, "Mine")); writeFileSync(join(collectionsDir, "Mine", "keep.txt"), "mine"); // File 2 leaves Trip, Work is renamed Office, Old is deleted. albums[0]!.files.splice(1, 1); albums[1]!.collection = collection(12, "Office"); albums.splice(2, 1); const result = await runBackup(lib, { downloadDirectory: outDir }); expect(result.failed).toBe(0); expect(tree(outDir)).toEqual([ "Mine/", "Mine/keep.txt", "Office/", "Office/a.jpg -> ../../originals/5.jpg", "Office.json", "Trip/", "Trip/IMG_0001.JPG -> ../../originals/1.JPG", "Trip/mine -> ../elsewhere", "Trip/notes.txt", "Trip/other.jpg -> ../../originals/3.jpg", "Trip.json", "Work/", "Work/keep.txt", "Work.json", ]); }); // One album backed up, then a folder the user made beside it holding a // symlink into originals/, with `json` (if given) as its sibling JSON. const backupWithUserFolder = async ( json: string | undefined, ): Promise<{ outDir: string; failed: number }> => { const outDir = join(root, "backup"); const lib = libraryOf([ { collection: collection(10, "Trip"), files: [file(1, 10, "a.jpg")], }, ]); await runBackup(lib, { downloadDirectory: outDir }); const collectionsDir = join(outDir, "collections"); mkdirSync(join(collectionsDir, "Mine")); symlinkSync( "../../originals/1.jpg", join(collectionsDir, "Mine", "a.jpg"), ); if (json !== undefined) { writeFileSync(join(collectionsDir, "Mine.json"), json); } const result = await runBackup(lib, { downloadDirectory: outDir }); return { outDir, failed: result.failed }; }; it("leaves a user folder with no JSON beside it as it was", async () => { const { outDir, failed } = await backupWithUserFolder(undefined); expect(failed).toBe(0); expect(tree(outDir)).toEqual([ "Mine/", "Mine/a.jpg -> ../../originals/1.jpg", "Trip/", "Trip/a.jpg -> ../../originals/1.jpg", "Trip.json", ]); }); it("leaves a user folder whose JSON has no album ID as it was", async () => { const json = '{"name":"Mine"}'; const { outDir, failed } = await backupWithUserFolder(json); expect(failed).toBe(0); expect(tree(outDir)).toEqual([ "Mine/", "Mine/a.jpg -> ../../originals/1.jpg", "Mine.json", "Trip/", "Trip/a.jpg -> ../../originals/1.jpg", "Trip.json", ]); expect( readFileSync(join(outDir, "collections", "Mine.json"), "utf-8"), ).toBe(json); }); }); // A live photo, which Ente stores as one ZIP, is backed up as its image and // its video, which a photo viewer can open, beside a JSON file naming them, // and its album folder links both. These tests download a live photo ZIP // through the real download layer (test/live-photo.ts). describe("backup of live photos", () => { // An account of one album, Trip (10), holding `files`. class TripClient extends MockClient { constructor(private readonly files: EnteFile[]) { super(); } override async collectionsSince(): Promise<CollectionsPage> { return { collections: [collection(10, "Trip")], deleted: [], cursor: 1, }; } override async filesSince(): Promise<FilesPage> { return { files: this.files, deleted: [], cursor: 1 }; } } const open = (files: EnteFile[], bodies: Map<number, Uint8Array>) => openLibrary(cdnSource(bodies), new TripClient(files)); // What an earlier version stored for live photo 500: the ZIP under the // image's name, and its link. const earlierZIP = (outDir: string): void => { mkdirSync(join(outDir, "originals"), { recursive: true }); mkdirSync(join(outDir, "collections", "Trip"), { recursive: true }); writeFileSync(join(outDir, "originals", "500.HEIC"), livePhotoZip()); symlinkSync( "../../originals/500.HEIC", join(outDir, "collections", "Trip", "IMG_0500.HEIC"), ); }; const stored = ["500.heic", "500.json", "500.livephoto.json", "500.mov"]; const linked = [ "Trip/", "Trip/IMG_0500.heic -> ../../originals/500.heic", "Trip/IMG_0500.mov -> ../../originals/500.mov", "Trip.json", ]; it("stores a live photo as its image and its video and links both", async () => { const { file: live, body } = await asLivePhoto( file(500, 10, "IMG_0500.HEIC"), ); const lib = await open([live], new Map([[500, body]])); const outDir = join(root, "backup"); const originals = join(outDir, "originals"); const result = await lib.backup({ downloadDirectory: outDir }); expect(result).toMatchObject({ downloaded: 1, failed: 0 }); expect(readdirSync(originals).sort()).toEqual(stored); expect(readFileSync(join(originals, "500.heic"))).toEqual( Buffer.from(IMAGE), ); expect(readFileSync(join(originals, "500.mov"))).toEqual( Buffer.from(VIDEO), ); expect(tree(outDir)).toEqual(linked); // Both parts are there, so the next run fetches nothing. const second = await lib.backup({ downloadDirectory: outDir }); expect(second).toMatchObject({ downloaded: 0, skipped: 1, failed: 0 }); await lib.close(); }); it("gives both links of each live photo their own names when titles clash", async () => { const a = await asLivePhoto(file(500, 10, "IMG_0001.HEIC")); const b = await asLivePhoto(file(501, 10, "IMG_0001.HEIC")); const lib = await open( [a.file, b.file], new Map([ [500, a.body], [501, b.body], ]), ); const outDir = join(root, "backup"); await lib.backup({ downloadDirectory: outDir }); expect(tree(outDir)).toEqual([ "Trip/", "Trip/IMG_0001 (500).heic -> ../../originals/500.heic", "Trip/IMG_0001 (500).mov -> ../../originals/500.mov", "Trip/IMG_0001 (501).heic -> ../../originals/501.heic", "Trip/IMG_0001 (501).mov -> ../../originals/501.mov", "Trip.json", ]); await lib.close(); }); it("replaces the ZIP an earlier version stored, and its link", async () => { const { file: live, body } = await asLivePhoto( file(500, 10, "IMG_0500.HEIC"), ); const outDir = join(root, "backup"); earlierZIP(outDir); const lib = await open([live], new Map([[500, body]])); const result = await lib.backup({ downloadDirectory: outDir }); expect(result).toMatchObject({ downloaded: 1, failed: 0 }); expect(readdirSync(join(outDir, "originals")).sort()).toEqual(stored); expect(tree(outDir)).toEqual(linked); await lib.close(); }); it("stores nothing for a live photo that fails its hash, and keeps what was there", async () => { const { file: live, body } = await asLivePhoto( file(500, 10, "IMG_0500.HEIC"), livePhotoZip(), "not:the recorded hash", ); const outDir = join(root, "backup"); earlierZIP(outDir); const lib = await open([live], new Map([[500, body]])); const result = await lib.backup({ downloadDirectory: outDir }); expect(result).toMatchObject({ downloaded: 0, failed: 1 }); expect(result.errors.map((e) => e.fileID)).toEqual([500]); expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]); expect(readdirSync(join(outDir, "originals"))).toEqual(["500.HEIC"]); expect(tree(outDir)).toEqual([ "Trip/", "Trip/IMG_0500.HEIC -> ../../originals/500.HEIC", "Trip.json", ]); await lib.close(); }); it("copies both parts of a live photo the cache already holds", async () => { const { file: live, body } = await asLivePhoto( file(500, 10, "IMG_0500.HEIC"), ); const lib = await open([live], new Map([[500, body]])); const cached = await lib.photos.byID({ fileID: 500 })!.original(); const outDir = join(root, "backup"); const result = await lib.backup({ downloadDirectory: outDir }); expect(result).toMatchObject({ downloaded: 1, failed: 0 }); expect(readdirSync(join(outDir, "originals")).sort()).toEqual(stored); expect(readFileSync(join(outDir, "originals", "500.mov"))).toEqual( Buffer.from(VIDEO), ); expect(tree(outDir)).toEqual(linked); // The cache keeps its own copy. expect(existsSync(cached.videoPath!)).toBe(true); await lib.close(); }); it("serves a live photo the backup stored to a library reading the backup", async () => { const { file: live, body } = await asLivePhoto( file(500, 10, "IMG_0500.HEIC"), ); const lib = await open([live], new Map([[500, body]])); const outDir = join(root, "backup"); await lib.backup({ downloadDirectory: outDir }); await lib.close(); // Another cache over the same backup, whose server has nothing. const reader = await Library.open({ client: new TripClient([live]), cacheDirectory: join(root, "other-cache"), downloadDirectory: outDir, contentSource: cdnSource(new Map()), refreshIntervalSeconds: 3600, precacheThumbnails: false, precacheOriginals: false, }); const read = await reader.photos.byID({ fileID: 500 })!.original(); expect(read).toEqual({ path: join(outDir, "originals", "500.heic"), videoPath: join(outDir, "originals", "500.mov"), bytes: IMAGE.length, }); await reader.close(); }); });