← back to Butlr
test/upload-watcher.test.js
110 lines
#!/usr/bin/env node
// Unit test for lib/upload-watcher.scanOnce().
// Builds a tmp meta + watched folder, asserts new files get imported,
// existing files (by size+mtime) get skipped, unsupported ext gets skipped.
const fs = require('fs');
const os = require('os');
const path = require('path');
const assert = require('assert');
process.env.HFM_NO_WATCHER = '1';
function ok(name, fn) {
try { fn(); console.log(` ✓ ${name}`); return 1; }
catch (e) { console.error(` ✗ ${name}`); console.error(' ', e.message); return 0; }
}
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'butlr-upload-watch-'));
const WATCHED = path.join(ROOT, 'inbox');
const UPLOADS = path.join(ROOT, 'uploads');
const META = path.join(ROOT, 'uploads-meta.json');
fs.mkdirSync(WATCHED, { recursive: true });
fs.mkdirSync(UPLOADS, { recursive: true });
// Seed input files
fs.writeFileSync(path.join(WATCHED, 'a.png'), 'fakea');
fs.writeFileSync(path.join(WATCHED, 'b.jpg'), 'fakeb');
fs.writeFileSync(path.join(WATCHED, 'c.exe'), 'badext'); // should skip
fs.writeFileSync(path.join(WATCHED, '.hidden'), 'hide'); // should skip
// Patch __dirname-relative paths in the module by intercepting fs reads/writes
// of the upload paths. Simpler: stub the require to substitute paths.
// Cleanest approach: use the env-var pattern, but the module hardcodes paths.
// Workaround: set up a fake project root in tmp with data/uploads/ structure
// and chdir to it.
const FAKE_ROOT = path.join(ROOT, 'project');
fs.mkdirSync(path.join(FAKE_ROOT, 'data', 'uploads'), { recursive: true });
fs.mkdirSync(path.join(FAKE_ROOT, 'lib'), { recursive: true });
// Symlink the lib file
fs.copyFileSync(path.join(__dirname, '..', 'lib', 'upload-watcher.js'),
path.join(FAKE_ROOT, 'lib', 'upload-watcher.js'));
// Write meta pointing at WATCHED
fs.writeFileSync(path.join(FAKE_ROOT, 'data', 'uploads-meta.json'),
JSON.stringify({ files: [], folder: WATCHED }, null, 2));
const watcher = require(path.join(FAKE_ROOT, 'lib', 'upload-watcher.js'));
let pass = 0, total = 0;
// ── 1. Fresh folder — 2 files imported, 1 skipped (bad ext) ─────────
total++; pass += ok('imports allowed ext, skips badext + hidden', () => {
const r = watcher.scanOnce();
assert.strictEqual(r.imported, 2, 'should import 2 files');
assert.strictEqual(r.skipped, 1, 'should skip 1 (bad ext)');
});
// ── 2. Idempotent — second scan skips everything ────────────────────
total++; pass += ok('second scan is idempotent (same files skipped)', () => {
const r = watcher.scanOnce();
assert.strictEqual(r.imported, 0, 'second scan must not re-import');
});
// ── 3. New file added → imported ────────────────────────────────────
total++; pass += ok('new file gets imported on next scan', () => {
fs.writeFileSync(path.join(WATCHED, 'd.webp'), 'newpic');
const r = watcher.scanOnce();
assert.strictEqual(r.imported, 1);
});
// ── 4. Meta updated with source_path/size/mtime ─────────────────────
total++; pass += ok('meta entries have source_* fields for dedupe', () => {
const meta = JSON.parse(fs.readFileSync(path.join(FAKE_ROOT, 'data', 'uploads-meta.json'), 'utf8'));
assert.strictEqual(meta.files.length, 3);
for (const f of meta.files) {
assert.ok(f.source_path, 'source_path required');
assert.ok(typeof f.source_size === 'number');
assert.ok(typeof f.source_mtime === 'number');
assert.strictEqual(f.source, 'auto_watcher');
}
});
// ── 5. Filenames are 24-hex unguessable ─────────────────────────────
total++; pass += ok('filenames are 24-hex + valid ext', () => {
const meta = JSON.parse(fs.readFileSync(path.join(FAKE_ROOT, 'data', 'uploads-meta.json'), 'utf8'));
for (const f of meta.files) {
assert.ok(/^[a-f0-9]{24}\.[a-z]+$/.test(f.filename), 'bad filename: ' + f.filename);
}
});
// ── 6. Missing folder gracefully reported ───────────────────────────
total++; pass += ok('missing folder returns skipped_missing', () => {
const meta = JSON.parse(fs.readFileSync(path.join(FAKE_ROOT, 'data', 'uploads-meta.json'), 'utf8'));
meta.folder = '/no/such/path/anywhere';
fs.writeFileSync(path.join(FAKE_ROOT, 'data', 'uploads-meta.json'), JSON.stringify(meta));
const r = watcher.scanOnce();
assert.ok(r.skipped_missing);
});
// ── 7. No folder configured → graceful noop ─────────────────────────
total++; pass += ok('no folder configured → skipped_no_folder', () => {
fs.writeFileSync(path.join(FAKE_ROOT, 'data', 'uploads-meta.json'), JSON.stringify({ files: [] }));
const r = watcher.scanOnce();
assert.strictEqual(r.skipped_no_folder, true);
});
fs.rmSync(ROOT, { recursive: true, force: true });
console.log(`\n${pass}/${total} passed`);
process.exit(pass === total ? 0 : 1);