System > Backup & export replaces its hand-off with the concept's section on the S8 backend: Back up downloads the verified copy and the status line shows only what GET /backup/status reports (D103); Review restore stages a chosen file and shows the server's review in the shared review dialog, Cancel discards the stage and Restore runs the Database restore job, after which the open views reload (D105); Export JSON plus the components and saved-pairs exports (D103); Import / inspect opens the shared Import & inspect dialog; the safety-backup policy saves through the Save row and scheduled backups offer Manual only (D104). Overview's Backup status row reads the same status. Refs #326
218 lines
14 KiB
JavaScript
218 lines
14 KiB
JavaScript
// System › Backup & export (#249 System S9 Task 24): the concept's copy (app.js 691–693), the status line from
|
||
// GET /backup/status (D103), the restore review's rows from the S8 preview (D105), its upload and refusals, the export
|
||
// list, the restore job's Activity copy and the reload after a finished restore.
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const { ApiError } = require('../../web/js/api-client.js');
|
||
|
||
const backups = import('../../web/js/workspace/system-backups.mjs');
|
||
const kinds = import('../../web/js/workspace/activity-kinds.mjs');
|
||
const scan = import('../../web/js/workspace/activity-scan.mjs');
|
||
const dialogs = import('../../web/js/workspace/workspace-dialogs.mjs');
|
||
|
||
const CONCEPT = fs.readFileSync(path.join(__dirname, '../../docs/concepts/workspace/app.js'), 'utf8');
|
||
// The module's code without its comments (which name the concept's prototype labels they replace).
|
||
const SOURCE = fs.readFileSync(path.join(__dirname, '../../web/js/workspace/system-backups.mjs'), 'utf8')
|
||
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||
|
||
const conceptSection = () => {
|
||
const start = CONCEPT.indexOf('case "backups":');
|
||
return CONCEPT.slice(start, CONCEPT.indexOf('break;', start));
|
||
};
|
||
|
||
test('the section keeps the concept copy: surfaces, descriptions and the two kept labels', () => {
|
||
const body = conceptSection();
|
||
for (const text of ['Database backup', 'A complete recovery snapshot, including prompt pairs, metadata and image links. Media files are separate.',
|
||
'Review restore…', 'Portable export', 'Export reusable recipes and tags as versioned JSON. This is distinct from a complete database backup.',
|
||
'Import / inspect file', 'Recovery policy', 'Proposed', 'Create a safety backup before maintenance',
|
||
'Preview a verified backup before applying operations that change records.', 'Scheduled database backups',
|
||
'Manual only', 'Daily', 'Weekly']) {
|
||
assert.ok(body.includes(text), `concept lacks ${text}`);
|
||
assert.ok(SOURCE.includes(text), `production lacks ${text}`);
|
||
}
|
||
// D103/D104: prototype copy is never shipped.
|
||
for (const sample of ['Simulate backup', 'Sample backup', 'Export sample JSON', 'Production feature proposal', 'A restore would be']) {
|
||
assert.ok(body.includes(sample));
|
||
assert.ok(!SOURCE.includes(sample), `production ships ${sample}`);
|
||
}
|
||
});
|
||
|
||
test('status line shows only server-reported backups (D103)', async () => {
|
||
const { backupStatusText, NO_BACKUP, STATUS_LOADING, STATUS_FAILED, whenText } = await backups;
|
||
assert.equal(backupStatusText(undefined), STATUS_LOADING);
|
||
assert.equal(backupStatusText(null), STATUS_FAILED);
|
||
assert.equal(backupStatusText({ success: true, safety_backups: [], last_download: null }), NO_BACKUP);
|
||
assert.equal(NO_BACKUP, 'No backup recorded.');
|
||
// A time the server did not send in a readable form names nothing.
|
||
assert.equal(backupStatusText({ safety_backups: [{ name: 'x', modified_at: 'soon' }], last_download: { at: 7, verified: true } }),
|
||
NO_BACKUP);
|
||
const at = '2026-10-25T09:30:00+00:00';
|
||
assert.equal(backupStatusText({ safety_backups: [], last_download: { at, bytes: 10, verified: true } }),
|
||
`Downloaded backup · ${whenText(at)} · verified.`);
|
||
assert.equal(backupStatusText({ safety_backups: [], last_download: { at, bytes: 10, verified: false } }),
|
||
`Downloaded backup · ${whenText(at)} · not verified.`);
|
||
const safety = [{ name: 'prompts.db.backup_2', bytes: 1, modified_at: at }, { name: 'prompts.db.backup_1', bytes: 1, modified_at: at }];
|
||
assert.equal(backupStatusText({ safety_backups: safety, last_download: null }), `Safety backup · ${whenText(at)} · 2 on disk.`);
|
||
assert.equal(backupStatusText({ safety_backups: safety.slice(0, 1), last_download: { at, verified: true } }),
|
||
`Downloaded backup · ${whenText(at)} · verified. Safety backup · ${whenText(at)} · 1 on disk.`);
|
||
assert.equal(whenText(undefined), null);
|
||
});
|
||
|
||
const PREVIEW = Object.freeze({
|
||
success: true, token: 'r'.repeat(43), expires_at: '2026-10-25T12:15:00+00:00',
|
||
summary: { prompts: 1234, positives: 900, pairs: 3, archived_pairs: 0, image_links: 56, runs: 1, saved_views: 2 },
|
||
schema: { migrated: true, library_state: true }, integrity: 'ok', documents: { repaired: 0, cleared: 0, images: 0 },
|
||
live: { prompts: 1, positives: 1, pairs: 0, archived_pairs: 0, image_links: 0, runs: 0, saved_views: 0 }, size: 4096,
|
||
safety_backup: { destination_label: 'prompts.db.backup_<date and time>' },
|
||
});
|
||
|
||
test('Review restore shows the real summary in the concept rows (D105)', async () => {
|
||
const { restoreGrid, restoreNote, countsText, integrityText, snapshotText, whenText } = await backups;
|
||
const file = { name: 'prompts_backup_20261025_093000.db', size: 3 * 2 ** 20, lastModified: Date.parse('2026-10-25T09:30:00Z') };
|
||
assert.deepEqual(restoreGrid(PREVIEW, file), [
|
||
['Snapshot', `prompts_backup_20261025_093000.db · 3.0 MB · ${whenText('2026-10-25T09:30:00.000Z')}`],
|
||
['Integrity', 'Verified'],
|
||
['Migration', 'Upgraded to the current schema'],
|
||
['Safety backup', 'Created before replacing (prompts.db.backup_<date and time>)'],
|
||
['In the backup', '1,234 prompts · 56 image links · 3 saved pairs · 1 run'],
|
||
['In the library now', '1 prompt · 0 image links · 0 saved pairs · 0 runs'],
|
||
]);
|
||
assert.equal(restoreGrid({ ...PREVIEW, schema: { migrated: false } }, file)[2][1], 'Current schema');
|
||
assert.equal(restoreGrid({ ...PREVIEW, safety_backup: null }, file)[3][1], 'Created before replacing');
|
||
assert.equal(countsText({}), 'Not reported');
|
||
assert.equal(snapshotText({ name: 'a.db' }), 'a.db');
|
||
assert.equal(integrityText({ integrity: 'ok', documents: { repaired: 2, cleared: 1, images: 1 } }),
|
||
'Verified · repaired 2 damaged workflow documents · could not repair 1 workflow document: 1 image shows no workflow');
|
||
assert.equal(integrityText({ integrity: 'ok', documents: { repaired: 1, cleared: 2, images: 3 } }),
|
||
'Verified · repaired 1 damaged workflow document · could not repair 2 workflow documents: 3 images show no workflow');
|
||
assert.equal(integrityText({}), 'Not verified');
|
||
const now = Date.parse('2026-10-25T12:00:00+00:00');
|
||
assert.equal(restoreNote(PREVIEW, { now }), 'Restore replaces the whole library with this backup and runs alone in Activity. ' +
|
||
'Nothing changes until you restore. This review holds for 15 minutes.');
|
||
assert.equal(restoreNote({ expires_at: 'x' }, { now }), 'Restore replaces the whole library with this backup and runs alone ' +
|
||
'in Activity. Nothing changes until you restore.');
|
||
});
|
||
|
||
function response(status, body, headers = {}) {
|
||
return { ok: status >= 200 && status < 300, status, json: async () => {
|
||
if (body === undefined) throw new SyntaxError('not JSON');
|
||
return body;
|
||
}, headers: { get: name => headers[name] ?? null }, blob: async () => new Blob(['x']) };
|
||
}
|
||
|
||
test('invalid file shows the validator message; a refusal names its job; a lost answer stages nothing', async () => {
|
||
const { uploadRestore, reviewRefusal, REVIEW_FAILED, PREVIEW_URL } = await backups;
|
||
const file = new File(['not a database'], 'bad.db');
|
||
let seen;
|
||
const refuse = async (url, init) => { seen = [url, init.method, init.cache]; return response(400,
|
||
{ success: false, code: 'invalid-backup', error: 'Not a SQLite database file' }); };
|
||
const invalid = await uploadRestore(file, { fetchUrl: refuse, ApiError }).catch(error => error);
|
||
assert.deepEqual(seen, [PREVIEW_URL, 'POST', 'no-store']);
|
||
assert.equal(reviewRefusal(invalid), "This file can't be restored: Not a SQLite database file.");
|
||
const busy = await uploadRestore(file, { ApiError, fetchUrl: async () => response(409,
|
||
{ success: false, code: 'busy', error: 'Busy', running: { id: 'j1', title: 'Metadata scan' } }) }).catch(error => error);
|
||
assert.deepEqual(reviewRefusal(busy), ['Another job is running: ', { text: 'Metadata scan', href: '#/activity/j1' }, '.']);
|
||
const full = await uploadRestore(file, { ApiError, fetchUrl: async () => response(429,
|
||
{ success: false, code: 'too-large', error: 'Two restores are already being reviewed. Discard one first.' }) })
|
||
.catch(error => error);
|
||
assert.equal(reviewRefusal(full), 'Two restores are already being reviewed. Discard one first.');
|
||
const proxy = await uploadRestore(file, { ApiError, fetchUrl: async () => response(502, undefined) }).catch(error => error);
|
||
assert.equal(reviewRefusal(proxy), REVIEW_FAILED);
|
||
const odd = await uploadRestore(file, { ApiError, fetchUrl: async () => response(200, { success: true }) }).catch(error => error);
|
||
assert.equal(reviewRefusal(odd), REVIEW_FAILED);
|
||
assert.equal(reviewRefusal(new Error('offline')), REVIEW_FAILED);
|
||
assert.deepEqual(await uploadRestore(file, { ApiError, fetchUrl: async () => response(200, PREVIEW) }), PREVIEW);
|
||
});
|
||
|
||
test('Cancel discards the stage through the api client and never throws', async () => {
|
||
const { discardRestore, PREVIEW_URL } = await backups;
|
||
const calls = [];
|
||
await discardRestore('a/b', { fetchUrl: async (url, init) => { calls.push([url, init.method]); return response(200, {}); } });
|
||
await discardRestore('gone', { fetchUrl: async () => { throw new TypeError('offline'); } });
|
||
assert.deepEqual(calls, [[`${PREVIEW_URL}/a%2Fb`, 'DELETE']]);
|
||
});
|
||
|
||
test('Export JSON downloads /library/export; secondary buttons download /export and /pairs/export (D103)', async () => {
|
||
const { EXPORTS } = await backups;
|
||
assert.deepEqual(EXPORTS.map(([label, url]) => [label, url]), [
|
||
['Export JSON', '/prompt_manager/library/export'],
|
||
['Export components JSON', '/prompt_manager/export'],
|
||
['Export saved pairs JSON', '/prompt_manager/pairs/export'],
|
||
]);
|
||
});
|
||
|
||
test('a failed export toasts and saves nothing', async () => {
|
||
const { downloadExport, EXPORTS, EXPORT_FAILED } = await backups;
|
||
const toasts = [];
|
||
assert.equal(await downloadExport(EXPORTS[1], { fetchUrl: async () => response(500, {}), notify: text => toasts.push(text) }), false);
|
||
const aborted = AbortSignal.abort();
|
||
assert.equal(await downloadExport(EXPORTS[1], { fetchUrl: async () => { throw new Error('x'); }, notify: text => toasts.push(text),
|
||
signal: aborted }), false);
|
||
assert.deepEqual(toasts, [EXPORT_FAILED]);
|
||
});
|
||
|
||
test('Restore starts the job: Activity names it and ends with the server line', async () => {
|
||
const { finishToast, outcomeLine, pillParts, canCancel, startToast } = await kinds;
|
||
const job = { id: 'j', kind: 'restore', title: 'Database restore', state: 'running', cancellable: false, progress: 0, total: 0,
|
||
counters: { errors: 0 }, status_line: 'Restoring the database' };
|
||
assert.equal(startToast(job), 'Database restore started.');
|
||
assert.equal(pillParts(job).label, 'Restoring the database');
|
||
assert.equal(canCancel(job), false);
|
||
assert.equal(outcomeLine(job), '');
|
||
const done = { ...job, state: 'finished', status_line: 'Restored 3 prompts, 1 image link, 0 saved pairs and 0 runs' };
|
||
assert.equal(finishToast(done), 'Database restore finished · Restored 3 prompts, 1 image link, 0 saved pairs and 0 runs');
|
||
assert.equal(outcomeLine(done), done.status_line);
|
||
const failed = { ...job, state: 'failed', status_line: 'The library changed since the review; nothing was changed. Review the restore again.' };
|
||
assert.equal(finishToast(failed), `Database restore failed · ${failed.status_line}`);
|
||
assert.equal(finishToast({ ...job, state: 'failed', status_line: '' }), 'Database restore failed.');
|
||
});
|
||
|
||
test('views reload once after a restore this page saw finish; a failed restore changes nothing', async () => {
|
||
const { onRestoreFinished } = await backups;
|
||
let list = [];
|
||
const listeners = new Set();
|
||
const activity = { jobs: () => list, subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); } };
|
||
const emit = next => { list = next; for (const listener of listeners) listener(); };
|
||
let reloads = 0;
|
||
const stop = onRestoreFinished(activity, () => { reloads += 1; });
|
||
emit([{ id: 'old', kind: 'restore', state: 'finished' }]);
|
||
assert.equal(reloads, 0, 'a restore that ended before this page saw it run');
|
||
emit([{ id: 'a', kind: 'restore', state: 'running' }, { id: 's', kind: 'scan', state: 'running' }]);
|
||
emit([{ id: 'a', kind: 'restore', state: 'finished' }, { id: 's', kind: 'scan', state: 'finished' }]);
|
||
emit([{ id: 'a', kind: 'restore', state: 'finished' }]);
|
||
assert.equal(reloads, 1);
|
||
emit([{ id: 'b', kind: 'restore', state: 'running' }]);
|
||
emit([{ id: 'b', kind: 'restore', state: 'failed' }]);
|
||
assert.equal(reloads, 1);
|
||
stop();
|
||
assert.equal(listeners.size, 0);
|
||
});
|
||
|
||
test('a downloaded backup the server could not verify says so', async () => {
|
||
const { downloadDatabaseBackup, BACKUP_DONE, backupUnverified } = await scan;
|
||
const toasts = [];
|
||
const body = globalThis.document;
|
||
globalThis.document = { createElement: () => ({ click() {}, remove() {} }), body: { append() {} } };
|
||
try {
|
||
await downloadDatabaseBackup({ notify: text => toasts.push(text),
|
||
fetchUrl: async () => response(200, {}, { 'X-PromptManager-Verify': 'ok' }) });
|
||
await downloadDatabaseBackup({ notify: text => toasts.push(text),
|
||
fetchUrl: async () => response(200, {}, { 'X-PromptManager-Verify': 'Database does not contain a pair table' }) });
|
||
} finally {
|
||
globalThis.document = body;
|
||
}
|
||
assert.deepEqual(toasts, [BACKUP_DONE, backupUnverified('Database does not contain a pair table')]);
|
||
assert.equal(toasts[1], 'Database backup downloaded, but it did not pass verification: Database does not contain a pair table');
|
||
});
|
||
|
||
test('the Import dialog shares the rescan copy with Images (D15)', async () => {
|
||
const { rescanFinished, RESCAN_STARTED, RESCAN_FAILED } = await dialogs;
|
||
assert.equal(RESCAN_STARTED, 'Rescanning output folders…');
|
||
assert.equal(RESCAN_FAILED, 'Rescan could not finish. Try again.');
|
||
assert.equal(rescanFinished({ total: 1, roots: 2 }), 'Rescan finished. 1 file in 2 output folders.');
|
||
assert.equal(rescanFinished({ total: 1200, roots: 1 }), 'Rescan finished. 1,200 files in 1 output folder.');
|
||
assert.equal(rescanFinished(null), 'Rescan finished.');
|
||
});
|