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 config = { cwa_enabled: true, job_list_max_hours: 720, job_list_max_count: 500 }; 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, pause_uploads: 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' }, { cwa_enabled: true, metadata_fields: {}, mode_labels: {} }); t.after(() => cleanup?.()); await settle(); const summary = nodes(main).find((el) => el.className === 'card story-summary'); return { backend, summary, 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, link_books: true, hold_new_links: true, pause_uploads: false, ingest_available: true, personal_ini: '' }); backend.on('PUT', 'settings', { link_books: false, hold_new_links: true, pause_uploads: true, ingest_available: true }); backend.on('GET', 'rules', { rules: [], paragraphs: false }); backend.on('GET', 'stories', []); const main = new Element('main'); settingsPage(main, {}, config); await settle(); const card = section(main, 'Uploads to CWA'); const [link, hold, pause] = nodes(card).filter((el) => el.attributes.type === 'checkbox'); assert.deepEqual([link.checked, hold.checked, pause.checked], [true, true, false]); assert.equal(find(card, 'label', ' Link books to CWA library').children[0], link); assert.match(card.textContent, /send a plain ePub to INGEST_DIR/); const warning = nodes(card).find((el) => el.attributes.id === 'link-note'); assert.equal(warning.className, 'notice'); assert.equal(warning.hidden, true); assert.equal(link.attributes['aria-describedby'], ''); assert.match(warning.textContent, /Updates without a saved CWA book ID create duplicate books if CWA imports them/); assert.equal(nodes(card).find((el) => el.attributes.id === 'ingest-note').hidden, true); assert.equal(pause.disabled, false); assert.equal(hold.disabled, false); assert.equal(link.disabled, false); assert.equal(nodes(card).find((el) => el.attributes.id === 'library-note').hidden, true); change(link, false); assert.equal(warning.hidden, false); assert.equal(link.attributes['aria-describedby'], warning.attributes.id); assert.equal(hold.disabled, true); assert.equal(hold.checked, true); change(link, true); assert.equal(warning.hidden, true); assert.equal(hold.disabled, false); assert.equal(hold.checked, true); change(link, false); change(pause, true); await click(card, 'Save'); assert.deepEqual(backend.sent('PUT', 'settings')[0].body, { link_books: false, hold_new_links: true, pause_uploads: true }); assert.deepEqual([link.checked, hold.checked, pause.checked], [false, true, true]); assert.equal(hold.disabled, true); assert.equal(warning.hidden, false); }); for (const ingestAvailable of [true, false]) { test(`missing library sources disable linking and approval with ingest available ${ingestAvailable}`, async (t) => { const backend = server(t); const saved = { link_books: true, linking_available: false, hold_new_links: true, pause_uploads: false, ingest_available: ingestAvailable }; backend.on('GET', 'settings', saved); backend.on('PUT', 'settings', saved); backend.on('GET', 'rules', { rules: [], paragraphs: false }); backend.on('GET', 'stories', []); const main = new Element('main'); settingsPage(main, {}, config); await settle(); const card = section(main, 'Uploads to CWA'); const [link, hold, pause] = nodes(card).filter((el) => el.attributes.type === 'checkbox'); const note = nodes(card).find((el) => el.attributes.id === 'library-note'); assert.equal(link.checked, false); assert.equal(link.disabled, true); assert.equal(hold.checked, true); assert.equal(hold.disabled, true); assert.equal(pause.checked, !ingestAvailable); assert.equal(pause.disabled, !ingestAvailable); assert.equal(note.hidden, false); assert.equal(note.className, 'notice'); assert.match(note.textContent, /CWA_URL and CALIBRE_DB_PATH are not set/); assert.match(note.textContent, /Set one or both and restart Bindery/); assert.equal(link.attributes['aria-describedby'], 'library-note link-note'); assert.equal(nodes(card).find((el) => el.attributes.id === 'link-note').hidden, false); await click(card, 'Save'); assert.equal(backend.sent('PUT', 'settings')[0].body.link_books, false); assert.equal(link.checked, false); assert.equal(link.disabled, true); }); } for (const linkBooks of [true, false]) { test(`missing INGEST_DIR forces pause and shows a highlighted note with linking ${linkBooks}`, async (t) => { const backend = server(t); const saved = { link_books: linkBooks, hold_new_links: false, pause_uploads: true, ingest_available: false }; backend.on('GET', 'settings', saved); backend.on('PUT', 'settings', saved); backend.on('GET', 'rules', { rules: [], paragraphs: false }); backend.on('GET', 'stories', []); const main = new Element('main'); settingsPage(main, {}, config); await settle(); const card = section(main, 'Uploads to CWA'); const [link, hold, pause] = nodes(card).filter((el) => el.attributes.type === 'checkbox'); assert.equal(link.checked, linkBooks); assert.equal(hold.disabled, !linkBooks); assert.equal(nodes(card).find((el) => el.attributes.id === 'link-note').hidden, linkBooks); assert.equal(pause.checked, true); assert.equal(pause.disabled, true); const note = nodes(card).find((el) => el.attributes.id === 'ingest-note'); assert.equal(note.hidden, false); assert.equal(note.className, 'notice'); assert.equal(pause.attributes['aria-describedby'], note.attributes.id); assert.match(note.textContent, /INGEST_DIR is not set/); await click(card, 'Save'); assert.equal(pause.checked, true); assert.equal(pause.disabled, true); assert.equal(backend.sent('PUT', 'settings')[0].body.pause_uploads, true); }); } test('a saved approval preference stays checked but disabled while linking is off', async (t) => { const backend = server(t); backend.on('GET', 'settings', { link_books: false, hold_new_links: true, pause_uploads: false, ingest_available: true }); backend.on('GET', 'rules', { rules: [], paragraphs: false }); backend.on('GET', 'stories', []); const main = new Element('main'); settingsPage(main, {}, config); await settle(); const card = section(main, 'Uploads to CWA'); const [link, hold] = nodes(card).filter((el) => el.attributes.type === 'checkbox'); assert.equal(hold.disabled, true); assert.equal(hold.checked, true); change(link, true); assert.equal(hold.disabled, false); assert.equal(hold.checked, 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, /^Approve replacing 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\. Rebuild queued\./); }); 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 }]], ); }); for (const uuid of [null, 'book-7']) { test(`approval while paused saves the decision without promising an upload: ${uuid}`, async (t) => { const data = details( { calibre_id: uuid ? 7 : null, calibre_uuid: uuid, upload_held: true, needs_approval: true }, { link_books: true, pause_uploads: true }, ); const { backend, notice } = await renderStory(t, data); assert.ok(find(notice, 'button', 'Approve')); assert.equal(find(notice, 'button', 'Approve and send'), undefined); assert.equal(find(notice, 'button', 'Send as a new book instead'), undefined); backend.on('POST', 'stories/1/approve', { story: data.story, job: null }); await click(notice, 'Approve'); assert.deepEqual(backend.sent('POST', 'stories/1/approve')[0].body, { uuid }); assert.equal(ui.toasts.children.at(-1).textContent, 'Approved'); }); } test('approval as a new book while paused keeps the pause in the confirmation', async (t) => { const data = details( { calibre_id: 7, calibre_uuid: 'book-7', upload_held: true, needs_approval: true }, { link_books: true, pause_uploads: true }, ); const { backend, notice } = await renderStory(t, data); const confirm = t.mock.method(window, 'confirm', () => true); backend.on('POST', 'stories/1/link', data.story); backend.on('POST', 'stories/1/approve', { story: data.story, job: null }); await click(notice, 'Approve as a new book instead'); assert.match(confirm.mock.calls[0].arguments[0], /approve this story as a new book\? Uploads remain paused/); assert.match(confirm.mock.calls[0].arguments[0], /you get a duplicate/); assert.deepEqual(backend.sent('POST', 'stories/1/approve')[0].body, { uuid: null }); assert.equal(ui.toasts.children.at(-1).textContent, 'Approved'); }); test('a saved ID without a UUID still offers approval', async (t) => { const { notice } = await renderStory(t, details( { calibre_id: 7, ingested_at: '2026-01-01', upload_held: true, needs_approval: true }, )); assert.ok(find(notice, 'button', 'Approve and send')); assert.match(notice.textContent, /library book 7/); }); 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, /^Approve this story as a new CWA 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, pause_uploads: 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); }); for (const savedId of [null, 7]) { for (const paused of [false, true]) { for (const needsApproval of [false, true]) { test(`linking off hides approval with saved ID ${savedId}, pause ${paused}, and approval ${needsApproval}`, async (t) => { const { notice } = await renderStory(t, details( { calibre_id: savedId, calibre_uuid: savedId ? 'book-7' : null, upload_held: true, needs_approval: needsApproval }, { link_books: false, pause_uploads: paused }, )); if (savedId !== null && !paused) { assert.equal(notice, undefined); return; } assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0); assert.doesNotMatch(notice.textContent, /waits for your approval|as a new book|Bindery sends this book/); if (savedId === null) { assert.match(notice.textContent, /If CWA imports them, updates will create duplicate books/); assert.equal(notice.className, 'notice'); } else { assert.doesNotMatch(notice.textContent, /duplicate books/); } if (paused) { assert.match(notice.textContent, /Uploads to CWA are paused/); assert.equal(find(notice, 'a', 'Settings').attributes.href, '#/settings'); } }); } } } test('linking off warns that repeated exports without a saved ID create duplicates in CWA', async (t) => { const { notice, summary } = await renderStory(t, details( { ingested_at: '2026-01-01', needs_approval: true, upload_held: true }, { link_books: false }, )); assert.match(notice.textContent, /If CWA imports them, updates will create duplicate books/); assert.match(summary.textContent, /Sent to ingest folder/); assert.doesNotMatch(summary.textContent, /Sent to CWA, unlinked/); 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('missing INGEST_DIR points to the environment rather than the disabled pause switch', async (t) => { const { notice } = await renderStory(t, details( { upload_held: true }, { pause_uploads: true, ingest_available: false }, )); assert.equal(notice.textContent, 'Set INGEST_DIR and restart Bindery to enable uploads.'); assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0); }); test('missing library sources leave uploads enabled with the duplicate warning', async (t) => { const { notice } = await renderStory(t, details( { ingested_at: '2026-01-01', upload_held: true, needs_approval: true }, { link_books: false }, )); assert.match(notice.textContent, /If CWA imports them, updates will create duplicate books/); assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0); }); test('a new book asks for approval when library linking is enabled', async (t) => { const { notice } = await renderStory(t, details( { upload_held: true, needs_approval: true }, { link_books: true }, )); assert.match(notice.textContent, /^Approve this story as a new CWA book\./); assert.ok(find(notice, 'button', 'Approve and send')); }); test('approval before a build still shows the pause and never offers to send', async (t) => { const { notice } = await renderStory(t, details( { needs_approval: true }, { pause_uploads: true }, )); assert.match(notice.textContent, /Uploads to CWA are paused\. Resume them in Settings\./); assert.ok(find(notice, 'button', 'Approve')); assert.equal(find(notice, 'button', 'Approve and send'), undefined); }); test('the story list marks only an approval hold, not every unsent 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, approval_held: true }, { id: 2, url: 'u2', title: 'Sent', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok', message: '', upload_held: false, approval_held: false }, { id: 3, url: 'u3', title: 'Paused', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok', message: '', upload_held: true, approval_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/); assert.match(find(held[0], 'span', 'held').attributes.title, /waits for your approval/); });