check / check (push) Successful in 1m0s
backup-metadata, helper list-missing-thumbnails and helper fix-missing-thumbnails now await lib.fresh() before reading the library, as collections, files, get and get-thumb already do. A file added since the cache was written is included, and a failed refresh is thrown, so the CLI prints one line and exits 1 instead of answering from a stale or empty cache. The README lists them among the commands that refresh first. Model: opus-5-5
579 lines
20 KiB
TypeScript
579 lines
20 KiB
TypeScript
/**
|
|
* Tests for the CLI commands (`src/cli-commands.ts`, issue #12).
|
|
*
|
|
* Each command is called directly with a context whose output streams collect
|
|
* text, whose session directory is a fresh temp directory, and whose session
|
|
* loader hands back a fake client. The fake serves two albums and three files
|
|
* from memory, writes stand-in bytes for originals and thumbnails, and makes no
|
|
* network calls. The helpers the commands call (`cli-read`, `cli-output`,
|
|
* backup, thumbnails) have their own tests; these check what each command
|
|
* prints and the exit code it returns.
|
|
*/
|
|
|
|
import {
|
|
existsSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { PassThrough } from "node:stream";
|
|
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
|
|
|
import {
|
|
type CliContext,
|
|
saveSession,
|
|
whoamiCommand,
|
|
logoutCommand,
|
|
collectionsCommand,
|
|
filesCommand,
|
|
getCommand,
|
|
getThumbCommand,
|
|
backupCommand,
|
|
backupMetadataCommand,
|
|
listMissingThumbnailsCommand,
|
|
fixMissingThumbnailsCommand,
|
|
} from "../../src/cli-commands.js";
|
|
import { run } from "../../src/cli-run.js";
|
|
import { loadSession } from "../../src/cli-session.js";
|
|
import type { Client, ClientSnapshot } from "../../src/client.js";
|
|
import type { ContentSource } from "../../src/library/content.js";
|
|
import type { Collection, EnteFile } from "../../src/model/types.js";
|
|
import { init, toBase64 } from "../../src/crypto/index.js";
|
|
import { defaultCacheDirectory } from "../../src/library/index.js";
|
|
|
|
const USER_ID = 42;
|
|
|
|
const collection = (
|
|
id: number,
|
|
name: string,
|
|
isShared = false,
|
|
): Collection => ({
|
|
id,
|
|
ownerID: USER_ID,
|
|
key: new Uint8Array([id]),
|
|
name,
|
|
type: "album",
|
|
updationTime: 1,
|
|
isShared,
|
|
});
|
|
|
|
const file = (id: number, collectionID: number, title: string): EnteFile => ({
|
|
id,
|
|
collectionID,
|
|
ownerID: USER_ID,
|
|
key: new Uint8Array([id & 0xff]),
|
|
metadata: {
|
|
title,
|
|
fileType: "image",
|
|
creationTime: 1000,
|
|
modificationTime: 1000,
|
|
},
|
|
file: { decryptionHeader: "aGVhZGVy" },
|
|
thumbnail: { decryptionHeader: "dGh1bWI=" },
|
|
updationTime: 1,
|
|
});
|
|
|
|
const COLLECTIONS = [collection(1, "Vacation"), collection(2, "Work", true)];
|
|
|
|
const FILES: Record<number, EnteFile[]> = {
|
|
1: [file(100, 1, "beach.jpg"), file(101, 1, "sunset.jpg")],
|
|
2: [file(200, 2, "diagram.png")],
|
|
};
|
|
|
|
// An original is 7 bytes and a thumbnail 3. `failID` makes that file's
|
|
// original fail; `emptyThumbID` makes the server report that file's
|
|
// thumbnail as empty. `withNewFile` adds new.jpg (102) to Vacation, advancing
|
|
// the collection's updationTime as the server does, and `refreshError` makes
|
|
// listing collections fail with that message.
|
|
const fakeClient = (
|
|
opts: {
|
|
failID?: number;
|
|
emptyThumbID?: number;
|
|
withNewFile?: boolean;
|
|
refreshError?: string;
|
|
} = {},
|
|
) => {
|
|
const collections = opts.withNewFile
|
|
? [{ ...COLLECTIONS[0], updationTime: 2 }, COLLECTIONS[1]]
|
|
: COLLECTIONS;
|
|
const files = opts.withNewFile
|
|
? {
|
|
...FILES,
|
|
1: [...FILES[1], { ...file(102, 1, "new.jpg"), updationTime: 2 }],
|
|
}
|
|
: FILES;
|
|
const source: ContentSource = {
|
|
original: async ({ file: f, destination }) => {
|
|
if (f.id === opts.failID) throw new Error("HTTP 500 from server");
|
|
writeFileSync(destination, Buffer.alloc(7, f.id & 0xff));
|
|
return { bytesWritten: 7 };
|
|
},
|
|
thumbnail: async ({ file: f, destination }) => {
|
|
writeFileSync(destination, Buffer.alloc(3, f.id & 0xff));
|
|
return { bytesWritten: 3 };
|
|
},
|
|
};
|
|
const fake = {
|
|
whoami: () => ({ email: "cli@example.com", userID: USER_ID }),
|
|
collectionsSince: async () => {
|
|
if (opts.refreshError) throw new Error(opts.refreshError);
|
|
return { collections, deleted: [], cursor: 1 };
|
|
},
|
|
filesSince: async (args: { collectionID: number }) => ({
|
|
files: files[args.collectionID] ?? [],
|
|
deleted: [],
|
|
cursor: 1,
|
|
}),
|
|
contentSource: () => source,
|
|
getApiClient: () => ({
|
|
// The ML data request of `backup-metadata`: no file has any.
|
|
postJSON: async () => ({ data: [] }),
|
|
getThumbnailStream: async (fileID: number) =>
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
if (fileID !== opts.emptyThumbID) {
|
|
controller.enqueue(new Uint8Array(3));
|
|
}
|
|
controller.close();
|
|
},
|
|
}),
|
|
}),
|
|
};
|
|
// The commands only call the methods above.
|
|
return fake as unknown as Client;
|
|
};
|
|
|
|
// Collects everything written to it.
|
|
class Output {
|
|
text = "";
|
|
write(text: string): void {
|
|
this.text += text;
|
|
}
|
|
}
|
|
|
|
let root: string;
|
|
let stdout: Output;
|
|
let stderr: Output;
|
|
|
|
const context = (client: Client | null = fakeClient()): CliContext => ({
|
|
stdout,
|
|
stderr,
|
|
sessionDir: join(root, "session"),
|
|
cacheDir: join(root, "cache"),
|
|
loadSession: () => client,
|
|
});
|
|
|
|
beforeAll(async () => {
|
|
await init();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
root = mkdtempSync(join(tmpdir(), "quak-cli-test-"));
|
|
stdout = new Output();
|
|
stderr = new Output();
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("session file", () => {
|
|
const snapshot: ClientSnapshot = {
|
|
email: "cli@example.com",
|
|
userID: USER_ID,
|
|
token: "token",
|
|
masterKey: "a",
|
|
secretKey: "b",
|
|
publicKey: "c",
|
|
};
|
|
|
|
it("is written with mode 0600 in a directory with mode 0700", () => {
|
|
const dir = join(root, "new", "session");
|
|
saveSession(dir, snapshot);
|
|
expect(statSync(dir).mode & 0o777).toBe(0o700);
|
|
const path = join(dir, "session.json");
|
|
expect(statSync(path).mode & 0o777).toBe(0o600);
|
|
expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(snapshot);
|
|
});
|
|
|
|
it("a missing session exits 1 with 'Not logged in'", async () => {
|
|
const ctx = { ...context(), loadSession };
|
|
expect(await whoamiCommand(ctx)).toBe(1);
|
|
expect(stderr.text).toBe(
|
|
`Not logged in. Run "quak login" first.\n` +
|
|
`Session file: ${join(ctx.sessionDir, "session.json")}\n`,
|
|
);
|
|
expect(stdout.text).toBe("");
|
|
});
|
|
|
|
it("a corrupt session exits 1 and says it is corrupt", async () => {
|
|
const ctx = { ...context(), loadSession };
|
|
saveSession(ctx.sessionDir, snapshot);
|
|
expect(await collectionsCommand(ctx, {})).toBe(1);
|
|
expect(stderr.text).toContain("is corrupt");
|
|
expect(stderr.text).toContain(
|
|
`Run "quak logout" and then "quak login" to replace it.\n`,
|
|
);
|
|
expect(stdout.text).toBe("");
|
|
});
|
|
});
|
|
|
|
// These use a real client read from the session file, over a fake API that
|
|
// records each request and answers with `status`.
|
|
describe("logout", () => {
|
|
const snapshot: ClientSnapshot = {
|
|
email: "cli@example.com",
|
|
userID: USER_ID,
|
|
token: "saved-token",
|
|
masterKey: toBase64(new Uint8Array(32)),
|
|
secretKey: toBase64(new Uint8Array(32)),
|
|
publicKey: toBase64(new Uint8Array(32)),
|
|
};
|
|
|
|
const requests: Request[] = [];
|
|
|
|
const logoutContext = (status: number): CliContext => ({
|
|
...context(),
|
|
loadSession: (path) =>
|
|
loadSession(path, {
|
|
fetch: async (url, init) => {
|
|
requests.push(new Request(url, init));
|
|
return new Response(JSON.stringify({}), {
|
|
status,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
},
|
|
}),
|
|
});
|
|
|
|
beforeEach(() => {
|
|
requests.length = 0;
|
|
});
|
|
|
|
it("ends the session on the server, then deletes the file", async () => {
|
|
const ctx = logoutContext(200);
|
|
saveSession(ctx.sessionDir, snapshot);
|
|
expect(await logoutCommand(ctx)).toBe(0);
|
|
|
|
expect(requests).toHaveLength(1);
|
|
expect(requests[0]!.method).toBe("POST");
|
|
expect(new URL(requests[0]!.url).pathname).toBe("/users/logout");
|
|
expect(requests[0]!.headers.get("X-Auth-Token")).toBe("saved-token");
|
|
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false);
|
|
expect(stderr.text).toBe(
|
|
"Session ended on the server.\n" +
|
|
"Session deleted.\n" +
|
|
`Cache directory ${ctx.cacheDir} still holds decrypted data; delete it to remove that data.\n`,
|
|
);
|
|
});
|
|
|
|
it("still deletes the file when the server call fails, and says so", async () => {
|
|
const ctx = logoutContext(500);
|
|
saveSession(ctx.sessionDir, snapshot);
|
|
expect(await logoutCommand(ctx)).toBe(1);
|
|
|
|
expect(requests).toHaveLength(1);
|
|
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false);
|
|
expect(stderr.text).toBe(
|
|
"Could not end the session on the server: HTTP 500\n" +
|
|
"Session deleted.\n" +
|
|
`Cache directory ${ctx.cacheDir} still holds decrypted data; delete it to remove that data.\n`,
|
|
);
|
|
});
|
|
|
|
it("names the account's default cache directory without --cache-dir", async () => {
|
|
const ctx = { ...logoutContext(200), cacheDir: undefined };
|
|
saveSession(ctx.sessionDir, snapshot);
|
|
expect(await logoutCommand(ctx)).toBe(0);
|
|
expect(stderr.text).toContain(
|
|
`Cache directory ${defaultCacheDirectory(USER_ID)} still holds decrypted data`,
|
|
);
|
|
});
|
|
|
|
it("without a session says so, calls nothing and exits 0", async () => {
|
|
expect(await logoutCommand(logoutContext(200))).toBe(0);
|
|
expect(requests).toHaveLength(0);
|
|
expect(stderr.text).toBe("No session found.\n");
|
|
});
|
|
});
|
|
|
|
describe("whoami", () => {
|
|
it("prints the account as one line of JSON", async () => {
|
|
expect(await whoamiCommand(context())).toBe(0);
|
|
expect(stdout.text).toBe(
|
|
`{"email":"cli@example.com","userID":${USER_ID}}\n`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("collections", () => {
|
|
it("prints one tab-separated line per album", async () => {
|
|
expect(await collectionsCommand(context(), {})).toBe(0);
|
|
expect(stdout.text).toBe(
|
|
"1\talbum\tVacation\n" + "2\talbum\tWork (shared)\n",
|
|
);
|
|
});
|
|
|
|
it("prints a JSON array with --json", async () => {
|
|
expect(await collectionsCommand(context(), { json: true })).toBe(0);
|
|
expect(JSON.parse(stdout.text)).toEqual([
|
|
{
|
|
id: 1,
|
|
name: "Vacation",
|
|
type: "album",
|
|
ownerID: USER_ID,
|
|
isShared: false,
|
|
updationTime: 1,
|
|
},
|
|
{
|
|
id: 2,
|
|
name: "Work",
|
|
type: "album",
|
|
ownerID: USER_ID,
|
|
isShared: true,
|
|
updationTime: 1,
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("files", () => {
|
|
it("prints one tab-separated line per file", async () => {
|
|
expect(await filesCommand(context(), { collection: "1" })).toBe(0);
|
|
expect(stdout.text).toBe(
|
|
"100\timage\tbeach.jpg\n" + "101\timage\tsunset.jpg\n",
|
|
);
|
|
});
|
|
|
|
it("prints a JSON array with --json", async () => {
|
|
const code = await filesCommand(context(), {
|
|
collection: "2",
|
|
json: true,
|
|
});
|
|
expect(code).toBe(0);
|
|
expect(JSON.parse(stdout.text)).toEqual([
|
|
{
|
|
id: 200,
|
|
title: "diagram.png",
|
|
fileType: "image",
|
|
creationTime: 1000,
|
|
collectionID: 2,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("exits 1 for an unknown collection", async () => {
|
|
expect(await filesCommand(context(), { collection: "9" })).toBe(1);
|
|
expect(stderr.text).toBe("Collection 9 not found\n");
|
|
});
|
|
|
|
it("exits 1 for a collection ID that is not a number", async () => {
|
|
expect(await filesCommand(context(), { collection: "abc" })).toBe(1);
|
|
expect(stderr.text).toBe("Invalid collection ID\n");
|
|
});
|
|
});
|
|
|
|
describe("get and get-thumb", () => {
|
|
it("get finds a file in any album without --collection", async () => {
|
|
const out = join(root, "diagram.png");
|
|
expect(await getCommand(context(), "200", { out })).toBe(0);
|
|
expect(readFileSync(out)).toEqual(Buffer.alloc(7, 200));
|
|
expect(stderr.text).toBe(`7 bytes -> ${out}\n`);
|
|
});
|
|
|
|
it("get-thumb finds a file in any album without --collection", async () => {
|
|
const out = join(root, "thumb.jpg");
|
|
expect(await getThumbCommand(context(), "200", { out })).toBe(0);
|
|
expect(readFileSync(out)).toEqual(Buffer.alloc(3, 200));
|
|
expect(stderr.text).toBe(`3 bytes -> ${out}\n`);
|
|
});
|
|
|
|
it("get exits 1 when no album has the file", async () => {
|
|
const out = join(root, "x");
|
|
expect(await getCommand(context(), "999", { out })).toBe(1);
|
|
expect(stderr.text).toBe("File 999 not found\n");
|
|
expect(existsSync(out)).toBe(false);
|
|
});
|
|
|
|
it("get-thumb exits 1 when no album has the file", async () => {
|
|
const out = join(root, "x");
|
|
expect(await getThumbCommand(context(), "999", { out })).toBe(1);
|
|
expect(stderr.text).toBe("File 999 not found\n");
|
|
expect(existsSync(out)).toBe(false);
|
|
});
|
|
|
|
it("both exit 1 for a file ID that is not a number", async () => {
|
|
expect(await getCommand(context(), "abc", {})).toBe(1);
|
|
expect(await getThumbCommand(context(), "abc", {})).toBe(1);
|
|
expect(stderr.text).toBe("Invalid file ID\nInvalid file ID\n");
|
|
});
|
|
});
|
|
|
|
describe("backup", () => {
|
|
it("exits 0 and prints a summary when every file is saved", async () => {
|
|
const dir = join(root, "backup");
|
|
expect(await backupCommand(context(), dir, {})).toBe(0);
|
|
expect(stderr.text).toContain(
|
|
"\n--- Backup complete ---\n" +
|
|
" Total files: 3\n" +
|
|
" Downloaded: 3\n" +
|
|
" Skipped: 0\n" +
|
|
" Failed: 0\n",
|
|
);
|
|
expect(stdout.text).toBe("");
|
|
});
|
|
|
|
it("exits 1 and lists the file when one download fails", async () => {
|
|
const ctx = context(fakeClient({ failID: 101 }));
|
|
expect(await backupCommand(ctx, join(root, "backup"), {})).toBe(1);
|
|
expect(stderr.text).toContain(" Failed: 1\n");
|
|
expect(stderr.text).toContain(
|
|
"\nFailed files:\n" +
|
|
" [Vacation] sunset.jpg (id 101): HTTP 500 from server\n",
|
|
);
|
|
});
|
|
|
|
it("prints the result as JSON with --json, still exiting 1 on a failure", async () => {
|
|
const ctx = context(fakeClient({ failID: 101 }));
|
|
const code = await backupCommand(ctx, join(root, "backup"), {
|
|
json: true,
|
|
});
|
|
expect(code).toBe(1);
|
|
const result = JSON.parse(stdout.text);
|
|
expect(result).toMatchObject({
|
|
totalFiles: 3,
|
|
downloaded: 2,
|
|
skipped: 0,
|
|
failed: 1,
|
|
});
|
|
expect(result.errors[0].fileID).toBe(101);
|
|
expect(stderr.text).toBe("Starting backup...\n");
|
|
});
|
|
|
|
it("exits 1 with the error on one line when the refresh fails", async () => {
|
|
const client = {
|
|
...fakeClient(),
|
|
collectionsSince: async () => {
|
|
throw new Error("HTTP 401 from server");
|
|
},
|
|
} as unknown as Client;
|
|
const dir = join(root, "backup");
|
|
// Through `run`, as `bin/quak.ts` does, which prints a thrown error.
|
|
const runStderr = new PassThrough();
|
|
let runText = "";
|
|
runStderr.on("data", (chunk: Buffer) => {
|
|
runText += chunk.toString();
|
|
});
|
|
const code = await new Promise<number>((resolve) => {
|
|
void run(
|
|
backupCommand(context(client), dir, {}),
|
|
new PassThrough(),
|
|
runStderr,
|
|
resolve,
|
|
);
|
|
});
|
|
expect(code).toBe(1);
|
|
expect(runText).toBe("quak: HTTP 401 from server\n");
|
|
expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n");
|
|
expect(existsSync(join(dir, "originals"))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("helper list-missing-thumbnails", () => {
|
|
it("prints one line per file with an empty thumbnail", async () => {
|
|
const ctx = context(fakeClient({ emptyThumbID: 200 }));
|
|
expect(await listMissingThumbnailsCommand(ctx, {})).toBe(0);
|
|
expect(stdout.text).toBe(
|
|
"200\tdiagram.png\tWork\tempty thumbnail (0 bytes)\n",
|
|
);
|
|
expect(stderr.text).toContain("\n1 file(s) with missing thumbnails:\n");
|
|
});
|
|
|
|
it("says so when nothing is missing", async () => {
|
|
expect(await listMissingThumbnailsCommand(context(), {})).toBe(0);
|
|
expect(stdout.text).toBe("");
|
|
expect(stderr.text).toContain("No missing thumbnails found.\n");
|
|
});
|
|
|
|
it("prints a JSON array with --json and no progress", async () => {
|
|
const ctx = context(fakeClient({ emptyThumbID: 200 }));
|
|
expect(await listMissingThumbnailsCommand(ctx, { json: true })).toBe(0);
|
|
expect(JSON.parse(stdout.text)).toEqual([
|
|
{
|
|
fileID: 200,
|
|
title: "diagram.png",
|
|
collection: "Work",
|
|
reason: "empty thumbnail (0 bytes)",
|
|
},
|
|
]);
|
|
expect(stderr.text).toBe("");
|
|
});
|
|
});
|
|
|
|
// Each test first runs `collections` so the cache holds the account as it was,
|
|
// then changes the server under it.
|
|
describe("backup-metadata and the thumbnail helpers refresh first", () => {
|
|
beforeEach(async () => {
|
|
expect(await collectionsCommand(context(), {})).toBe(0);
|
|
stdout.text = "";
|
|
stderr.text = "";
|
|
});
|
|
|
|
it("backup-metadata writes a file added since the cache was written", async () => {
|
|
const ctx = context(fakeClient({ withNewFile: true }));
|
|
const dir = join(root, "dump");
|
|
expect(await backupMetadataCommand(ctx, dir, {})).toBe(0);
|
|
expect(
|
|
existsSync(join(dir, "collections", "1-Vacation", "102.json")),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("list-missing-thumbnails checks a file added since the cache was written", async () => {
|
|
const ctx = context(
|
|
fakeClient({ withNewFile: true, emptyThumbID: 102 }),
|
|
);
|
|
expect(await listMissingThumbnailsCommand(ctx, {})).toBe(0);
|
|
expect(stdout.text).toBe(
|
|
"102\tnew.jpg\tVacation\tempty thumbnail (0 bytes)\n",
|
|
);
|
|
});
|
|
|
|
it("fix-missing-thumbnails finds a file added since the cache was written", async () => {
|
|
const ctx = context(fakeClient({ withNewFile: true }));
|
|
expect(
|
|
await fixMissingThumbnailsCommand(ctx, {
|
|
file: ["102"],
|
|
json: true,
|
|
}),
|
|
).toBe(0);
|
|
// Found, then skipped because the server records no thumbnail size
|
|
// for it; a file missing from the cache would fail as not found.
|
|
expect(JSON.parse(stdout.text)).toMatchObject([
|
|
{ fileID: 102, title: "new.jpg", status: "skipped" },
|
|
]);
|
|
});
|
|
|
|
// `run` in `cli-run.ts` prints a thrown error as one line and exits 1.
|
|
it("all three throw when the refresh fails", async () => {
|
|
const ctx = context(
|
|
fakeClient({ refreshError: "HTTP 503 from server" }),
|
|
);
|
|
const dir = join(root, "dump");
|
|
await expect(backupMetadataCommand(ctx, dir, {})).rejects.toThrow(
|
|
"HTTP 503 from server",
|
|
);
|
|
expect(existsSync(dir)).toBe(false);
|
|
await expect(listMissingThumbnailsCommand(ctx, {})).rejects.toThrow(
|
|
"HTTP 503 from server",
|
|
);
|
|
await expect(
|
|
fixMissingThumbnailsCommand(ctx, { file: ["100"] }),
|
|
).rejects.toThrow("HTTP 503 from server");
|
|
expect(stdout.text).toBe("");
|
|
});
|
|
});
|