/** * Tests for the CLI read helpers (`src/cli-read.ts`, owner amendment to * issue #36, issue #52). * * The `collections`, `files`, `get`, and `get-thumb` commands must answer for * current server state, not the local cache. `get`, `get-thumb` and `files` * without a collection ID force a `Library.fresh()` round-trip before they * read. The stand-in library below serves no cached record until `fresh()` has * been awaited, so a helper that read without refreshing would come back empty * and fail here. `collections` asks the library for the collection list alone * (`freshCollections()`, issue #210). `files` with a collection ID asks for * that list, then for that one collection's files (`freshFiles()`, issue * #217). Neither may wait for a refresh, which would fetch the files of every * collection. On an empty cache, `get` and `get-thumb` ask for that list, then * for one collection's files at a time, until they find the file (issue #222). * `helper fix-missing-thumbnails --file` does the same until it finds every * file it names, reading the most recently updated collection first (issue * #223). * * `collections` and `files` also list in the order the library gives them, not * the albums/photos projection's newest-first order. The fixtures are seeded in * an order that a newest-first sort would rearrange, so a regression to the * projection order would fail here too. Field values still come from the raw * metadata via `cli-output.ts`. */ import { describe, it, expect } from "vitest"; import { freshCollections, freshFiles, freshFile, freshFilesByID, type FreshReadLibrary, } from "../../src/cli-read.js"; import { fileListRow } from "../../src/cli-output.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; const collection = (id: number, updationTime: number): Collection => ({ id, ownerID: 42, key: new Uint8Array(), name: `album-${id}`, type: "album", updationTime, isShared: false, }); // Microseconds, as Ente stores times. const file = ( id: number, collectionID: number, creationTime: number, ): EnteFile => ({ id, collectionID, ownerID: 42, key: new Uint8Array(), metadata: { title: `file-${id}.jpg`, fileType: "image", creationTime, modificationTime: creationTime, }, file: { decryptionHeader: "" }, thumbnail: { decryptionHeader: "" }, updationTime: creationTime, }); // A library that reveals its cached records only after `fresh()` has been // awaited, and serves them in the enumeration order it was given. // `freshCollections()` and `freshFiles()` answer in that order without a // refresh. class FakeLibrary implements FreshReadLibrary { freshCalls = 0; freshCollectionsCalls = 0; // The ID of each collection `freshFiles()` was asked for. freshFilesCalls: number[] = []; private refreshed = false; constructor( private readonly collections: Collection[], private readonly files: EnteFile[], ) {} async fresh(): Promise { this.freshCalls++; this.refreshed = true; return {}; } async freshCollections(): Promise { this.freshCollectionsCalls++; return this.collections; } async freshFiles(collection: Collection): Promise { this.freshFilesCalls.push(collection.id); return this.files.filter((f) => f.collectionID === collection.id); } listCollections(): Collection[] { return this.refreshed ? this.collections : []; } listFiles(collectionID: number): EnteFile[] { return this.refreshed ? this.files.filter((f) => f.collectionID === collectionID) : []; } getFileByID(fileID: number): EnteFile | undefined { if (!this.refreshed) return undefined; return this.files.find((f) => f.id === fileID); } } describe("CLI read helpers (issue #36 amendment, issue #52)", () => { it("freshCollections reads the collection list alone, in enumeration order", async () => { // Enumeration order 2, 1, 3; a newest-first sort would be 3, 2, 1. const lib = new FakeLibrary( [collection(2, 200), collection(1, 300), collection(3, 100)], [], ); const rows = await freshCollections(lib); expect(lib.freshCollectionsCalls).toBe(1); expect(lib.freshCalls).toBe(0); expect(rows.map((c) => c.id)).toEqual([2, 1, 3]); // The projection's newest-first order is a different sequence, so this // is not accidentally that order. const newestFirst = [...rows] .sort((a, b) => b.updationTime - a.updationTime) .map((c) => c.id); expect(newestFirst).toEqual([1, 2, 3]); expect(rows.map((c) => c.id)).not.toEqual(newestFirst); }); it("freshFiles with a collection ID reads that collection's files alone and keeps their order and raw fields", async () => { // Given in the order 10, 11, 12; creationTimes ascending, so a // newest-first sort would reverse them. const files = [ file(10, 1, 1_700_000_000_000_000), file(11, 1, 1_700_000_000_000_001), file(12, 1, 1_700_000_000_000_002), file(20, 2, 1_700_000_000_000_003), ]; const lib = new FakeLibrary( [collection(1, 100), collection(2, 200)], files, ); const rows = await freshFiles(lib, 1); expect(lib.freshCalls).toBe(0); expect(lib.freshCollectionsCalls).toBe(1); expect(lib.freshFilesCalls).toEqual([1]); expect(rows?.map((f) => f.id)).toEqual([10, 11, 12]); // Field values come from raw metadata: microsecond creationTime and the // raw title, unchanged. expect(rows?.map(fileListRow)).toEqual([ { id: 10, title: "file-10.jpg", fileType: "image", creationTime: 1_700_000_000_000_000, collectionID: 1, }, { id: 11, title: "file-11.jpg", fileType: "image", creationTime: 1_700_000_000_000_001, collectionID: 1, }, { id: 12, title: "file-12.jpg", fileType: "image", creationTime: 1_700_000_000_000_002, collectionID: 1, }, ]); }); // Without a collection ID, `files` lists every file in the account once // (issue #211). File 11 is in collections 1 and 2; collection 2 is // enumerated first, so 11 is listed once, with collection 2's record. it("freshFiles without a collection ID lists every file once, from the first collection holding it", async () => { const files = [ file(10, 1, 1_700_000_000_000_000), file(11, 1, 1_700_000_000_000_001), file(20, 2, 1_700_000_000_000_002), file(11, 2, 1_700_000_000_000_001), ]; const lib = new FakeLibrary( [collection(2, 100), collection(1, 200)], files, ); const rows = await freshFiles(lib); expect(lib.freshCalls).toBe(1); expect(rows?.map((f) => [f.id, f.collectionID])).toEqual([ [20, 2], [11, 2], [10, 1], ]); }); it("freshFiles returns undefined for an unknown collection", async () => { const lib = new FakeLibrary([collection(1, 100)], []); const rows = await freshFiles(lib, 999); expect(lib.freshCalls).toBe(0); expect(lib.freshFilesCalls).toEqual([]); expect(rows).toBeUndefined(); }); // Three collections, each holding one file. const threeCollections = (): FakeLibrary => new FakeLibrary( [collection(1, 100), collection(2, 200), collection(3, 300)], [ file(10, 1, 1_700_000_000_000_000), file(20, 2, 1_700_000_000_000_001), file(30, 3, 1_700_000_000_000_002), ], ); it("freshFile on an empty cache reads each collection's files in the server's order until one holds the file", async () => { const lib = threeCollections(); const found = await freshFile(lib, 20); expect(lib.freshCalls).toBe(0); expect(lib.freshCollectionsCalls).toBe(1); expect(lib.freshFilesCalls).toEqual([1, 2]); expect(found?.metadata.title).toBe("file-20.jpg"); expect(found?.metadata.creationTime).toBe(1_700_000_000_000_001); }); // The stand-in's cache holds what the server holds once `fresh()` has run. it("freshFile on a cache that holds the collections refreshes, then looks the file up in the cache", async () => { const lib = threeCollections(); await lib.fresh(); const found = await freshFile(lib, 30); expect(lib.freshCalls).toBe(2); expect(lib.freshCollectionsCalls).toBe(0); expect(lib.freshFilesCalls).toEqual([]); expect(found?.collectionID).toBe(3); }); it("freshFile on an empty cache returns undefined for an unknown file, after reading every collection's files", async () => { const lib = threeCollections(); const found = await freshFile(lib, 404); expect(lib.freshCalls).toBe(0); expect(lib.freshFilesCalls).toEqual([1, 2, 3]); expect(found).toBeUndefined(); }); // The server lists collections 1, 2 and 3, and collection 3 was updated // most recently. File 11 is in collections 1 and 2. const fileInTwoCollections = (): FakeLibrary => new FakeLibrary( [collection(1, 100), collection(2, 200), collection(3, 300)], [ file(10, 1, 1_700_000_000_000_000), file(11, 1, 1_700_000_000_000_001), file(11, 2, 1_700_000_000_000_001), file(20, 2, 1_700_000_000_000_002), file(30, 3, 1_700_000_000_000_003), ], ); it("freshFilesByID on an empty cache reads the most recently updated collection's files first, until it has found every file", async () => { const lib = fileInTwoCollections(); const found = await freshFilesByID(lib, [11, 20]); expect(lib.freshCalls).toBe(0); expect(lib.freshCollectionsCalls).toBe(1); expect(lib.freshFilesCalls).toEqual([3, 2]); expect(found.get(11)?.collection.id).toBe(2); expect(found.get(11)?.file.collectionID).toBe(2); expect(found.get(20)?.collection.id).toBe(2); }); // Collection 1 was updated last, though collections 2 and 3 have higher // IDs. File 11 is in collections 1 and 2. it("freshFilesByID on an empty cache reads the most recently updated collection first, though its ID is the lowest", async () => { const lib = new FakeLibrary( [collection(1, 400), collection(2, 200), collection(3, 300)], [ file(11, 1, 1_700_000_000_000_001), file(11, 2, 1_700_000_000_000_001), ], ); const found = await freshFilesByID(lib, [11]); expect(lib.freshFilesCalls).toEqual([1]); expect(found.get(11)?.collection.id).toBe(1); expect(found.get(11)?.file.collectionID).toBe(1); }); // The stand-in's cache holds what the server holds once `fresh()` has run. it("freshFilesByID on a cache that holds the collections refreshes, then finds each file in the most recently updated collection that holds it", async () => { const lib = fileInTwoCollections(); await lib.fresh(); const found = await freshFilesByID(lib, [11, 404]); expect(lib.freshCalls).toBe(2); expect(lib.freshCollectionsCalls).toBe(0); expect(lib.freshFilesCalls).toEqual([]); expect(found.get(11)?.collection.id).toBe(2); expect(found.get(11)?.file.collectionID).toBe(2); expect(found.has(404)).toBe(false); }); it("freshFilesByID on an empty cache reads every collection's files when a file is in none of them", async () => { const lib = fileInTwoCollections(); const found = await freshFilesByID(lib, [10, 404]); expect(lib.freshFilesCalls).toEqual([3, 2, 1]); expect(found.get(10)?.collection.id).toBe(1); expect(found.has(404)).toBe(false); }); });