Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

836 lines
40 KiB
JavaScript

/**
* Tests for collections_manager.js — the Collections page.
*
* Covers the index-status surface and reindex action added in the
* #3939/#4627 follow-up, plus the background-sweep toggle persistence:
* - indexStatusMarkup(): "<indexed> of <total> indexed" string + the
* "<pending> pending indexing" badge, with numeric coercion / clamping.
* - triggerReindex(): POSTs to the index/start endpoint, polls status to a
* terminal state, keeps the button disabled in-flight, refreshes counts,
* and (critically) the card is an <a> so the click must NOT navigate.
* - loadBackgroundSweepSetting()/saveBackgroundSweepSetting(): read/write the
* document_scheduler.sweep_library_collections setting.
*/
// --- Global stubs the module reaches for at call time ----------------------
window.URLS = {
LIBRARY_API: {
COLLECTIONS: '/library/api/collections',
COLLECTION_INDEX_START: '/library/api/collections/{id}/index/start',
COLLECTION_INDEX_STATUS: '/library/api/collections/{id}/index/status',
},
};
// escapeHtml is captured at module load via `window.escapeHtml || fallback`.
window.escapeHtml = (s) => String(s);
window.api = { getCsrfToken: () => 'test-csrf' };
globalThis.alert = () => {};
// safeFetchWithAuth is the global the module calls (auth-aware fetch wrapper); default to a benign stub so the
// import-time DOMContentLoaded handler (if it fires) doesn't explode.
globalThis.safeFetchWithAuth = vi.fn(() =>
Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) })
);
let indexStatusMarkup;
let triggerReindex;
let bindReindexButtons;
let collectionApiUrl;
let loadBackgroundSweepSetting;
let saveBackgroundSweepSetting;
beforeAll(async () => {
await import('@js/collections_manager.js');
indexStatusMarkup = window.indexStatusMarkup;
triggerReindex = window.triggerReindex;
bindReindexButtons = window.bindReindexButtons;
collectionApiUrl = window.collectionApiUrl;
loadBackgroundSweepSetting = window.loadBackgroundSweepSetting;
saveBackgroundSweepSetting = window.saveBackgroundSweepSetting;
});
beforeEach(() => {
globalThis.safeFetchWithAuth.mockReset();
});
describe('indexStatusMarkup', () => {
it('reports "<indexed> of <total> indexed" and a pending badge when work remains', () => {
const html = indexStatusMarkup({ document_count: 5, indexed_document_count: 2 });
expect(html).toContain('2 of 5 indexed');
expect(html).toContain('3 pending indexing');
expect(html).toContain('ldr-pending-index-badge');
});
it('omits the pending badge when everything is indexed', () => {
const html = indexStatusMarkup({ document_count: 4, indexed_document_count: 4 });
expect(html).toContain('4 of 4 indexed');
expect(html).not.toContain('pending indexing');
// Fully-indexed gets the success styling, not the warning badge.
expect(html).toContain('fa-check-circle');
});
it('shows "No documents" for an empty collection (no divide-by-zero / no badge)', () => {
const html = indexStatusMarkup({ document_count: 0, indexed_document_count: 0 });
expect(html).toContain('No documents');
expect(html).not.toContain('pending indexing');
});
it('clamps indexed to total and coerces non-numeric counts to integers', () => {
// indexed > total (stale payload) must not produce a negative pending.
const html = indexStatusMarkup({ document_count: 3, indexed_document_count: 9 });
expect(html).toContain('3 of 3 indexed');
expect(html).not.toContain('pending indexing');
// Garbage values coerce to 0, not NaN.
const html2 = indexStatusMarkup({ document_count: 'x', indexed_document_count: 'y' });
expect(html2).toContain('No documents');
expect(html2).not.toContain('NaN');
});
});
describe('collectionApiUrl', () => {
it('substitutes {id} with an encoded collection id', () => {
expect(collectionApiUrl('/a/{id}/b', 'c d')).toBe('/a/c%20d/b');
});
});
describe('triggerReindex', () => {
function makeButton(id) {
// Build via the DOM API (not innerHTML interpolation) so the
// no-unsanitized lint rule stays clean. Mirrors renderCollections():
// a wrapper <div> holds the clickable card <a> plus a SIBLING actions
// row that contains the Reindex button (never nested in the anchor).
document.body.innerHTML = '';
const wrapper = document.createElement('div');
wrapper.className = 'ldr-collection-card-wrapper';
wrapper.setAttribute('data-id', id);
const link = document.createElement('a');
link.href = `/library/collections/${encodeURIComponent(id)}`;
link.className = 'ldr-collection-card';
const actions = document.createElement('div');
actions.className = 'ldr-collection-card-actions';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ldr-reindex-btn';
btn.setAttribute('data-reindex-id', id);
const icon = document.createElement('i');
icon.className = 'fas fa-sync-alt';
const label = document.createElement('span');
label.className = 'ldr-reindex-label';
label.textContent = 'Reindex';
btn.append(icon, label);
actions.appendChild(btn);
wrapper.append(link, actions);
document.body.appendChild(wrapper);
return btn;
}
// triggerReindex() ends with loadCollections(), which writes to
// #collections-container / #no-collections-message. The bounded-poll tests
// below assert "no error alert", so those elements must exist or
// loadCollections() throws and spuriously fires showError('Failed to load
// collections'). Add them and re-parent the card wrapper into the
// container (so the loadCollections success path is exercised, not an
// empty-DOM crash).
function withCollectionsContainer(btn) {
const wrapper = btn.closest('.ldr-collection-card-wrapper');
const container = document.createElement('div');
container.id = 'collections-container';
container.appendChild(wrapper);
const noMsg = document.createElement('div');
noMsg.id = 'no-collections-message';
document.body.append(container, noMsg);
}
it('POSTs to the start endpoint, polls to completion, and refreshes counts', async () => {
const btn = makeButton('coll-1');
const calls = [];
globalThis.safeFetchWithAuth.mockImplementation((url, opts) => {
calls.push({ url, method: opts && opts.method });
if (url.includes('/index/start')) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, task_id: 't1' }) });
}
if (url.includes('/index/status')) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ status: 'completed' }) });
}
// loadCollections() refresh
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) });
});
await triggerReindex(btn);
const start = calls.find(c => c.url.includes('/index/start'));
expect(start).toBeTruthy();
expect(start.method).toBe('POST');
expect(start.url).toBe('/library/api/collections/coll-1/index/start');
// Polled status at least once.
expect(calls.some(c => c.url.includes('/index/status'))).toBe(true);
// Refreshed the collection list afterwards.
expect(calls.some(c => c.url === '/library/api/collections')).toBe(true);
// Button re-enabled after completion.
expect(btn.disabled).toBe(false);
});
it('keeps the button disabled while indexing is in flight', async () => {
const btn = makeButton('coll-2');
let resolveStatus;
globalThis.safeFetchWithAuth.mockImplementation((url) => {
if (url.includes('/index/start')) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true }) });
}
if (url.includes('/index/status')) {
// First status call hangs until we resolve it — simulates in-flight.
return new Promise((res) => { resolveStatus = () => res({ ok: true, status: 200, json: () => Promise.resolve({ status: 'completed' }) }); });
}
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) });
});
const promise = triggerReindex(btn);
// Let the start POST + first status fetch be issued.
await Promise.resolve();
await Promise.resolve();
expect(btn.disabled).toBe(true);
resolveStatus();
await promise;
expect(btn.disabled).toBe(false);
});
it('does not navigate when the Reindex button is clicked (button is a sibling of the card <a>)', async () => {
const btn = makeButton('coll-3');
const container = document.createElement('div');
container.id = 'collections-container';
// Move the whole card wrapper into a real container so delegation matches.
container.appendChild(btn.closest('.ldr-collection-card-wrapper'));
document.body.appendChild(container);
globalThis.safeFetchWithAuth.mockImplementation((url) => {
if (url.includes('/index/start')) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true }) });
}
if (url.includes('/index/status')) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ status: 'idle' }) });
}
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) });
});
bindReindexButtons(container);
const event = new window.MouseEvent('click', { bubbles: true, cancelable: true });
btn.dispatchEvent(event);
// The delegated handler must have prevented default navigation.
expect(event.defaultPrevented).toBe(true);
});
it('surfaces an error to the user when indexing fails during polling', async () => {
const btn = makeButton('coll-4');
// showError() routes through alert(); spy on it.
const alertSpy = vi.spyOn(globalThis, 'alert').mockImplementation(() => {});
globalThis.safeFetchWithAuth.mockImplementation((url) => {
if (url.includes('/index/start')) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true }) });
}
if (url.includes('/index/status')) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ status: 'failed', error_message: 'embedding model missing' }) });
}
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) });
});
await triggerReindex(btn);
// The terminal failed status must reach the user, not just the log.
expect(alertSpy).toHaveBeenCalled();
const messages = alertSpy.mock.calls.map(c => String(c[0])).join(' ');
expect(messages).toContain('embedding model missing');
// Button re-enabled even though it failed.
expect(btn.disabled).toBe(false);
alertSpy.mockRestore();
});
// --- Start-endpoint failure handling -----------------------------------
// The /index/start POST can come back non-OK. The handler distinguishes a
// generic start failure (any non-409) — surface the error, skip polling,
// re-enable the button — from a 409 "already running" (fall THROUGH and
// poll the in-flight task to completion). The mocks above always return a
// 200/success start, so neither branch is exercised by them.
it('on a generic start failure: surfaces the error, does NOT poll, and re-enables the button', async () => {
const btn = makeButton('coll-start-fail');
withCollectionsContainer(btn);
const alertSpy = vi.spyOn(globalThis, 'alert').mockImplementation(() => {});
const logSpy = vi.spyOn(globalThis.SafeLogger, 'error');
const calls = [];
globalThis.safeFetchWithAuth.mockImplementation((url) => {
calls.push({ url });
if (url.includes('/index/start')) {
// Non-OK, non-409 start: e.g. the worker is busy / a 500.
return Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({ success: false, error: 'busy' }) });
}
if (url.includes('/index/status')) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ status: 'completed' }) });
}
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) });
});
await triggerReindex(btn);
// The start error reaches the user (showError → alert) with the message.
expect(alertSpy).toHaveBeenCalled();
const messages = alertSpy.mock.calls.map(c => String(c[0])).join(' ');
expect(messages).toContain('busy');
expect(logSpy).toHaveBeenCalled();
// The early return skips polling entirely — the status endpoint is never hit.
expect(calls.some(c => c.url.includes('/index/status'))).toBe(false);
// Button re-enabled and label restored so the user can retry.
expect(btn.disabled).toBe(false);
expect(btn.querySelector('.ldr-reindex-label').textContent).toBe('Reindex');
logSpy.mockRestore();
alertSpy.mockRestore();
});
it('on a 409 "already running" start: does NOT early-return — falls through and polls to completion', async () => {
const btn = makeButton('coll-start-409');
withCollectionsContainer(btn);
const alertSpy = vi.spyOn(globalThis, 'alert').mockImplementation(() => {});
const calls = [];
globalThis.safeFetchWithAuth.mockImplementation((url) => {
calls.push({ url });
if (url.includes('/index/start')) {
// 409 = indexing is already in flight for this collection.
return Promise.resolve({ ok: false, status: 409, json: () => Promise.resolve({ success: false, error: 'already running' }) });
}
if (url.includes('/index/status')) {
// Drive a terminal status so the fall-through poll resolves.
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ status: 'completed' }) });
}
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) });
});
await triggerReindex(btn);
// 409 must fall through to polling — the status endpoint IS hit, unlike
// the generic-failure case above which returns before polling.
expect(calls.some(c => c.url.includes('/index/status'))).toBe(true);
// The 409 itself is not surfaced as an error; completion is clean.
expect(alertSpy).not.toHaveBeenCalled();
// Button re-enabled once the in-flight task is tracked to completion.
expect(btn.disabled).toBe(false);
alertSpy.mockRestore();
});
// --- Bounded-poll loop coverage ----------------------------------------
// The status mocks above all resolve TERMINAL on the FIRST poll, so the
// loop's bounding logic (the 2s setTimeout re-poll, the consecutive-error
// counter + its reset-on-success, the POLL_MAX_ERRORS bail, the
// POLL_MAX_ATTEMPTS timeout cap, and the !response.ok HTTP-error guard) is
// never executed by them. These tests drive the loop across multiple polls
// using fake timers so mutations to that logic fail.
it('polls across the 2s interval through several "processing" responses before completing', async () => {
vi.useFakeTimers();
try {
const btn = makeButton('coll-poll-1');
withCollectionsContainer(btn);
let statusCalls = 0;
globalThis.safeFetchWithAuth.mockImplementation((url) => {
if (url.includes('/index/start')) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true }) });
}
if (url.includes('/index/status')) {
statusCalls += 1;
// Stay "processing" for the first 3 polls, then complete.
const status = statusCalls >= 4 ? 'completed' : 'processing';
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ status }) });
}
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) });
});
const promise = triggerReindex(btn);
// Each non-terminal poll re-arms a 2s setTimeout; advance enough to
// walk through all of them.
await vi.advanceTimersByTimeAsync(2000 * 5);
await promise;
// Must have polled more than once (the loop actually iterated).
expect(statusCalls).toBeGreaterThanOrEqual(4);
expect(btn.disabled).toBe(false);
} finally {
vi.useRealTimers();
}
});
it('retries through consecutive fetch errors (error counter resets on success) and completes', async () => {
vi.useFakeTimers();
try {
const btn = makeButton('coll-poll-errreset');
withCollectionsContainer(btn);
const alertSpy = vi.spyOn(globalThis, 'alert').mockImplementation(() => {});
let statusCalls = 0;
globalThis.safeFetchWithAuth.mockImplementation((url) => {
if (url.includes('/index/start')) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true }) });
}
if (url.includes('/index/status')) {
statusCalls += 1;
// Pattern: 4 errors, success(processing), 4 errors,
// success(completed). 8 transient errors total, but never 5
// IN A ROW. This is mutation-sensitive: if the reset-on-
// success (`errorCount = 0;`) is removed, the counter
// accumulates past POLL_MAX_ERRORS (5) on the 5th cumulative
// error → the poll bails (resolve null), the loop never
// reaches the completed status, and statusCalls stops at 5.
// With the reset, each group of 4 stays under the limit and
// the poll reaches completion.
if (statusCalls >= 1 && statusCalls <= 4) {
return Promise.reject(new Error('network down'));
}
if (statusCalls === 5) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ status: 'processing' }) });
}
if (statusCalls >= 6 && statusCalls <= 9) {
return Promise.reject(new Error('network down'));
}
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ status: 'completed' }) });
}
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) });
});
const promise = triggerReindex(btn);
await vi.advanceTimersByTimeAsync(2000 * 12);
await promise;
// Reached the terminal completed status (statusCall #10) despite 8
// transient errors — only possible because errorCount resets on the
// intervening success.
expect(statusCalls).toBeGreaterThanOrEqual(10);
// Completion is not a failure → no error alert.
expect(alertSpy).not.toHaveBeenCalled();
expect(btn.disabled).toBe(false);
alertSpy.mockRestore();
} finally {
vi.useRealTimers();
}
});
it('bails (resolve null, no error alert) after POLL_MAX_ERRORS consecutive fetch errors', async () => {
vi.useFakeTimers();
try {
const btn = makeButton('coll-poll-errbail');
withCollectionsContainer(btn);
const alertSpy = vi.spyOn(globalThis, 'alert').mockImplementation(() => {});
let statusCalls = 0;
globalThis.safeFetchWithAuth.mockImplementation((url) => {
if (url.includes('/index/start')) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true }) });
}
if (url.includes('/index/status')) {
statusCalls += 1;
return Promise.reject(new Error('network down'));
}
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) });
});
const promise = triggerReindex(btn);
await vi.advanceTimersByTimeAsync(2000 * 10);
await promise;
// Bails after exactly POLL_MAX_ERRORS (5) consecutive errors —
// does NOT keep polling forever.
expect(statusCalls).toBe(5);
// Bail resolves null → triggerReindex shows no error alert.
expect(alertSpy).not.toHaveBeenCalled();
// Button re-enabled.
expect(btn.disabled).toBe(false);
alertSpy.mockRestore();
} finally {
vi.useRealTimers();
}
});
it('caps at POLL_MAX_ATTEMPTS, resolves {status:"timeout"}, and shows a timeout error', async () => {
vi.useFakeTimers();
try {
const btn = makeButton('coll-poll-timeout');
withCollectionsContainer(btn);
const alertSpy = vi.spyOn(globalThis, 'alert').mockImplementation(() => {});
let statusCalls = 0;
globalThis.safeFetchWithAuth.mockImplementation((url) => {
if (url.includes('/index/start')) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true }) });
}
if (url.includes('/index/status')) {
statusCalls += 1;
// Never terminal → must hit the POLL_MAX_ATTEMPTS cap.
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ status: 'processing' }) });
}
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) });
});
const promise = triggerReindex(btn);
// POLL_MAX_ATTEMPTS = 300 at 2s/poll ≈ 600s; advance generously.
await vi.advanceTimersByTimeAsync(2000 * 305);
await promise;
// Stopped at the 300-attempt cap, not forever.
expect(statusCalls).toBe(300);
// timeout status → showError fires the "taking longer" message.
expect(alertSpy).toHaveBeenCalled();
const messages = alertSpy.mock.calls.map(c => String(c[0])).join(' ');
expect(messages).toContain('taking longer than expected');
expect(btn.disabled).toBe(false);
alertSpy.mockRestore();
} finally {
vi.useRealTimers();
}
});
it('treats a transient HTTP 500 as retryable (not terminal) and completes without a false failure alert', async () => {
vi.useFakeTimers();
try {
const btn = makeButton('coll-poll-http500');
withCollectionsContainer(btn);
const alertSpy = vi.spyOn(globalThis, 'alert').mockImplementation(() => {});
let statusCalls = 0;
globalThis.safeFetchWithAuth.mockImplementation((url) => {
if (url.includes('/index/start')) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true }) });
}
if (url.includes('/index/status')) {
statusCalls += 1;
if (statusCalls === 1) {
// The endpoint's except path returns 500 {status:'error'}.
// Without the !response.ok guard this 'error' body is
// TERMINAL and would fire a false "reindex failed" alert.
return Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({ status: 'error' }) });
}
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ status: 'completed' }) });
}
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) });
});
const promise = triggerReindex(btn);
await vi.advanceTimersByTimeAsync(2000 * 3);
await promise;
// Retried after the 500 and reached completion.
expect(statusCalls).toBeGreaterThanOrEqual(2);
// No false "failed" alert — the transient 500 was not treated as terminal.
expect(alertSpy).not.toHaveBeenCalled();
expect(btn.disabled).toBe(false);
alertSpy.mockRestore();
} finally {
vi.useRealTimers();
}
});
});
describe('background-sweep toggle', () => {
function makeToggle() {
// Mirror the template: the checkbox lives inside a wrapper row that
// starts hidden and is only revealed once the backend setting exists.
document.body.innerHTML =
'<label id="background-sweep-toggle-row" style="display: none;">' +
'<input type="checkbox" id="background-sweep-toggle"></label>';
return document.getElementById('background-sweep-toggle');
}
function getRow() {
return document.getElementById('background-sweep-toggle-row');
}
it('loads the toggle state from document_scheduler.sweep_library_collections', async () => {
const toggle = makeToggle();
globalThis.safeFetchWithAuth.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: true }),
});
await loadBackgroundSweepSetting();
expect(globalThis.safeFetchWithAuth).toHaveBeenCalledWith(
'/settings/api/document_scheduler.sweep_library_collections'
);
expect(toggle.checked).toBe(true);
// Setting exists → the row is revealed.
expect(getRow().style.display).toBe('flex');
});
it('leaves the toggle unchecked when the setting value is false', async () => {
const toggle = makeToggle();
toggle.checked = true; // start checked to prove it flips off
globalThis.safeFetchWithAuth.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: false }),
});
await loadBackgroundSweepSetting();
expect(toggle.checked).toBe(false);
expect(getRow().style.display).toBe('flex');
});
it('treats a stringified "true" value as checked', async () => {
const toggle = makeToggle();
globalThis.safeFetchWithAuth.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: 'true' }),
});
await loadBackgroundSweepSetting();
expect(toggle.checked).toBe(true);
expect(getRow().style.display).toBe('flex');
});
it('hides the row when the setting is not registered (404 / not ok)', async () => {
// Before #4627 merges, the setting GET returns 404 — the toggle must be
// hidden rather than shown as a dead, always-unchecked control.
makeToggle();
getRow().style.display = 'flex'; // pretend it was visible
globalThis.safeFetchWithAuth.mockResolvedValue({
ok: false,
status: 404,
json: () => Promise.resolve({ error: 'not found' }),
});
await loadBackgroundSweepSetting();
expect(getRow().style.display).toBe('none');
});
it('persists a toggle change via PUT to the same setting key', async () => {
const toggle = makeToggle();
toggle.checked = true;
globalThis.safeFetchWithAuth.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: true }),
});
await saveBackgroundSweepSetting();
const [url, opts] = globalThis.safeFetchWithAuth.mock.calls[0];
expect(url).toBe('/settings/api/document_scheduler.sweep_library_collections');
expect(opts.method).toBe('PUT');
expect(JSON.parse(opts.body)).toEqual({ value: true });
// Save succeeded, so the optimistic UI state is preserved.
expect(toggle.checked).toBe(true);
});
it('reverts the toggle when the save fails', async () => {
const toggle = makeToggle();
toggle.checked = true;
globalThis.safeFetchWithAuth.mockResolvedValue({
ok: false,
json: () => Promise.resolve({ error: 'boom' }),
});
await saveBackgroundSweepSetting();
expect(toggle.checked).toBe(false);
});
it('shows the toggle ON when only the legacy generate_rag arm is set (effective gate)', async () => {
// The reconciler runs on (sweep OR generate_rag). An upgraded user with
// generate_rag=true, sweep=false must NOT see an OFF toggle while the
// sweep is actively running every tick.
const toggle = makeToggle();
globalThis.safeFetchWithAuth
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }) // sweep
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: true }) }); // generate_rag
await loadBackgroundSweepSetting();
expect(globalThis.safeFetchWithAuth).toHaveBeenNthCalledWith(
1, '/settings/api/document_scheduler.sweep_library_collections'
);
expect(globalThis.safeFetchWithAuth).toHaveBeenNthCalledWith(
2, '/settings/api/document_scheduler.generate_rag'
);
expect(toggle.checked).toBe(true);
expect(getRow().style.display).toBe('flex');
});
it('does not read the legacy arm when sweep is already ON', async () => {
const toggle = makeToggle();
globalThis.safeFetchWithAuth.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: true }),
});
await loadBackgroundSweepSetting();
// Only the sweep key is read; the legacy arm is irrelevant when sweep is on.
expect(globalThis.safeFetchWithAuth).toHaveBeenCalledTimes(1);
expect(toggle.checked).toBe(true);
});
it('turning OFF clears the legacy generate_rag arm WHEN it is set', async () => {
// Otherwise OFF would leave generate_rag armed and the sweep running.
const toggle = makeToggle();
toggle.checked = false; // user just turned it OFF
globalThis.safeFetchWithAuth
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }) // PUT sweep=false
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: true }) }) // GET generate_rag (set)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }); // PUT generate_rag=false
await saveBackgroundSweepSetting();
const calls = globalThis.safeFetchWithAuth.mock.calls;
expect(calls).toHaveLength(3);
expect(calls[0][0]).toBe('/settings/api/document_scheduler.sweep_library_collections');
expect(calls[0][1].method).toBe('PUT');
expect(JSON.parse(calls[0][1].body)).toEqual({ value: false });
expect(calls[1][0]).toBe('/settings/api/document_scheduler.generate_rag');
expect(calls[1][1]).toBeUndefined(); // a GET (read-first), not a PUT
expect(calls[2][0]).toBe('/settings/api/document_scheduler.generate_rag');
expect(JSON.parse(calls[2][1].body)).toEqual({ value: false });
expect(toggle.checked).toBe(false);
});
it('turning OFF does NOT write generate_rag when it is already false', async () => {
// Read-first: skip the redundant PUT (and the cross-page surprise +
// extra reschedule) for the common default-install case.
const toggle = makeToggle();
toggle.checked = false;
globalThis.safeFetchWithAuth
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }) // PUT sweep=false
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }); // GET generate_rag (already off)
await saveBackgroundSweepSetting();
const calls = globalThis.safeFetchWithAuth.mock.calls;
expect(calls).toHaveLength(2); // sweep PUT + generate_rag GET, no second PUT
expect(calls[1][1]).toBeUndefined(); // the generate_rag call was a GET
expect(toggle.checked).toBe(false);
});
it('OFF with a failed generate_rag clear stays ON (still armed via legacy arm)', async () => {
// sweep=false persisted, but clearing generate_rag failed, so the
// reconciler is still armed — the honest displayed state is ON, not OFF.
const toggle = makeToggle();
toggle.checked = false;
globalThis.safeFetchWithAuth
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }) // PUT sweep=false
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: true }) }) // GET generate_rag (set)
.mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({ error: 'boom' }) }); // PUT generate_rag fails
await saveBackgroundSweepSetting();
expect(toggle.checked).toBe(true);
});
it('a transient failure reading the legacy arm on load does not hide the control', async () => {
// sweep read succeeds (false); the secondary generate_rag read rejects
// at the network level. The row must be REVEALED (start hidden, as in
// the template) and show the sweep-derived state.
const toggle = makeToggle();
expect(getRow().style.display).toBe('none'); // starts hidden per template
globalThis.safeFetchWithAuth
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }) // sweep
.mockRejectedValueOnce(new Error('network down')); // generate_rag GET rejects
await loadBackgroundSweepSetting();
expect(getRow().style.display).toBe('flex'); // reveal still fired
expect(toggle.checked).toBe(false);
});
it('treats a stringified "true" generate_rag value as ON (effective gate)', async () => {
const toggle = makeToggle();
globalThis.safeFetchWithAuth
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }) // sweep
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: 'true' }) }); // generate_rag (string)
await loadBackgroundSweepSetting();
expect(toggle.checked).toBe(true);
});
it('turning OFF still clears generate_rag when its value cannot be read (conservative on unknown)', async () => {
// The regression the prior fix reopened: a transient generate_rag read
// failure must NOT be treated as "already off" — OFF must still attempt
// the clear so the reconciler can never stay silently armed.
const toggle = makeToggle();
toggle.checked = false;
globalThis.safeFetchWithAuth
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }) // PUT sweep=false
.mockRejectedValueOnce(new Error('network blip')) // GET generate_rag rejects -> null (unknown)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }); // PUT generate_rag=false
await saveBackgroundSweepSetting();
const calls = globalThis.safeFetchWithAuth.mock.calls;
expect(calls).toHaveLength(3);
expect(calls[2][0]).toBe('/settings/api/document_scheduler.generate_rag');
expect(calls[2][1].method).toBe('PUT'); // clear was still attempted
expect(JSON.parse(calls[2][1].body)).toEqual({ value: false });
expect(toggle.checked).toBe(false); // clear succeeded -> honest OFF
});
it('turning OFF clears generate_rag when the read returns not-ok (unknown, not just reject)', async () => {
const toggle = makeToggle();
toggle.checked = false;
globalThis.safeFetchWithAuth
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }) // PUT sweep=false
.mockResolvedValueOnce({ ok: false, status: 500, json: () => Promise.resolve({ error: 'x' }) }) // GET generate_rag !ok -> null
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }); // PUT generate_rag=false
await saveBackgroundSweepSetting();
const calls = globalThis.safeFetchWithAuth.mock.calls;
expect(calls).toHaveLength(3);
expect(calls[2][1].method).toBe('PUT'); // unknown -> still cleared
expect(toggle.checked).toBe(false);
});
it('OFF with unknown legacy read AND a failed clear stays ON (conservative)', async () => {
const toggle = makeToggle();
toggle.checked = false;
globalThis.safeFetchWithAuth
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }) // PUT sweep=false
.mockRejectedValueOnce(new Error('blip')) // GET generate_rag -> null
.mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({ error: 'boom' }) }); // PUT generate_rag fails
await saveBackgroundSweepSetting();
// Couldn't confirm generate_rag is off -> don't claim a false OFF.
expect(toggle.checked).toBe(true);
});
it('OFF stays ON when the clear PUT rejects at the network level (outer catch)', async () => {
// The clear-PUT failure must be handled whether it surfaces as {ok:false}
// or as a thrown network rejection (the outer catch) — both must leave
// the toggle honestly ON since generate_rag may still be armed.
const toggle = makeToggle();
toggle.checked = false;
globalThis.safeFetchWithAuth
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }) // PUT sweep=false
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: true }) }) // GET generate_rag (set)
.mockRejectedValueOnce(new Error('network down')); // PUT generate_rag rejects -> outer catch
await saveBackgroundSweepSetting();
expect(toggle.checked).toBe(true);
});
it('turning ON writes only sweep=true (does not touch the legacy arm)', async () => {
const toggle = makeToggle();
toggle.checked = true; // user just turned it ON
globalThis.safeFetchWithAuth.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: true }),
});
await saveBackgroundSweepSetting();
const calls = globalThis.safeFetchWithAuth.mock.calls;
expect(calls).toHaveLength(1);
expect(calls[0][0]).toBe('/settings/api/document_scheduler.sweep_library_collections');
expect(JSON.parse(calls[0][1].body)).toEqual({ value: true });
expect(toggle.checked).toBe(true);
});
});