check / check (push) Successful in 30s
Adds a synchronous, key-free snapshot() returning LibrarySnapshot (one PhotoRecord per fileID, deduped, newest first) with edited-name/time precedence (pubMagicMetadata over basic metadata) in milliseconds, plus AlbumRecord (favorites identified by type). subscribe({onChange}) delivers LibraryChange (changed/removed albums and files, refreshedAt) only when a refresh changes something; unsubscribe stops delivery. Built on the existing refresh loop; served from RAM, safe to send over IPC.
Model: opus-4-8
304 lines
9.8 KiB
TypeScript
304 lines
9.8 KiB
TypeScript
/**
|
|
* Tests for `Library.snapshot()` and `Library.subscribe()` (issue #43).
|
|
*
|
|
* These are the surface the GUI consumes across Electron IPC. `snapshot()` is
|
|
* synchronous — it reads the in-RAM store and projects it into plain records
|
|
* (no keys) — and `subscribe({ onChange })` delivers a `LibraryChange` whenever
|
|
* a background refresh actually changes the derived records. The contracts:
|
|
*
|
|
* 1. `snapshot()` deduplicates a file across memberships into one record with
|
|
* every album id, orders photos newest first, and carries no key material.
|
|
* 2. `subscribe` fires on a refresh that changes something, with the exact
|
|
* changed and removed sets for both albums and photos.
|
|
* 3. A refresh that changes nothing (an empty diff) fires no change.
|
|
* 4. `unsubscribe()` stops further delivery.
|
|
*
|
|
* The client is the same scripted mock used by the refresh-loop tests: no
|
|
* crypto, no network. Interval tests use a short real interval and `vi.waitFor`.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } 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 { LibraryChange } from "../../src/library/records.js";
|
|
import type { CollectionsPage, FilesPage } from "../../src/client.js";
|
|
import type { Collection, EnteFile } from "../../src/model/types.js";
|
|
|
|
const USER_ID = 42;
|
|
const FAST_INTERVAL = 0.02;
|
|
|
|
const collection = (
|
|
id: number,
|
|
updationTime: number,
|
|
name = `album-${id}`,
|
|
): Collection => ({
|
|
id,
|
|
ownerID: USER_ID,
|
|
key: new Uint8Array([id & 0xff]),
|
|
name,
|
|
type: "album",
|
|
updationTime,
|
|
isShared: false,
|
|
});
|
|
|
|
const file = (
|
|
id: number,
|
|
collectionID: number,
|
|
creationTime: number,
|
|
): EnteFile => ({
|
|
id,
|
|
collectionID,
|
|
ownerID: USER_ID,
|
|
key: new Uint8Array([id & 0xff]),
|
|
metadata: {
|
|
title: `file-${id}.jpg`,
|
|
fileType: "image",
|
|
creationTime,
|
|
modificationTime: creationTime,
|
|
},
|
|
file: { decryptionHeader: "aGVhZGVy" },
|
|
thumbnail: { decryptionHeader: "dGh1bWI=" },
|
|
updationTime: creationTime,
|
|
});
|
|
|
|
class MockClient {
|
|
userID = USER_ID;
|
|
collectionsQueue: CollectionsPage[] = [];
|
|
filesByCollection = new Map<number, FilesPage[]>();
|
|
|
|
whoami(): { email: string; userID: number } {
|
|
return { email: "user@example.com", userID: this.userID };
|
|
}
|
|
|
|
async collectionsSince(args: {
|
|
sinceTime: number;
|
|
}): Promise<CollectionsPage> {
|
|
return (
|
|
this.collectionsQueue.shift() ?? {
|
|
collections: [],
|
|
deleted: [],
|
|
cursor: args.sinceTime,
|
|
}
|
|
);
|
|
}
|
|
|
|
async filesSince(args: {
|
|
collectionID: number;
|
|
collectionKey: Uint8Array;
|
|
sinceTime: number;
|
|
}): Promise<FilesPage> {
|
|
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);
|
|
}
|
|
}
|
|
|
|
describe("Library.snapshot and Library.subscribe", () => {
|
|
let dir: string;
|
|
let cacheDirectory: string;
|
|
|
|
beforeEach(() => {
|
|
dir = mkdtempSync(join(tmpdir(), "quak-snapshot-"));
|
|
cacheDirectory = join(dir, "cache");
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("snapshot() dedupes across memberships, orders newest first, holds no keys", async () => {
|
|
const client = new MockClient();
|
|
client.collectionsQueue.push({
|
|
collections: [collection(1, 100), collection(2, 100)],
|
|
deleted: [],
|
|
cursor: 100,
|
|
});
|
|
// File 1001 is in both collections; 2002 only in collection 2 and newer.
|
|
client.filesFor(1, {
|
|
files: [file(1001, 1, 1_600_000_000_000_000)],
|
|
deleted: [],
|
|
cursor: 1_600_000_000_000_000,
|
|
});
|
|
client.filesFor(2, {
|
|
files: [
|
|
file(1001, 2, 1_600_000_000_000_000),
|
|
file(2002, 2, 1_800_000_000_000_000),
|
|
],
|
|
deleted: [],
|
|
cursor: 1_800_000_000_000_000,
|
|
});
|
|
|
|
const lib = await Library.open({ client, cacheDirectory });
|
|
try {
|
|
const snap = lib.snapshot();
|
|
|
|
// One record per fileID, newest first, both albums on the shared file.
|
|
expect(snap.photos.map((p) => p.fileID)).toEqual([2002, 1001]);
|
|
const shared = snap.photos.find((p) => p.fileID === 1001)!;
|
|
expect(shared.albumIDs).toEqual([1, 2]);
|
|
expect(shared.takenAt).toBe(1_600_000_000_000);
|
|
|
|
expect(snap.albums.map((a) => a.collectionID).sort()).toEqual([
|
|
1, 2,
|
|
]);
|
|
|
|
// Nothing carries key material; the whole snapshot is JSON-safe.
|
|
expect(JSON.parse(JSON.stringify(snap))).toEqual(snap);
|
|
for (const p of snap.photos) expect("key" in p).toBe(false);
|
|
for (const a of snap.albums) expect("key" in a).toBe(false);
|
|
} finally {
|
|
lib.close();
|
|
}
|
|
});
|
|
|
|
it("subscribe fires on a refresh change with the correct changed/removed sets", async () => {
|
|
const client = new MockClient();
|
|
client.collectionsQueue.push({
|
|
collections: [collection(1, 100), collection(2, 100)],
|
|
deleted: [],
|
|
cursor: 100,
|
|
});
|
|
client.filesFor(1, {
|
|
files: [file(1001, 1, 1_600_000_000_000_000)],
|
|
deleted: [],
|
|
cursor: 1_600_000_000_000_000,
|
|
});
|
|
client.filesFor(2, {
|
|
files: [file(2002, 2, 1_600_000_000_000_000)],
|
|
deleted: [],
|
|
cursor: 1_600_000_000_000_000,
|
|
});
|
|
|
|
const lib = await Library.open({
|
|
client,
|
|
cacheDirectory,
|
|
refreshIntervalSeconds: FAST_INTERVAL,
|
|
});
|
|
const changes: LibraryChange[] = [];
|
|
const { unsubscribe } = lib.subscribe({
|
|
onChange: (c) => changes.push(c),
|
|
});
|
|
try {
|
|
// Next refresh: collection 2 (and its file) tombstoned; collection 1
|
|
// gains file 1003.
|
|
client.filesFor(1, {
|
|
files: [file(1003, 1, 1_700_000_000_000_000)],
|
|
deleted: [],
|
|
cursor: 1_700_000_000_000_000,
|
|
});
|
|
client.collectionsQueue.push({
|
|
collections: [collection(1, 200)],
|
|
deleted: [2],
|
|
cursor: 200,
|
|
});
|
|
|
|
await vi.waitFor(() => expect(changes.length).toBeGreaterThan(0), {
|
|
timeout: 2000,
|
|
interval: 5,
|
|
});
|
|
|
|
const change = changes[0]!;
|
|
expect(change.albumIDsRemoved).toEqual([2]);
|
|
expect(change.fileIDsRemoved).toEqual([2002]);
|
|
expect(change.photosChanged.map((p) => p.fileID)).toEqual([1003]);
|
|
expect(change.albumsChanged.map((a) => a.collectionID)).toEqual([
|
|
1,
|
|
]);
|
|
expect(change.refreshedAt).toBeGreaterThan(0);
|
|
} finally {
|
|
unsubscribe();
|
|
lib.close();
|
|
}
|
|
});
|
|
|
|
it("a refresh that changes nothing fires no change", async () => {
|
|
const client = new MockClient();
|
|
client.collectionsQueue.push({
|
|
collections: [collection(1, 100)],
|
|
deleted: [],
|
|
cursor: 100,
|
|
});
|
|
client.filesFor(1, {
|
|
files: [file(1001, 1, 1_600_000_000_000_000)],
|
|
deleted: [],
|
|
cursor: 1_600_000_000_000_000,
|
|
});
|
|
|
|
const lib = await Library.open({
|
|
client,
|
|
cacheDirectory,
|
|
refreshIntervalSeconds: FAST_INTERVAL,
|
|
});
|
|
const changes: LibraryChange[] = [];
|
|
const { unsubscribe } = lib.subscribe({
|
|
onChange: (c) => changes.push(c),
|
|
});
|
|
try {
|
|
// Let several empty-diff ticks pass; none may deliver a change.
|
|
await new Promise((r) => setTimeout(r, FAST_INTERVAL * 1000 * 6));
|
|
expect(changes).toEqual([]);
|
|
} finally {
|
|
unsubscribe();
|
|
lib.close();
|
|
}
|
|
});
|
|
|
|
it("unsubscribe stops further delivery", async () => {
|
|
const client = new MockClient();
|
|
client.collectionsQueue.push({
|
|
collections: [collection(1, 100)],
|
|
deleted: [],
|
|
cursor: 100,
|
|
});
|
|
client.filesFor(1, {
|
|
files: [file(1001, 1, 1_600_000_000_000_000)],
|
|
deleted: [],
|
|
cursor: 1_600_000_000_000_000,
|
|
});
|
|
|
|
const lib = await Library.open({
|
|
client,
|
|
cacheDirectory,
|
|
refreshIntervalSeconds: FAST_INTERVAL,
|
|
});
|
|
const changes: LibraryChange[] = [];
|
|
const { unsubscribe } = lib.subscribe({
|
|
onChange: (c) => changes.push(c),
|
|
});
|
|
unsubscribe();
|
|
try {
|
|
client.collectionsQueue.push({
|
|
collections: [collection(3, 300)],
|
|
deleted: [],
|
|
cursor: 300,
|
|
});
|
|
client.filesFor(3, {
|
|
files: [file(3003, 3, 1_700_000_000_000_000)],
|
|
deleted: [],
|
|
cursor: 1_700_000_000_000_000,
|
|
});
|
|
// The change lands in the store, but the cancelled subscriber sees
|
|
// nothing.
|
|
await vi.waitFor(
|
|
() => expect(lib.snapshot().albums.length).toBe(2),
|
|
{ timeout: 2000, interval: 5 },
|
|
);
|
|
expect(changes).toEqual([]);
|
|
} finally {
|
|
lib.close();
|
|
}
|
|
});
|
|
});
|