In-process read surface: albums, photos, and timeline grouping #64
@@ -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> = {},
|
||||||
|
): 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<EnteFile> & {
|
||||||
|
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<typeof apis>["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<CollectionsPage> {
|
||||||
|
this.collectionsCalls++;
|
||||||
|
return { collections: [], deleted: [], cursor: args.sinceTime };
|
||||||
|
}
|
||||||
|
async filesSince(args: {
|
||||||
|
collectionID: number;
|
||||||
|
collectionKey: Uint8Array;
|
||||||
|
sinceTime: number;
|
||||||
|
}): Promise<FilesPage> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user