diff --git a/test/library/read.test.ts b/test/library/read.test.ts new file mode 100644 index 0000000..d8ad24f --- /dev/null +++ b/test/library/read.test.ts @@ -0,0 +1,536 @@ +/** + * 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" }); + expect(groups.map((g) => g.key)).toEqual(["2024-01-22", "2024-01-15"]); + const first = groups.find((g) => g.key === "2024-01-15")!; + 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-01-08"]); + 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 { + lib.close(); + } + }); +});