/** * Tests for the plain-record mapping in `src/library/records.ts` (issue #43). * * The library keeps decrypted `Collection`/`EnteFile` objects in RAM, but those * carry binary keys and cannot cross the Electron IPC boundary. `deriveRecords` * projects them into plain `AlbumRecord`/`PhotoRecord` values — no keys, no * `Uint8Array`, JSON-safe — that the GUI process consumes. This file pins: * * 1. Field mapping from `metadata` and the two magic-metadata layers, using the * real Ente field names confirmed against the fixtures in * `test/cli/metadata-backup.test.ts` (`w`/`h`) and `test/library/store.test.ts` * (`visibility`): title/takenAt precedence, caption, width/height, geo, * visibility → isArchived/isHidden, fileType. * 2. `takenAt` is milliseconds; Ente stores creationTime/editedTime in * microseconds, so the record divides by 1000. * 3. Deduplication: one `PhotoRecord` per fileID even when the file belongs to * several collections, with every membership's collection id in `albumIDs`. * 4. Ordering: photos and album `fileIDs` are newest first. * 5. No key material survives the projection. * 6. `diffRecords` reports exactly what changed between two derivations, and * returns undefined when nothing changed. */ import { describe, it, expect } from "vitest"; import { deriveRecords, snapshotFrom, diffRecords, } from "../../src/library/records.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; const OWNER = 42; // Microsecond epoch values, as Ente stores times. 1e15 ≈ 2001 in microseconds. const T = (micros: number): number => micros; const collection = ( id: number, opts: Partial = {}, ): Collection => ({ id, ownerID: OWNER, key: new Uint8Array([id & 0xff, 1, 2, 3]), name: `album-${id}`, type: "album", updationTime: T(1_700_000_000_000_000), isShared: false, ...opts, }); const file = ( id: number, collectionID: number, opts: Partial & { creationTime?: number; title?: string; } = {}, ): EnteFile => { const { creationTime, title, ...rest } = opts; return { id, collectionID, ownerID: OWNER, key: new Uint8Array([id & 0xff, 9, 8, 7]), metadata: { title: title ?? `file-${id}.jpg`, fileType: "image", creationTime: creationTime ?? T(1_700_000_000_000_000), modificationTime: T(1_700_000_000_000_000), }, file: { decryptionHeader: "aGVhZGVy" }, thumbnail: { decryptionHeader: "dGh1bWI=" }, updationTime: T(1_700_000_000_000_000), ...rest, }; }; describe("deriveRecords: photo mapping", () => { it("projects a file into a plain PhotoRecord with no key material", () => { const f = file(1001, 1, { metadata: { title: "IMG_1.jpg", fileType: "image", creationTime: T(1_699_000_000_000_000), modificationTime: T(1_699_000_000_000_000), latitude: 52.52, longitude: 13.405, }, }); const { photos } = deriveRecords([collection(1)], [f]); const rec = photos.get(1001)!; expect(rec.fileID).toBe(1001); expect(rec.albumIDs).toEqual([1]); expect(rec.title).toBe("IMG_1.jpg"); expect(rec.fileType).toBe("image"); expect(rec.latitude).toBeCloseTo(52.52); expect(rec.longitude).toBeCloseTo(13.405); expect(rec.isArchived).toBe(false); expect(rec.isHidden).toBe(false); // Safe to send over IPC: no key, no Uint8Array, JSON round-trips whole. expect("key" in rec).toBe(false); expect(JSON.parse(JSON.stringify(rec))).toEqual(rec); }); it("takenAt is creationTime converted from microseconds to milliseconds", () => { const f = file(1001, 1, { creationTime: T(1_699_000_000_000_000) }); const { photos } = deriveRecords([collection(1)], [f]); expect(photos.get(1001)!.takenAt).toBe(1_699_000_000_000); }); it("prefers pubMagicMetadata.editedName and editedTime over metadata", () => { const f = file(1001, 1, { title: "original.jpg", creationTime: T(1_699_000_000_000_000), pubMagicMetadata: { editedName: "Sunset over the bay", editedTime: T(1_650_000_000_000_000), }, }); const { photos } = deriveRecords([collection(1)], [f]); const rec = photos.get(1001)!; expect(rec.title).toBe("Sunset over the bay"); expect(rec.takenAt).toBe(1_650_000_000_000); }); it("falls back to metadata when the edited fields are empty or absent", () => { const f = file(1001, 1, { title: "original.jpg", creationTime: T(1_699_000_000_000_000), pubMagicMetadata: { editedName: "" }, }); const { photos } = deriveRecords([collection(1)], [f]); const rec = photos.get(1001)!; expect(rec.title).toBe("original.jpg"); expect(rec.takenAt).toBe(1_699_000_000_000); }); it("maps caption and width/height from the public magic metadata", () => { const f = file(1001, 1, { pubMagicMetadata: { caption: "at the beach", w: 3000, h: 2000, }, }); const rec = deriveRecords([collection(1)], [f]).photos.get(1001)!; expect(rec.caption).toBe("at the beach"); expect(rec.width).toBe(3000); expect(rec.height).toBe(2000); }); it("omits optional fields that are absent from the metadata", () => { const rec = deriveRecords([collection(1)], [file(1001, 1)]).photos.get( 1001, )!; expect("caption" in rec).toBe(false); expect("width" in rec).toBe(false); expect("height" in rec).toBe(false); expect("latitude" in rec).toBe(false); }); it("reads archived and hidden from private magicMetadata.visibility", () => { const archived = file(1, 1, { magicMetadata: { visibility: 1 } }); const hidden = file(2, 1, { magicMetadata: { visibility: 2 } }); const visible = file(3, 1, { magicMetadata: { visibility: 0 } }); const { photos } = deriveRecords( [collection(1)], [archived, hidden, visible], ); expect(photos.get(1)).toMatchObject({ isArchived: true, isHidden: false, }); expect(photos.get(2)).toMatchObject({ isArchived: false, isHidden: true, }); expect(photos.get(3)).toMatchObject({ isArchived: false, isHidden: false, }); }); }); describe("deriveRecords: dedup and ordering", () => { it("emits one PhotoRecord per fileID across memberships, all albums listed", () => { // File 1001 belongs to collections 1 and 2; 2002 only to 2. const files = [ file(1001, 1, { creationTime: T(1_700_000_000_000_000) }), file(1001, 2, { creationTime: T(1_700_000_000_000_000) }), file(2002, 2, { creationTime: T(1_710_000_000_000_000) }), ]; const { photos } = deriveRecords([collection(1), collection(2)], files); expect([...photos.keys()].sort((a, b) => a - b)).toEqual([1001, 2002]); expect(photos.get(1001)!.albumIDs).toEqual([1, 2]); expect(photos.get(2002)!.albumIDs).toEqual([2]); }); it("orders snapshot photos newest first by takenAt", () => { const files = [ file(1, 1, { creationTime: T(1_600_000_000_000_000) }), file(2, 1, { creationTime: T(1_800_000_000_000_000) }), file(3, 1, { creationTime: T(1_700_000_000_000_000) }), ]; const snap = snapshotFrom(deriveRecords([collection(1)], files), 123); expect(snap.photos.map((p) => p.fileID)).toEqual([2, 3, 1]); expect(snap.takenAt).toBe(123); }); it("orders album fileIDs newest first", () => { const files = [ file(1, 7, { creationTime: T(1_600_000_000_000_000) }), file(2, 7, { creationTime: T(1_800_000_000_000_000) }), file(3, 7, { creationTime: T(1_700_000_000_000_000) }), ]; const { albums } = deriveRecords([collection(7)], files); expect(albums.get(7)!.fileIDs).toEqual([2, 3, 1]); }); }); describe("deriveRecords: album mapping", () => { it("carries collection identity, sharing, and the favorites type", () => { const fav = collection(9, { name: "Favorites", type: "favorites", isShared: true, updationTime: T(1_705_000_000_000_000), }); const rec = deriveRecords([fav], [file(1, 9)]).albums.get(9)!; expect(rec).toMatchObject({ collectionID: 9, name: "Favorites", type: "favorites", isShared: true, updationTime: T(1_705_000_000_000_000), }); expect("key" in rec).toBe(false); expect(JSON.parse(JSON.stringify(rec))).toEqual(rec); }); }); describe("diffRecords", () => { const at = 999; it("returns undefined when nothing changed", () => { const a = deriveRecords([collection(1)], [file(1, 1)]); const b = deriveRecords([collection(1)], [file(1, 1)]); expect(diffRecords(a, b, at)).toBeUndefined(); }); it("reports added and changed albums and photos and removals", () => { const before = deriveRecords( [collection(1), collection(2)], [file(1, 1), file(2, 2)], ); // Collection 2 is gone (album + its only file removed). Collection 1 is // renamed (changed album), gains file 3, and file 1 is retitled. const after = deriveRecords( [collection(1, { name: "renamed" })], [ file(1, 1, { pubMagicMetadata: { editedName: "new title" }, }), file(3, 1), ], ); const change = diffRecords(before, after, at)!; expect(change.refreshedAt).toBe(at); expect(change.albumIDsRemoved).toEqual([2]); expect(change.fileIDsRemoved).toEqual([2]); expect(change.albumsChanged.map((a) => a.collectionID)).toEqual([1]); expect(change.albumsChanged[0]!.name).toBe("renamed"); expect( change.photosChanged.map((p) => p.fileID).sort((x, y) => x - y), ).toEqual([1, 3]); }); });