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.
162 lines
6.1 KiB
JavaScript
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 <b><br/>line</p><p>Two & 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 & Magic <1>'), 'Folly & Magic <1>');
|
|
assert.equal(chapterTitle('Literal &lt;'), 'Literal <');
|
|
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');
|
|
});
|