Files
SwipeAnything/test/api.test.js
T
ilia 0c9919f93c Add second adapter, thumbnails, a11y pass, and a real test suite
- Immich adapter (adapters/immich.js): swipe a self-hosted photo library
  over its REST API, proving the adapter contract works for remote
  sources, not just the filesystem.
- Refactor the preview contract from resolvePreviewPath() to
  streamPreview()/streamThumbnail(), so adapters can serve previews from
  anywhere (local file, proxied fetch, etc).
- macOS Quick Look-backed thumbnail cache (lib/thumbnails.js): real
  resized thumbnails, HEIC/HEIF, and video poster frames, with a hard
  timeout and graceful fallback to the original file everywhere else.
- Native "Browse..." folder picker (osascript) in Settings, with manual
  typing as the fallback on other platforms.
- Confirm-guarded "Empty trash" action -- the only place this project
  permanently deletes anything.
- Session resume: progress now survives a server restart and an
  unchanged Rescan via .swipeanything-session.json.
- Accessibility pass: live region item announcements, aria-labels on
  action buttons, focus-trapped shortcuts dialog with focus restore,
  visible focus rings, a real role="progressbar", and <button>s instead
  of <a href="#"> in the header.
- node:test suite (23 tests) covering the folder adapter, the Immich
  adapter (mocked fetch, no live server needed), and the HTTP API
  end-to-end; wired up as `npm test`.
- Also includes the keyboard shortcuts (up/down undo/skip), swipe stamps,
  and shortcuts modal from the previous session that hadn't been
  committed yet.
2026-07-25 19:41:47 -04:00

140 lines
4.9 KiB
JavaScript

'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
// Point the server at a throwaway config/session file before requiring it,
// so this suite never touches the real project config.
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-api-test-'));
process.env.SWIPEANYTHING_CONFIG_PATH = path.join(tmpRoot, 'config.json');
process.env.SWIPEANYTHING_SESSION_PATH = path.join(tmpRoot, 'session.json');
const { start } = require('../server');
const folderDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-api-folder-'));
fs.writeFileSync(path.join(folderDir, 'a.txt'), 'A');
fs.writeFileSync(path.join(folderDir, 'b.txt'), 'B');
let server;
let base;
test.before(async () => {
server = await start(0);
base = `http://127.0.0.1:${server.address().port}`;
});
test.after(async () => {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(tmpRoot, { recursive: true, force: true });
fs.rmSync(folderDir, { recursive: true, force: true });
});
async function api(pathname, options) {
const res = await fetch(`${base}${pathname}`, {
headers: { 'Content-Type': 'application/json' },
...options,
});
const body = await res.json().catch(() => ({}));
return { status: res.status, body };
}
test('GET /api/adapters lists the registered adapters', async () => {
const { status, body } = await api('/api/adapters');
assert.equal(status, 200);
assert.ok(body.some((a) => a.id === 'folder'));
assert.ok(body.some((a) => a.id === 'immich'));
});
test('GET /api/queue returns 409 before configuration', async () => {
const { status, body } = await api('/api/queue');
assert.equal(status, 409);
assert.match(body.error, /Not configured/);
});
test('POST /api/config rejects an unknown adapter', async () => {
const { status, body } = await api('/api/config', {
method: 'POST',
body: JSON.stringify({ adapter: 'nope', settings: {} }),
});
assert.equal(status, 400);
assert.match(body.error, /Unknown adapter/);
});
test('POST /api/config rejects a missing folder', async () => {
const { status, body } = await api('/api/config', {
method: 'POST',
body: JSON.stringify({ adapter: 'folder', settings: { folderPath: '/definitely/not/real' } }),
});
assert.equal(status, 400);
assert.match(body.error, /Folder not found/);
});
test('full flow: configure, queue, keep, reject, undo, empty-trash', async () => {
const configRes = await api('/api/config', {
method: 'POST',
body: JSON.stringify({ adapter: 'folder', settings: { folderPath: folderDir, extensions: 'txt' } }),
});
assert.equal(configRes.status, 200);
const queue1 = await api('/api/queue');
assert.equal(queue1.status, 200);
assert.equal(queue1.body.total, 2);
assert.equal(queue1.body.reviewed, 0);
const first = queue1.body.current;
const afterKeep = await api('/api/action', {
method: 'POST',
body: JSON.stringify({ itemId: first.id, actionId: 'keep' }),
});
assert.equal(afterKeep.status, 200);
assert.equal(afterKeep.body.reviewed, 1);
assert.equal(afterKeep.body.canUndo, true);
const second = afterKeep.body.current;
const afterReject = await api('/api/action', {
method: 'POST',
body: JSON.stringify({ itemId: second.id, actionId: 'reject' }),
});
assert.equal(afterReject.status, 200);
assert.equal(afterReject.body.reviewed, 2);
assert.equal(afterReject.body.current, null);
assert.equal(afterReject.body.trashInfo.count, 1);
const afterUndo = await api('/api/undo', { method: 'POST' });
assert.equal(afterUndo.status, 200);
assert.equal(afterUndo.body.reviewed, 1);
assert.equal(afterUndo.body.current.id, second.id);
// put it back so trash state is predictable for the next assertion
await api('/api/action', { method: 'POST', body: JSON.stringify({ itemId: second.id, actionId: 'reject' }) });
const emptied = await api('/api/empty-trash', { method: 'POST' });
assert.equal(emptied.status, 200);
assert.equal(emptied.body.trashInfo.count, 0);
});
test('POST /api/action with a stale itemId is rejected', async () => {
await api('/api/rescan', { method: 'POST' });
const { status, body } = await api('/api/action', {
method: 'POST',
body: JSON.stringify({ itemId: 'not-the-current-item', actionId: 'keep' }),
});
assert.equal(status, 409);
assert.match(body.error, /stale/);
});
test('POST /api/undo with nothing to undo is rejected', async () => {
await api('/api/rescan', { method: 'POST' });
const { status, body } = await api('/api/undo', { method: 'POST' });
assert.equal(status, 409);
assert.match(body.error, /Nothing to undo/);
});
test('GET /api/preview/:itemId 404s for an unknown item', async () => {
const missingId = Buffer.from('nonexistent.txt').toString('base64url');
const res = await fetch(`${base}/api/preview/${missingId}`);
assert.equal(res.status, 404);
});