diff --git a/bindery/static/favicon.svg b/bindery/static/favicon.svg new file mode 100644 index 0000000..130b62d --- /dev/null +++ b/bindery/static/favicon.svg @@ -0,0 +1,6 @@ + diff --git a/bindery/static/index.html b/bindery/static/index.html new file mode 100644 index 0000000..1782707 --- /dev/null +++ b/bindery/static/index.html @@ -0,0 +1,20 @@ + + +
+ + + + + + + + +${escape(p).replace(/\n/g, '
')}
Description
', series: 'Saga', series_index: 2, pubdate: '2024-01-02T00:00:00', tags: ['Fantasy'], languages: 'en' }, + auto_update: true, recheck_days: 3, last_full_recheck: '2026-01-01', last_checked: '2026-01-02', status: 'ok' }, + chapters: [{ id: 2, title: 'One', url: 'chapter-1', included: true, on_site: true, current_rev: 1, revisions: 2 }, + { id: 3, title: 'Two', url: 'chapter-2', included: false, on_site: false, current_rev: 1, revisions: 1 }], + recent_chapters: 3, + }; + api.on('GET', 'jobs', []); + api.on('GET', 'stories', () => [details.story]); + api.on('GET', 'stories/1', () => details); + api.on('GET', 'rules', emptyRules); + api.on('GET', 'stories/1/rules', emptyRules); + api.on('GET', 'settings', { recent_chapters: 3, personal_ini: '[defaults]' }); +}); +afterEach(() => { cleanups.forEach((fn) => fn?.()); stopPolling(); }); +const mountStory = async (cfg = config) => { cleanups.push(storyPage(main, { storyId: '1' }, cfg)); await settle(); }; +const mountStories = async (cfg = config) => { cleanups.push(storiesPage(main, {}, cfg)); await settle(); }; +const writes = () => api.requests.filter((r) => r.method !== 'GET'); + +for (const value of ['7', '']) { + test(`settings save converts ${JSON.stringify(value)} to a count`, async () => { + settingsPage(main); + await settle(); + api.on('PUT', 'settings', {}); + field(main, 'recent-chapters').value = value; + await click(section(main, 'Settings'), 'Save'); + assert.deepEqual(api.sent('PUT', 'settings')[0].body, { recent_chapters: value === '' ? 0 : 7 }); + assert.match(ui.toasts.textContent, /Saved/); + }); +} + +test('personal.ini save reports warnings and clears them after a clean save', async () => { + settingsPage(main); + await settle(); + const card = section(main, 'FanFicFare settings (personal.ini)'); + field(main, 'personal.ini').value = '[defaults]\nis_adult: true'; + api.on('PUT', 'settings', { warnings: ['Unknown setting'] }); + await click(card, 'Save'); + assert.deepEqual(api.sent('PUT', 'settings')[0].body, { personal_ini: '[defaults]\nis_adult: true' }); + assert.match(card.textContent, /Unknown setting/); + api.on('PUT', 'settings', { warnings: [] }); + await click(card, 'Save'); + assert.doesNotMatch(card.textContent, /Unknown setting/); +}); + +test('failed settings saves retain edits and re-enable the action', async () => { + settingsPage(main); + await settle(); + const card = section(main, 'FanFicFare settings (personal.ini)'); + field(main, 'personal.ini').value = 'invalid ini'; + api.on('PUT', 'settings', { error: 'Invalid settings' }, 400); + await click(card, 'Save'); + assert.equal(field(main, 'personal.ini').value, 'invalid ini'); + assert.equal(find(card, 'button', 'Save').disabled, false); + assert.match(ui.toasts.textContent, /Invalid settings/); +}); + +test('adding stories keeps input on rejection and clears it after a mixed result', async () => { + await mountStories({ ...config, library_available: false }); + const urls = nodes(main).find((el) => el.tagName === 'textarea'); + urls.value = ' https://site.test/1\n\nhttps://site.test/2 '; + api.on('POST', 'stories', { error: 'Unavailable' }, 503); + await click(main, 'Add'); + assert.match(urls.value, /site.test/); + api.on('POST', 'stories', { jobs: [{ id: 'job' }], errors: ['Unsupported URL'] }); + await click(main, 'Add'); + assert.deepEqual(api.sent('POST', 'stories').at(-1).body, { urls: ' https://site.test/1\n\nhttps://site.test/2 ' }); + assert.equal(urls.value, ''); + assert.match(ui.toasts.textContent, /Unsupported URL/); + await click(main, 'Add'); + assert.equal(api.sent('POST', 'stories').length, 2); +}); + +test('story filtering handles early input, empty results and case-insensitive matches', async () => { + let resolve; + api.on('GET', 'stories', () => new Promise((done) => { resolve = done; })); + cleanups.push(storiesPage(main, {}, config)); + input(field(main, 'Filter stories'), ' WRITER '); + resolve([details.story]); + await settle(); + assert.ok(find(main, 'a', 'Story')); + input(field(main, 'Filter stories'), 'missing'); + assert.match(main.textContent, /No stories match/); + input(field(main, 'Filter stories'), 'example.com'); + assert.ok(find(main, 'a', 'Story')); + cleanups.pop()(); + api.on('GET', 'stories', []); + await mountStories(); + assert.match(main.textContent, /No stories yet/); +}); + +for (const storyId of [undefined, 1]) { + test(`epub import ${storyId ?? 'new story'} sends the file and check option`, async () => { + const root = epubImport(storyId); + const path = storyId ? 'stories/1/import-epub' : 'stories/import-epub'; + await click(root, 'Import'); + assert.equal(writes().length, 0); + assert.match(ui.toasts.textContent, /Choose an ePub/); + const file = field(root, 'Epub file'); + const book = new File(['book'], 'story.epub', { type: 'application/epub+zip' }); + file.files = [book]; + file.value = 'story.epub'; + change(nodes(root).find((el) => el.attributes.type === 'checkbox'), false); + api.on('POST', path, { error: 'Wrong story' }, 400); + await click(root, 'Import'); + assert.equal(file.value, 'story.epub'); + api.on('POST', path, { id: 'import' }); + await click(root, 'Import'); + const sent = api.sent('POST', path).at(-1).body; + assert.equal(sent.get('check'), 'false'); + assert.equal(sent.get('file').name, 'story.epub'); + assert.equal(await sent.get('file').text(), 'book'); + assert.equal(file.value, ''); + }); +} + +test('library import sends only selected books and handles an empty candidate list', async () => { + await mountStories(); + api.on('GET', 'library/candidates', []); + await click(main, 'Find stories in the library'); + assert.match(main.textContent, /tracked already/); + const books = [{ calibre_id: 8, uuid: 'a', title: 'A' }, { calibre_id: 9, uuid: 'b', title: 'B' }]; + api.on('GET', 'library/candidates', books); + await click(main, 'Find stories in the library'); + change(field(main, 'Select all'), false); + await click(main, 'Import selected'); + assert.equal(writes().length, 0); + change(field(main, 'Select all'), true); + change(field(main, 'A'), false); + change(field(main, 'A'), true); + change(field(main, 'B'), false); + const card = section(main, 'Import from the library'); + change(nodes(card).find((el) => el.attributes.type === 'checkbox'), false); + api.on('POST', 'library/import', [{ id: 'job' }]); + await click(main, 'Import selected'); + assert.deepEqual(api.sent('POST', 'library/import')[0].body, { books: [{ id: 8, uuid: 'a', title: 'A' }], check: false }); +}); + +test('rules edits, moves, removal and paragraph choices are saved in order', async () => { + api.on('GET', 'stories/1/rules', { ...emptyRules, rules: [rule, { ...rule, pattern: 'second' }] }); + const editor = rulesEditor('stories/1/rules'); + await settle(); + const root = editor.element; + const controls = (label) => nodes(root).filter((el) => el.attributes['aria-label'] === label); + emit(controls('Move up')[0], 'click'); + emit(controls('Move down')[1], 'click'); + emit(controls('Move down')[0], 'click'); + input(controls('Pattern')[0], 'edited'); + change(controls('Enabled')[0], false); + emit(controls('Move up')[1], 'click'); + emit(controls('Remove')[0], 'click'); + await click(root, 'Add rule'); + input(controls('Replacement')[1], 'replacement'); + api.on('PUT', 'stories/1/rules', (req) => ({ ...req.body, global_paragraphs: true })); + for (const [choice, expected] of [['off', false], ['on', true], ['', null]]) { + change(field(root, 'paragraphs'), choice); + await click(root, 'Save'); + const sent = api.sent('PUT', 'stories/1/rules').at(-1).body; + assert.equal(sent.paragraphs, expected); + assert.equal(sent.rules[0].pattern, 'edited'); + assert.equal(sent.rules[0].enabled, false); + assert.equal(sent.rules[1].replacement, 'replacement'); + } +}); + +test('rule validation failures retain edits and preview uses unsaved values', async () => { + api.on('GET', 'rules', { ...emptyRules, rules: [rule], paragraphs: false }); + const editor = rulesEditor('rules'); + await settle(); + input(field(editor.element, 'Pattern'), '['); + change(nodes(editor.element).find((el) => el.attributes.type === 'checkbox'), true); + api.on('PUT', 'rules', { error: 'Invalid pattern', index: 0 }, 400); + await click(editor.element, 'Save'); + assert.equal(field(editor.element, 'Pattern').value, '['); + assert.match(ui.toasts.textContent, /Invalid pattern/); + api.on('POST', 'rules/preview', { error: 'Invalid pattern', index: 0 }, 400); + await assert.rejects(editor.preview(2, false), /Invalid pattern/); + input(field(editor.element, 'Pattern'), 'fixed'); + api.on('POST', 'rules/preview', { identical: true }); + assert.deepEqual(await editor.preview(2, true), { identical: true }); + assert.deepEqual(api.sent('POST', 'rules/preview').at(-1).body, + { rules: [{ ...rule, pattern: 'fixed' }], paragraphs: true, chapter_id: 2, source: true }); +}); + +test('rule previews keep a valid chapter selection and skip empty lists', async () => { + const calls = []; + const editor = { preview: async (...args) => { calls.push(args); return { identical: calls.length < 3, html: 'diff
' }; } }; + const preview = rulesPreview(editor); + await click(preview.element, 'Preview'); + assert.equal(calls.length, 0); + preview.setChapters(details.chapters); + change(field(preview.element, 'Chapter to preview'), '3'); + preview.setChapters(details.chapters); + await click(preview.element, 'Preview'); + change(nodes(preview.element).find((el) => el.attributes.type === 'checkbox'), true); + await click(preview.element, 'Preview'); + preview.setChapters([details.chapters[0]]); + await click(preview.element, 'Preview'); + assert.deepEqual(calls, [[3, false], [3, true], [2, true]]); + preview.setChapters([]); + await click(preview.element, 'Preview'); + assert.equal(calls.length, 3); +}); + +test('story download modes send selected chapters and clear forced fields after success', async () => { + await mountStory(); + api.on('POST', 'stories/1/download', { mode_label: 'Queued' }); + const radio = (mode) => nodes(main).find((el) => el.attributes.type === 'radio' && el.value === mode); + change(radio('recheck'), 'recheck'); + await click(main, 'Start'); + await click(main, 'Recheck selected'); + await click(main, 'Take out of book'); + assert.equal(writes().length, 0); + change(field(main, 'Select One'), true); + change(field(main, 'Select One'), false); + change(field(main, 'Select all chapters'), true); + change(field(main, 'Select all chapters'), false); + change(field(main, 'Select One'), true); + const force = nodes(section(main, 'Download')).filter((el) => el.attributes.type === 'checkbox'); + change(force[0], true); + change(force[1], true); + change(force[1], false); + await click(main, 'Start'); + assert.deepEqual(api.sent('POST', 'stories/1/download').at(-1).body, { mode: 'recheck', force_fields: ['title'], chapter_ids: [2] }); + for (const mode of ['new', 'recheck_all', 'full', 'metadata']) { + change(radio(mode), mode); + await click(main, 'Start'); + assert.deepEqual(api.sent('POST', 'stories/1/download').at(-1).body, { mode, force_fields: [], chapter_ids: [] }); + } + await click(main, 'Recheck selected'); + assert.deepEqual(api.sent('POST', 'stories/1/download').at(-1).body, { mode: 'recheck', chapter_ids: [2] }); + await click(main, 'Rebuild book'); + assert.deepEqual(api.sent('POST', 'stories/1/download').at(-1).body, { mode: 'rebuild' }); +}); + +test('chapter inclusion changes reload the story after queuing the rebuild', async () => { + await mountStory(); + change(field(main, 'Select One'), true); + api.on('POST', 'stories/1/chapters', {}); + for (const [label, included] of [['Take out of book', false], ['Put back in book', true]]) { + await click(main, label); + assert.deepEqual(api.sent('POST', 'stories/1/chapters').at(-1).body, { chapter_ids: [2], included }); + } + assert.equal(api.sent('GET', 'stories/1').length, 3); +}); + +test('unsaved metadata and update settings survive job completion, then save typed values', async () => { + await mountStory(); + startPolling(); + await settle(); + api.on('GET', 'jobs', [{ id: 'job', story_id: 1, status: 'running' }]); + await refreshJobs(); + input(field(main, 'Title'), 'My title'); + input(field(main, 'Description'), 'One\n\nTwoOne
Two <three>
' } }); + await click(section(main, 'Updates'), 'Save'); + assert.deepEqual(api.sent('PUT', 'stories/1/settings').at(-1).body, { auto_update: false, recheck_days: 5 }); + input(field(main, 'recheck-days'), ''); + await click(section(main, 'Updates'), 'Save'); + assert.equal(api.sent('PUT', 'stories/1/settings').at(-1).body.recheck_days, 0); +}); + +test('metadata saves skip unchanged drafts, allow edits without a rebuild, and restore site values', async () => { + await mountStory(); + api.on('PUT', 'stories/1/settings', {}); + api.on('PUT', 'stories/1/metadata', { job: null }); + input(field(main, 'Title'), 'Edited title'); + await click(section(main, 'Metadata'), 'Save'); + assert.equal(api.sent('PUT', 'stories/1/metadata').length, 0); + input(field(main, 'Title'), 'New title'); + await click(section(main, 'Metadata'), 'Save'); + assert.deepEqual(api.sent('PUT', 'stories/1/metadata')[0].body, { values: { title: 'New title' } }); + await click(main, 'Use the site value'); + assert.deepEqual(api.sent('PUT', 'stories/1/metadata').at(-1).body, { values: { title: null } }); +}); + +test('failed rule saves do not queue a rebuild', async () => { + await mountStory(); + const card = section(main, 'Replacement rules'); + api.on('PUT', 'stories/1/rules', { error: 'Bad rule' }, 400); + await click(card, 'Save and rebuild'); + assert.equal(api.sent('POST', 'stories/1/download').length, 0); + api.on('PUT', 'stories/1/rules', emptyRules); + api.on('POST', 'stories/1/download', { mode_label: 'Rebuild' }); + await click(card, 'Save and rebuild'); + assert.deepEqual(api.sent('POST', 'stories/1/download')[0].body, { mode: 'rebuild' }); +}); + +test('library search trims the query and links by UUID', async () => { + await mountStory(); + const form = nodes(section(main, 'Library link')).find((el) => el.tagName === 'form'); + emit(form, 'submit'); + assert.equal(api.requests.some((r) => r.path.startsWith('library/search')), false); + field(main, 'Search the library').value = ' A & B '; + api.on('GET', 'library/search?q=A%20%26%20B', []); + emit(form, 'submit'); + await settle(); + assert.match(main.textContent, /No library books match/); + api.on('GET', 'library/search?q=A%20%26%20B', [{ id: 7, uuid: 'book-7', title: 'A & B', authors: ['A', 'B'] }, + { id: 8, uuid: 'book-8', title: 'Tracked', authors: 'Writer', tracked_as: 'Other' }]); + emit(form, 'submit'); + await settle(); + const links = nodes(main).filter((el) => el.tagName === 'button' && el.textContent === 'Link'); + assert.equal(links[1].disabled, true); + api.on('POST', 'stories/1/link', {}); + await emit(links[0], 'click'); + assert.deepEqual(api.sent('POST', 'stories/1/link')[0].body, { uuid: 'book-7' }); + assert.equal(api.sent('GET', 'stories/1').length, 2); +}); + +test('sending as a new book and stopping tracking require confirmation', async () => { + details.story.ingested_at = '2026-01-01'; + await mountStory(); + window.confirm = () => false; + await click(main, 'Send to CWA as a new book'); + await click(main, 'Stop tracking this story'); + assert.equal(writes().length, 0); + window.confirm = () => true; + api.on('POST', 'stories/1/link', {}); + api.on('POST', 'stories/1/download', { mode_label: 'Rebuild' }); + await click(main, 'Send to CWA as a new book'); + assert.deepEqual(writes().map((r) => [r.path, r.body]), [['stories/1/link', { uuid: null }], ['stories/1/download', { mode: 'rebuild' }]]); + api.on('DELETE', 'stories/1', {}); + window.location.hash = '#/story/1'; + await click(main, 'Stop tracking this story'); + assert.equal(api.sent('DELETE', 'stories/1').length, 1); + assert.equal(window.location.hash, '#/'); +}); + +test('story load failures are reported and sparse offline stories remain usable', async () => { + api.on('GET', 'stories/1', { error: 'Missing story' }, 404); + await mountStory(); + assert.match(ui.toasts.textContent, /Missing story/); + cleanups.pop()(); + details.story.pinned = {}; + details.story.site_values = {}; + details.chapters = []; + details.recent_chapters = 0; + details.story.last_full_recheck = null; + details.story.calibre_id = 4; + details.library_error = 'Offline'; + api.on('GET', 'stories/1', () => details); + await mountStory({ ...config, library_available: false, update_interval: 0 }); + assert.equal(field(main, 'Title').value, ''); + assert.match(main.textContent, /Offline/); +}); + +test('chapter comparisons and revision changes send the chosen revision numbers', async () => { + let diff = { chapter: details.chapters[0], a: 1, b: 2, identical: false, html: 'diff
', + changed: 1, added: 0, removed: 0, revisions: [{ rev: 1, size: 100 }, { rev: 2, size: 110 }, { rev: 3, size: 120 }] }; + api.on('GET', 'chapters/2/diff?', () => diff); + cleanups.push(chapterPage(main, { storyId: '1', chapterId: '2' })); + await settle(); + change(field(main, 'Older revision'), '2'); + change(field(main, 'Newer revision'), '1'); + change(nodes(main).find((el) => el.attributes.type === 'checkbox'), true); + api.on('GET', 'chapters/2/diff?a=2&b=1&source=1', () => diff); + await click(main, 'Compare'); + assert.equal(api.sent('GET', 'chapters/2/diff?a=2&b=1&source=1').length, 1); + api.on('POST', 'chapters/2/revision', {}); + api.on('GET', 'chapters/2/diff?a=1&b=2&source=1', () => diff); + await click(main, 'Use this revision'); + assert.deepEqual(api.sent('POST', 'chapters/2/revision')[0].body, { rev: 2 }); + window.confirm = () => false; + await click(main, 'Delete'); + assert.equal(api.sent('DELETE', 'chapters/2/revisions/2').length, 0); + window.confirm = () => true; + api.on('DELETE', 'chapters/2/revisions/2', {}); + diff = { ...diff, a: 1, b: 1, revisions: [{ rev: 1, size: 100 }] }; + api.on('GET', 'chapters/2/diff?source=1', () => diff); + await click(main, 'Delete'); + assert.equal(api.sent('DELETE', 'chapters/2/revisions/2').length, 1); + assert.equal(api.sent('GET', 'chapters/2/diff?source=1').length, 1); +}); + +test('job cancellation posts the job ID and refreshes polling', async () => { + startPolling(); + await settle(); + const { el: root, render } = jobPanel(); + render([{ id: 'cancel-me', status: 'queued', url: 'story-url' }, + { id: 'running', status: 'running', story_id: 1, progress: 0.5, + result: { changed: [{ chapter_id: 2, title: 'One' }, { chapter_id: 3, title: 'Two' }] } }]); + api.on('POST', 'jobs/cancel-me/cancel', {}); + await click(root, 'Cancel'); + await settle(); + assert.equal(api.sent('POST', 'jobs/cancel-me/cancel').length, 1); + assert.equal(api.sent('GET', 'jobs').length, 2); +}); + +test('chapter titles show the entities that FanFicFare keeps as text', async () => { + details.chapters[0].title = 'Folly & Magic <1>'; + await mountStory(); + assert.ok(find(main, 'a', 'Folly & Magic <1>')); + const { el, render } = jobPanel(); + render([{ id: 'j', status: 'done', story_id: 1, result: { changed: [{ chapter_id: 2, title: 'A & B' }] } }]); + assert.ok(find(el, 'a', 'A & B')); +}); diff --git a/tests/js/frontend.test.mjs b/tests/js/frontend.test.mjs new file mode 100644 index 0000000..ce5389a --- /dev/null +++ b/tests/js/frontend.test.mjs @@ -0,0 +1,235 @@ +// DOM and stylesheet checks without a browser. + +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { beforeEach, test } from 'node:test'; + +class Element { + constructor(tag) { + this.tagName = tag; + this.attributes = {}; + this.children = []; + this.listeners = {}; + this.className = ''; + } + + append(...children) { this.children.push(...children); } + replaceChildren(...children) { this.children = children; } + setAttribute(name, value) { this.attributes[name] = String(value); } + getAttribute(name) { return this.attributes[name] ?? null; } + removeAttribute(name) { delete this.attributes[name]; } + addEventListener(name, listener) { this.listeners[name] = listener; } +} + +const toasts = new Element('div'); +globalThis.document = { + createElement: (tag) => new Element(tag), + addEventListener() {}, + getElementById: () => toasts, + querySelectorAll: () => [], +}; + +const { button } = await import('../../bindery/static/js/lib.js'); +const { settingsPage } = await import('../../bindery/static/js/settings.js'); +const { storiesPage } = await import('../../bindery/static/js/stories.js'); +const { storyPage } = await import('../../bindery/static/js/story.js'); +const { chapterPage } = await import('../../bindery/static/js/chapter.js'); +const { jobPanel, stopPolling } = await import('../../bindery/static/js/jobs.js'); + +const obsolete = new Set([ + 'btn', 'btn-primary', 'btn-danger', 'btn-small', 'btn-link', 'input', 'input-number', + 'row', 'spread', 'stack', 'push', 'grow', 'wide', 'block', 'plain', 'muted', 'small', + 'mono', 'nowrap', 'wrap', 'faded', +]); +const elements = (node) => node instanceof Element ? [node, ...node.children.flatMap(elements)] : []; +const text = (node) => node instanceof Element ? node.children.map(text).join('') : String(node); +const find = (root, tag, label) => elements(root).find((el) => el.tagName === tag && text(el) === label); +const settle = () => new Promise((resolve) => setImmediate(resolve)); +const config = { metadata_fields: { title: 'Title' }, mode_labels: {}, library_available: true, update_interval: 0 }; +const story = { + id: 1, title: 'Story', author: 'Author', url: 'https://example.com/story', site: 'example.com', + locked_fields: [], pinned: { title: 'Edited title' }, site_values: { title: 'Site title' }, +}; +const chapter = { + id: 2, title: 'Chapter', url: 'https://example.com/chapter', included: false, + on_site: true, current_rev: 2, revisions: 2, +}; +const rule = { pattern: 'before', replacement: 'after', flags: 'i', note: 'A note', enabled: true }; +beforeEach(() => { + toasts.replaceChildren(); + globalThis.fetch = async (url) => { + const replies = { + 'api/auth/check': { authenticated: true, mode: 'cwa', user: 'Reader' }, + 'api/config': config, + 'api/jobs': [], + 'api/settings': { recent_chapters: 3, personal_ini: '[defaults]' }, + 'api/rules': { rules: [rule], paragraphs: false, global_paragraphs: false }, + 'api/stories/1/rules': { rules: [rule], paragraphs: null, global_paragraphs: false }, + 'api/stories': [story], + 'api/stories/1': { story, chapters: [chapter] }, + 'api/chapters/2/diff?': { + chapter, a: 1, b: 2, identical: true, + revisions: [{ rev: 1, source: 'download', size: 100 }, { rev: 2, source: 'recheck', size: 100 }], + }, + }; + assert.ok(url in replies, `Unexpected request: ${url}`); + return { ok: true, json: async () => replies[url] }; + }; +}); + +test('plain buttons need no class and stay disabled until the action finishes', async () => { + let finish; + const pending = new Promise((resolve) => { finish = resolve; }); + const control = button('Save', () => pending); + assert.equal(control.tagName, 'button'); + assert.equal(control.attributes.type, 'button'); + assert.equal(control.className, ''); + let prevented = false; + const clicked = control.listeners.click({ preventDefault() { prevented = true; } }); + assert.equal(prevented, true); + assert.equal(control.disabled, true); + finish(); + await clicked; + assert.equal(control.disabled, false); +}); + +test('button variants and attributes do not need a base class', () => { + const control = button('Delete', () => {}, { class: 'danger', disabled: true, 'aria-label': 'Delete revision' }); + assert.equal(control.className, 'danger'); + assert.equal(control.disabled, true); + assert.equal(control.attributes['aria-label'], 'Delete revision'); +}); + +for (const [name, render] of [ + ['settings', (main) => settingsPage(main)], + ['stories', (main) => storiesPage(main, {}, config)], + ['story', (main) => storyPage(main, { storyId: '1' }, config)], + ['chapter', (main) => chapterPage(main, { storyId: '1', chapterId: '2' })], +]) { + test(`${name} renders native controls without utility classes`, async () => { + const main = new Element('main'); + const cleanup = render(main); + try { + await settle(); + assert.equal(toasts.children.length, 0); + const nodes = elements(main); + assert.ok(nodes.some((el) => el.tagName === 'button')); + assert.ok(nodes.some((el) => el.tagName === 'input' || el.tagName === 'select')); + for (const el of nodes) { + for (const cls of el.className.split(/\s+/)) assert.ok(!obsolete.has(cls), `${el.tagName}.${cls}`); + } + if (name === 'settings') { + assert.equal(nodes.find((el) => el.attributes.id === 'recent-chapters').value, 3); + assert.equal(nodes.find((el) => el.tagName === 'textarea').className, 'ini'); + const pattern = nodes.find((el) => el.attributes['aria-label'] === 'Pattern'); + assert.equal(pattern.value, 'before'); + assert.equal(pattern.className, ''); + assert.equal(nodes.find((el) => el.attributes['aria-label'] === 'Note').className, 'rule-note'); + } + if (name === 'story') { + const download = find(main, 'a', 'Download ePub'); + assert.equal(download.attributes.href, 'api/stories/1/epub'); + assert.equal(download.attributes.download, ''); + assert.equal(download.className, ''); + assert.equal(find(main, 'button', 'Use the site value').className, ''); + assert.ok(nodes.some((el) => el.className === 'chapter-excluded')); + } + if (name === 'chapter') { + assert.equal(find(main, 'button', 'Delete').className, 'danger'); + assert.equal(find(main, 'button', 'Use this revision').className, ''); + } + } finally { + cleanup?.(); + } + }); +} + +test('jobs use native secondary text and timestamps', () => { + const updated = '2026-01-02T03:04:05Z'; + const { el: list, render } = jobPanel(); + render([{ id: 'job', status: 'queued', title: 'Story', mode_label: 'Update', updated, message: 'Waiting' }]); + const nodes = elements(list); + assert.ok(find(list, 'small', 'Update')); + assert.equal(nodes.find((el) => el.tagName === 'time').attributes.datetime, updated); + assert.equal(find(list, 'button', 'Cancel').className, ''); + assert.ok(find(list, 'p', 'Waiting')); +}); + +test('a job update keeps the scroll box of the job list', () => { + const { el, render } = jobPanel(); + const job = { id: 'job', status: 'running', title: 'Story', mode_label: 'Update', progress: 0.1 }; + render([job]); + const [box] = el.children; + assert.equal(box.className, 'jobs'); + render([{ ...job, progress: 0.5 }, { ...job, id: 'next', status: 'queued' }]); + assert.deepEqual(el.children, [box]); + assert.equal(box.children.length, 2); + const [first] = box.children; + render([{ ...job, progress: 0.5 }, { ...job, id: 'next', status: 'queued' }]); + assert.equal(box.children[0], first, 'A poll without changes leaves the list alone'); + render([]); + assert.deepEqual(el.children[0].children, ['No jobs.']); + render([job]); + assert.deepEqual(el.children, [box]); +}); + +test('header navigation follows account controls and marks the current page', async (t) => { + const html = readFileSync(new URL('../../bindery/static/index.html', import.meta.url), 'utf8'); + assert.match(html, /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 <'), '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'); +}); diff --git a/tests/js/polling-flows.test.mjs b/tests/js/polling-flows.test.mjs new file mode 100644 index 0000000..07b20cd --- /dev/null +++ b/tests/js/polling-flows.test.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { install, server, settle } from './flow-helpers.mjs'; + +const ui = install(); +const { startPolling, stopPolling, watchJobs } = await import('../../bindery/static/js/jobs.js'); + +test('polling adapts to work and visibility, retries failures, and stops after an in-flight request', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const api = server(t); + const rendered = []; + const unsubscribe = watchJobs({ render: (jobs) => rendered.push(jobs) }); + t.after(() => { unsubscribe(); stopPolling(); }); + let jobs = []; + api.on('GET', 'jobs', () => jobs); + startPolling(); + await settle(); + t.mock.timers.tick(9999); + assert.equal(api.requests.length, 1); + jobs = [{ id: 'job', status: 'running' }]; + t.mock.timers.tick(1); + await settle(); + assert.equal(api.requests.length, 2); + t.mock.timers.tick(1999); + assert.equal(api.requests.length, 2); + t.mock.timers.tick(1); + await settle(); + assert.equal(api.requests.length, 3); + document.hidden = true; + ui.documentEvents.visibilitychange(); + assert.equal(api.requests.length, 3); + t.mock.timers.tick(2000); + await settle(); + t.mock.timers.tick(59999); + assert.equal(api.requests.length, 4); + t.mock.timers.tick(1); + await settle(); + assert.equal(api.requests.length, 5); + document.hidden = false; + api.on('GET', 'jobs', () => { throw new Error('Connection lost'); }); + const previous = rendered.length; + ui.documentEvents.visibilitychange(); + await settle(); + assert.equal(rendered.length, previous); + jobs = [{ id: 'job', status: 'done' }]; + api.on('GET', 'jobs', () => jobs); + t.mock.timers.tick(2000); + await settle(); + assert.deepEqual(rendered.at(-1), jobs); + let resolve; + api.on('GET', 'jobs', () => new Promise((done) => { resolve = done; })); + t.mock.timers.tick(10000); + stopPolling(); + resolve([]); + await settle(); + const total = api.requests.length; + t.mock.timers.tick(60000); + await settle(); + assert.equal(api.requests.length, total); +}); diff --git a/tests/js/proxy-auth.test.mjs b/tests/js/proxy-auth.test.mjs new file mode 100644 index 0000000..302e384 --- /dev/null +++ b/tests/js/proxy-auth.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { install, server, find, settle } from './flow-helpers.mjs'; + +const ui = install(); + +test('proxy authentication without a user does not offer a password login', async (t) => { + const backend = server(t); + backend.on('GET', 'auth/check', { authenticated: false, mode: 'proxy' }); + await import('../../bindery/static/js/main.js'); + await settle(); + assert.match(ui.app.textContent, /Log in through the proxy/); + assert.equal(find(ui.app, 'button', 'Log in'), undefined); + assert.deepEqual(backend.requests.map((r) => r.path), ['auth/check']); +}); diff --git a/tests/js/proxy-reload.test.mjs b/tests/js/proxy-reload.test.mjs new file mode 100644 index 0000000..e0df5a1 --- /dev/null +++ b/tests/js/proxy-reload.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { find, install, server, settle } from './flow-helpers.mjs'; + +const ui = install(); +const { api, setUnauthorizedHandler } = await import('../../bindery/static/js/lib.js'); +const { stopPolling } = await import('../../bindery/static/js/jobs.js'); + +// The proxy login page has no form to retry from, and a fetch keeps the proxy's own session +// cookie, so only a full page load lets the proxy step in again. +test('a 401 while logged in through the proxy reloads the page', async (t) => { + t.after(() => { + stopPolling(); + setUnauthorizedHandler(() => {}); + }); + const backend = server(t); + backend.on('GET', 'auth/check', { authenticated: true, mode: 'proxy', user: 'alice' }); + backend.on('GET', 'config', { metadata_fields: {}, mode_labels: {}, library_available: false }); + backend.on('GET', 'jobs', []); + backend.on('GET', 'stories', []); + backend.on('GET', 'expired', { error: 'Session ended' }, 401); + await import('../../bindery/static/js/main.js'); + await settle(); + assert.match(ui.app.textContent, /alice/); + let reloaded = 0; + window.location.reload = () => reloaded++; + await assert.rejects(api('expired'), /Session ended/); + await settle(); + assert.equal(reloaded, 1); + assert.equal(backend.sent('GET', 'auth/check').length, 1); +}); diff --git a/tests/js/regex-docs.test.mjs b/tests/js/regex-docs.test.mjs new file mode 100644 index 0000000..13fc567 --- /dev/null +++ b/tests/js/regex-docs.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { install, nodes, server, settle } from './flow-helpers.mjs'; + +install(); +const { globalRulesCard, regexDocs } = await import('../../bindery/static/js/rules.js'); + +const SYNTAX = 'https://docs.python.org/3/library/re.html#regular-expression-syntax'; +const HOWTO = 'https://docs.python.org/3/howto/regex.html'; +const links = (root) => nodes(root).filter((el) => el.tagName === 'a').map((a) => [a.textContent, a.attributes.href]); + +test('the rule hints link the regular expression syntax and an introduction', async (t) => { + const backend = server(t); + backend.on('GET', 'rules', { rules: [], paragraphs: null, global_paragraphs: false }); + backend.on('GET', 'stories', []); + const card = globalRulesCard(); + await settle(); + const expected = [['Python regular expressions', SYNTAX], ['introduction', HOWTO]]; + assert.deepEqual(links(card).filter(([, href]) => href.startsWith('https://docs.python.org/')), expected); + const [syntax] = regexDocs(); + assert.equal(syntax.attributes.target, '_blank'); + assert.equal(syntax.attributes.rel, 'noreferrer noopener'); +}); diff --git a/tests/js/revisions.test.mjs b/tests/js/revisions.test.mjs new file mode 100644 index 0000000..cecb7a7 --- /dev/null +++ b/tests/js/revisions.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { Element, find, install, nodes, server, settle } from './flow-helpers.mjs'; + +install(); +const { chapterPage } = await import('../../bindery/static/js/chapter.js'); + +test('the revision in the book and the newest revision have no Delete button', async (t) => { + const backend = server(t); + backend.on('GET', 'jobs', []); + backend.on('GET', 'chapters/2/diff?', { + chapter: { id: 2, title: 'One', url: 'https://example.com/c/1', current_rev: 1 }, story_title: 'Story', + a: 1, b: 3, identical: false, html: '', changed: 1, added: 0, removed: 0, + revisions: [{ rev: 1, size: 100 }, { rev: 2, size: 100 }, { rev: 3, size: 100 }], + }); + const main = new Element('main'); + const cleanup = chapterPage(main, { storyId: '1', chapterId: '2' }); + await settle(); + cleanup?.(); + const rows = nodes(main).filter((el) => el.tagName === 'tr').slice(1); + const badges = rows.map((row) => nodes(row.children[0]).filter((el) => el.tagName === 'span').map((el) => el.textContent)); + assert.deepEqual(badges, [['in book'], [], ['newest']]); + assert.deepEqual(rows.map((row) => Boolean(find(row, 'button', 'Delete'))), [false, true, false]); +}); diff --git a/tests/js/rule-fields.test.mjs b/tests/js/rule-fields.test.mjs new file mode 100644 index 0000000..c48cb97 --- /dev/null +++ b/tests/js/rule-fields.test.mjs @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; + +test('the monospace rule fields use the small text size, and stay as tall as the note field', () => { + const css = readFileSync(new URL('../../bindery/static/style.css', import.meta.url), 'utf8'); + const rules = [...css.matchAll(/\.rule input:not\(\.rule-note\) \{([^}]*)\}/g)].map((m) => m[1]).join(''); + for (const declaration of ['font-family: var(--mono)', 'font-size: var(--text-sm)', 'line-height: 1.5rem']) { + assert.ok(rules.includes(`${declaration};`), declaration); + } +}); diff --git a/tests/js/startup-error.test.mjs b/tests/js/startup-error.test.mjs new file mode 100644 index 0000000..af92a86 --- /dev/null +++ b/tests/js/startup-error.test.mjs @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { install, server, settle } from './flow-helpers.mjs'; + +const ui = install(); + +test('startup failure reports the API error rather than leaving a blank application', async (t) => { + const backend = server(t); + backend.on('GET', 'auth/check', { error: 'Service unavailable' }, 503); + await import('../../bindery/static/js/main.js'); + await settle(); + assert.match(ui.app.textContent, /Service unavailable/); + assert.deepEqual(backend.requests.map((r) => r.path), ['auth/check']); +}); diff --git a/tests/js/story-cover.test.mjs b/tests/js/story-cover.test.mjs new file mode 100644 index 0000000..bb19e2b --- /dev/null +++ b/tests/js/story-cover.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { Element, install, nodes, server, settle } from './flow-helpers.mjs'; + +install(); +const { storyPage } = await import('../../bindery/static/js/story.js'); + +const details = (cover) => ({ + story: { id: 1, title: 'Story', author: 'Writer', url: 'https://example.com/story', site: 'example.com', + locked_fields: [], pinned: {}, site_values: {}, auto_update: true, status: 'ok', cover }, + chapters: [], + recent_chapters: 0, +}); +const rules = { rules: [], paragraphs: null, global_paragraphs: false }; + +async function render(t, cover) { + const backend = server(t); + backend.on('GET', 'stories/1', details(cover)); + backend.on('GET', 'stories/1/rules', rules); + backend.on('GET', 'jobs?story_id=1', []); + const main = new Element('main'); + const cleanup = storyPage(main, { storyId: '1' }, { metadata_fields: {}, mode_labels: {} }); + await settle(); + cleanup?.(); + const summary = nodes(main).find((el) => el.className === 'card story-summary'); + return summary.children.filter((el) => el instanceof Element); +} + +test('the cover comes first in the story summary, with its version in the URL', async (t) => { + const [cover, heading] = await render(t, 'abc123'); + assert.equal(cover.tagName, 'img'); + assert.equal(cover.className, 'cover'); + assert.equal(cover.attributes.src, 'api/stories/1/cover?v=abc123'); + assert.equal(heading.className, 'story-heading'); + assert.equal(heading.children[0].textContent, 'Story'); +}); + +test('a story without a cover shows only its heading', async (t) => { + const children = await render(t, null); + assert.deepEqual(children.map((el) => el.className), ['story-heading']); +}); diff --git a/tests/js/story-tab.test.mjs b/tests/js/story-tab.test.mjs new file mode 100644 index 0000000..eeeaeff --- /dev/null +++ b/tests/js/story-tab.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { install, nodes, server, settle } from './flow-helpers.mjs'; + +const ui = install(); +const { stopPolling } = await import('../../bindery/static/js/jobs.js'); + +const title = 'A very long story title that the tab cuts off with an ellipsis'; +const story = (id, storyTitle) => ({ + story: { id, title: storyTitle, author: 'Writer', url: 'https://example.com/story', site: 'example.com', + locked_fields: [], pinned: {}, site_values: {}, auto_update: true, status: 'ok' }, + chapters: [{ id: 2, title: 'One', url: 'chapter-1', included: true, on_site: true, current_rev: 1, revisions: 1 }], + recent_chapters: 0, +}); +const diff = { chapter: { id: 2, title: 'One', url: 'https://example.com/c/1', current_rev: 1 }, story_title: title, + a: 1, b: 1, identical: true, html: '', changed: 0, added: 0, removed: 0, revisions: [{ rev: 1, size: 10 }] }; +const rules = { rules: [], paragraphs: null, global_paragraphs: false }; + +const tabs = () => nodes(ui.app).filter((el) => el.tagName === 'nav').flatMap((nav) => nav.children); +const storyTabs = () => tabs().filter((a) => a.className === 'story-tab'); +const current = () => tabs().filter((a) => a.attributes['aria-current'] === 'page').map((a) => a.textContent); +const go = async (hash) => { + window.location.hash = hash; + ui.windowEvents.hashchange(); + await settle(); +}; + +test('an open story has a tab with its title, also on its chapter pages', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + t.after(stopPolling); + const backend = server(t); + backend.on('GET', 'auth/check', { authenticated: true, mode: 'none', user: '' }); + backend.on('GET', 'config', { metadata_fields: {}, mode_labels: {}, library_available: false }); + backend.on('GET', 'jobs', []); + backend.on('GET', 'stories', []); + backend.on('GET', 'settings', { recent_chapters: 0, personal_ini: '' }); + backend.on('GET', 'rules', rules); + backend.on('GET', 'stories/1', story(1, title)); + backend.on('GET', 'stories/1/rules', rules); + backend.on('GET', 'stories/2', story(2, 'Second')); + backend.on('GET', 'stories/2/rules', rules); + backend.on('GET', 'chapters/2/diff?', diff); + await import('../../bindery/static/js/main.js'); + await settle(); + assert.deepEqual(storyTabs(), []); + + await go('#/story/1'); + const [tab] = storyTabs(); + assert.equal(tab.textContent, title); + assert.equal(tab.attributes.title, title); + assert.equal(tab.attributes.href, '#/story/1'); + assert.deepEqual(current(), [title]); + + await go('#/story/1/chapter/2'); + assert.deepEqual(storyTabs(), [tab]); + assert.deepEqual(current(), [title]); + + await go('#/story/2'); + assert.equal(storyTabs().length, 1); + assert.deepEqual(current(), ['Second']); + + await go('#/settings'); + assert.deepEqual(storyTabs(), []); + assert.deepEqual(current(), ['Settings']); +}); + +test('a story that loads after the user left it gets no tab', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + t.after(stopPolling); + const backend = server(t); + let release; + backend.on('GET', 'stories', []); + backend.on('GET', 'jobs', []); + backend.on('GET', 'stories/3', () => new Promise((resolve) => { release = () => resolve(story(3, 'Late')); })); + backend.on('GET', 'stories/3/rules', rules); + await go('#/story/3'); + await go('#/'); + release(); + await settle(); + assert.deepEqual(storyTabs(), []); + assert.deepEqual(current(), ['Stories']); +}); + +test('a long title ends in an ellipsis', () => { + const css = readFileSync(new URL('../../bindery/static/style.css', import.meta.url), 'utf8'); + const rule = css.match(/header nav a\.story-tab \{([^}]*)\}/)[1]; + for (const declaration of ['max-width: 24ch', 'overflow: hidden', 'text-overflow: ellipsis', 'white-space: nowrap']) { + assert.ok(rule.includes(`${declaration};`), declaration); + } + // Without it, the nav keeps the width of the whole title and runs past the header on a phone. + assert.ok(css.match(/header nav \{([^}]*)\}/)[1].includes('min-width: 0;')); +}); diff --git a/tests/js/upload-hold.test.mjs b/tests/js/upload-hold.test.mjs new file mode 100644 index 0000000..a41a73d --- /dev/null +++ b/tests/js/upload-hold.test.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { Element, change, click, find, install, nodes, section, server, settle } from './flow-helpers.mjs'; + +const ui = install(); +const { settingsPage } = await import('../../bindery/static/js/settings.js'); +const { storyPage } = await import('../../bindery/static/js/story.js'); +const { storiesPage } = await import('../../bindery/static/js/stories.js'); + +const book = { id: 7, uuid: 'book-7', title: 'Library Title', authors: ['Writer'] }; +const details = (story, extra = {}) => ({ + story: { id: 1, title: 'Story', author: 'Writer', url: 'https://example.com/story', site: 'example.com', + locked_fields: [], pinned: {}, site_values: {}, auto_update: true, status: 'ok', cover: null, + calibre_id: null, calibre_uuid: null, ingested_at: null, upload_held: false, needs_approval: false, ...story }, + chapters: [], + recent_chapters: 0, + uploads_paused: false, + book: null, + ...extra, +}); + +async function renderStory(t, data) { + const backend = server(t); + backend.on('GET', 'stories/1', data); + backend.on('GET', 'stories/1/rules', { rules: [], paragraphs: null, global_paragraphs: false }); + backend.on('GET', 'jobs?story_id=1', []); + backend.on('GET', 'jobs', []); + const main = new Element('main'); + const cleanup = storyPage(main, { storyId: '1' }, { metadata_fields: {}, mode_labels: {} }); + t.after(() => cleanup?.()); + await settle(); + const summary = nodes(main).find((el) => el.className === 'card story-summary'); + return { backend, notice: nodes(summary).find((el) => el.className === 'notice') }; +} + +test('the upload switches load and save on the settings page', async (t) => { + const backend = server(t); + backend.on('GET', 'settings', { recent_chapters: 0, hold_new_links: true, pause_uploads: false, personal_ini: '' }); + backend.on('PUT', 'settings', {}); + backend.on('GET', 'rules', { rules: [], paragraphs: false }); + backend.on('GET', 'stories', []); + const main = new Element('main'); + settingsPage(main); + await settle(); + const card = section(main, 'Uploads to CWA'); + const [hold, pause] = nodes(card).filter((el) => el.attributes.type === 'checkbox'); + assert.deepEqual([hold.checked, pause.checked], [true, false]); + change(pause, true); + await click(card, 'Save'); + assert.deepEqual(backend.sent('PUT', 'settings')[0].body, { hold_new_links: true, pause_uploads: true }); +}); + +test('a held book with a new link names the book and sends it on approval', async (t) => { + const data = details( + { calibre_id: 7, calibre_uuid: 'book-7', ingested_at: '2026-01-01', upload_held: true, needs_approval: true }, + { book }, + ); + const { backend, notice } = await renderStory(t, data); + assert.match(notice.textContent, /waits for your approval\. The next upload replaces the ePub of library book 7: Library Title · Writer\./); + backend.on('POST', 'stories/1/approve', { story: data.story, job: { id: 'j1' } }); + await click(notice, 'Approve and send'); + await settle(); + assert.deepEqual(backend.sent('POST', 'stories/1/approve')[0].body, { uuid: 'book-7' }); + assert.match(ui.toasts.textContent, /Approved\. Bindery rebuilds the book and sends it\./); +}); + +test('a wrong link can go to CWA as a new book instead', async (t) => { + const data = details( + { calibre_id: 7, calibre_uuid: 'book-7', ingested_at: '2026-01-01', upload_held: true, needs_approval: true }, + { book }, + ); + const { backend, notice } = await renderStory(t, data); + backend.on('POST', 'stories/1/link', data.story); + backend.on('POST', 'stories/1/approve', { story: data.story, job: null }); + await click(notice, 'Send as a new book instead'); + await settle(); + assert.deepEqual( + backend.requests.filter((r) => r.method === 'POST').map((r) => [r.path, r.body]), + [['stories/1/link', { uuid: null }], ['stories/1/approve', { uuid: null }]], + ); +}); + +test('a new story asks for approval to go to CWA as a new book', async (t) => { + const { notice } = await renderStory(t, details({ needs_approval: true })); + assert.match(notice.textContent, /The next upload goes to CWA as a new book\./); + assert.ok(find(notice, 'button', 'Approve')); + assert.equal(find(notice, 'button', 'Send as a new book instead'), undefined); +}); + +test('a paused upload points to the settings', async (t) => { + const { notice } = await renderStory( + t, + details({ calibre_id: 7, calibre_uuid: 'book-7', ingested_at: '2026-01-01', upload_held: true }, { book, uploads_paused: true }), + ); + assert.match(notice.textContent, /Uploads to CWA are paused/); + assert.equal(find(notice, 'a', 'Settings').attributes.href, '#/settings'); + assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0); +}); + +test('a story that went to CWA without a link shows no approval until Bindery finds its book', async (t) => { + const { notice } = await renderStory(t, details({ ingested_at: '2026-01-01', needs_approval: true })); + assert.equal(notice, undefined); +}); + +test('the story list marks a held book', async (t) => { + const backend = server(t); + backend.on('GET', 'stories', [ + { id: 1, url: 'u1', title: 'Held', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok', + message: '', upload_held: true }, + { id: 2, url: 'u2', title: 'Sent', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok', + message: '', upload_held: false }, + ]); + backend.on('GET', 'jobs', []); + const main = new Element('main'); + const cleanup = storiesPage(main, {}, { library_available: false }); + t.after(() => cleanup?.()); + await settle(); + const held = nodes(main).filter((el) => el.tagName === 'tr').filter((row) => find(row, 'span', 'held')); + assert.equal(held.length, 1); + assert.match(held[0].textContent, /^Held/); +}); diff --git a/tests/test_api.py b/tests/test_api.py index 6a29718..3ec45ed 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,6 +1,9 @@ """The web API: login modes, the CSRF header, URL_BASE and the web interface.""" +import base64 +import hashlib import io +import re import time import pytest @@ -177,6 +180,7 @@ def test_incomplete_login_settings_refuse_every_request(client, monkeypatch, set ): assert (response.status_code, response.json) == (503, {"error": message}) assert client.get("/api/health").status_code == 200 + assert client.get("/").status_code == 200 def test_api_key(client, cwa, monkeypatch): @@ -309,3 +313,34 @@ def test_metadata_edit(client): assert (response.status_code, response.json) == (400, {"error": "A story needs a title"}) assert client.put(f"/api/stories/{story_id}/metadata", json={}, headers=H).status_code == 400 assert client.put("/api/stories/999/metadata", json={"values": {}}, headers=H).status_code == 404 + + +def test_web_interface_is_served(client): + page = client.get("/") + assert page.status_code == 200 and b'", page.get_data(as_text=True), re.DOTALL) + assert scripts + for script in scripts: + digest = base64.b64encode(hashlib.sha256(script.encode()).digest()).decode() + assert f"'sha256-{digest}'" in policy + + +def test_url_base(): + app = web.PrefixMiddleware(web.app.wsgi_app, "/bindery") + web.app.wsgi_app, original = app, web.app.wsgi_app + try: + client = web.app.test_client() + assert client.get("/bindery/api/health").status_code == 200 + assert client.get("/api/health").status_code == 200 + assert client.get("/bindery/static/style.css").status_code == 200 + assert client.get("/other").status_code == 404 + finally: + web.app.wsgi_app = original