← 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);