// 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', '

A bold start.

'), 'A bold start.'); const long = formatField('comments', `

${'x'.repeat(300)}

`); assert.equal(long.length, 201); assert.ok(long.endsWith('…')); }); test('textToHtml makes paragraphs and escapes the text', () => { assert.equal(textToHtml('One \nline\n\n Two & more \n\n\n'), '

One <b>
line

Two & more

'); 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'); });