- 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.
140 lines
4.9 KiB
JavaScript
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);
|
|
});
|