Rebuild backup on the library API with a durable failure ledger (closes #51)
check / check (push) Successful in 26s
check / check (push) Successful in 26s
backup() is a Library method: it refreshes, fetches each pending original (and optional thumbnails) through the content cache and pools, then rebuilds the sidecar, symlink, and per-collection JSON views from the model. The downloadDirectory layout and exit-code contract are unchanged. An original already on disk is complete and never re-fetched, so runs are idempotent and an interrupted run resumes. Per-file download and symlink failures are recorded in a durable failures.json and no longer abort the run, subsuming #8. Each run reconciles the ledger against the files it attempted: an entry survives only for a file that failed this run, so a failure for a since-deleted or out-of-scope file clears instead of failing every future scheduled backup. Model: opus-4-8
This commit is contained in:
+369
-381
@@ -1,368 +1,198 @@
|
||||
/**
|
||||
* Tests for the `quak backup` command's core logic.
|
||||
* Tests for the `quak backup` logic, now built on the library API (issue #51).
|
||||
*
|
||||
* `quak backup <dir>` downloads every file from every collection into
|
||||
* a local directory tree:
|
||||
* `lib.backup({ downloadDirectory })` refreshes the library, fetches each
|
||||
* pending file's original through the content cache/pools, and materialises the
|
||||
* unchanged on-disk layout:
|
||||
*
|
||||
* <dir>/
|
||||
* <collection-name>/
|
||||
* <file-title>
|
||||
* <file-title>
|
||||
* <collection-name>/
|
||||
* ...
|
||||
* metadata.json (all decrypted collection + file metadata)
|
||||
* <downloadDirectory>/
|
||||
* originals/
|
||||
* <fileID>.<ext> the decrypted bytes ("present means complete")
|
||||
* <fileID>.json per-file metadata sidecar (rebuilt each run)
|
||||
* collections/
|
||||
* <name>/<title> symlink into ../originals (rebuilt each run)
|
||||
* <name>.json per-collection metadata (rebuilt each run)
|
||||
* failures.json durable ledger of unresolved failures
|
||||
*
|
||||
* The backup command has two properties that distinguish it from a naive
|
||||
* "download everything" loop:
|
||||
* The properties that distinguish backup from a naive download loop, and that
|
||||
* these tests lock down:
|
||||
*
|
||||
* 1. **Skip existing files.** If `<dir>/<collection>/<title>` already
|
||||
* exists on disk and its size matches the decrypted content length
|
||||
* recorded in metadata.json from a prior run, the file is not
|
||||
* re-downloaded. This makes interrupted backups resumable and
|
||||
* incremental runs fast.
|
||||
* 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.
|
||||
*
|
||||
* 2. **Never crash on a single file failure.** If a file download or
|
||||
* decryption fails, the error is logged and the backup continues
|
||||
* with the next file. At the end, the exit code is non-zero if any
|
||||
* files failed, and the summary lists them. The Ente first-party
|
||||
* CLI crashes entirely when a single file can't be retrieved,
|
||||
* which defeats the purpose of a backup tool.
|
||||
*
|
||||
* These tests exercise the backup logic (in src/backup.ts) using the
|
||||
* same mock server from the Client usage tests. The CLI binary itself
|
||||
* is a thin wrapper around this module.
|
||||
* 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,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import sodium from "libsodium-wrappers-sumo";
|
||||
import { SRP, SrpServer } from "fast-srp-hap";
|
||||
import { beforeAll, afterAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
init,
|
||||
toBase64,
|
||||
deriveKEK,
|
||||
deriveLoginSubkey,
|
||||
} from "../../src/crypto/index.js";
|
||||
import { Client } from "../../src/client.js";
|
||||
import { runBackup } from "../../src/backup.js";
|
||||
import type { KeyAttributes } from "../../src/auth/types.js";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock server (condensed from usage.test.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
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";
|
||||
|
||||
const TEST_EMAIL = "backup@example.com";
|
||||
const TEST_PASSWORD = "backuppass";
|
||||
const TEST_OPS = 2;
|
||||
const TEST_MEM = 64 * 1024 * 1024;
|
||||
const USER_ID = 42;
|
||||
|
||||
interface MockState {
|
||||
verifier: Buffer;
|
||||
srpAttributes: Record<string, unknown>;
|
||||
keyAttributes: KeyAttributes;
|
||||
encryptedToken: string;
|
||||
collections: Record<string, unknown>[];
|
||||
files: Record<
|
||||
number,
|
||||
{ raw: Record<string, unknown>; plaintext: Uint8Array }
|
||||
>;
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
|
||||
let mock: MockState;
|
||||
let testDir: string;
|
||||
// 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;
|
||||
originalCalls: number;
|
||||
}
|
||||
|
||||
const buildMock = async (): Promise<MockState> => {
|
||||
const kekSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
|
||||
const kek = await deriveKEK(TEST_PASSWORD, kekSalt, TEST_OPS, TEST_MEM);
|
||||
const loginSubKeyBytes = deriveLoginSubkey(kek);
|
||||
|
||||
const srpUserID = "backup-srp";
|
||||
const srpSalt = sodium.randombytes_buf(16);
|
||||
const verifier = SRP.computeVerifier(
|
||||
SRP.params["4096"],
|
||||
Buffer.from(srpSalt),
|
||||
Buffer.from(srpUserID),
|
||||
Buffer.from(loginSubKeyBytes),
|
||||
);
|
||||
|
||||
const masterKey = sodium.randombytes_buf(32);
|
||||
const keyNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||
const encryptedKey = sodium.crypto_secretbox_easy(masterKey, keyNonce, kek);
|
||||
const kp = sodium.crypto_box_keypair();
|
||||
const skNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||
const encSK = sodium.crypto_secretbox_easy(
|
||||
kp.privateKey,
|
||||
skNonce,
|
||||
masterKey,
|
||||
);
|
||||
const tokenBytes = sodium.randombytes_buf(32);
|
||||
const encToken = sodium.crypto_box_seal(tokenBytes, kp.publicKey);
|
||||
|
||||
const keyAttributes: KeyAttributes = {
|
||||
kekSalt: toBase64(kekSalt),
|
||||
encryptedKey: toBase64(encryptedKey),
|
||||
keyDecryptionNonce: toBase64(keyNonce),
|
||||
publicKey: toBase64(kp.publicKey),
|
||||
encryptedSecretKey: toBase64(encSK),
|
||||
secretKeyDecryptionNonce: toBase64(skNonce),
|
||||
memLimit: TEST_MEM,
|
||||
opsLimit: TEST_OPS,
|
||||
};
|
||||
|
||||
const makeCollection = (id: number, name: string) => {
|
||||
const ck = sodium.crypto_secretbox_keygen();
|
||||
const ckN = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||
const encCK = sodium.crypto_secretbox_easy(ck, ckN, masterKey);
|
||||
const nameBytes = new TextEncoder().encode(name);
|
||||
const cnN = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||
const encCN = sodium.crypto_secretbox_easy(nameBytes, cnN, ck);
|
||||
return {
|
||||
raw: {
|
||||
id,
|
||||
owner: { id: 42 },
|
||||
encryptedKey: toBase64(encCK),
|
||||
keyDecryptionNonce: toBase64(ckN),
|
||||
encryptedName: toBase64(encCN),
|
||||
nameDecryptionNonce: toBase64(cnN),
|
||||
type: "album",
|
||||
updationTime: 1700000000000000,
|
||||
},
|
||||
key: ck,
|
||||
};
|
||||
};
|
||||
|
||||
const makeFile = (
|
||||
id: number,
|
||||
collKey: Uint8Array,
|
||||
title: string,
|
||||
plaintext: Uint8Array,
|
||||
collID: number,
|
||||
) => {
|
||||
const fk = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||
const fkN = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||
const encFK = sodium.crypto_secretbox_easy(fk, fkN, collKey);
|
||||
|
||||
const meta = JSON.stringify({
|
||||
title,
|
||||
fileType: 0,
|
||||
creationTime: 1700000000000000,
|
||||
modificationTime: 1700000000000000,
|
||||
});
|
||||
const metaPush =
|
||||
sodium.crypto_secretstream_xchacha20poly1305_init_push(fk);
|
||||
const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push(
|
||||
metaPush.state,
|
||||
new TextEncoder().encode(meta),
|
||||
null,
|
||||
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
|
||||
);
|
||||
|
||||
const filePush =
|
||||
sodium.crypto_secretstream_xchacha20poly1305_init_push(fk);
|
||||
const encFile = sodium.crypto_secretstream_xchacha20poly1305_push(
|
||||
filePush.state,
|
||||
plaintext,
|
||||
null,
|
||||
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL,
|
||||
);
|
||||
|
||||
return {
|
||||
raw: {
|
||||
id,
|
||||
collectionID: collID,
|
||||
ownerID: 42,
|
||||
encryptedKey: toBase64(encFK),
|
||||
keyDecryptionNonce: toBase64(fkN),
|
||||
metadata: {
|
||||
encryptedData: toBase64(encMeta),
|
||||
decryptionHeader: toBase64(metaPush.header),
|
||||
},
|
||||
file: { decryptionHeader: toBase64(filePush.header) },
|
||||
thumbnail: {
|
||||
decryptionHeader: toBase64(sodium.randombytes_buf(24)),
|
||||
},
|
||||
updationTime: 1700000000000000,
|
||||
},
|
||||
plaintext,
|
||||
ciphertext: encFile,
|
||||
};
|
||||
};
|
||||
|
||||
const col1 = makeCollection(1, "Vacation");
|
||||
const col2 = makeCollection(2, "Work");
|
||||
const file1 = makeFile(
|
||||
100,
|
||||
col1.key,
|
||||
"beach.jpg",
|
||||
sodium.randombytes_buf(3000),
|
||||
1,
|
||||
);
|
||||
const file2 = makeFile(
|
||||
101,
|
||||
col1.key,
|
||||
"sunset.jpg",
|
||||
sodium.randombytes_buf(2000),
|
||||
1,
|
||||
);
|
||||
const file3 = makeFile(
|
||||
200,
|
||||
col2.key,
|
||||
"diagram.png",
|
||||
sodium.randombytes_buf(1500),
|
||||
2,
|
||||
);
|
||||
|
||||
return {
|
||||
verifier,
|
||||
srpAttributes: {
|
||||
srpUserID,
|
||||
srpSalt: toBase64(srpSalt),
|
||||
memLimit: TEST_MEM,
|
||||
opsLimit: TEST_OPS,
|
||||
kekSalt: toBase64(kekSalt),
|
||||
isEmailMFAEnabled: false,
|
||||
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 ({ destination }) => {
|
||||
writeFileSync(destination, Buffer.alloc(5));
|
||||
return { bytesWritten: 5 };
|
||||
},
|
||||
keyAttributes,
|
||||
encryptedToken: toBase64(encToken),
|
||||
collections: [col1.raw, col2.raw],
|
||||
files: {
|
||||
1: {
|
||||
raw: [file1.raw, file2.raw],
|
||||
ciphertexts: { 100: file1.ciphertext, 101: file2.ciphertext },
|
||||
},
|
||||
2: { raw: [file3.raw], ciphertexts: { 200: file3.ciphertext } },
|
||||
100: { plaintext: file1.plaintext },
|
||||
101: { plaintext: file2.plaintext },
|
||||
200: { plaintext: file3.plaintext },
|
||||
} as Record<number, unknown>,
|
||||
};
|
||||
return s;
|
||||
};
|
||||
|
||||
const buildMockFetch = (m: MockState, opts?: { failFileID?: number }) => {
|
||||
let srpServer: SrpServer;
|
||||
let root: string;
|
||||
|
||||
return (async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
const parsed = new URL(url);
|
||||
const path = parsed.pathname;
|
||||
const json = (body: unknown) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const openLibrary = (source: ContentSource): Promise<Library> =>
|
||||
Library.open({
|
||||
client: new MockClient(),
|
||||
cacheDirectory: join(root, "cache"),
|
||||
contentSource: source,
|
||||
refreshIntervalSeconds: 3600,
|
||||
});
|
||||
|
||||
if (path === "/users/srp/attributes")
|
||||
return json({ attributes: m.srpAttributes });
|
||||
const readLedger = (
|
||||
outDir: string,
|
||||
): { files: Record<string, Record<string, unknown>> } =>
|
||||
JSON.parse(readFileSync(join(outDir, "failures.json"), "utf-8"));
|
||||
|
||||
if (path === "/users/srp/create-session") {
|
||||
const body = JSON.parse(init?.body as string);
|
||||
const serverKey = await SRP.genKey();
|
||||
srpServer = new SrpServer(
|
||||
SRP.params["4096"],
|
||||
m.verifier,
|
||||
serverKey,
|
||||
);
|
||||
const B = srpServer.computeB();
|
||||
srpServer.setA(Buffer.from(body.srpA, "base64"));
|
||||
return json({ sessionID: "s1", srpB: B.toString("base64") });
|
||||
}
|
||||
|
||||
if (path === "/users/srp/verify-session") {
|
||||
const body = JSON.parse(init?.body as string);
|
||||
srpServer.checkM1(Buffer.from(body.srpM1, "base64"));
|
||||
return json({
|
||||
srpM2: srpServer.computeM2().toString("base64"),
|
||||
id: 42,
|
||||
keyAttributes: m.keyAttributes,
|
||||
encryptedToken: m.encryptedToken,
|
||||
});
|
||||
}
|
||||
|
||||
if (path === "/collections/v2")
|
||||
return json({ collections: m.collections });
|
||||
|
||||
if (path === "/collections/v2/diff") {
|
||||
const collID = Number(parsed.searchParams.get("collectionID"));
|
||||
const collData = m.files[collID] as { raw: unknown[] } | undefined;
|
||||
return json({ diff: collData?.raw ?? [], hasMore: false });
|
||||
}
|
||||
|
||||
// File download
|
||||
if (url.includes("fileID=") || path.startsWith("/files/download/")) {
|
||||
const fileID = Number(
|
||||
parsed.searchParams.get("fileID") ?? path.split("/").pop(),
|
||||
);
|
||||
if (opts?.failFileID === fileID) {
|
||||
return new Response("Internal Server Error", { status: 500 });
|
||||
}
|
||||
const collData = Object.values(m.files).find(
|
||||
(v: unknown) =>
|
||||
v &&
|
||||
typeof v === "object" &&
|
||||
"ciphertexts" in (v as Record<string, unknown>) &&
|
||||
fileID in
|
||||
(v as Record<string, Record<number, unknown>>)
|
||||
.ciphertexts,
|
||||
) as { ciphertexts: Record<number, Uint8Array> } | undefined;
|
||||
if (collData) {
|
||||
return new Response(collData.ciphertexts[fileID], {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
|
||||
return new Response("not found", { status: 404 });
|
||||
}) as typeof globalThis.fetch;
|
||||
// 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",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup / teardown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeAll(async () => {
|
||||
await init();
|
||||
await sodium.ready;
|
||||
mock = await buildMock();
|
||||
testDir = mkdtempSync(join(tmpdir(), "quak-backup-test-"));
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "quak-backup-test-"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (testDir && existsSync(testDir))
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
afterEach(() => {
|
||||
if (root && existsSync(root))
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
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();
|
||||
});
|
||||
|
||||
describe("quak backup", () => {
|
||||
it("downloads all files organized by collection name", async () => {
|
||||
const outDir = join(testDir, "full-backup");
|
||||
const client = await Client.login({
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
apiOptions: { fetch: buildMockFetch(mock) },
|
||||
});
|
||||
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 runBackup(client, outDir);
|
||||
const result = await lib.backup({ downloadDirectory: outDir });
|
||||
|
||||
expect(result.totalFiles).toBe(3);
|
||||
expect(result.downloaded).toBe(3);
|
||||
@@ -370,7 +200,7 @@ describe("quak backup", () => {
|
||||
expect(result.failed).toBe(0);
|
||||
expect(result.errors).toEqual([]);
|
||||
|
||||
// Originals are under <outDir>/originals/<fileID>.<ext>
|
||||
// Originals under originals/<fileID>.<ext>.
|
||||
expect(readFileSync(join(outDir, "originals", "100.jpg")).length).toBe(
|
||||
3000,
|
||||
);
|
||||
@@ -381,55 +211,57 @@ describe("quak backup", () => {
|
||||
1500,
|
||||
);
|
||||
|
||||
// Collection dirs under collections/ contain symlinks to originals
|
||||
const beachLink = join(outDir, "collections", "Vacation", "beach.jpg");
|
||||
expect(lstatSync(beachLink).isSymbolicLink()).toBe(true);
|
||||
expect(readlinkSync(beachLink)).toContain("originals");
|
||||
expect(readFileSync(beachLink).length).toBe(3000);
|
||||
// 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("skips files that already exist on disk with matching size", async () => {
|
||||
const outDir = join(testDir, "incremental");
|
||||
const client = await Client.login({
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
apiOptions: { fetch: buildMockFetch(mock) },
|
||||
});
|
||||
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");
|
||||
|
||||
// First run: download everything
|
||||
const first = await runBackup(client, outDir);
|
||||
const first = await lib.backup({ downloadDirectory: outDir });
|
||||
expect(first.downloaded).toBe(3);
|
||||
const callsAfterFirst = source.originalCalls;
|
||||
|
||||
// Second run: everything should be skipped
|
||||
const second = await runBackup(client, outDir);
|
||||
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 after a single file download failure", async () => {
|
||||
// File 101 (sunset.jpg) will return HTTP 500. The other two
|
||||
// files must still download. The result must report the failure
|
||||
// without throwing.
|
||||
//
|
||||
// A 500 is retryable, so this file now costs several requests before
|
||||
// it is given up on — that is the point of the retry policy, and
|
||||
// `runBackup`'s own resilience is unchanged by it: the retry lives
|
||||
// strictly below this loop, and an exhausted file is still logged,
|
||||
// counted, and stepped over rather than aborting the run. The
|
||||
// injected `sleep` is what keeps the suite from actually waiting out
|
||||
// the backoff.
|
||||
const outDir = join(testDir, "partial-failure");
|
||||
const client = await Client.login({
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
apiOptions: {
|
||||
fetch: buildMockFetch(mock, { failFileID: 101 }),
|
||||
retry: { sleep: () => Promise.resolve(), random: () => 0 },
|
||||
},
|
||||
});
|
||||
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 runBackup(client, outDir);
|
||||
const result = await lib.backup({ downloadDirectory: outDir });
|
||||
|
||||
expect(result.totalFiles).toBe(3);
|
||||
expect(result.downloaded).toBe(2);
|
||||
@@ -438,32 +270,188 @@ describe("quak backup", () => {
|
||||
expect(result.errors[0]!.fileID).toBe(101);
|
||||
expect(result.errors[0]!.title).toBe("sunset.jpg");
|
||||
|
||||
// The two successful originals are on disk
|
||||
// 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);
|
||||
// The failed file has no original and no symlink
|
||||
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("writes per-collection JSON metadata", async () => {
|
||||
const outDir = join(testDir, "metadata-check");
|
||||
const client = await Client.login({
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
apiOptions: { fetch: buildMockFetch(mock) },
|
||||
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,
|
||||
});
|
||||
|
||||
await runBackup(client, outDir);
|
||||
const result = await lib.backup({ downloadDirectory: outDir });
|
||||
|
||||
// Each collection gets a <name>.json next to its image dir
|
||||
const vacationMeta = join(outDir, "collections", "Vacation.json");
|
||||
expect(existsSync(vacationMeta)).toBe(true);
|
||||
const meta = JSON.parse(readFileSync(vacationMeta, "utf-8"));
|
||||
expect(meta.name).toBe("Vacation");
|
||||
expect(meta.files.length).toBeGreaterThan(0);
|
||||
expect(meta.files[0].metadata.title).toBeDefined();
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
|
||||
Fai riferimento in un nuovo problema
Block a user