/** * Integration between `Library` and the content cache (issue #46). * * The cache itself is covered in `content.test.ts`; this file locks the wiring: * `Library.open` builds the cache from a content source, `lib.photos` hands out * `Photo` objects that fetch through it, `lib.thumbnails.ensure` drives it, and * a cached path shows up on the projected record. A library opened without a * content source leaves those methods throwing rather than silently doing * nothing. */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtempSync, readdirSync, rmSync, existsSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Library, type LibraryOptions } 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"; import { asLivePhoto, cdnSource, livePhotoZip } from "../live-photo.js"; const USER_ID = 7; const collection = (id: number): Collection => ({ id, ownerID: USER_ID, key: new Uint8Array([id & 0xff]), name: `album-${id}`, type: "album", updationTime: 1, isShared: false, }); const file = (id: number, collectionID: number): EnteFile => ({ id, collectionID, ownerID: USER_ID, key: new Uint8Array([id & 0xff]), metadata: { title: `file-${id}.jpg`, fileType: "image", creationTime: 1, modificationTime: 1, }, file: { decryptionHeader: "aGVhZGVy" }, thumbnail: { decryptionHeader: "dGh1bWI=" }, updationTime: 1, }); // A metadata-only client serving one album with one file, once. class MockClient { served = false; whoami(): { email: string; userID: number } { return { email: "u@example.com", userID: USER_ID }; } async collectionsSince(): Promise { if (this.served) return { collections: [], deleted: [], cursor: 1 }; this.served = true; return { collections: [collection(1)], deleted: [], cursor: 1 }; } async filesSince(): Promise { return { files: [file(1, 1)], deleted: [], cursor: 1 }; } } // A content source that writes a marker file and counts thumbnail fetches. const stubSource = (): ContentSource & { thumbCalls: () => number } => { let thumbCalls = 0; return { thumbCalls: () => thumbCalls, original: async ({ destination }) => { writeFileSync(destination, "orig-bytes"); return { bytesWritten: 10 }; }, thumbnail: async ({ destination }) => { thumbCalls++; writeFileSync(destination, "thumb"); return { bytesWritten: 5 }; }, }; }; let root: string; beforeEach(() => { root = mkdtempSync(join(tmpdir(), "quak-content-lib-")); }); afterEach(() => { if (root && existsSync(root)) rmSync(root, { recursive: true, force: true }); }); describe("Library content wiring", () => { it("fetches a thumbnail through a Photo and records its cache path", async () => { const source = stubSource(); const lib = await Library.open({ client: new MockClient(), cacheDirectory: join(root, "cache"), contentSource: source, refreshIntervalSeconds: 3600, // On-demand wiring only; the background precache (#48) is covered // in precache.test.ts and would race the exact-count assertions. precacheThumbnails: false, precacheOriginals: false, }); const photo = lib.photos.byID({ fileID: 1 }); expect(photo).toBeDefined(); const result = await photo!.thumbnail(); expect(source.thumbCalls()).toBe(1); expect(result.path).toBe(join(root, "cache", "thumbnails", "1.jpg")); expect(existsSync(result.path)).toBe(true); // The cached path is now on the projected record. expect(lib.photos.byID({ fileID: 1 })!.record().thumbnailPath).toBe( result.path, ); await lib.close(); }); it("drives thumbnails.ensure through the cache", async () => { const source = stubSource(); const lib = await Library.open({ client: new MockClient(), cacheDirectory: join(root, "cache"), contentSource: source, refreshIntervalSeconds: 3600, // On-demand wiring only; the background precache (#48) is covered // in precache.test.ts and would race the exact-count assertions. precacheThumbnails: false, precacheOriginals: false, }); const results = await lib.thumbnails.ensure({ fileIDs: [1], priority: "visible", }); expect(results).toEqual([ { fileID: 1, path: join(root, "cache", "thumbnails", "1.jpg") }, ]); await lib.close(); }); it("removes a live photo's ZIP an earlier version cached when it opens, and precaches its image and video", async () => { const { file: live, body } = await asLivePhoto(file(1, 1)); class LiveClient extends MockClient { override async filesSince(): Promise { return { files: [live], deleted: [], cursor: 1 }; } } const originals = join(root, "cache", "originals"); const open = (opts: Partial): Promise => Library.open({ client: new LiveClient(), cacheDirectory: join(root, "cache"), contentSource: cdnSource(new Map([[1, body]])), refreshIntervalSeconds: 3600, precacheThumbnails: false, precacheOriginals: false, ...opts, }); // A first run records the library, so the next one knows that file 1 // is a live photo when it opens the cache. await (await open({})).close(); writeFileSync(join(originals, "1.jpg"), livePhotoZip()); let precached!: () => void; const done = new Promise((r) => (precached = r)); const lib = await open({ precacheOriginals: true, onProgress: (e) => { if (e.operation === "precacheOriginals" && e.status === "done") precached(); }, }); expect(existsSync(join(originals, "1.jpg"))).toBe(false); await done; expect(readdirSync(originals).sort()).toEqual([ "1.heic", "1.livephoto.json", "1.mov", ]); expect(lib.photos.byID({ fileID: 1 })!.record().originalPath).toBe( join(originals, "1.heic"), ); await lib.close(); }); it("throws from content methods when opened without a content source", async () => { const lib = await Library.open({ client: new MockClient(), cacheDirectory: join(root, "cache"), refreshIntervalSeconds: 3600, }); await expect( lib.photos.byID({ fileID: 1 })!.thumbnail(), ).rejects.toThrow(/content cache/i); await expect( lib.thumbnails.ensure({ fileIDs: [1], priority: "visible" }), ).rejects.toThrow(/content cache/i); await lib.close(); }); });