← back to Photo Cleanup

test.js

203 lines

#!/usr/bin/env node
// HTTP + DOM hook tests for photo-cleanup server.
// Spawns the server with a synthetic candidates file (so we never touch real files),
// hits every endpoint, and verifies the inline page wires up every documented sort
// option and event hook. Cleans up before exit.

const assert = require('assert');
const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const cp = require('child_process');

// Grab a free ephemeral port so the test never collides with a stale server
// from a prior run (a hardcoded port silently routes POSTs to a stranger).
function freePort() {
  const srv = http.createServer();
  return new Promise((resolve, reject) => {
    srv.once('error', reject);
    srv.listen(0, '127.0.0.1', () => {
      const p = srv.address().port;
      srv.close(() => resolve(p));
    });
  });
}

const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'photo-cleanup-test-'));
const SANDBOX = fs.mkdtempSync(path.join(os.homedir(), '.photo-cleanup-sandbox-'));
const CAND_FILE = path.join(TMP, 'candidates.txt');

function makeFakeImage(p) {
  const tinyPng = Buffer.from(
    'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
    'base64'
  );
  fs.writeFileSync(p, tinyPng);
}
const fakeA = path.join(SANDBOX, 'steve-test-a.png');
const fakeB = path.join(SANDBOX, 'steve-test-b.png');
const fakeC = path.join(SANDBOX, 'steve-test-c.png');
[fakeA, fakeB, fakeC].forEach(makeFakeImage);
fs.writeFileSync(CAND_FILE, [fakeA, fakeB, fakeC].join('\n') + '\n');

let PORT;        // resolved to a free ephemeral port before the server spawns
let child;       // server child process
let serverOut = '';

function get(p) {
  return new Promise((resolve, reject) => {
    http.get({ host: '127.0.0.1', port: PORT, path: p }, (res) => {
      let b = ''; res.on('data', (c) => b += c); res.on('end', () => resolve({ status: res.statusCode, body: b }));
    }).on('error', reject);
  });
}

function post(p, payload) {
  return new Promise((resolve, reject) => {
    const data = JSON.stringify(payload || {});
    const req = http.request({
      host: '127.0.0.1', port: PORT, path: p, method: 'POST',
      headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) },
    }, (res) => {
      let b = ''; res.on('data', (c) => b += c); res.on('end', () => resolve({ status: res.statusCode, body: b }));
    });
    req.on('error', reject);
    req.write(data); req.end();
  });
}

async function waitReady(retries = 20) {
  for (let i = 0; i < retries; i++) {
    try {
      const r = await get('/api/list');
      // Confirm it is OUR server: it must serve the 3-file sandbox, not a stranger.
      if (r.status === 200 && JSON.parse(r.body).rows.length === 3) return;
    } catch (_) {}
    await new Promise((r) => setTimeout(r, 150));
  }
  throw new Error('server never came up (or wrong server on port)\n' + serverOut);
}

const tests = [];
function t(name, fn) { tests.push({ name, fn }); }

t('GET / returns HTML 200 with all sort options', async () => {
  const r = await get('/');
  assert.strictEqual(r.status, 200);
  const expectedSorts = ['kind','name-asc','name-desc','size-desc','size-asc','bright-asc','bright-desc','spread-asc','mtime-desc','mtime-asc','dir-asc','ext-asc'];
  for (const s of expectedSorts) {
    assert.ok(r.body.includes(`value="${s}"`), 'sort option missing in HTML: ' + s);
  }
  for (const k of ['photo','pattern','document','logo','illustration','unknown']) {
    assert.ok(r.body.includes(`value="${k}"`), 'kind filter option missing: ' + k);
  }
});

t('GET / wires up every documented click + change hook', async () => {
  const r = await get('/');
  assert.ok(/document\.getElementById\('sort'\)\.addEventListener\('change'/.test(r.body), 'sort change hook missing');
  assert.ok(/document\.getElementById\('kindFilter'\)\.addEventListener\('change'/.test(r.body), 'kindFilter change hook missing');
  assert.ok(/document\.getElementById\('q'\)\.addEventListener\('input'/.test(r.body), 'q input hook missing');
  assert.ok(/document\.getElementById\('refresh'\)\.addEventListener\('click'/.test(r.body), 'refresh click hook missing');
  assert.ok(/document\.getElementById\('show-removed'\)\.addEventListener\('click'/.test(r.body), 'show-removed click hook missing');
  assert.ok(/document\.addEventListener\('click'/.test(r.body), 'card-action delegated click hook missing');
  ['\\.remove', '\\.keep', '\\.undo', '\\.thumb'].forEach((cls) => {
    assert.ok(new RegExp(`e\\.target\\.closest\\('${cls}'\\)`).test(r.body), 'delegated handler missing for ' + cls);
  });
});

t('GET /api/list returns 3 rows for the sandbox', async () => {
  const r = await get('/api/list');
  assert.strictEqual(r.status, 200);
  const j = JSON.parse(r.body);
  assert.strictEqual(j.rows.length, 3);
  assert.ok(j.rows.every((row) => row.path && row.name && row.kind));
  assert.ok(j.trashDir.startsWith(path.join(os.homedir(), '.Trash')));
});

t('GET /thumb returns image bytes', async () => {
  const r = await get('/thumb?path=' + encodeURIComponent(fakeA));
  assert.strictEqual(r.status, 200);
  assert.ok(r.body.length > 0);
});

t('GET /file returns image bytes', async () => {
  const r = await get('/file?path=' + encodeURIComponent(fakeA));
  assert.strictEqual(r.status, 200);
  assert.ok(r.body.length > 0);
});

t('POST /api/remove with no path → 400', async () => {
  const r = await post('/api/remove', {});
  assert.strictEqual(r.status, 400);
  assert.ok(JSON.parse(r.body).error);
});

t('POST /api/remove on /etc/hosts → refused (outside $HOME)', async () => {
  const r = await post('/api/remove', { path: '/etc/hosts' });
  assert.strictEqual(r.status, 400);
  assert.ok(/outside .HOME/.test(JSON.parse(r.body).error));
});

t('POST /api/remove moves the file to ~/.Trash and reports trashed path', async () => {
  const r = await post('/api/remove', { path: fakeA });
  assert.strictEqual(r.status, 200);
  const j = JSON.parse(r.body);
  assert.strictEqual(j.ok, true);
  assert.ok(!fs.existsSync(fakeA), 'original should be gone after move');
  assert.ok(fs.existsSync(j.trashed), 'trashed file should exist at returned path');
  assert.ok(j.trashed.startsWith(path.join(os.homedir(), '.Trash')));
});

t('GET /api/trash shows the just-removed entry in undo log', async () => {
  const r = await get('/api/trash');
  assert.strictEqual(r.status, 200);
  const j = JSON.parse(r.body);
  assert.ok(j.entries.length >= 1);
  assert.ok(j.entries.some((e) => e.original === fakeA), 'undo log missing fakeA');
});

t('POST /api/restore moves the file back to its original path', async () => {
  const r = await post('/api/restore', { path: fakeA });
  assert.strictEqual(r.status, 200);
  assert.strictEqual(JSON.parse(r.body).ok, true);
  assert.ok(fs.existsSync(fakeA), 'restored file should be back at original path');
});

t('POST /api/restore on a path with no undo record → 400', async () => {
  const r = await post('/api/restore', { path: '/Users/nope/x.jpg' });
  assert.strictEqual(r.status, 400);
});

t('Name sort produces deterministic ordering for the sandbox', async () => {
  const r = await get('/api/list');
  const rows = JSON.parse(r.body).rows;
  const sortedName = rows.slice().sort((a,b) => a.name.localeCompare(b.name)).map(r => r.name);
  assert.deepStrictEqual(sortedName, ['steve-test-a.png', 'steve-test-b.png', 'steve-test-c.png']);
});

(async () => {
  try {
    PORT = await freePort();
    child = cp.spawn(process.execPath, [path.join(__dirname, 'server.js')], {
      env: { ...process.env, PORT: String(PORT), CANDIDATES: CAND_FILE },
      stdio: ['ignore', 'pipe', 'pipe'],
    });
    child.stdout.on('data', (c) => { serverOut += c.toString(); });
    child.stderr.on('data', (c) => { serverOut += c.toString(); });
    await waitReady();
    let pass = 0, fail = 0;
    for (const test of tests) {
      try { await test.fn(); console.log('✓', test.name); pass++; }
      catch (err) { console.error('✗', test.name, '—', err.message); fail++; }
    }
    console.log(`\n${pass}/${tests.length} photo-cleanup tests passed`);
    process.exitCode = fail ? 1 : 0;
  } finally {
    if (child) child.kill();
    try { fs.rmSync(SANDBOX, { recursive: true, force: true }); } catch (_) {}
    try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (_) {}
  }
})();