check / check (push) Successful in 1m39s
A 401 from the server reaches run in src/cli-run.ts as the ApiError the refresh threw, so run recognises it there, prints one line saying to run "quak login" and exits 3. A missing or corrupt session file keeps its message and also exits 3. quak backup meets the 401 on its first refresh, before it touches any file. Every other error still exits 1. Judgement call: quak logout is unchanged; it handles its own errors and deletes the session file whatever the server answers. Judgement call: quak backup still prints its two progress lines before the error line. Model: opus-5-5
949 lines
33 KiB
TypeScript
949 lines
33 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,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { PassThrough } from "node:stream";
|
|
import * as jpegJs from "jpeg-js";
|
|
import {
|
|
describe,
|
|
it,
|
|
expect,
|
|
vi,
|
|
beforeAll,
|
|
beforeEach,
|
|
afterEach,
|
|
} from "vitest";
|
|
|
|
import {
|
|
type CliContext,
|
|
saveSession,
|
|
loginCommand,
|
|
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, LoginOptions } 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";
|
|
import { HEIC_WITH_EXIF } from "../exif-heic.js";
|
|
import {
|
|
asLivePhoto,
|
|
cdnSource,
|
|
IMAGE,
|
|
livePhotoHash,
|
|
livePhotoZip,
|
|
VIDEO,
|
|
} from "../live-photo.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,
|
|
login: async () => {
|
|
throw new Error("login not expected");
|
|
},
|
|
prompt: async () => {
|
|
throw new Error("prompt not expected");
|
|
},
|
|
promptSecret: async () => {
|
|
throw new Error("prompt not expected");
|
|
},
|
|
});
|
|
|
|
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 3 with 'Not logged in' from every command that needs one", async () => {
|
|
const ctx = { ...context(), loadSession };
|
|
const dir = join(root, "backup");
|
|
expect(await whoamiCommand(ctx)).toBe(3);
|
|
expect(await collectionsCommand(ctx, {})).toBe(3);
|
|
expect(await filesCommand(ctx, { collection: "1" })).toBe(3);
|
|
expect(await getCommand(ctx, "100", {})).toBe(3);
|
|
expect(await getThumbCommand(ctx, "100", {})).toBe(3);
|
|
expect(await backupMetadataCommand(ctx, dir, {})).toBe(3);
|
|
expect(await backupCommand(ctx, dir, {})).toBe(3);
|
|
expect(await listMissingThumbnailsCommand(ctx, {})).toBe(3);
|
|
expect(await fixMissingThumbnailsCommand(ctx, {})).toBe(3);
|
|
const notLoggedIn =
|
|
`Not logged in. Run "quak login" first.\n` +
|
|
`Session file: ${join(ctx.sessionDir, "session.json")}\n`;
|
|
expect(stderr.text).toBe(notLoggedIn.repeat(9));
|
|
expect(stdout.text).toBe("");
|
|
expect(existsSync(dir)).toBe(false);
|
|
});
|
|
|
|
it("a corrupt session exits 3 and says it is corrupt", async () => {
|
|
const ctx = { ...context(), loadSession };
|
|
saveSession(ctx.sessionDir, snapshot);
|
|
expect(await collectionsCommand(ctx, {})).toBe(3);
|
|
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("");
|
|
});
|
|
});
|
|
|
|
// The login function is a fake that hands back a client whose snapshot is
|
|
// `snapshot`; each prompt is recorded and answered with "123456".
|
|
describe("login", () => {
|
|
const snapshot: ClientSnapshot = {
|
|
email: "cli@example.com",
|
|
userID: USER_ID,
|
|
token: "token",
|
|
masterKey: "a",
|
|
secretKey: "b",
|
|
publicKey: "c",
|
|
};
|
|
|
|
const loggedIn = {
|
|
whoami: () => ({ email: "cli@example.com", userID: USER_ID }),
|
|
toJSON: () => snapshot,
|
|
} as unknown as Client;
|
|
|
|
let prompts: string[];
|
|
|
|
const loginContext = (
|
|
login: (opts: LoginOptions) => Promise<Client>,
|
|
): CliContext => ({
|
|
...context(),
|
|
login,
|
|
prompt: async (message) => {
|
|
prompts.push(message);
|
|
return "123456";
|
|
},
|
|
promptSecret: async (message) => {
|
|
prompts.push(message);
|
|
return "123456";
|
|
},
|
|
});
|
|
|
|
beforeEach(() => {
|
|
prompts = [];
|
|
vi.stubEnv("QUAK_EMAIL", "cli@example.com");
|
|
vi.stubEnv("QUAK_PASSWORD", "hunter2");
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it("takes the email and password from the environment without a prompt", async () => {
|
|
const calls: LoginOptions[] = [];
|
|
const ctx = loginContext(async (opts) => {
|
|
calls.push(opts);
|
|
return loggedIn;
|
|
});
|
|
expect(await loginCommand(ctx)).toBe(0);
|
|
|
|
expect(prompts).toEqual([]);
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]!.email).toBe("cli@example.com");
|
|
expect(calls[0]!.password).toBe("hunter2");
|
|
const path = join(ctx.sessionDir, "session.json");
|
|
expect(stderr.text).toBe(
|
|
"Authenticating...\n" +
|
|
`Logged in as cli@example.com (user ${USER_ID})\n` +
|
|
`Session saved to ${path}\n`,
|
|
);
|
|
});
|
|
|
|
it("saves the session with mode 0600 in a directory with mode 0700", async () => {
|
|
const ctx = loginContext(async () => loggedIn);
|
|
expect(await loginCommand(ctx)).toBe(0);
|
|
|
|
expect(statSync(ctx.sessionDir).mode & 0o777).toBe(0o700);
|
|
const path = join(ctx.sessionDir, "session.json");
|
|
expect(statSync(path).mode & 0o777).toBe(0o600);
|
|
expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(snapshot);
|
|
});
|
|
|
|
it("asks for the TOTP code when the account needs one", async () => {
|
|
let code: string | undefined;
|
|
const ctx = loginContext(async (opts) => {
|
|
code = await opts.totp!();
|
|
return loggedIn;
|
|
});
|
|
expect(await loginCommand(ctx)).toBe(0);
|
|
|
|
expect(prompts).toEqual(["TOTP code: "]);
|
|
expect(code).toBe("123456");
|
|
});
|
|
|
|
it("a failed login exits 1, says why and writes no session", async () => {
|
|
const ctx = loginContext(async () => {
|
|
throw new Error("HTTP 401 from server");
|
|
});
|
|
expect(await loginCommand(ctx)).toBe(1);
|
|
|
|
expect(stderr.text).toBe(
|
|
"Authenticating...\nLogin failed: HTTP 401 from server\n",
|
|
);
|
|
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false);
|
|
});
|
|
});
|
|
|
|
// 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");
|
|
});
|
|
});
|
|
|
|
// A live photo, which Ente stores as one ZIP, is written as its image and its
|
|
// video, which a photo viewer can open. The account here is Vacation holding
|
|
// one live photo, 300, downloaded through the real download layer
|
|
// (test/live-photo.ts).
|
|
describe("a live photo", () => {
|
|
const livePhotoClient = async (image = IMAGE): Promise<Client> => {
|
|
const { file: live, body } = await asLivePhoto(
|
|
file(300, 1, "IMG_0300.HEIC"),
|
|
livePhotoZip({ "image.heic": image, "video.mov": VIDEO }),
|
|
livePhotoHash(image, VIDEO),
|
|
);
|
|
const fake = {
|
|
whoami: () => ({ email: "cli@example.com", userID: USER_ID }),
|
|
collectionsSince: async () => ({
|
|
collections: [collection(1, "Vacation")],
|
|
deleted: [],
|
|
cursor: 1,
|
|
}),
|
|
filesSince: async () => ({ files: [live], deleted: [], cursor: 1 }),
|
|
contentSource: () => cdnSource(new Map([[300, body]])),
|
|
// The ML data request of `backup-metadata`: no file has any.
|
|
getApiClient: () => ({ postJSON: async () => ({ data: [] }) }),
|
|
};
|
|
return fake as unknown as Client;
|
|
};
|
|
|
|
it("get writes its image and video, named after the title with their own extensions", async () => {
|
|
const ctx = context(await livePhotoClient());
|
|
const dir = join(root, "cwd");
|
|
mkdirSync(dir);
|
|
const previous = process.cwd();
|
|
process.chdir(dir);
|
|
try {
|
|
expect(await getCommand(ctx, "300", {})).toBe(0);
|
|
} finally {
|
|
process.chdir(previous);
|
|
}
|
|
|
|
expect(readdirSync(dir).sort()).toEqual([
|
|
"IMG_0300.heic",
|
|
"IMG_0300.mov",
|
|
]);
|
|
expect(readFileSync(join(dir, "IMG_0300.heic"))).toEqual(
|
|
Buffer.from(IMAGE),
|
|
);
|
|
expect(readFileSync(join(dir, "IMG_0300.mov"))).toEqual(
|
|
Buffer.from(VIDEO),
|
|
);
|
|
expect(stderr.text).toBe(
|
|
`${IMAGE.length} bytes -> IMG_0300.heic\n` +
|
|
`${VIDEO.length} bytes -> IMG_0300.mov\n`,
|
|
);
|
|
});
|
|
|
|
it("get --out writes the image there and the video beside it", async () => {
|
|
const out = join(root, "photo.jpg");
|
|
const video = join(root, "photo.mov");
|
|
|
|
expect(
|
|
await getCommand(context(await livePhotoClient()), "300", { out }),
|
|
).toBe(0);
|
|
|
|
expect(readFileSync(out)).toEqual(Buffer.from(IMAGE));
|
|
expect(readFileSync(video)).toEqual(Buffer.from(VIDEO));
|
|
expect(stderr.text).toBe(
|
|
`${IMAGE.length} bytes -> ${out}\n${VIDEO.length} bytes -> ${video}\n`,
|
|
);
|
|
});
|
|
|
|
it("get exits 1 and writes nothing when --out has the video's extension", async () => {
|
|
const out = join(root, "photo.MOV");
|
|
|
|
expect(
|
|
await getCommand(context(await livePhotoClient()), "300", { out }),
|
|
).toBe(1);
|
|
|
|
expect(existsSync(out)).toBe(false);
|
|
expect(existsSync(join(root, "photo.mov"))).toBe(false);
|
|
expect(stderr.text).toBe(
|
|
`File 300 is a live photo, and its video would also be written to ${out}\n`,
|
|
);
|
|
});
|
|
|
|
it("backup-metadata --exif reads its image", async () => {
|
|
// A 4x4 JPEG, whose size can only come from reading the image; the
|
|
// ZIP and the video are not JPEGs.
|
|
const jpeg = jpegJs.encode(
|
|
{ data: new Uint8Array(4 * 4 * 4), width: 4, height: 4 },
|
|
50,
|
|
).data;
|
|
const dir = join(root, "dump");
|
|
|
|
expect(
|
|
await backupMetadataCommand(
|
|
context(await livePhotoClient(new Uint8Array(jpeg))),
|
|
dir,
|
|
{ exif: true },
|
|
),
|
|
).toBe(0);
|
|
|
|
const record = JSON.parse(
|
|
readFileSync(
|
|
join(dir, "collections", "1-Vacation", "300.json"),
|
|
"utf-8",
|
|
),
|
|
);
|
|
expect(record.imageMetadata).toMatchObject({
|
|
format: "jpeg",
|
|
width: 4,
|
|
height: 4,
|
|
});
|
|
});
|
|
|
|
it("backup-metadata --exif records the EXIF of a HEIC image", async () => {
|
|
const dir = join(root, "dump");
|
|
|
|
expect(
|
|
await backupMetadataCommand(
|
|
context(await livePhotoClient(HEIC_WITH_EXIF)),
|
|
dir,
|
|
{ exif: true },
|
|
),
|
|
).toBe(0);
|
|
|
|
const record = JSON.parse(
|
|
readFileSync(
|
|
join(dir, "collections", "1-Vacation", "300.json"),
|
|
"utf-8",
|
|
),
|
|
);
|
|
expect(record.imageMetadata.exifError).toBeUndefined();
|
|
expect(record.imageMetadata.exif).toMatchObject({
|
|
Make: { value: ["Canon"] },
|
|
Model: { value: ["EOS R5"] },
|
|
DateTimeOriginal: { value: ["2021:07:15 14:30:00"] },
|
|
});
|
|
});
|
|
});
|
|
|
|
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("");
|
|
});
|
|
|
|
// The backup opens its library with the precache off: it fetches the
|
|
// originals it needs into the backup, and must not also fetch every
|
|
// thumbnail in the account, or keep originals, in the per-user cache.
|
|
it("leaves nothing in the cache's originals and thumbnails", async () => {
|
|
const dir = join(root, "backup");
|
|
expect(await backupCommand(context(), dir, {})).toBe(0);
|
|
expect(readdirSync(join(root, "cache", "thumbnails"))).toEqual([]);
|
|
expect(readdirSync(join(root, "cache", "originals"))).toEqual([]);
|
|
});
|
|
|
|
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 and lists each file when the ML data fetch fails", async () => {
|
|
const client = {
|
|
...fakeClient(),
|
|
fetchMLData: async () => {
|
|
throw new Error("HTTP 503 from server");
|
|
},
|
|
} as unknown as Client;
|
|
expect(
|
|
await backupCommand(context(client), join(root, "backup"), {}),
|
|
).toBe(1);
|
|
expect(stderr.text).toContain(" Failed: 3\n");
|
|
expect(stderr.text).toContain(
|
|
" [Vacation] beach.jpg (id 100): ML data: HTTP 503 from server\n",
|
|
);
|
|
});
|
|
|
|
// Runs `backup` through `run`, as `bin/quak.ts` does, which prints a thrown
|
|
// error; returns the exit code and what `run` printed.
|
|
const backupThroughRun = async (
|
|
ctx: CliContext,
|
|
dir: string,
|
|
): Promise<{ code: number; runText: string }> => {
|
|
const runStderr = new PassThrough();
|
|
let runText = "";
|
|
runStderr.on("data", (chunk: Buffer) => {
|
|
runText += chunk.toString();
|
|
});
|
|
const code = await new Promise<number>((resolve) => {
|
|
void run(
|
|
backupCommand(ctx, dir, {}),
|
|
new PassThrough(),
|
|
runStderr,
|
|
resolve,
|
|
);
|
|
});
|
|
return { code, runText };
|
|
};
|
|
|
|
it("exits 1 with the error on one line when the refresh fails", async () => {
|
|
const client = {
|
|
...fakeClient(),
|
|
collectionsSince: async () => {
|
|
throw new Error("HTTP 503 from server");
|
|
},
|
|
} as unknown as Client;
|
|
const dir = join(root, "backup");
|
|
const { code, runText } = await backupThroughRun(context(client), dir);
|
|
expect(code).toBe(1);
|
|
expect(runText).toBe("quak: HTTP 503 from server\n");
|
|
expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n");
|
|
expect(existsSync(dir)).toBe(false);
|
|
});
|
|
|
|
// A real saved session, read back by `loadSession`, whose server answers
|
|
// every request with 401, as it does once it no longer accepts the token.
|
|
// The context's prompts throw, so a prompt would end the run with another
|
|
// line and exit 1.
|
|
it("exits 3 with one line saying to log in again when the server answers 401", async () => {
|
|
const key = toBase64(new Uint8Array(32));
|
|
saveSession(join(root, "session"), {
|
|
email: "cli@example.com",
|
|
userID: USER_ID,
|
|
token: "expired",
|
|
masterKey: key,
|
|
secretKey: key,
|
|
publicKey: key,
|
|
});
|
|
const unauthorized = async (): Promise<Response> =>
|
|
new Response(null, { status: 401 });
|
|
const ctx = {
|
|
...context(),
|
|
loadSession: (path: string) =>
|
|
loadSession(path, { fetch: unauthorized }),
|
|
};
|
|
const dir = join(root, "backup");
|
|
const { code, runText } = await backupThroughRun(ctx, dir);
|
|
expect(code).toBe(3);
|
|
expect(runText).toBe(
|
|
`quak: the saved session is no longer valid; run "quak login"\n`,
|
|
);
|
|
expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n");
|
|
expect(existsSync(dir)).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("");
|
|
});
|
|
});
|
|
|
|
describe("backup-metadata --exif", () => {
|
|
// Runs the command and returns what it printed to stderr.
|
|
const backupMetadata = async (opts: { exif?: boolean; all?: boolean }) => {
|
|
expect(
|
|
await backupMetadataCommand(context(), join(root, "dump"), opts),
|
|
).toBe(0);
|
|
return stderr.text;
|
|
};
|
|
|
|
it("--exif extracts EXIF", async () => {
|
|
expect(await backupMetadata({ exif: true })).toContain(
|
|
"[beach.jpg] Extracting EXIF...\n",
|
|
);
|
|
});
|
|
|
|
it("--all extracts EXIF", async () => {
|
|
expect(await backupMetadata({ all: true })).toContain(
|
|
"[beach.jpg] Extracting EXIF...\n",
|
|
);
|
|
});
|
|
|
|
it("without either flag extracts no EXIF", async () => {
|
|
expect(await backupMetadata({})).not.toContain("Extracting EXIF");
|
|
});
|
|
});
|
|
|
|
// 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("");
|
|
});
|
|
});
|