/** * Tests for `Library.snapshot()` and `Library.subscribe()` (issue #43). * * These are the surface the GUI consumes across Electron IPC. `snapshot()` is * synchronous — it reads the in-RAM store and projects it into plain records * (no keys) — and `subscribe({ onChange })` delivers a `LibraryChange` whenever * a background refresh actually changes the derived records. The contracts: * * 1. `snapshot()` deduplicates a file across memberships into one record with * every album id, orders photos newest first, and carries no key material. * 2. `subscribe` fires on a refresh that changes something, with the exact * changed and removed sets for both albums and photos. * 3. A refresh that changes nothing (an empty diff) fires no change. * 4. `unsubscribe()` stops further delivery. * * The client is the same scripted mock used by the refresh-loop tests: no * crypto, no network. Interval tests use a short real interval and `vi.waitFor`. */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Library } from "../../src/library/index.js"; import type { LibraryChange } from "../../src/library/records.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; const USER_ID = 42; const FAST_INTERVAL = 0.02; const collection = ( id: number, updationTime: number, name = `album-${id}`, ): Collection => ({ id, ownerID: USER_ID, key: new Uint8Array([id & 0xff]), name, type: "album", updationTime, isShared: false, }); const file = ( id: number, collectionID: number, creationTime: number, ): EnteFile => ({ id, collectionID, ownerID: USER_ID, key: new Uint8Array([id & 0xff]), metadata: { title: `file-${id}.jpg`, fileType: "image", creationTime, modificationTime: creationTime, }, file: { decryptionHeader: "aGVhZGVy" }, thumbnail: { decryptionHeader: "dGh1bWI=" }, updationTime: creationTime, }); class MockClient { userID = USER_ID; collectionsQueue: CollectionsPage[] = []; filesByCollection = new Map(); whoami(): { email: string; userID: number } { return { email: "user@example.com", userID: this.userID }; } async collectionsSince(args: { sinceTime: number; }): Promise { return ( this.collectionsQueue.shift() ?? { collections: [], deleted: [], cursor: args.sinceTime, } ); } async filesSince(args: { collectionID: number; collectionKey: Uint8Array; sinceTime: number; }): Promise { const queue = this.filesByCollection.get(args.collectionID); return ( queue?.shift() ?? { files: [], deleted: [], cursor: args.sinceTime, } ); } filesFor(collectionID: number, ...pages: FilesPage[]): void { this.filesByCollection.set(collectionID, pages); } } describe("Library.snapshot and Library.subscribe", () => { let dir: string; let cacheDirectory: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "quak-snapshot-")); cacheDirectory = join(dir, "cache"); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); it("snapshot() dedupes across memberships, orders newest first, holds no keys", async () => { const client = new MockClient(); client.collectionsQueue.push({ collections: [collection(1, 100), collection(2, 100)], deleted: [], cursor: 100, }); // File 1001 is in both collections; 2002 only in collection 2 and newer. client.filesFor(1, { files: [file(1001, 1, 1_600_000_000_000_000)], deleted: [], cursor: 1_600_000_000_000_000, }); client.filesFor(2, { files: [ file(1001, 2, 1_600_000_000_000_000), file(2002, 2, 1_800_000_000_000_000), ], deleted: [], cursor: 1_800_000_000_000_000, }); const lib = await Library.open({ client, cacheDirectory }); try { const snap = lib.snapshot(); // One record per fileID, newest first, both albums on the shared file. expect(snap.photos.map((p) => p.fileID)).toEqual([2002, 1001]); const shared = snap.photos.find((p) => p.fileID === 1001)!; expect(shared.albumIDs).toEqual([1, 2]); expect(shared.takenAt).toBe(1_600_000_000_000); expect(snap.albums.map((a) => a.collectionID).sort()).toEqual([ 1, 2, ]); // Nothing carries key material; the whole snapshot is JSON-safe. expect(JSON.parse(JSON.stringify(snap))).toEqual(snap); for (const p of snap.photos) expect("key" in p).toBe(false); for (const a of snap.albums) expect("key" in a).toBe(false); } finally { lib.close(); } }); it("subscribe fires on a refresh change with the correct changed/removed sets", async () => { const client = new MockClient(); client.collectionsQueue.push({ collections: [collection(1, 100), collection(2, 100)], deleted: [], cursor: 100, }); client.filesFor(1, { files: [file(1001, 1, 1_600_000_000_000_000)], deleted: [], cursor: 1_600_000_000_000_000, }); client.filesFor(2, { files: [file(2002, 2, 1_600_000_000_000_000)], deleted: [], cursor: 1_600_000_000_000_000, }); const lib = await Library.open({ client, cacheDirectory, refreshIntervalSeconds: FAST_INTERVAL, }); const changes: LibraryChange[] = []; const { unsubscribe } = lib.subscribe({ onChange: (c) => changes.push(c), }); try { // Next refresh: collection 2 (and its file) tombstoned; collection 1 // gains file 1003. client.filesFor(1, { files: [file(1003, 1, 1_700_000_000_000_000)], deleted: [], cursor: 1_700_000_000_000_000, }); client.collectionsQueue.push({ collections: [collection(1, 200)], deleted: [2], cursor: 200, }); await vi.waitFor(() => expect(changes.length).toBeGreaterThan(0), { timeout: 2000, interval: 5, }); const change = changes[0]!; expect(change.albumIDsRemoved).toEqual([2]); expect(change.fileIDsRemoved).toEqual([2002]); expect(change.photosChanged.map((p) => p.fileID)).toEqual([1003]); expect(change.albumsChanged.map((a) => a.collectionID)).toEqual([ 1, ]); expect(change.refreshedAt).toBeGreaterThan(0); } finally { unsubscribe(); lib.close(); } }); it("a refresh that changes nothing fires no change", async () => { const client = new MockClient(); client.collectionsQueue.push({ collections: [collection(1, 100)], deleted: [], cursor: 100, }); client.filesFor(1, { files: [file(1001, 1, 1_600_000_000_000_000)], deleted: [], cursor: 1_600_000_000_000_000, }); const lib = await Library.open({ client, cacheDirectory, refreshIntervalSeconds: FAST_INTERVAL, }); const changes: LibraryChange[] = []; const { unsubscribe } = lib.subscribe({ onChange: (c) => changes.push(c), }); try { // Let several empty-diff ticks pass; none may deliver a change. await new Promise((r) => setTimeout(r, FAST_INTERVAL * 1000 * 6)); expect(changes).toEqual([]); } finally { unsubscribe(); lib.close(); } }); it("unsubscribe stops further delivery", async () => { const client = new MockClient(); client.collectionsQueue.push({ collections: [collection(1, 100)], deleted: [], cursor: 100, }); client.filesFor(1, { files: [file(1001, 1, 1_600_000_000_000_000)], deleted: [], cursor: 1_600_000_000_000_000, }); const lib = await Library.open({ client, cacheDirectory, refreshIntervalSeconds: FAST_INTERVAL, }); const changes: LibraryChange[] = []; const { unsubscribe } = lib.subscribe({ onChange: (c) => changes.push(c), }); unsubscribe(); try { client.collectionsQueue.push({ collections: [collection(3, 300)], deleted: [], cursor: 300, }); client.filesFor(3, { files: [file(3003, 3, 1_700_000_000_000_000)], deleted: [], cursor: 1_700_000_000_000_000, }); // The change lands in the store, but the cancelled subscriber sees // nothing. await vi.waitFor( () => expect(lib.snapshot().albums.length).toBe(2), { timeout: 2000, interval: 5 }, ); expect(changes).toEqual([]); } finally { lib.close(); } }); });