CWA_ENABLED now defaults to false. If a CWA variable is set while CWA is off, the start log has a warning that names it. On the Settings page, the alert about INGEST_DIR moves to Book Downloads and shows with CWA on or off. The Uploads to CWA card moves below Book Downloads.
350 lines
17 KiB
JavaScript
350 lines
17 KiB
JavaScript
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, download_fields: {} };
|
|
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(section(main, 'Book Downloads')).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(section(main, 'Book Downloads')).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', sort_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', sort_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', sort_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/);
|
|
});
|