/** * Tests for the in-process read surface (issue #44). * * Phase 1 (#43) projected the decrypted store into plain `AlbumRecord` / * `PhotoRecord` values. This phase adds the read API a CLI or in-process script * uses, all served from RAM with no network: * * - `lib.albums` — `list` / `byName` / `byID`, returning thin `Album` wrappers. * - `lib.photos` — `byID` (a `Photo` wrapper) and `records` (plain records). * - `lib.timeline.groups` — photos bucketed by local day / week / month. * * Every call takes a single named-argument object; there are no positional * arguments. The wrapper classes are for in-process callers only (they hold * object identity, not JSON); the plain records remain the IPC-safe surface. * Content-fetch methods (`Photo.original` / `thumbnail`) are a later unit and * deliberately absent here — this surface is read-only. * * The detailed cases drive the API factories directly over a hand-built * projection (`deriveRecords`), which keeps them free of disk and timers. A * final section opens a real `Library` to prove the namespaces are wired to the * live store and that a read never touches the client. */ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { deriveRecords, type DerivedRecords, } from "../../src/library/records.js"; import { Album, Photo, makeAlbumsAPI, makePhotosAPI, makeTimelineAPI, type TimelineGroup, } from "../../src/library/read.js"; import { Library } from "../../src/library/index.js"; import { MetadataStore } from "../../src/library/store.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; const OWNER = 42; // Ente stores times in microseconds; records expose milliseconds. These // helpers keep the fixtures readable: `ms(...)` picks an epoch-millisecond // instant, `micros(...)` is what the fixture stores so the derived record's // `takenAt` comes back as the same millisecond value. const ms = (epochMillis: number): number => epochMillis; const micros = (epochMillis: number): number => epochMillis * 1000; const collection = ( id: number, opts: Partial = {}, ): Collection => ({ id, ownerID: OWNER, key: new Uint8Array([id & 0xff, 1, 2, 3]), name: `album-${id}`, type: "album", updationTime: micros(1_700_000_000_000), isShared: false, ...opts, }); const file = ( id: number, collectionID: number, opts: Partial & { creationTime?: number; title?: string; fileType?: EnteFile["metadata"]["fileType"]; latitude?: number; longitude?: number; } = {}, ): EnteFile => { const { creationTime, title, fileType, latitude, longitude, ...rest } = opts; const metadata: EnteFile["metadata"] = { title: title ?? `file-${id}.jpg`, fileType: fileType ?? "image", creationTime: creationTime ?? micros(1_700_000_000_000), modificationTime: micros(1_700_000_000_000), }; if (latitude !== undefined) metadata.latitude = latitude; if (longitude !== undefined) metadata.longitude = longitude; return { id, collectionID, ownerID: OWNER, key: new Uint8Array([id & 0xff, 9, 8, 7]), metadata, file: { decryptionHeader: "aGVhZGVy" }, thumbnail: { decryptionHeader: "dGh1bWI=" }, updationTime: micros(1_700_000_000_000), ...rest, }; }; // Build the three API objects over one fixed projection, the way `Library` // wires them over its live store. const apis = (records: DerivedRecords) => { const derive = () => records; return { albums: makeAlbumsAPI(derive), photos: makePhotosAPI(derive), timeline: makeTimelineAPI(derive), }; }; // Every file id present across all timeline groups, in group-then-member order. const allFileIDs = (groups: TimelineGroup[]): number[] => groups.flatMap((g) => g.fileIDs); describe("lib.albums", () => { it("lists albums as Album wrappers, newest updated first", () => { const records = deriveRecords( [ collection(1, { updationTime: micros(1_700_000_000_000) }), collection(2, { updationTime: micros(1_705_000_000_000) }), ], [file(10, 1), file(20, 2)], ); const albums = apis(records).albums.list(); expect(albums.every((a) => a instanceof Album)).toBe(true); // Collection 2 updated later, so it sorts ahead of collection 1. expect(albums.map((a) => a.collectionID)).toEqual([2, 1]); }); it("exposes album fields and its photos newest first", () => { const records = deriveRecords( [ collection(7, { name: "Trip", type: "favorites", isShared: true, }), ], [ file(1, 7, { creationTime: micros(1_600_000_000_000) }), file(2, 7, { creationTime: micros(1_800_000_000_000) }), file(3, 7, { creationTime: micros(1_700_000_000_000) }), ], ); const album = apis(records).albums.byID({ collectionID: 7 })!; expect(album.name).toBe("Trip"); expect(album.type).toBe("favorites"); expect(album.isShared).toBe(true); expect(album.fileIDs).toEqual([2, 3, 1]); const photos = album.photos.list(); expect(photos.every((p) => p instanceof Photo)).toBe(true); expect(photos.map((p) => p.fileID)).toEqual([2, 3, 1]); // record() hands back the plain, JSON-safe projection. expect("key" in album.record()).toBe(false); }); it("finds an album by exact name and returns undefined when absent", () => { const records = deriveRecords( [ collection(1, { name: "Berlin" }), collection(2, { name: "Paris" }), ], [file(10, 1), file(20, 2)], ); const { albums } = apis(records); expect(albums.byName({ albumName: "Paris" })?.collectionID).toBe(2); expect(albums.byName({ albumName: "paris" })).toBeUndefined(); expect(albums.byName({ albumName: "Nowhere" })).toBeUndefined(); }); it("returns undefined for an unknown collection id", () => { const records = deriveRecords([collection(1)], [file(10, 1)]); expect( apis(records).albums.byID({ collectionID: 999 }), ).toBeUndefined(); }); }); describe("lib.photos", () => { it("byID returns a Photo wrapper carrying the mapped fields", () => { const records = deriveRecords( [collection(1)], [ file(1001, 1, { title: "IMG.jpg", fileType: "video", creationTime: micros(1_699_000_000_000), latitude: 52.52, longitude: 13.405, pubMagicMetadata: { caption: "at the lake" }, }), ], ); const photo = apis(records).photos.byID({ fileID: 1001 })!; expect(photo).toBeInstanceOf(Photo); expect(photo.title).toBe("IMG.jpg"); expect(photo.fileType).toBe("video"); expect(photo.takenAt).toBe(ms(1_699_000_000_000)); expect(photo.caption).toBe("at the lake"); expect(photo.latitude).toBeCloseTo(52.52); expect(photo.isArchived).toBe(false); expect(photo.isHidden).toBe(false); // The wrapper hands back the plain record, IPC-safe. expect("key" in photo.record()).toBe(false); }); it("byID returns undefined for an unknown file id", () => { const records = deriveRecords([collection(1)], [file(1, 1)]); expect(apis(records).photos.byID({ fileID: 999 })).toBeUndefined(); }); it("records() returns plain records in requested order, deduped, skipping unknowns", () => { const records = deriveRecords( [collection(1)], [file(1, 1), file(2, 1), file(3, 1)], ); const out = apis(records).photos.records({ fileIDs: [3, 1, 3, 999, 2], }); // Requested order preserved; the repeated 3 appears once; 999 is dropped. expect(out.map((r) => r.fileID)).toEqual([3, 1, 2]); // Plain records, not wrappers, and JSON round-trips whole. expect(out[0]).not.toBeInstanceOf(Photo); expect(JSON.parse(JSON.stringify(out[0]))).toEqual(out[0]); }); it("emits one record for a file even when it belongs to several albums", () => { // File 1001 is a member of collections 1 and 2. const records = deriveRecords( [collection(1), collection(2)], [file(1001, 1), file(1001, 2)], ); const out = apis(records).photos.records({ fileIDs: [1001, 1001] }); expect(out).toHaveLength(1); expect(out[0]!.albumIDs).toEqual([1, 2]); }); }); describe("lib.timeline grouping", () => { // Group keys and `startsAt` are computed in local time. Pinning the zone to // UTC makes the expected values exact and lets the fixtures use `Date.UTC`. const savedTZ = process.env.TZ; beforeAll(() => { process.env.TZ = "UTC"; }); afterAll(() => { if (savedTZ === undefined) delete process.env.TZ; else process.env.TZ = savedTZ; }); it("buckets by local day, newest group and newest member first", () => { const records = deriveRecords( [collection(1)], [ file(1, 1, { creationTime: micros(Date.UTC(2024, 0, 15, 9)) }), file(2, 1, { creationTime: micros(Date.UTC(2024, 0, 15, 18)) }), file(3, 1, { creationTime: micros(Date.UTC(2024, 0, 16, 12)) }), file(4, 1, { creationTime: micros(Date.UTC(2024, 1, 1, 12)) }), ], ); const groups = apis(records).timeline.groups({ groupBy: "day" }); expect(groups.map((g) => g.key)).toEqual([ "2024-02-01", "2024-01-16", "2024-01-15", ]); // Group start is local midnight of the day. expect(groups[2]!.startsAt).toBe(Date.UTC(2024, 0, 15)); // Within the 2024-01-15 group, the later photo (id 2) is first. expect(groups[2]!.fileIDs).toEqual([2, 1]); }); it("buckets by week with weeks starting on Monday", () => { // 2024-01-15 is a Monday; the week runs through Sunday 2024-01-21. const records = deriveRecords( [collection(1)], [ file(1, 1, { creationTime: micros(Date.UTC(2024, 0, 15, 12)) }), // Mon file(2, 1, { creationTime: micros(Date.UTC(2024, 0, 17, 12)) }), // Wed file(3, 1, { creationTime: micros(Date.UTC(2024, 0, 21, 12)) }), // Sun file(4, 1, { creationTime: micros(Date.UTC(2024, 0, 22, 12)) }), // next Mon ], ); const groups = apis(records).timeline.groups({ groupBy: "week" }); // ISO week keys: 2024-01-15 is in 2024-W03, the next Monday in 2024-W04. expect(groups.map((g) => g.key)).toEqual(["2024-W04", "2024-W03"]); const first = groups.find((g) => g.key === "2024-W03")!; expect(first.startsAt).toBe(Date.UTC(2024, 0, 15)); // The Sunday belongs to the Monday-started week, not the next one. expect(first.fileIDs.sort((a, b) => a - b)).toEqual([1, 2, 3]); }); it("assigns a Sunday to the preceding Monday's week across a month boundary", () => { // 2024-01-14 is a Sunday; its week started Monday 2024-01-08. const records = deriveRecords( [collection(1)], [file(1, 1, { creationTime: micros(Date.UTC(2024, 0, 14, 12)) })], ); const groups = apis(records).timeline.groups({ groupBy: "week" }); expect(groups.map((g) => g.key)).toEqual(["2024-W02"]); expect(groups[0]!.startsAt).toBe(Date.UTC(2024, 0, 8)); }); it("buckets by month", () => { const records = deriveRecords( [collection(1)], [ file(1, 1, { creationTime: micros(Date.UTC(2024, 0, 3, 12)) }), file(2, 1, { creationTime: micros(Date.UTC(2024, 0, 28, 12)) }), file(3, 1, { creationTime: micros(Date.UTC(2024, 1, 9, 12)) }), ], ); const groups = apis(records).timeline.groups({ groupBy: "month" }); expect(groups.map((g) => g.key)).toEqual(["2024-02", "2024-01"]); expect(groups[1]!.startsAt).toBe(Date.UTC(2024, 0, 1)); expect(groups[1]!.fileIDs).toEqual([2, 1]); }); it("lists each file once even when it belongs to several albums", () => { // File 1001 is in collections 1 and 2 but must appear once in a group. const records = deriveRecords( [collection(1), collection(2)], [ file(1001, 1, { creationTime: micros(Date.UTC(2024, 0, 15, 12)), }), file(1001, 2, { creationTime: micros(Date.UTC(2024, 0, 15, 12)), }), ], ); const groups = apis(records).timeline.groups({ groupBy: "day" }); expect(allFileIDs(groups)).toEqual([1001]); }); }); describe("lib.timeline uses local time, not UTC", () => { const savedTZ = process.env.TZ; afterAll(() => { if (savedTZ === undefined) delete process.env.TZ; else process.env.TZ = savedTZ; }); it("buckets by the viewer's local day", () => { // Kolkata is UTC+5:30 with no DST. An instant at 2024-01-14T20:00Z is // 2024-01-15 01:30 local, so it belongs to the local day 2024-01-15. process.env.TZ = "Asia/Kolkata"; const records = deriveRecords( [collection(1)], [file(1, 1, { creationTime: micros(Date.UTC(2024, 0, 14, 20)) })], ); const groups = apis(records).timeline.groups({ groupBy: "day" }); expect(groups[0]!.key).toBe("2024-01-15"); // Local midnight of 2024-01-15, which is 2024-01-14T18:30Z. expect(groups[0]!.startsAt).toBe(new Date(2024, 0, 15).getTime()); expect(groups[0]!.startsAt).toBe(Date.UTC(2024, 0, 14, 18, 30)); }); }); describe("PhotoFilter", () => { // A fixture spanning albums, file types, geotags, captions, and the two // visibility states, all on the same local day so grouping is incidental. const day = (h: number): number => micros(Date.UTC(2024, 2, 4, h)); const records = (): DerivedRecords => deriveRecords( [ collection(1, { name: "Holidays" }), collection(2, { name: "Work" }), ], [ file(1, 1, { title: "Beach sunset", creationTime: day(1), latitude: 1, longitude: 2, }), file(2, 1, { title: "clip.mov", fileType: "video", creationTime: day(2), }), file(3, 2, { title: "invoice scan", creationTime: day(3), pubMagicMetadata: { caption: "SUNSET colours" }, }), file(4, 2, { title: "archived note", creationTime: day(4), magicMetadata: { visibility: 1 }, }), file(5, 2, { title: "secret", creationTime: day(5), magicMetadata: { visibility: 2 }, }), ], ); const idsWith = ( filter: Parameters< ReturnType["timeline"]["groups"] >[0]["filter"], ): number[] => allFileIDs( apis(records()).timeline.groups({ groupBy: "day", filter }), ).sort((a, b) => a - b); it("never includes hidden photos and excludes archived by default", () => { // No filter: hidden (5) always gone, archived (4) gone unless asked for. expect(idsWith(undefined)).toEqual([1, 2, 3]); }); it("includes archived photos when includeArchived is set, hidden still never", () => { expect(idsWith({ includeArchived: true })).toEqual([1, 2, 3, 4]); }); it("filters by album membership", () => { expect(idsWith({ albumID: 1 })).toEqual([1, 2]); expect(idsWith({ albumID: 2 })).toEqual([3]); }); it("filters by file type", () => { expect(idsWith({ fileTypes: ["video"] })).toEqual([2]); expect(idsWith({ fileTypes: ["image", "video"] })).toEqual([1, 2, 3]); }); it("filters by presence or absence of location", () => { expect(idsWith({ hasLocation: true })).toEqual([1]); expect(idsWith({ hasLocation: false })).toEqual([2, 3]); }); it("matches text case-insensitively against title, caption, and album name", () => { // Title match (case-insensitive): "Beach sunset". expect(idsWith({ text: "SUNSET" })).toEqual([1, 3]); // Caption-only match: file 3's caption is "SUNSET colours". expect(idsWith({ text: "colours" })).toEqual([3]); // Album-name match: everything in "Holidays". expect(idsWith({ text: "holiday" })).toEqual([1, 2]); }); it("combines filters", () => { // Images in album 1 with a location: only file 1. expect( idsWith({ albumID: 1, fileTypes: ["image"], hasLocation: true }), ).toEqual([1]); }); }); /** * A minimal mock `Client`, enough for `Library.open` to run its refresh loop. * The queues are empty, so the background refresh over a seeded cache changes * nothing; the counters prove that a read never calls the client. */ class MockClient { userID = OWNER; collectionsCalls = 0; filesCalls = 0; whoami(): { email: string; userID: number } { return { email: "user@example.com", userID: this.userID }; } async collectionsSince(args: { sinceTime: number; }): Promise { this.collectionsCalls++; return { collections: [], deleted: [], cursor: args.sinceTime }; } async filesSince(args: { collectionID: number; collectionKey: Uint8Array; sinceTime: number; }): Promise { this.filesCalls++; return { files: [], deleted: [], cursor: args.sinceTime }; } } describe("Library exposes the read surface over its live store", () => { let dir: string; beforeAll(() => { dir = mkdtempSync(join(tmpdir(), "quak-read-")); }); afterAll(() => { rmSync(dir, { recursive: true, force: true }); }); it("serves albums, photos, and timeline from RAM without calling the client", async () => { const cacheDirectory = join(dir, "cache"); const path = join(cacheDirectory, "metadata.json"); // Seed a cache as a prior run left it, so open() serves it at once. const seed = await MetadataStore.load(path); seed.userID = OWNER; seed.collectionsSinceTime = 100; seed.putCollection(collection(1, { name: "Seeded" })); seed.putFile( file(1001, 1, { creationTime: micros(Date.UTC(2024, 5, 1, 12)) }), ); await seed.save(); const client = new MockClient(); // A long interval keeps the background timer from firing during the test. const lib = await Library.open({ client, cacheDirectory, refreshIntervalSeconds: 3600, }); try { const collectionsBefore = client.collectionsCalls; const filesBefore = client.filesCalls; expect(lib.albums.list().map((a) => a.name)).toEqual(["Seeded"]); expect( lib.albums.byName({ albumName: "Seeded" })?.collectionID, ).toBe(1); expect(lib.photos.byID({ fileID: 1001 })?.fileID).toBe(1001); expect( lib.photos.records({ fileIDs: [1001] }).map((r) => r.fileID), ).toEqual([1001]); const groups = lib.timeline.groups({ groupBy: "month" }); expect(allFileIDs(groups)).toEqual([1001]); // Reads are answered from RAM: no read called the client. expect(client.collectionsCalls).toBe(collectionsBefore); expect(client.filesCalls).toBe(filesBefore); } finally { await lib.close(); } }); });