Move CLI commands into testable functions and test them (closes #12)
check / check (push) Successful in 30s
check / check (push) Successful in 30s
The command bodies in bin/quak.ts become functions in src/cli-commands.ts that take their options and a context (output streams, session directory, cache directory, session loader) and return an exit code. bin/quak.ts wires them to commander and exits with that code once stdout and stderr have drained; nothing below it calls process.exit. test/cli/commands.test.ts drives the commands with a fake client and temp directories. Output is unchanged. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* 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 { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
type CliContext,
|
||||
saveSession,
|
||||
whoamiCommand,
|
||||
logoutCommand,
|
||||
collectionsCommand,
|
||||
filesCommand,
|
||||
getCommand,
|
||||
getThumbCommand,
|
||||
backupCommand,
|
||||
listMissingThumbnailsCommand,
|
||||
} from "../../src/cli-commands.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 } from "../../src/crypto/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.
|
||||
const fakeClient = (opts: { failID?: number; emptyThumbID?: number } = {}) => {
|
||||
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 () => ({
|
||||
collections: COLLECTIONS,
|
||||
deleted: [],
|
||||
cursor: 1,
|
||||
}),
|
||||
filesSince: async (args: { collectionID: number }) => ({
|
||||
files: FILES[args.collectionID] ?? [],
|
||||
deleted: [],
|
||||
cursor: 1,
|
||||
}),
|
||||
contentSource: () => source,
|
||||
getApiClient: () => ({
|
||||
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("is removed by logout", async () => {
|
||||
const ctx = context();
|
||||
saveSession(ctx.sessionDir, snapshot);
|
||||
expect(await logoutCommand(ctx)).toBe(0);
|
||||
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false);
|
||||
expect(stderr.text).toBe("Session deleted.\n");
|
||||
});
|
||||
|
||||
it("logout without a session says so and exits 0", async () => {
|
||||
expect(await logoutCommand(context())).toBe(0);
|
||||
expect(stderr.text).toBe("No session found.\n");
|
||||
});
|
||||
|
||||
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("");
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
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("");
|
||||
});
|
||||
});
|
||||
Fai riferimento in un nuovo problema
Block a user