check / check (push) Failing after 55s
quak keeps no handling of old data. The content cache no longer checks whether a live photo's file with no JSON file naming it is a ZIP an earlier version cached, and no longer removes it; such a file is left alone, as any other one already was. The README sentences and the tests about that ZIP are gone, and one download test no longer names it in its title. How a live photo is stored and served today is unchanged. Model: opus-5-5
695 lines
26 KiB
TypeScript
695 lines
26 KiB
TypeScript
/**
|
|
* Tests for the on-disk content and thumbnail cache (issue #46).
|
|
*
|
|
* The cache keys stored bytes by `fileID` under `cacheDirectory`:
|
|
* `originals/<fileID>.<ext>` and `thumbnails/<fileID>.<ext>`. Its contract:
|
|
*
|
|
* 1. **Fetch once, then serve from disk.** The first `original`/`thumbnail`
|
|
* fetches through the request pool and stores the bytes; the next finds the
|
|
* file present and returns its path with a single `skipped` event and no
|
|
* network. A file already stored at its save path under the
|
|
* `downloadDirectory` counts as present too.
|
|
* 2. **Present-means-complete.** Content appears only by the streaming atomic
|
|
* writer's rename, so a file that exists is whole. The directory listing
|
|
* taken at `open()` is the record of what is cached, and the orphan temp
|
|
* files a crashed write may have left are reaped there.
|
|
* 3. **`thumbnails.ensure` drives the thumbnail pool with priority, dedup, and
|
|
* abort.** A `fileID` asked for twice downloads once; a visible request is
|
|
* served ahead of a background one; and an `AbortSignal` drops work still
|
|
* queued while letting an in-flight fetch finish.
|
|
*
|
|
* The `ContentSource` is a stand-in: it writes deterministic bytes to the
|
|
* destination and returns the count, so the cache logic is exercised with no
|
|
* crypto and no network. Ordering tests gate the stand-in on explicit deferreds
|
|
* and assert the persisted result, never a bare call or a timer.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
mkdtempSync,
|
|
rmSync,
|
|
existsSync,
|
|
writeFileSync,
|
|
mkdirSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
statSync,
|
|
} from "node:fs";
|
|
import { spawnSync } from "node:child_process";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import {
|
|
ContentCache,
|
|
savePath,
|
|
type ContentSource,
|
|
type EnsureEvent,
|
|
} from "../../src/library/content.js";
|
|
import { RequestPools } from "../../src/library/pools.js";
|
|
import type { EnteFile } from "../../src/model/types.js";
|
|
import {
|
|
asLivePhoto,
|
|
cdnSource,
|
|
IMAGE,
|
|
livePhotoZip,
|
|
VIDEO,
|
|
} from "../live-photo.js";
|
|
|
|
const file = (id: number, title = `file-${id}.jpg`): EnteFile => ({
|
|
id,
|
|
collectionID: 1,
|
|
ownerID: 1,
|
|
key: new Uint8Array([id & 0xff]),
|
|
metadata: {
|
|
title,
|
|
fileType: "image",
|
|
creationTime: 0,
|
|
modificationTime: 0,
|
|
},
|
|
file: { decryptionHeader: "aGVhZGVy" },
|
|
thumbnail: { decryptionHeader: "dGh1bWI=" },
|
|
updationTime: 0,
|
|
});
|
|
|
|
// A deferred with externally callable resolve, used to gate the stand-in source
|
|
// so ordering is controlled by the test rather than by timing.
|
|
const deferred = (): { promise: Promise<void>; resolve: () => void } => {
|
|
let resolve!: () => void;
|
|
const promise = new Promise<void>((r) => {
|
|
resolve = r;
|
|
});
|
|
return { promise, resolve };
|
|
};
|
|
|
|
// A ContentSource that writes `${kind}:${fileID}` bytes to the destination and
|
|
// records every call. `gate` optionally blocks a call until released, and
|
|
// `completed` records the order in which fetches finished — the observable used
|
|
// by the priority and abort tests instead of a timer.
|
|
class StubSource implements ContentSource {
|
|
originalCalls: number[] = [];
|
|
thumbnailCalls: number[] = [];
|
|
completed: number[] = [];
|
|
emptyFor = new Set<number>();
|
|
gates = new Map<number, Promise<void>>();
|
|
|
|
private async run(
|
|
kind: "original" | "thumbnail",
|
|
file: EnteFile,
|
|
destination: string,
|
|
): Promise<{ bytesWritten: number }> {
|
|
const gate = this.gates.get(file.id);
|
|
if (gate) await gate;
|
|
const bytes = this.emptyFor.has(file.id)
|
|
? new Uint8Array(0)
|
|
: new TextEncoder().encode(`${kind}:${file.id}`);
|
|
writeFileSync(destination, bytes);
|
|
this.completed.push(file.id);
|
|
return { bytesWritten: bytes.length };
|
|
}
|
|
|
|
async original(args: {
|
|
file: EnteFile;
|
|
destination: string;
|
|
}): Promise<{ bytesWritten: number }> {
|
|
this.originalCalls.push(args.file.id);
|
|
return this.run("original", args.file, args.destination);
|
|
}
|
|
|
|
async thumbnail(args: {
|
|
file: EnteFile;
|
|
destination: string;
|
|
}): Promise<{ bytesWritten: number }> {
|
|
this.thumbnailCalls.push(args.file.id);
|
|
return this.run("thumbnail", args.file, args.destination);
|
|
}
|
|
}
|
|
|
|
let root: string;
|
|
let cacheDir: string;
|
|
|
|
beforeEach(() => {
|
|
root = mkdtempSync(join(tmpdir(), "quak-content-"));
|
|
cacheDir = join(root, "cache");
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (root && existsSync(root))
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
const buildCache = (
|
|
args: {
|
|
source?: ContentSource;
|
|
files?: EnteFile[];
|
|
pools?: RequestPools;
|
|
downloadDirectory?: string;
|
|
} = {},
|
|
): { cache: ContentCache; source: StubSource } => {
|
|
const source = (args.source as StubSource) ?? new StubSource();
|
|
const byID = new Map<number, EnteFile>();
|
|
for (const f of args.files ?? [file(1), file(2), file(3)])
|
|
byID.set(f.id, f);
|
|
const cache = new ContentCache({
|
|
pools: args.pools ?? new RequestPools(),
|
|
source,
|
|
cacheDirectory: cacheDir,
|
|
downloadDirectory: args.downloadDirectory ?? join(root, "photos"),
|
|
getFile: (id) => byID.get(id),
|
|
});
|
|
return { cache, source };
|
|
};
|
|
|
|
// Microseconds, as Ente stores times, for noon local time on a day, so the
|
|
// machine's time zone cannot move the photo to another day.
|
|
const noon = (year: number, month: number, day: number): number =>
|
|
new Date(year, month - 1, day, 12).getTime() * 1000;
|
|
|
|
describe("savePath", () => {
|
|
it("files an original by year, month and day under the root", () => {
|
|
const f = file(12345, "IMG_0001.HEIC");
|
|
f.metadata.creationTime = noon(2026, 3, 1);
|
|
|
|
expect(savePath("/photos", f)).toBe(
|
|
"/photos/2026/2026-03/2026-03-01/2026-03-01.12345.HEIC",
|
|
);
|
|
});
|
|
|
|
it("dates an original by the date the user set, when there is one", () => {
|
|
const f = file(7, "a.jpg");
|
|
f.metadata.creationTime = noon(2026, 3, 1);
|
|
f.pubMagicMetadata = { editedTime: noon(1999, 12, 31) };
|
|
|
|
expect(savePath("/photos", f)).toBe(
|
|
"/photos/1999/1999-12/1999-12-31/1999-12-31.7.jpg",
|
|
);
|
|
});
|
|
|
|
it("takes the extension from the title it was uploaded with, not a new name", () => {
|
|
const f = file(8, "upload");
|
|
f.metadata.creationTime = noon(2026, 3, 1);
|
|
f.pubMagicMetadata = { editedName: "renamed.png" };
|
|
|
|
expect(savePath("/photos", f)).toBe(
|
|
"/photos/2026/2026-03/2026-03-01/2026-03-01.8.bin",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("ContentCache.open", () => {
|
|
it("creates the cache directories with 0700 permissions", async () => {
|
|
const { cache } = buildCache();
|
|
await cache.open();
|
|
|
|
const originals = join(cacheDir, "originals");
|
|
const thumbnails = join(cacheDir, "thumbnails");
|
|
expect(existsSync(originals)).toBe(true);
|
|
expect(existsSync(thumbnails)).toBe(true);
|
|
expect(statSync(originals).mode & 0o777).toBe(0o700);
|
|
expect(statSync(thumbnails).mode & 0o777).toBe(0o700);
|
|
});
|
|
|
|
it("removes temp files of an exited process, keeping those of a running one and complete content", async () => {
|
|
const originals = join(cacheDir, "originals");
|
|
const thumbnails = join(cacheDir, "thumbnails");
|
|
mkdirSync(originals, { recursive: true });
|
|
mkdirSync(thumbnails, { recursive: true });
|
|
// A child that has already exited: its process ID is not running.
|
|
const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid;
|
|
const orphan = join(originals, `.quak-${exitedPID}-abc123.tmp`);
|
|
const orphanThumb = join(thumbnails, `.quak-${exitedPID}-abc456.tmp`);
|
|
// This test's own process stands in for another process still
|
|
// downloading into the same cache.
|
|
const inProgress = join(originals, `.quak-${process.pid}-def123.tmp`);
|
|
const inProgressThumb = join(
|
|
thumbnails,
|
|
`.quak-${process.pid}-def456.tmp`,
|
|
);
|
|
const complete = join(originals, "1.jpg");
|
|
const thumb = join(thumbnails, "2.jpg");
|
|
writeFileSync(orphan, "half-written");
|
|
writeFileSync(orphanThumb, "half-written");
|
|
writeFileSync(inProgress, "half-written");
|
|
writeFileSync(inProgressThumb, "half-written");
|
|
writeFileSync(complete, "whole");
|
|
writeFileSync(thumb, "whole-thumb");
|
|
|
|
const { cache } = buildCache();
|
|
await cache.open();
|
|
|
|
expect(existsSync(orphan)).toBe(false);
|
|
expect(existsSync(orphanThumb)).toBe(false);
|
|
expect(existsSync(inProgress)).toBe(true);
|
|
expect(existsSync(inProgressThumb)).toBe(true);
|
|
expect(existsSync(complete)).toBe(true);
|
|
expect(existsSync(thumb)).toBe(true);
|
|
expect(cache.pathsFor(1)).toEqual({ originalPath: complete });
|
|
});
|
|
|
|
it("records already-cached files so their paths appear in pathsFor", async () => {
|
|
const originals = join(cacheDir, "originals");
|
|
const thumbnails = join(cacheDir, "thumbnails");
|
|
mkdirSync(originals, { recursive: true });
|
|
mkdirSync(thumbnails, { recursive: true });
|
|
writeFileSync(join(originals, "1.jpg"), "orig");
|
|
writeFileSync(join(thumbnails, "1.jpg"), "thumb");
|
|
|
|
const { cache } = buildCache();
|
|
await cache.open();
|
|
|
|
expect(cache.pathsFor(1)).toEqual({
|
|
originalPath: join(originals, "1.jpg"),
|
|
thumbnailPath: join(thumbnails, "1.jpg"),
|
|
});
|
|
expect(cache.pathsFor(2)).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe("ContentCache.original / thumbnail", () => {
|
|
it("fetches once, then serves the cached file with a single skipped event", async () => {
|
|
const { cache, source } = buildCache();
|
|
await cache.open();
|
|
|
|
const events: string[] = [];
|
|
const first = await cache.original(1, {
|
|
onProgress: (e) => events.push(e.status),
|
|
});
|
|
expect(source.originalCalls).toEqual([1]);
|
|
expect(first.path).toBe(join(cacheDir, "originals", "1.jpg"));
|
|
expect(first.bytes).toBe("original:1".length);
|
|
expect(existsSync(first.path)).toBe(true);
|
|
expect(statSync(first.path).mode & 0o777).toBe(0o600);
|
|
expect(cache.pathsFor(1).originalPath).toBe(first.path);
|
|
|
|
const skips: string[] = [];
|
|
const second = await cache.original(1, {
|
|
onProgress: (e) => skips.push(e.status),
|
|
});
|
|
// No second download, and exactly one skipped event.
|
|
expect(source.originalCalls).toEqual([1]);
|
|
expect(second.path).toBe(first.path);
|
|
expect(skips).toEqual(["skipped"]);
|
|
});
|
|
|
|
it("takes only a letters-and-digits extension from the title", async () => {
|
|
// The title comes from the server; an extension such as `.\..\x`
|
|
// must not reach the cache file name, so it becomes `.bin`.
|
|
const { cache } = buildCache({
|
|
files: [file(1, "a.jpg"), file(2, "b.\\..\\x"), file(3, "")],
|
|
});
|
|
await cache.open();
|
|
|
|
expect((await cache.original(1)).path).toBe(
|
|
join(cacheDir, "originals", "1.jpg"),
|
|
);
|
|
expect((await cache.original(2)).path).toBe(
|
|
join(cacheDir, "originals", "2.bin"),
|
|
);
|
|
expect((await cache.original(3)).path).toBe(
|
|
join(cacheDir, "originals", "3.bin"),
|
|
);
|
|
});
|
|
|
|
it("serves a file already present in the download directory without fetching", async () => {
|
|
const downloadDirectory = join(root, "backup");
|
|
const day = join(downloadDirectory, "2026", "2026-03", "2026-03-01");
|
|
mkdirSync(day, { recursive: true });
|
|
const backupPath = join(day, "2026-03-01.1.jpg");
|
|
writeFileSync(backupPath, "from-backup");
|
|
const f = file(1);
|
|
f.metadata.creationTime = noon(2026, 3, 1);
|
|
|
|
const { cache, source } = buildCache({
|
|
downloadDirectory,
|
|
files: [f],
|
|
});
|
|
await cache.open();
|
|
|
|
const events: EnsureEvent["status"][] = [];
|
|
const result = await cache.original(1, {
|
|
onProgress: (e) => events.push(e.status),
|
|
});
|
|
|
|
expect(source.originalCalls).toEqual([]);
|
|
expect(result.path).toBe(backupPath);
|
|
expect(result.bytes).toBe("from-backup".length);
|
|
expect(events).toEqual(["skipped"]);
|
|
});
|
|
|
|
it("fetches and caches a thumbnail", async () => {
|
|
const { cache, source } = buildCache();
|
|
await cache.open();
|
|
|
|
const result = await cache.thumbnail(2);
|
|
expect(source.thumbnailCalls).toEqual([2]);
|
|
expect(result.path).toBe(join(cacheDir, "thumbnails", "2.jpg"));
|
|
expect(existsSync(result.path)).toBe(true);
|
|
expect(cache.pathsFor(2).thumbnailPath).toBe(result.path);
|
|
});
|
|
|
|
it("shares one download between concurrent callers for the same file", async () => {
|
|
const { cache, source } = buildCache();
|
|
await cache.open();
|
|
const gate = deferred();
|
|
source.gates.set(1, gate.promise);
|
|
|
|
const a = cache.original(1);
|
|
const b = cache.original(1);
|
|
gate.resolve();
|
|
const [ra, rb] = await Promise.all([a, b]);
|
|
|
|
expect(source.originalCalls).toEqual([1]);
|
|
expect(ra.path).toBe(rb.path);
|
|
});
|
|
|
|
it("does not record a path when the fetched file is empty", async () => {
|
|
const { cache, source } = buildCache();
|
|
source.emptyFor.add(1);
|
|
await cache.open();
|
|
|
|
await expect(cache.original(1)).rejects.toThrow(/empty/i);
|
|
expect(cache.pathsFor(1).originalPath).toBeUndefined();
|
|
});
|
|
|
|
it("rejects an unknown file", async () => {
|
|
const { cache } = buildCache({ files: [] });
|
|
await cache.open();
|
|
await expect(cache.original(999)).rejects.toThrow(/unknown file/i);
|
|
});
|
|
});
|
|
|
|
describe("ContentCache.ensureThumbnails", () => {
|
|
it("downloads once for a file listed twice and reports every id", async () => {
|
|
const { cache, source } = buildCache();
|
|
await cache.open();
|
|
|
|
const results = await cache.ensureThumbnails({
|
|
fileIDs: [1, 1, 2],
|
|
priority: "visible",
|
|
});
|
|
|
|
expect(source.thumbnailCalls.sort()).toEqual([1, 2]);
|
|
expect(results).toEqual([
|
|
{ fileID: 1, path: join(cacheDir, "thumbnails", "1.jpg") },
|
|
{ fileID: 2, path: join(cacheDir, "thumbnails", "2.jpg") },
|
|
]);
|
|
});
|
|
|
|
it("skips present files and reports a skipped event", async () => {
|
|
const thumbnails = join(cacheDir, "thumbnails");
|
|
mkdirSync(thumbnails, { recursive: true });
|
|
writeFileSync(join(thumbnails, "1.jpg"), "present");
|
|
|
|
const { cache, source } = buildCache();
|
|
await cache.open();
|
|
|
|
const events: EnsureEvent[] = [];
|
|
const results = await cache.ensureThumbnails({
|
|
fileIDs: [1, 2],
|
|
priority: "ahead",
|
|
onProgress: (e) => events.push(e),
|
|
});
|
|
|
|
expect(source.thumbnailCalls).toEqual([2]);
|
|
expect(results).toEqual([
|
|
{ fileID: 1, path: join(thumbnails, "1.jpg") },
|
|
{ fileID: 2, path: join(thumbnails, "2.jpg") },
|
|
]);
|
|
expect(events).toContainEqual({
|
|
fileID: 1,
|
|
status: "skipped",
|
|
path: join(thumbnails, "1.jpg"),
|
|
});
|
|
});
|
|
|
|
it("serves a visible request ahead of an already-queued background one", async () => {
|
|
// One thumbnail slot, so exactly one fetch runs at a time and the rest
|
|
// wait in the pool. A background fetch takes the slot; a background and
|
|
// a visible fetch queue behind it. When the slot frees, the pool must
|
|
// pick the visible (on-demand) request ahead of the background one that
|
|
// was submitted first. The completion order is the observable.
|
|
const pools = new RequestPools({ thumbnailConcurrency: 1 });
|
|
const { cache, source } = buildCache({ pools });
|
|
await cache.open();
|
|
|
|
const gateA = deferred();
|
|
const gateB = deferred();
|
|
const gateC = deferred();
|
|
source.gates.set(1, gateA.promise);
|
|
source.gates.set(2, gateB.promise);
|
|
source.gates.set(3, gateC.promise);
|
|
|
|
const bgFirst = cache.ensureThumbnails({
|
|
fileIDs: [1],
|
|
priority: "background",
|
|
});
|
|
// Let fetch 1 take the only slot before the others queue.
|
|
await Promise.resolve();
|
|
const bgSecond = cache.ensureThumbnails({
|
|
fileIDs: [2],
|
|
priority: "background",
|
|
});
|
|
const visible = cache.ensureThumbnails({
|
|
fileIDs: [3],
|
|
priority: "visible",
|
|
});
|
|
|
|
gateA.resolve();
|
|
gateC.resolve();
|
|
gateB.resolve();
|
|
await Promise.all([bgFirst, bgSecond, visible]);
|
|
|
|
// 1 ran first (it held the slot). Of the two that were queued, the
|
|
// visible id 3 was served before the background id 2.
|
|
expect(source.completed).toEqual([1, 3, 2]);
|
|
});
|
|
|
|
it("drops queued work on abort but keeps an in-flight fetch", async () => {
|
|
const pools = new RequestPools({ thumbnailConcurrency: 1 });
|
|
const { cache, source } = buildCache({ pools });
|
|
await cache.open();
|
|
|
|
const gate = deferred();
|
|
source.gates.set(1, gate.promise);
|
|
const controller = new AbortController();
|
|
|
|
const pending = cache.ensureThumbnails({
|
|
fileIDs: [1, 2],
|
|
priority: "ahead",
|
|
signal: controller.signal,
|
|
});
|
|
// Fetch 1 is in flight (holds the slot); 2 is queued.
|
|
await Promise.resolve();
|
|
controller.abort();
|
|
gate.resolve();
|
|
|
|
const results = await pending;
|
|
|
|
// The in-flight fetch finished and is kept; the queued one was dropped
|
|
// before it ran.
|
|
expect(source.thumbnailCalls).toEqual([1]);
|
|
expect(results).toEqual([
|
|
{ fileID: 1, path: join(cacheDir, "thumbnails", "1.jpg") },
|
|
{ fileID: 2, error: "aborted" },
|
|
]);
|
|
});
|
|
|
|
it("captures a per-file failure without failing the batch", async () => {
|
|
const { cache } = buildCache({ files: [file(1)] });
|
|
await cache.open();
|
|
|
|
const results = await cache.ensureThumbnails({
|
|
fileIDs: [1, 2],
|
|
priority: "background",
|
|
});
|
|
|
|
expect(results[0]).toEqual({
|
|
fileID: 1,
|
|
path: join(cacheDir, "thumbnails", "1.jpg"),
|
|
});
|
|
expect(results[1]?.fileID).toBe(2);
|
|
expect(results[1]?.error).toMatch(/unknown file/i);
|
|
});
|
|
});
|
|
|
|
// A live photo's original is two files, its image and its video, which a
|
|
// photo viewer can open, and a JSON file naming them: the two are named with
|
|
// the extensions from inside the ZIP, so the names alone do not say which is
|
|
// which. These tests download a live photo ZIP through the real download
|
|
// layer (test/live-photo.ts).
|
|
describe("ContentCache live photos", () => {
|
|
const originals = (): string => join(cacheDir, "originals");
|
|
|
|
// A cache over the stand-in server, which holds `bodies` by file ID.
|
|
const cacheOf = (
|
|
files: EnteFile[],
|
|
bodies: Map<number, Uint8Array>,
|
|
): ContentCache => buildCache({ files, source: cdnSource(bodies) }).cache;
|
|
|
|
it("stores a live photo as its image and its video and a JSON file naming them", async () => {
|
|
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
|
|
const cache = cacheOf([live], new Map([[5, body]]));
|
|
await cache.open();
|
|
|
|
const result = await cache.original(5);
|
|
|
|
expect(result).toEqual({
|
|
path: join(originals(), "5.heic"),
|
|
videoPath: join(originals(), "5.mov"),
|
|
bytes: IMAGE.length,
|
|
});
|
|
expect(readFileSync(result.path)).toEqual(Buffer.from(IMAGE));
|
|
expect(readFileSync(result.videoPath!)).toEqual(Buffer.from(VIDEO));
|
|
expect(statSync(result.videoPath!).mode & 0o777).toBe(0o600);
|
|
expect(
|
|
JSON.parse(
|
|
readFileSync(join(originals(), "5.livephoto.json"), "utf-8"),
|
|
),
|
|
).toEqual({ image: "5.heic", video: "5.mov" });
|
|
expect(cache.pathsFor(5)).toEqual({ originalPath: result.path });
|
|
});
|
|
|
|
it("serves a stored live photo from disk after the cache is opened again", async () => {
|
|
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
|
|
const first = cacheOf([live], new Map([[5, body]]));
|
|
await first.open();
|
|
const stored = await first.original(5);
|
|
|
|
// This server has nothing, so a fetch would fail.
|
|
const second = cacheOf([live], new Map());
|
|
await second.open();
|
|
const events: string[] = [];
|
|
const served = await second.original(5, {
|
|
onProgress: (e) => events.push(e.status),
|
|
});
|
|
|
|
expect(served).toEqual(stored);
|
|
expect(events).toEqual(["skipped"]);
|
|
});
|
|
|
|
it("leaves the image and video another process has just stored when it opens before their JSON file is written", async () => {
|
|
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
|
|
const server = cdnSource(new Map([[5, body]]));
|
|
let second: ContentCache | undefined;
|
|
// Once the image and video are in place, and before the first cache
|
|
// writes the JSON file naming them, a second cache opens.
|
|
const source: ContentSource = {
|
|
original: async (args) => {
|
|
const stored = await server.original(args);
|
|
second = cacheOf([live], new Map());
|
|
await second.open((fileID) => fileID === 5);
|
|
return stored;
|
|
},
|
|
thumbnail: (args) => server.thumbnail(args),
|
|
};
|
|
const first = buildCache({ files: [live], source }).cache;
|
|
await first.open((fileID) => fileID === 5);
|
|
|
|
const result = await first.original(5);
|
|
|
|
expect(readdirSync(originals()).sort()).toEqual([
|
|
"5.heic",
|
|
"5.livephoto.json",
|
|
"5.mov",
|
|
]);
|
|
expect(readFileSync(result.path)).toEqual(Buffer.from(IMAGE));
|
|
expect(readFileSync(result.videoPath!)).toEqual(Buffer.from(VIDEO));
|
|
expect(second!.pathsFor(5)).toEqual({});
|
|
});
|
|
|
|
it.each(["missing", "empty"])(
|
|
"fetches a live photo again when the video its JSON file names is %s",
|
|
async (state) => {
|
|
const { file: live, body } = await asLivePhoto(
|
|
file(5, "IMG_5.HEIC"),
|
|
);
|
|
const cache = cacheOf([live], new Map([[5, body]]));
|
|
await cache.open();
|
|
const stored = await cache.original(5);
|
|
if (state === "missing") rmSync(stored.videoPath!);
|
|
else writeFileSync(stored.videoPath!, "");
|
|
|
|
const events: string[] = [];
|
|
const fetched = await cache.original(5, {
|
|
onProgress: (e) => events.push(e.status),
|
|
});
|
|
|
|
expect(events.at(-1)).toBe("done");
|
|
expect(fetched).toEqual(stored);
|
|
expect(readFileSync(fetched.videoPath!)).toEqual(
|
|
Buffer.from(VIDEO),
|
|
);
|
|
},
|
|
);
|
|
|
|
it("does not take a file outside originals/ that a live photo's JSON file names", async () => {
|
|
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
|
|
mkdirSync(originals(), { recursive: true });
|
|
writeFileSync(join(cacheDir, "5.heic"), "not this photo");
|
|
writeFileSync(join(originals(), "5.mov"), VIDEO);
|
|
writeFileSync(
|
|
join(originals(), "5.livephoto.json"),
|
|
JSON.stringify({ image: "../5.heic", video: "5.mov" }),
|
|
);
|
|
const cache = cacheOf([live], new Map([[5, body]]));
|
|
await cache.open((fileID) => fileID === 5);
|
|
|
|
const result = await cache.original(5);
|
|
|
|
expect(result.path).toBe(join(originals(), "5.heic"));
|
|
expect(readFileSync(result.path)).toEqual(Buffer.from(IMAGE));
|
|
expect(readFileSync(join(cacheDir, "5.heic"), "utf-8")).toBe(
|
|
"not this photo",
|
|
);
|
|
});
|
|
|
|
it("evicts a live photo's image, video and JSON file together", async () => {
|
|
const a = await asLivePhoto(file(5, "a.HEIC"));
|
|
const b = await asLivePhoto(file(6, "b.HEIC"));
|
|
const size = IMAGE.length + VIDEO.length;
|
|
const cache = new ContentCache({
|
|
pools: new RequestPools(),
|
|
source: cdnSource(
|
|
new Map([
|
|
[5, a.body],
|
|
[6, b.body],
|
|
]),
|
|
),
|
|
cacheDirectory: cacheDir,
|
|
downloadDirectory: join(root, "photos"),
|
|
getFile: (id) => [a.file, b.file].find((f) => f.id === id),
|
|
// Room for one live photo, on a disk with plenty free.
|
|
cacheOriginalsMaxBytes: size,
|
|
freeBelowBytes: 0,
|
|
statfs: async () => ({ bsize: 1, bavail: 1e12 }),
|
|
});
|
|
await cache.open();
|
|
|
|
await cache.original(5);
|
|
await cache.original(6);
|
|
|
|
expect(readdirSync(originals()).sort()).toEqual([
|
|
"6.heic",
|
|
"6.livephoto.json",
|
|
"6.mov",
|
|
]);
|
|
expect(cache.originalsStatus().usedBytes).toBe(size);
|
|
});
|
|
|
|
it("stores nothing when a live photo does not match its recorded hash", async () => {
|
|
const { file: live, body } = await asLivePhoto(
|
|
file(5, "IMG_5.HEIC"),
|
|
livePhotoZip(),
|
|
"not:the recorded hash",
|
|
);
|
|
const cache = cacheOf([live], new Map([[5, body]]));
|
|
await cache.open();
|
|
|
|
await expect(cache.original(5)).rejects.toThrow(
|
|
/file 5: content hash .* does not match/,
|
|
);
|
|
|
|
expect(readdirSync(originals())).toEqual([]);
|
|
expect(cache.pathsFor(5)).toEqual({});
|
|
});
|
|
});
|