154 lines
5.6 KiB
JavaScript
154 lines
5.6 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { mkdtemp, readFile, rm, stat, utimes, access } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { loadModule } from "./test-helpers.mjs";
|
|
|
|
const { DropStore, DropStoreError, pathsFor } = await loadModule(
|
|
"server/utils/drop-store.ts",
|
|
);
|
|
const { CHUNK_SIZE } = await loadModule("shared/types/drop.ts");
|
|
const noncePrefix = "AQIDBAUGBwg";
|
|
let roots = [];
|
|
async function fresh(clock = { now: () => Date.now() }) {
|
|
const root = await mkdtemp(join(tmpdir(), "quickdrop-test-"));
|
|
roots.push(root);
|
|
return { store: new DropStore({ root, clock }), root };
|
|
}
|
|
async function reserve(store, maxReads = 1, expiresIn = "24h") {
|
|
return store.reserve({
|
|
version: 2,
|
|
chunkSize: CHUNK_SIZE,
|
|
chunkCount: 1,
|
|
noncePrefix,
|
|
expiresIn,
|
|
maxReads,
|
|
});
|
|
}
|
|
async function ready(store, maxReads = 1, expiresIn = "24h") {
|
|
const result = await reserve(store, maxReads, expiresIn);
|
|
await store.putChunk(result.id, 0, new Uint8Array(16));
|
|
await store.finalize(result.id, new Uint8Array(17));
|
|
return result;
|
|
}
|
|
async function exists(path) {
|
|
try {
|
|
await access(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
test.after(async () => {
|
|
await Promise.all(
|
|
roots.map((root) => rm(root, { recursive: true, force: true })),
|
|
);
|
|
});
|
|
|
|
test("finalize starts expiry and rejects missing chunks", async () => {
|
|
let now = 1000;
|
|
const { store } = await fresh({ now: () => now });
|
|
const result = await reserve(store, 1, "1h");
|
|
await assert.rejects(
|
|
store.finalize(result.id, new Uint8Array(17)),
|
|
(error) => error instanceof DropStoreError && error.code === "incomplete",
|
|
);
|
|
await store.putChunk(result.id, 0, new Uint8Array(16));
|
|
const metadata = await store.finalize(result.id, new Uint8Array(17));
|
|
assert.equal(metadata.expiresAt, now + 3600000);
|
|
now += 3599999;
|
|
assert.equal(
|
|
(await store.descriptor(result.id, result.access)).id,
|
|
result.id,
|
|
);
|
|
now++;
|
|
await assert.rejects(
|
|
store.descriptor(result.id, result.access),
|
|
(error) => error instanceof DropStoreError && error.code === "expired",
|
|
);
|
|
});
|
|
|
|
test("chunk retries are idempotent and conflicting contents are rejected", async () => {
|
|
const { store } = await fresh();
|
|
const result = await reserve(store);
|
|
const body = new Uint8Array(16).fill(7);
|
|
assert.equal(await store.putChunk(result.id, 0, body), "created");
|
|
assert.equal(await store.putChunk(result.id, 0, body), "same");
|
|
await assert.rejects(
|
|
store.putChunk(result.id, 0, new Uint8Array(16).fill(8)),
|
|
(error) => error instanceof DropStoreError && error.code === "conflict",
|
|
);
|
|
});
|
|
|
|
test("maxReads claims are serialized, valid lease reuse does not increment, and completion cleans up", async () => {
|
|
const { store, root } = await fresh();
|
|
const result = await ready(store, 1);
|
|
const claims = await Promise.allSettled([
|
|
store.claim(result.id, result.access),
|
|
store.claim(result.id, result.access),
|
|
]);
|
|
assert.equal(
|
|
claims.filter((value) => value.status === "fulfilled").length,
|
|
1,
|
|
);
|
|
assert.equal(claims.filter((value) => value.status === "rejected").length, 1);
|
|
const first = claims.find((value) => value.status === "fulfilled").value;
|
|
const metadataBefore = await store.readMetadata(result.id);
|
|
const reused = await store.claim(result.id, result.access, first.token);
|
|
assert.equal(reused.reused, true);
|
|
assert.equal(
|
|
(await store.readMetadata(result.id)).readsClaimed,
|
|
metadataBefore.readsClaimed,
|
|
);
|
|
const chunk = await store.readChunk(result.id, 0, first.token);
|
|
assert.equal(chunk.size, 16);
|
|
await chunk.handle.close();
|
|
await store.complete(result.id, first.token);
|
|
assert.equal(await exists(pathsFor(result.id, root).directory), false);
|
|
});
|
|
|
|
test("unlimited drops allow multiple leases and metadata remains parseable", async () => {
|
|
const { store, root } = await fresh();
|
|
const result = await ready(store, null);
|
|
const one = await store.claim(result.id, result.access);
|
|
const two = await store.claim(result.id, result.access);
|
|
assert.notEqual(one.token, two.token);
|
|
assert.equal((await store.readMetadata(result.id)).readsClaimed, 2);
|
|
JSON.parse(await readFile(pathsFor(result.id, root).metadata, "utf8"));
|
|
await store.complete(result.id, one.token);
|
|
assert.equal(await exists(pathsFor(result.id, root).directory), true);
|
|
});
|
|
|
|
test("idle lease cleanup and stale temporary files are safe", async () => {
|
|
let now = 100000;
|
|
const { store, root } = await fresh({ now: () => now });
|
|
const result = await ready(store, 1);
|
|
await store.claim(result.id, result.access);
|
|
const paths = pathsFor(result.id, root);
|
|
const stale = join(paths.directory, ".chunk-stale");
|
|
await (await import("node:fs/promises")).writeFile(stale, "stale");
|
|
const old = new Date(now - 2 * 3600000);
|
|
await utimes(stale, old, old);
|
|
now += 16 * 60 * 1000;
|
|
await store.cleanup();
|
|
assert.equal(await exists(stale), false);
|
|
assert.equal(await exists(paths.directory), false);
|
|
});
|
|
|
|
test("metadata-less valid directories are removed only after grace period", async () => {
|
|
let now = 10_000_000;
|
|
const { store, root } = await fresh({ now: () => now });
|
|
const result = await reserve(store);
|
|
const paths = pathsFor(result.id, root);
|
|
await rm(paths.metadata);
|
|
const recent = new Date(now);
|
|
await utimes(paths.directory, recent, recent);
|
|
await store.cleanup();
|
|
assert.equal(await exists(paths.directory), true);
|
|
const old = new Date(now - 2 * 3600000);
|
|
await utimes(paths.directory, old, old);
|
|
await store.cleanup();
|
|
assert.equal(await exists(paths.directory), false);
|
|
});
|