/** * Tests for fresh reads (issue #75, an owner amendment to design #36). * * The default read namespaces answer from RAM and never touch the network; a * background loop keeps the local copy current. `fresh()` adds an awaited path: * it forces a refresh, waits for it to complete and persist, and only then * hands back the `albums`/`photos`/`timeline` namespaces, guaranteeing the * local copy reflects a completed server round-trip. The contracts here: * * 1. A fresh read observes a server change that a same-instant default read * would miss (the default path has not refreshed yet). * 2. Concurrent fresh reads coalesce onto one in-flight refresh — three * concurrent `fresh()` calls make exactly one collections round-trip, not * three. * 3. A refresh that fails rejects the fresh read (currency was unavailable), * while the default reads stay silent and keep serving the last good copy. * * The client is the same metadata-only mock the background-refresh tests use: * no crypto, no network, scripted pages, and a record of each call. A long * refresh interval keeps the background timer out of the way so each test's * refreshes are exactly the ones it triggers. */ import { describe, it, expect, beforeEach, afterEach } 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 { CollectionsPage, FilesPage } from "../../src/client.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; const USER_ID = 42; // Long enough that no background tick fires during a test; each test's // refreshes are only the ones its own `fresh()` calls force. const SLOW_INTERVAL = 3600; const collection = (id: number, updationTime: number): Collection => ({ id, ownerID: USER_ID, key: new Uint8Array([id & 0xff]), name: `album-${id}`, type: "album", updationTime, isShared: false, }); const file = ( id: number, collectionID: number, updationTime: number, ): EnteFile => ({ id, collectionID, ownerID: USER_ID, key: new Uint8Array([id & 0xff]), metadata: { title: `file-${id}.jpg`, fileType: "image", creationTime: updationTime, modificationTime: updationTime, }, file: { decryptionHeader: "aGVhZGVy" }, thumbnail: { decryptionHeader: "dGh1bWI=" }, updationTime, }); class MockClient { userID = USER_ID; failCollections = false; collectionsQueue: CollectionsPage[] = []; filesByCollection = new Map(); collectionsSinceTimes: number[] = []; filesCalls: { collectionID: number; sinceTime: number }[] = []; whoami(): { email: string; userID: number } { return { email: "user@example.com", userID: this.userID }; } async collectionsSince(args: { sinceTime: number; }): Promise { this.collectionsSinceTimes.push(args.sinceTime); if (this.failCollections) throw new Error("network down"); return ( this.collectionsQueue.shift() ?? { collections: [], deleted: [], cursor: args.sinceTime, } ); } async filesSince(args: { collectionID: number; collectionKey: Uint8Array; sinceTime: number; }): Promise { this.filesCalls.push({ collectionID: args.collectionID, sinceTime: args.sinceTime, }); 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); } } // A client seeded with one collection and one file, opened with an empty cache // so the initial refresh is awaited and post-`open()` state is deterministic. const openSeeded = async ( cacheDirectory: string, ): Promise<{ client: MockClient; lib: Library }> => { const client = new MockClient(); client.collectionsQueue.push({ collections: [collection(1, 100)], deleted: [], cursor: 100, }); client.filesFor(1, { files: [file(1001, 1, 90)], deleted: [], cursor: 90, }); const lib = await Library.open({ client, cacheDirectory, refreshIntervalSeconds: SLOW_INTERVAL, }); return { client, lib }; }; describe("Library.fresh", () => { let dir: string; let cacheDirectory: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "quak-fresh-")); cacheDirectory = join(dir, "cache"); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); it("observes a server change a same-instant default read would miss", async () => { const { client, lib } = await openSeeded(cacheDirectory); try { // A new file appears on the server after open, advancing its // collection so the next refresh re-enumerates it. client.collectionsQueue.push({ collections: [collection(1, 200)], deleted: [], cursor: 200, }); client.filesFor(1, { files: [file(1002, 1, 190)], deleted: [], cursor: 190, }); // A default read at this instant has not refreshed: it misses 1002. expect(lib.photos.byID({ fileID: 1002 })).toBeUndefined(); // A fresh read forces the round-trip and sees it. const reads = await lib.fresh(); expect(reads.photos.byID({ fileID: 1002 })?.fileID).toBe(1002); // And the change is now live for the default namespaces too. expect(lib.photos.byID({ fileID: 1002 })?.fileID).toBe(1002); } finally { lib.close(); } }); it("coalesces concurrent fresh reads onto one in-flight refresh", async () => { const { client, lib } = await openSeeded(cacheDirectory); try { client.collectionsQueue.push({ collections: [collection(1, 200)], deleted: [], cursor: 200, }); client.filesFor(1, { files: [file(1002, 1, 190)], deleted: [], cursor: 190, }); const collectionsBefore = client.collectionsSinceTimes.length; const filesBefore = client.filesCalls.length; // Gate the next collections fetch so all three fresh reads are in // flight together before any of them completes. let release: () => void = () => {}; const gate = new Promise((r) => { release = r; }); const inner = client.collectionsSince.bind(client); client.collectionsSince = async (args: { sinceTime: number }) => { await gate; return inner(args); }; const all = Promise.all([lib.fresh(), lib.fresh(), lib.fresh()]); release(); const [a, b, c] = await all; // Exactly one collections round-trip and one file round-trip served // all three fresh reads. expect(client.collectionsSinceTimes.length).toBe( collectionsBefore + 1, ); expect(client.filesCalls.length).toBe(filesBefore + 1); // All three observed the change. for (const reads of [a, b, c]) { expect(reads.photos.byID({ fileID: 1002 })?.fileID).toBe(1002); } } finally { lib.close(); } }); it("rejects the fresh read when the refresh fails, leaving defaults intact", async () => { const { client, lib } = await openSeeded(cacheDirectory); try { client.failCollections = true; // Two concurrent fresh reads both reject, and share one failed // round-trip rather than each making its own. const collectionsBefore = client.collectionsSinceTimes.length; const first = lib.fresh(); const second = lib.fresh(); await expect(first).rejects.toThrow(/network down/); await expect(second).rejects.toThrow(/network down/); expect(client.collectionsSinceTimes.length).toBe( collectionsBefore + 1, ); // The default reads never rejected: they still serve the last good // copy, and the failure surfaced through status(). expect(lib.photos.byID({ fileID: 1001 })?.fileID).toBe(1001); expect(lib.status().lastError).toMatch(/network down/); // Recovery: once the server answers, a fresh read resolves current. client.failCollections = false; client.collectionsQueue.push({ collections: [collection(2, 300)], deleted: [], cursor: 300, }); const reads = await lib.fresh(); expect(reads.albums.byID({ collectionID: 2 })?.collectionID).toBe( 2, ); expect(lib.status().lastError).toBeUndefined(); } finally { lib.close(); } }); });