Files
tim 2f4271314b Add the web interface
Plain HTML, CSS and JavaScript modules, with no build step. Hash routes
and relative URLs make it work under any path. The job list is polled:
every 2 seconds while a job runs, and less often otherwise.
2026-09-27 23:38:28 +02:00

162 lines
6.1 KiB
JavaScript

// Tests for the helpers in bindery/static/js/lib.js. Run with: node --test "tests/js/*.test.mjs"
import assert from 'node:assert/strict';
import { beforeEach, test } from 'node:test';
// The comments field is HTML. A browser turns it into text with DOMParser.
globalThis.DOMParser = class {
parseFromString(html) {
return { body: { textContent: html.replace(/<[^>]*>/g, '') } };
}
};
// h() builds elements. These fakes keep only what the tests read.
globalThis.document = {
createElement: (tagName) => ({
tagName,
attributes: {},
children: [],
append(...children) { this.children.push(...children); },
setAttribute(name, value) { this.attributes[name] = String(value); },
}),
};
const { api, ApiError, chapterTitle, externalLink, fmtDate, formatField, plural, setUnauthorizedHandler, textToHtml } = await import(
'../../bindery/static/js/lib.js'
);
let requests = [];
let reply = { status: 200, body: {} };
beforeEach(() => {
requests = [];
reply = { status: 200, body: {} };
globalThis.fetch = async (url, options) => {
requests.push({ url, ...options });
return {
ok: reply.status < 400,
status: reply.status,
statusText: 'Status',
json: async () => {
if (reply.body === undefined) throw new SyntaxError('not JSON');
return reply.body;
},
};
};
setUnauthorizedHandler(() => {});
});
test('formatField shows series with and without a number', () => {
assert.equal(formatField('series', ['Saga', 2]), 'Saga [2]');
assert.equal(formatField('series', ['Saga', null]), 'Saga');
assert.equal(formatField('series', ['', 2]), '');
assert.equal(formatField('series', 'Saga'), 'Saga');
});
test('formatField joins lists and handles empty values', () => {
assert.equal(formatField('tags', ['Fantasy', 'LitRPG']), 'Fantasy, LitRPG');
assert.equal(formatField('title', 'A Story'), 'A Story');
assert.equal(formatField('title', null), '');
assert.equal(formatField('title', undefined), '');
assert.equal(formatField('title', ''), '');
});
test('formatField turns the description into short text', () => {
assert.equal(formatField('comments', '<p>A <b>bold</b> start.</p>'), 'A bold start.');
const long = formatField('comments', `<p>${'x'.repeat(300)}</p>`);
assert.equal(long.length, 201);
assert.ok(long.endsWith('…'));
});
test('textToHtml makes paragraphs and escapes the text', () => {
assert.equal(textToHtml('One <b>\nline\n\n Two & more \n\n\n'), '<p>One &lt;b&gt;<br/>line</p><p>Two &amp; more</p>');
assert.equal(textToHtml(' '), '');
});
test('fmtDate', () => {
assert.equal(fmtDate(null), '');
assert.equal(fmtDate(''), '');
assert.equal(fmtDate('not a date'), 'not a date');
const text = fmtDate('2026-01-02T03:04:05');
assert.ok(text.length > 0 && text !== '2026-01-02T03:04:05');
});
test('chapterTitle turns the entities that FanFicFare keeps into text', () => {
assert.equal(chapterTitle('Folly &amp; Magic &lt;1&gt;'), 'Folly & Magic <1>');
assert.equal(chapterTitle('Literal &amp;lt;'), 'Literal &lt;');
assert.equal(chapterTitle(undefined), '');
});
test('plural', () => {
assert.equal(plural(1, 'chapter'), '1 chapter');
assert.equal(plural(0, 'chapter'), '0 chapters');
assert.equal(plural(3, 'chapter'), '3 chapters');
});
test('api reads with a relative GET and no CSRF header', async () => {
reply.body = [{ id: 1 }];
assert.deepEqual(await api('stories'), [{ id: 1 }]);
assert.equal(requests[0].url, 'api/stories');
assert.equal(requests[0].method, 'GET');
assert.deepEqual(requests[0].headers, {});
assert.equal(requests[0].body, undefined);
});
test('api sends changes as JSON with the CSRF header', async () => {
await api('stories', 'POST', { urls: 'x' });
assert.equal(requests[0].method, 'POST');
assert.equal(requests[0].headers['X-Requested-With'], 'bindery');
assert.equal(requests[0].headers['Content-Type'], 'application/json');
assert.equal(requests[0].body, '{"urls":"x"}');
await api('stories/1', 'DELETE');
assert.equal(requests[1].headers['X-Requested-With'], 'bindery');
assert.equal(requests[1].headers['Content-Type'], undefined);
});
test('api sends a file upload as it is, so the browser sets the multipart type', async () => {
const form = new FormData();
form.append('file', new Blob(['epub']), 'story.epub');
await api('stories/import-epub', 'POST', form);
assert.equal(requests[0].body, form);
assert.equal(requests[0].headers['X-Requested-With'], 'bindery');
assert.equal(requests[0].headers['Content-Type'], undefined);
});
test('api turns an error answer into an ApiError with its data', async () => {
reply = { status: 400, body: { error: 'Bad pattern', index: 2 } };
await assert.rejects(api('rules', 'PUT', { rules: [] }), (e) => {
assert.ok(e instanceof ApiError);
assert.equal(e.message, 'Bad pattern');
assert.equal(e.status, 400);
assert.equal(e.data.index, 2);
return true;
});
});
test('api reports an answer that is not JSON by its status', async () => {
reply = { status: 502, body: undefined };
await assert.rejects(api('stories'), { message: '502 Status', status: 502 });
});
test('api calls the unauthorized handler, except for login requests', async () => {
let calls = 0;
setUnauthorizedHandler(() => calls++);
reply = { status: 401, body: { error: 'Unauthorized' } };
await assert.rejects(api('stories'));
assert.equal(calls, 1);
await assert.rejects(api('auth/login', 'POST', {}));
assert.equal(calls, 1);
});
test('externalLink links http and https URLs, and leaves other URLs as text', () => {
const a = externalLink('https://example.com/s/1', 'example.com');
assert.equal(a.tagName, 'a');
assert.deepEqual(a.attributes, { href: 'https://example.com/s/1', target: '_blank', rel: 'noreferrer noopener' });
assert.deepEqual(a.children, ['example.com']);
assert.deepEqual(externalLink('HTTP://example.com').children, ['HTTP://example.com']);
assert.equal(externalLink('javascript:alert(1)'), 'javascript:alert(1)');
assert.equal(externalLink(' javascript:alert(1)', 'site'), 'site');
assert.equal(externalLink('data:text/html,x', 'site'), 'site');
assert.equal(externalLink(null, 'site'), 'site');
});