check / check (push) Successful in 26s
Library.close() now returns a promise that resolves once the work it started has finished: an in-flight refresh with its cache write, the ML data fetch, and running precache sweeps. The interval test could see a refresh's new state, close, and remove the directory while the write was still running. Every library test and CLI command now awaits close(), and tests hold each of the three writes open to prove close() waits for it. The precache test waited for its stub source to be called, but the cache records a file only after checking it on disk, so status() could lag. It now waits for both fills to report "done". Model: opus-5-5
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 {
|
|
await 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();
|
|
await 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();
|
|
await 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 {
|
|
await lib.close();
|
|
}
|
|
});
|
|
});
|