diff --git a/test/cli/backup-end-to-end.test.ts b/test/cli/backup-end-to-end.test.ts index c69884a..c53eaa7 100644 --- a/test/cli/backup-end-to-end.test.ts +++ b/test/cli/backup-end-to-end.test.ts @@ -9,7 +9,7 @@ * data this file encrypts with libsodium, as Ente's clients encrypt it. So * quak's own code fetches each original, decrypts it, checks it against the * hash its uploader recorded and unpacks a live photo, and quak's own retries - * repeat a failed request. One test runs `lib.backup()` instead, as a program + * repeat a failed request. Two tests run `lib.backup()` instead, as a program * using quak as a library does. * * The account, user 42, has three albums: @@ -58,7 +58,7 @@ import { run } from "../../src/cli-run.js"; import { loadSession } from "../../src/cli-session.js"; import { Client } from "../../src/client.js"; import { init, toBase64 } from "../../src/crypto/index.js"; -import { Library } from "../../src/library/index.js"; +import { Library, lockBackupDirectory } from "../../src/library/index.js"; import { UNATTENDED_RETRY_OPTIONS } from "../../src/retry.js"; import { buildAccount, @@ -653,32 +653,35 @@ describe("quak backup", () => { // seconds. Another backup may then be writing to the directory, so the backup // stops before it starts another file. // -// The fake server holds the download of beach.jpg, the first file backed up, -// open until the test lets it go, so the lock can be lost while the backup -// runs. Only `setTimeout` and `clearTimeout` are faked, so a test can fire -// proper-lockfile's timer without waiting 5 seconds. +// The fake server holds one file's download open until the test lets it go, +// so the lock can be lost while the backup runs: beach.jpg's, the first file +// backed up, unless the test names another. Only `setTimeout` and +// `clearTimeout` are faked, so a test can fire proper-lockfile's timer without +// waiting 5 seconds. describe("a backup whose lock is lost", () => { let dir: string; - let lock: string; + let lockPath: string; let downloads: number[]; - let beachRequested: Promise; - let releaseBeach: () => void; + let heldFile: number; + let heldRequested: Promise; + let releaseHeld: () => void; let fetch: typeof globalThis.fetch; beforeEach(() => { dir = mkdtempSync(join(root, "backup-")); - lock = join(dir, "backup.lock"); + lockPath = join(dir, "backup.lock"); downloads = []; + heldFile = BEACH.id; let requested!: () => void; - beachRequested = new Promise((resolve) => { + heldRequested = new Promise((resolve) => { requested = resolve; }); const released = new Promise((resolve) => { - releaseBeach = resolve; + releaseHeld = resolve; }); const recording = recordingDownloads(downloads); fetch = async (input, init) => { - if (downloadOf(input) === BEACH.id) { + if (downloadOf(input) === heldFile) { requested(); await released; } @@ -706,10 +709,10 @@ describe("a backup whose lock is lost", () => { // exits 1. it("stops quak backup before its next file when backup.lock is removed, and exits 1", async () => { const result = backup(dir, {}, fetch); - await beachRequested; - rmSync(lock, { recursive: true }); + await heldRequested; + rmSync(lockPath, { recursive: true }); await fireLockTimer(); - releaseBeach(); + releaseHeld(); const { code, stderr } = await result; expect(code).toBe(1); @@ -717,7 +720,7 @@ describe("a backup whose lock is lost", () => { "Starting backup...\n" + "Refreshing library...\n" + "Fetching original beach.jpg (100)...\n" + - `quak: lost the lock on ${dir}: ENOENT: no such file or directory, stat '${lock}'\n`, + `quak: lost the lock on ${dir}: ENOENT: no such file or directory, stat '${lockPath}'\n`, ); expect(downloads).toEqual([BEACH.id]); expect(readFileSync(join(dir, DAY, "2024-03-01.100.jpg"))).toEqual( @@ -725,26 +728,52 @@ describe("a backup whose lock is lost", () => { ); }); + // Here the lock is lost while toast.mp4, the last original, downloads. + // All three originals are stored, beside what the backup wrote before it + // started them, but no file's JSON and no album is written. + it("stops quak backup before any file's JSON when the lock is lost during the last original", async () => { + heldFile = TOAST_VIDEO.id; + const result = backup(dir, {}, fetch); + await heldRequested; + rmSync(lockPath, { recursive: true }); + await fireLockTimer(); + releaseHeld(); + const { code, stderr } = await result; + + expect(code).toBe(1); + expect(stderr).toContain(`quak: lost the lock on ${dir}: `); + expect(listTree(dir)).toEqual([ + "2024", + "2024/2024-03", + "2024/2024-03/2024-03-01", + "2024/2024-03/2024-03-01/2024-03-01.100.jpg", + "2024/2024-03/2024-03-01/2024-03-01.200.mp4", + "2024/2024-03/2024-03-01/2024-03-01.300.heic", + "2024/2024-03/2024-03-01/2024-03-01.300.livephoto.json", + "2024/2024-03/2024-03-01/2024-03-01.300.mov", + "account.json", + "collections", + ]); + }); + // Nothing touches the lock but quak, which finds it as it left it each // time it looks, so the backup runs to the end and removes its lock. it("lets quak backup run to the end while its lock stays", async () => { const result = backup(dir, {}, fetch); - await beachRequested; + await heldRequested; await fireLockTimer(); await fireLockTimer(); - releaseBeach(); + releaseHeld(); const { code } = await result; expect(code).toBe(0); expect(downloads.sort()).toEqual([100, 200, 300]); - expect(existsSync(lock)).toBe(false); + expect(existsSync(lockPath)).toBe(false); }); - // A program using quak as a library sees the same: `lib.backup()` rejects - // with the reason. Here another backup has made its own lock in place of - // quak's, and that lock is left where it is. - it("rejects lib.backup() when another backup has taken the lock over", async () => { - const lib = await Library.open({ + // The library a program using quak opens to back `dir` up. + const openLibrary = () => + Library.open({ client: loadSession(join(sessionDir, "session.json"), { fetch })!, cacheDirectory: `${dir}-cache`, downloadDirectory: dir, @@ -752,21 +781,51 @@ describe("a backup whose lock is lost", () => { precacheThumbnails: false, precacheOriginals: false, }); + + // A program using quak as a library sees the same: `lib.backup()` rejects + // with the reason. Here another backup has made its own lock in place of + // quak's, and that lock is left where it is. + it("rejects lib.backup() when another backup has taken the lock over", async () => { + const lib = await openLibrary(); try { const result = lib.backup(); - await beachRequested; - rmSync(lock, { recursive: true }); - mkdirSync(lock); + await heldRequested; + rmSync(lockPath, { recursive: true }); + mkdirSync(lockPath); await fireLockTimer(); - releaseBeach(); + releaseHeld(); await expect(result).rejects.toThrow( `lost the lock on ${dir}: Unable to update lock within the stale threshold`, ); expect(downloads).toEqual([BEACH.id]); - expect(existsSync(lock)).toBe(true); + expect(existsSync(lockPath)).toBe(true); } finally { await lib.close(); } }); + + // A program can take the lock before it opens its library, as `quak + // backup` does. `lockBackupDirectory` returns the lock; the program passes + // it to `lib.backup()`, which checks it as it checks a lock it took, and + // releases it once the backup ends. The lock's `lost` says why it was lost. + it("rejects lib.backup() given its caller's lock once that lock is lost", async () => { + const lock = await lockBackupDirectory(dir); + const lib = await openLibrary(); + try { + const result = lib.backup({ lock }); + await heldRequested; + rmSync(lockPath, { recursive: true }); + await fireLockTimer(); + releaseHeld(); + + const lost = `lost the lock on ${dir}: ENOENT: no such file or directory, stat '${lockPath}'`; + await expect(result).rejects.toThrow(lost); + expect(lock.lost?.message).toBe(lost); + expect(downloads).toEqual([BEACH.id]); + } finally { + await lib.close(); + await lock.release(); + } + }); });