90 lines
3.7 KiB
JavaScript
90 lines
3.7 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import test from 'node:test';
|
|
import { AssetStore } from '../src/asset-store.mjs';
|
|
|
|
const DAY = 86_400_000;
|
|
|
|
async function fixture(t) {
|
|
const root = await mkdtemp(join(tmpdir(), 'image-assets-'));
|
|
let now = Date.UTC(2028, 0, 1);
|
|
const config = {
|
|
uploadRoot: join(root, 'uploads'),
|
|
assetDbPath: join(root, 'metadata', 'assets.sqlite3'),
|
|
contentRetentionMs: 730 * DAY,
|
|
contentQuarantineMs: 30 * DAY,
|
|
assetMaintenanceIntervalMs: DAY,
|
|
assetTouchFlushIntervalMs: 60_000,
|
|
};
|
|
const store = new AssetStore(config, { now: () => now });
|
|
await store.initialize();
|
|
t.after(async () => {
|
|
store.close();
|
|
await rm(root, { recursive: true, force: true });
|
|
});
|
|
return { root, config, store, setNow(value) { now = value; }, getNow() { return now; } };
|
|
}
|
|
|
|
async function addFile(f, category, client, filename, body, createdAt) {
|
|
const directory = join(f.config.uploadRoot, category, client);
|
|
await mkdir(directory, { recursive: true });
|
|
await writeFile(join(directory, filename), body);
|
|
f.store.register({ category, client, filename, body, createdAt });
|
|
}
|
|
|
|
test('user icons remain permanent while old content becomes a deletion candidate', async (t) => {
|
|
const f = await fixture(t);
|
|
const old = f.getNow() - 731 * DAY;
|
|
const icon = `${'a'.repeat(32)}.png`;
|
|
const content = `${'b'.repeat(32)}.webp`;
|
|
await addFile(f, 'user-icons', 'core2026', icon, Buffer.from('icon'), old);
|
|
await addFile(f, 'content', 'core2026', content, Buffer.from('content'), old);
|
|
|
|
assert.equal(f.store.markCandidates(), 1);
|
|
assert.equal(f.store.get(`icons/users/core2026/${icon}`).state, 'active');
|
|
assert.equal(f.store.get(`uploads/core2026/${content}`).state, 'candidate');
|
|
assert.equal((await f.store.preview(`uploads/core2026/${content}`)).body.toString(), 'content');
|
|
assert.equal(f.store.get(`uploads/core2026/${content}`).state, 'candidate');
|
|
assert.deepEqual(f.store.summary().groups['user-icons:active'], { count: 1, bytes: 4 });
|
|
});
|
|
|
|
test('content access is batched, renews retention, and cancels candidate state', async (t) => {
|
|
const f = await fixture(t);
|
|
const path = `uploads/core/${'c'.repeat(32)}.png`;
|
|
await addFile(f, 'content', 'core', `${'c'.repeat(32)}.png`, Buffer.from('content'), f.getNow() - 731 * DAY);
|
|
f.store.markCandidates();
|
|
assert.equal(f.store.get(path).state, 'candidate');
|
|
|
|
assert.equal(f.store.touch(path), true);
|
|
assert.equal(f.store.touch(path), true);
|
|
assert.equal(f.store.flushTouches(), 1);
|
|
assert.equal(f.store.get(path).state, 'active');
|
|
assert.equal(f.store.get(path).lastSeenAt, new Date(f.getNow()).toISOString());
|
|
assert.equal(f.store.touch('icons/users/core/not-content.png'), false);
|
|
});
|
|
|
|
test('candidate quarantine is recoverable and permanent deletion requires the grace period', async (t) => {
|
|
const f = await fixture(t);
|
|
const filename = `${'d'.repeat(32)}.gif`;
|
|
const path = `uploads/core2026/${filename}`;
|
|
const body = Buffer.from('content');
|
|
await addFile(f, 'content', 'core2026', filename, body, f.getNow() - 731 * DAY);
|
|
f.store.markCandidates();
|
|
|
|
await f.store.quarantine(path);
|
|
await assert.rejects(f.store.delete(path), /grace period/);
|
|
await f.store.restore(path);
|
|
assert.equal(await readFile(join(f.config.uploadRoot, 'content', 'core2026', filename), 'utf8'), 'content');
|
|
assert.equal(f.store.get(path).state, 'active');
|
|
|
|
f.setNow(f.getNow() + 731 * DAY);
|
|
f.store.markCandidates();
|
|
await f.store.quarantine(path);
|
|
f.setNow(f.getNow() + 31 * DAY);
|
|
const deleted = await f.store.delete(path);
|
|
assert.equal(deleted.state, 'deleted');
|
|
assert.equal(f.store.summary().totalCount, 0);
|
|
});
|