Add local weekly upload windows with overnight support, live settings updates, and a shared abortable upload gate. Keep active transfers running while queued jobs wait outside the window without consuming attempts or parallel slots. Validate and localize the automation settings in German and English, including next-start status and hidden Electron coverage.
1101 lines
47 KiB
JavaScript
1101 lines
47 KiB
JavaScript
const { describe, it, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const Module = require('node:module');
|
|
const originalLoad = Module._load;
|
|
const safeStorage = {
|
|
isEncryptionAvailable: () => true,
|
|
encryptString: value => Buffer.from(`test-protected:${value}`),
|
|
decryptString: value => value.toString().replace(/^test-protected:/, '')
|
|
};
|
|
Module._load = function load(request, parent, isMain) {
|
|
if (request === 'electron') return { safeStorage };
|
|
return originalLoad.call(this, request, parent, isMain);
|
|
};
|
|
const ConfigStore = require('../lib/config-store');
|
|
require('../lib/secret-store').encryptField('test-initialization');
|
|
Module._load = originalLoad;
|
|
const { createCollectors } = require('../lib/diagnostics-collectors');
|
|
const { createAgent } = require('../lib/diagnostics-agent');
|
|
const support = require('../lib/support-bundle');
|
|
const stats = require('../lib/stats');
|
|
|
|
let tmpDir;
|
|
let store;
|
|
|
|
function thrownBy(fn) {
|
|
try {
|
|
fn();
|
|
} catch (error) {
|
|
return error;
|
|
}
|
|
assert.fail('Expected function to throw');
|
|
}
|
|
|
|
function createDiagnosticsAgent(configStore, logDir) {
|
|
return createAgent(createCollectors({
|
|
loadConfig: () => configStore.loadDiagnosticsConfig(),
|
|
loadHistory: () => configStore.loadDiagnosticsHistory(),
|
|
getAllLogPaths: () => ({ logDir }),
|
|
support,
|
|
stats,
|
|
appInfo: () => ({}),
|
|
systemInfo: () => ({}),
|
|
agentInfo: () => ({})
|
|
}));
|
|
}
|
|
|
|
function createStore() {
|
|
const fakeApp = {
|
|
isPackaged: false,
|
|
getPath: () => tmpDir
|
|
};
|
|
// ConfigStore uses path.join(__dirname, '..') for non-packaged
|
|
// We override by setting filePath directly
|
|
store = new ConfigStore(fakeApp);
|
|
store.filePath = path.join(tmpDir, 'electron-config.json');
|
|
store.historyPath = path.join(tmpDir, 'electron-history.json');
|
|
return store;
|
|
}
|
|
|
|
describe('ConfigStore', () => {
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-test-'));
|
|
store = createStore();
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('explicit user-data-dir isolates development config writes from the project', async () => {
|
|
const isolatedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-user-data-'));
|
|
const projectConfigPath = path.join(__dirname, '..', 'electron-config.json');
|
|
const projectConfigExisted = fs.existsSync(projectConfigPath);
|
|
const projectConfigBefore = projectConfigExisted ? fs.readFileSync(projectConfigPath) : null;
|
|
const explicitStore = new ConfigStore({
|
|
isPackaged: false,
|
|
commandLine: {
|
|
hasSwitch: (name) => name === 'user-data-dir'
|
|
},
|
|
getPath: (name) => {
|
|
if (name === 'userData') return isolatedDir;
|
|
if (name === 'exe') return path.join(isolatedDir, 'Multi-Hoster-Upload.exe');
|
|
throw new Error(`Unexpected app path: ${name}`);
|
|
}
|
|
});
|
|
|
|
try {
|
|
assert.equal(explicitStore.filePath, path.join(isolatedDir, 'electron-config.json'));
|
|
await explicitStore.save({ globalSettings: { alwaysOnTop: true } });
|
|
assert.equal(fs.existsSync(path.join(isolatedDir, 'electron-config.json')), true);
|
|
assert.equal(fs.existsSync(projectConfigPath), projectConfigExisted);
|
|
if (projectConfigBefore) {
|
|
assert.equal(fs.readFileSync(projectConfigPath).equals(projectConfigBefore), true);
|
|
}
|
|
} finally {
|
|
fs.rmSync(isolatedDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('load returns defaults when file does not exist', () => {
|
|
const config = store.load();
|
|
assert.ok(config.hosters);
|
|
assert.ok(config.hosters['doodstream.com']);
|
|
assert.ok(config.hosters['voe.sx']);
|
|
assert.ok(config.hosters['vidmoly.me']);
|
|
assert.ok(config.hosters['byse.sx']);
|
|
assert.ok(config.hosterSettings);
|
|
assert.equal(config.hosterSettings['doodstream.com'].retries, 3);
|
|
assert.equal(config.hosterSettings['doodstream.com'].parallelCount, 2);
|
|
assert.equal(config.globalSettings.alwaysOnTop, false);
|
|
assert.equal(config.globalSettings.language, 'en');
|
|
assert.equal(config.globalSettings.shutdownAfterFinish, 'nothing');
|
|
assert.equal(config.globalSettings.logFilePath, '');
|
|
assert.equal(config.globalSettings.resumeQueueOnLaunch, true);
|
|
assert.equal(Object.hasOwn(config.globalSettings, 'allowPlaintextCredentialStorage'), false);
|
|
assert.equal(config.globalSettings.parallelUploadCount, 0);
|
|
assert.equal(config.globalSettings.scaleParallelUploads, false);
|
|
assert.equal(config.globalSettings.lastBrowseDirectory, '');
|
|
assert.equal(config.globalSettings.pendingQueue, null);
|
|
assert.deepEqual(config.globalSettings.filenameFilter, {
|
|
enabled: false,
|
|
action: 'include',
|
|
matchMode: 'all',
|
|
conditions: []
|
|
});
|
|
assert.deepEqual(config.globalSettings.uploadSchedule, {
|
|
enabled: false,
|
|
weekdays: [1, 2, 3, 4, 5, 6, 0],
|
|
start: '00:00',
|
|
end: '23:59'
|
|
});
|
|
assert.deepEqual(config.history, []);
|
|
});
|
|
|
|
it('drops the retired plaintext credential setting from legacy configurations', () => {
|
|
fs.writeFileSync(store.filePath, JSON.stringify({
|
|
hosters: {},
|
|
hosterSettings: {},
|
|
globalSettings: { allowPlaintextCredentialStorage: true }
|
|
}), 'utf-8');
|
|
|
|
const config = store.load();
|
|
|
|
assert.equal(Object.hasOwn(config.globalSettings, 'allowPlaintextCredentialStorage'), false);
|
|
});
|
|
|
|
it('never writes the retired plaintext credential setting back to disk', async () => {
|
|
await store.save({
|
|
globalSettings: { allowPlaintextCredentialStorage: true }
|
|
});
|
|
|
|
const saved = JSON.parse(fs.readFileSync(store.filePath, 'utf-8'));
|
|
|
|
assert.equal(Object.hasOwn(saved.globalSettings, 'allowPlaintextCredentialStorage'), false);
|
|
});
|
|
|
|
it('keeps permanent source deletion disabled by default', () => {
|
|
const config = store.load();
|
|
assert.equal(config.globalSettings.deleteSourceAfterSuccessfulUpload, false);
|
|
});
|
|
|
|
it('save then load round-trips', async () => {
|
|
await store.save({ hosters: { 'doodstream.com': [{ id: 'test-1', enabled: true, authType: 'api', apiKey: 'test-key-123' }] } });
|
|
const config = store.load();
|
|
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'test-key-123');
|
|
});
|
|
|
|
it('default logMode is "single"', () => {
|
|
const config = store.load();
|
|
assert.equal(config.globalSettings.logMode, 'single');
|
|
});
|
|
|
|
it('persists pendingQueue incl. savedAt (number) and ts-bearing jobs across save/load', async () => {
|
|
// The queue-persistence fix stamps pendingQueue.savedAt and restores it on launch.
|
|
// This proves the persistence layer round-trips the new fields untouched (the
|
|
// ts-gate is worthless if savedAt does not survive serialization).
|
|
const pendingQueue = {
|
|
savedAt: 1750000000123,
|
|
selectedUploadHosters: ['voe.sx', 'byse.sx'],
|
|
selectedFiles: [{ path: 'C:/dl/a.mkv', name: 'a.mkv', size: 4242 }],
|
|
queueJobs: [
|
|
{ id: 'j1', file: 'C:/dl/a.mkv', fileName: 'a.mkv', hoster: 'voe.sx', status: 'preview', bytesTotal: 4242, maxAttempts: 0 },
|
|
{ id: 'j2', file: 'C:/dl/a.mkv', fileName: 'a.mkv', hoster: 'byse.sx', status: 'error', error: 'boom', maxAttempts: 3 }
|
|
]
|
|
};
|
|
const current = store.load();
|
|
await store.save({ globalSettings: { ...current.globalSettings, pendingQueue } });
|
|
const loaded = store.load();
|
|
const pq = loaded.globalSettings.pendingQueue;
|
|
assert.equal(pq.savedAt, 1750000000123, 'savedAt epoch survives JSON round-trip');
|
|
assert.equal(typeof pq.savedAt, 'number');
|
|
assert.equal(pq.queueJobs.length, 2);
|
|
assert.equal(pq.queueJobs[0].fileName, 'a.mkv');
|
|
assert.equal(pq.queueJobs[0].hoster, 'voe.sx');
|
|
assert.equal(pq.queueJobs[1].status, 'error');
|
|
assert.deepEqual(pq.selectedUploadHosters, ['voe.sx', 'byse.sx']);
|
|
assert.equal(pq.selectedFiles[0].path, 'C:/dl/a.mkv');
|
|
});
|
|
|
|
it('pendingQueue can be cleared back to null (clearPersistedQueueStateSoon path)', async () => {
|
|
const current = store.load();
|
|
await store.save({ globalSettings: { ...current.globalSettings, pendingQueue: { savedAt: 1, queueJobs: [] } } });
|
|
assert.ok(store.load().globalSettings.pendingQueue);
|
|
const c2 = store.load();
|
|
await store.save({ globalSettings: { ...c2.globalSettings, pendingQueue: null } });
|
|
assert.equal(store.load().globalSettings.pendingQueue, null);
|
|
});
|
|
|
|
it('regression: legacy sessionLog:true on disk normalizes to logMode "daily" (NOT "session")', async () => {
|
|
// Write a config with the legacy boolean only (what an existing user has).
|
|
await store.save({ globalSettings: { sessionLog: true } });
|
|
const config = store.load();
|
|
// The misnamed legacy field MUST map to daily — mapping to "session" would
|
|
// silently change every per-day user's behaviour on upgrade.
|
|
assert.equal(config.globalSettings.logMode, 'daily');
|
|
});
|
|
|
|
it('logMode round-trips for all three values', async () => {
|
|
for (const mode of ['single', 'daily', 'session']) {
|
|
await store.save({ globalSettings: { logMode: mode } });
|
|
const config = store.load();
|
|
assert.equal(config.globalSettings.logMode, mode, `mode ${mode}`);
|
|
}
|
|
});
|
|
|
|
it('load merges with defaults for missing hosters', () => {
|
|
// Write partial config in old single-object format (triggers migration)
|
|
fs.writeFileSync(store.filePath, JSON.stringify({
|
|
hosters: { 'doodstream.com': { apiKey: 'abc' } }
|
|
}), 'utf-8');
|
|
|
|
const config = store.load();
|
|
// Old format is migrated to array
|
|
assert.ok(Array.isArray(config.hosters['doodstream.com']));
|
|
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'abc');
|
|
// Other hosters should still have defaults (empty arrays)
|
|
assert.ok(Array.isArray(config.hosters['voe.sx']));
|
|
assert.equal(config.hosters['voe.sx'].length, 0);
|
|
});
|
|
|
|
it('hosterSettings merge fills gaps with defaults', () => {
|
|
fs.writeFileSync(store.filePath, JSON.stringify({
|
|
hosterSettings: { 'voe.sx': { retries: 5 } }
|
|
}), 'utf-8');
|
|
|
|
const config = store.load();
|
|
assert.equal(config.hosterSettings['voe.sx'].retries, 5);
|
|
assert.equal(config.hosterSettings['voe.sx'].parallelCount, 2); // default
|
|
assert.equal(config.hosterSettings['voe.sx'].maxSpeedKbs, 0); // default
|
|
assert.equal(config.hosterSettings['voe.sx'].logToFile, true); // default on
|
|
});
|
|
|
|
it('logToFile defaults to true for every hoster', () => {
|
|
const config = store.load();
|
|
for (const name of ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc']) {
|
|
assert.equal(config.hosterSettings[name].logToFile, true, `${name} should default logToFile=true`);
|
|
}
|
|
});
|
|
|
|
it('sizeMemoEnabled defaults to true for every hoster and persists when disabled', async () => {
|
|
const fresh = store.load();
|
|
for (const name of ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc']) {
|
|
assert.equal(fresh.hosterSettings[name].sizeMemoEnabled, true, `${name} should default sizeMemoEnabled=true`);
|
|
}
|
|
await store.save({ hosterSettings: { 'byse.sx': { sizeMemoEnabled: false } } });
|
|
const config = store.load();
|
|
assert.equal(config.hosterSettings['byse.sx'].sizeMemoEnabled, false, 'explicit false preserved');
|
|
assert.equal(config.hosterSettings['voe.sx'].sizeMemoEnabled, true, 'other hoster still defaults on');
|
|
});
|
|
|
|
it('logToFile=false persists and survives reload', async () => {
|
|
await store.save({ hosterSettings: { 'voe.sx': { logToFile: false } } });
|
|
const config = store.load();
|
|
assert.equal(config.hosterSettings['voe.sx'].logToFile, false, 'explicit false preserved');
|
|
assert.equal(config.hosterSettings['byse.sx'].logToFile, true, 'other hoster still defaults on');
|
|
});
|
|
|
|
it('save only updates provided sections', async () => {
|
|
// Save hoster settings first
|
|
await store.save({ hosterSettings: { 'doodstream.com': { retries: 10, maxSpeedKbs: 0, parallelCount: 2, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } } });
|
|
// Save hosters credentials separately (array format)
|
|
await store.save({ hosters: { 'doodstream.com': [{ id: 'test-1', enabled: true, authType: 'api', apiKey: 'key123' }] } });
|
|
|
|
const config = store.load();
|
|
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'key123');
|
|
assert.equal(config.hosterSettings['doodstream.com'].retries, 10); // preserved
|
|
});
|
|
|
|
it('appendHistory keeps complete history without truncation', async () => {
|
|
for (let i = 0; i < 105; i++) {
|
|
await store.appendHistory({ id: `batch-${i}`, timestamp: new Date().toISOString(), files: [] });
|
|
}
|
|
const history = store.loadHistory();
|
|
assert.equal(history.length, 105);
|
|
assert.equal(history[0].id, 'batch-0');
|
|
assert.equal(history[104].id, 'batch-104');
|
|
});
|
|
|
|
it('durably syncs migrated history before replacing the live file', async () => {
|
|
store._historyMigrated = true;
|
|
fs.writeFileSync(store.historyPath, '[]', 'utf-8');
|
|
const originalOpen = fs.promises.open;
|
|
const originalRename = fs.promises.rename;
|
|
let synced = false;
|
|
let renamed = false;
|
|
fs.promises.open = async (...args) => {
|
|
const handle = await originalOpen(...args);
|
|
const originalSync = handle.sync.bind(handle);
|
|
handle.sync = async () => {
|
|
await originalSync();
|
|
synced = true;
|
|
};
|
|
return handle;
|
|
};
|
|
fs.promises.rename = async (...args) => {
|
|
assert.equal(synced, true);
|
|
renamed = true;
|
|
return originalRename(...args);
|
|
};
|
|
|
|
try {
|
|
await store.appendHistory({ id: 'durable', files: [] });
|
|
} finally {
|
|
fs.promises.open = originalOpen;
|
|
fs.promises.rename = originalRename;
|
|
}
|
|
|
|
assert.equal(renamed, true);
|
|
assert.deepEqual(store.loadHistory().map(entry => entry.id), ['durable']);
|
|
});
|
|
|
|
it('clearHistory empties the array', async () => {
|
|
await store.appendHistory({ id: 'test', files: [] });
|
|
assert.equal(store.loadHistory().length, 1);
|
|
await store.clearHistory();
|
|
assert.equal(store.loadHistory().length, 0);
|
|
});
|
|
|
|
it('rotationCursors default to an empty object', () => {
|
|
const config = store.load();
|
|
assert.deepEqual(config.rotationCursors, {});
|
|
});
|
|
|
|
it('saveRotationCursors round-trips and survives reload', async () => {
|
|
await store.saveRotationCursors({ 'byse.sx': 7, 'voe.sx': 2 });
|
|
const config = store.load();
|
|
assert.equal(config.rotationCursors['byse.sx'], 7);
|
|
assert.equal(config.rotationCursors['voe.sx'], 2);
|
|
});
|
|
|
|
it('an unrelated save() does not clobber persisted rotationCursors', async () => {
|
|
await store.saveRotationCursors({ 'byse.sx': 3 });
|
|
await store.save({ globalSettings: { alwaysOnTop: true } });
|
|
const config = store.load();
|
|
assert.equal(config.rotationCursors['byse.sx'], 3, 'cursor preserved across a settings save');
|
|
assert.equal(config.globalSettings.alwaysOnTop, true);
|
|
});
|
|
|
|
it('saveRotationCursors does not disturb credentials', async () => {
|
|
await store.save({ hosters: { 'byse.sx': [{ id: 'k1', enabled: true, authType: 'api', apiKey: 'secret-key' }] } });
|
|
await store.saveRotationCursors({ 'byse.sx': 1 });
|
|
const config = store.load();
|
|
assert.equal(config.hosters['byse.sx'][0].apiKey, 'secret-key');
|
|
assert.equal(config.rotationCursors['byse.sx'], 1);
|
|
});
|
|
|
|
it('corrupted JSON falls back to defaults', () => {
|
|
fs.writeFileSync(store.filePath, '{invalid json!!!', 'utf-8');
|
|
const config = store.load();
|
|
assert.ok(config.hosters);
|
|
assert.ok(config.hosterSettings);
|
|
assert.deepEqual(config.history, []);
|
|
});
|
|
|
|
it('globalSettings merge preserves partial values', () => {
|
|
fs.writeFileSync(store.filePath, JSON.stringify({
|
|
globalSettings: { alwaysOnTop: true }
|
|
}), 'utf-8');
|
|
|
|
const config = store.load();
|
|
assert.equal(config.globalSettings.alwaysOnTop, true);
|
|
assert.equal(config.globalSettings.shutdownAfterFinish, 'nothing'); // default
|
|
assert.equal(config.globalSettings.resumeQueueOnLaunch, true);
|
|
assert.equal(config.globalSettings.parallelUploadCount, 0);
|
|
assert.equal(config.globalSettings.scaleParallelUploads, false);
|
|
assert.equal(config.globalSettings.logFilePath, '');
|
|
});
|
|
|
|
it('normalizes upload schedules across load and save boundaries', async () => {
|
|
fs.writeFileSync(store.filePath, JSON.stringify({
|
|
globalSettings: {
|
|
uploadSchedule: { enabled: true, weekdays: [0, 1, 1, 9], start: ' 22:00 ', end: '06:00' }
|
|
}
|
|
}), 'utf-8');
|
|
|
|
assert.deepEqual(store.load().globalSettings.uploadSchedule, {
|
|
enabled: true,
|
|
weekdays: [1, 0],
|
|
start: '22:00',
|
|
end: '06:00'
|
|
});
|
|
|
|
const current = store.load();
|
|
await store.save({
|
|
globalSettings: {
|
|
...current.globalSettings,
|
|
uploadSchedule: { enabled: true, weekdays: [], start: '08:00', end: '08:00' }
|
|
}
|
|
});
|
|
|
|
assert.deepEqual(store.load().globalSettings.uploadSchedule, {
|
|
enabled: true,
|
|
weekdays: [],
|
|
start: '08:00',
|
|
end: '08:00'
|
|
});
|
|
});
|
|
|
|
it('concurrent saves preserve both sections', async () => {
|
|
const save1 = store.save({ hosters: { 'doodstream.com': [{ id: 'c1', enabled: true, authType: 'api', apiKey: 'concurrent-key' }] } });
|
|
const save2 = store.save({ globalSettings: { alwaysOnTop: true } });
|
|
await Promise.all([save1, save2]);
|
|
const config = store.load();
|
|
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'concurrent-key');
|
|
assert.equal(config.globalSettings.alwaysOnTop, true);
|
|
});
|
|
|
|
it('drainWrites waits for config and history writes appended while draining', async () => {
|
|
assert.equal(typeof store.drainWrites, 'function');
|
|
await store.save({ globalSettings: { alwaysOnTop: false } });
|
|
store._historyMigrated = true;
|
|
fs.writeFileSync(store.historyPath, '[]', 'utf-8');
|
|
|
|
const originalAtomicWrite = store._atomicWrite.bind(store);
|
|
const originalHistoryWrite = store._writeHistoryFileAtomic.bind(store);
|
|
const configReleases = [];
|
|
const historyReleases = [];
|
|
const block = (releases, operation) => new Promise((resolve, reject) => {
|
|
releases.push(() => Promise.resolve().then(operation).then(resolve, reject));
|
|
});
|
|
store._atomicWrite = (data) => block(configReleases, () => originalAtomicWrite(data));
|
|
store._writeHistoryFileAtomic = (history) => block(historyReleases, () => originalHistoryWrite(history));
|
|
|
|
const configWrites = store.save({ globalSettings: { alwaysOnTop: true } })
|
|
.then(() => store.save({ hosterSettings: { 'byse.sx': { retries: 8 } } }));
|
|
const historyWrites = store.appendHistory({ id: 'first', files: [] })
|
|
.then(() => store.appendHistory({ id: 'second', files: [] }));
|
|
|
|
while (configReleases.length < 1 || historyReleases.length < 1) await new Promise(resolve => setImmediate(resolve));
|
|
let drained = false;
|
|
const draining = store.drainWrites().then(() => { drained = true; });
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
assert.equal(drained, false);
|
|
|
|
configReleases.shift()();
|
|
historyReleases.shift()();
|
|
while (configReleases.length < 1 || historyReleases.length < 1) await new Promise(resolve => setImmediate(resolve));
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
assert.equal(drained, false);
|
|
|
|
configReleases.shift()();
|
|
historyReleases.shift()();
|
|
await Promise.all([configWrites, historyWrites, draining]);
|
|
assert.equal(store.load().globalSettings.alwaysOnTop, true);
|
|
assert.equal(store.load().hosterSettings['byse.sx'].retries, 8);
|
|
assert.deepEqual(store.loadHistory().map(entry => entry.id), ['first', 'second']);
|
|
});
|
|
|
|
it('drainWrites ignores caller-handled failures but propagates a write failing during the drain', async () => {
|
|
const originalAtomicWrite = store._atomicWrite.bind(store);
|
|
store._atomicWrite = () => Promise.reject(new Error('config write failed'));
|
|
await assert.rejects(store.save({ globalSettings: { alwaysOnTop: true } }), /config write failed/);
|
|
await store.drainWrites();
|
|
|
|
store._atomicWrite = originalAtomicWrite;
|
|
store._historyMigrated = true;
|
|
fs.writeFileSync(store.historyPath, '[]', 'utf-8');
|
|
let rejectHistoryWrite;
|
|
store._writeHistoryFileAtomic = () => new Promise((_resolve, reject) => { rejectHistoryWrite = reject; });
|
|
const pendingWrite = store.appendHistory({ id: 'failed', files: [] });
|
|
pendingWrite.catch(() => {});
|
|
while (!rejectHistoryWrite) await new Promise(resolve => setImmediate(resolve));
|
|
const draining = store.drainWrites();
|
|
rejectHistoryWrite(new Error('history write failed'));
|
|
await assert.rejects(draining, /history write failed/);
|
|
await assert.rejects(pendingWrite, /history write failed/);
|
|
await store.drainWrites();
|
|
});
|
|
|
|
it('surfaces a failed queued write and keeps later writes usable', async () => {
|
|
const originalAtomicWrite = store._atomicWrite.bind(store);
|
|
let failNextWrite = true;
|
|
store._atomicWrite = (data) => {
|
|
if (failNextWrite) {
|
|
failNextWrite = false;
|
|
return Promise.reject(new Error('write failed'));
|
|
}
|
|
return originalAtomicWrite(data);
|
|
};
|
|
|
|
await assert.rejects(store.appendHistory({ id: 'failed', files: [] }), /write failed/);
|
|
await store.appendHistory({ id: 'saved', files: [] });
|
|
assert.deepEqual(store.loadHistory().map(entry => entry.id), ['saved']);
|
|
});
|
|
|
|
it('serializes a complete settings replacement with pending saves', async () => {
|
|
const originalAtomicWrite = store._atomicWrite.bind(store);
|
|
let activeWrites = 0;
|
|
let maximumActiveWrites = 0;
|
|
store._atomicWrite = async (data) => {
|
|
activeWrites += 1;
|
|
maximumActiveWrites = Math.max(maximumActiveWrites, activeWrites);
|
|
await new Promise((resolve) => setTimeout(resolve, 15));
|
|
try {
|
|
await originalAtomicWrite(data);
|
|
} finally {
|
|
activeWrites -= 1;
|
|
}
|
|
};
|
|
|
|
const save = store.save({ globalSettings: { alwaysOnTop: true, pendingQueue: { savedAt: 123, queueJobs: [{ id: 'local' }] } } });
|
|
const replace = store.replaceSettings({
|
|
hosters: { 'byse.sx': [{ id: 'imported', enabled: true, authType: 'api', apiKey: 'imported-key' }] },
|
|
hosterSettings: { 'byse.sx': { retries: 9 } },
|
|
globalSettings: { alwaysOnTop: false, pendingQueue: null },
|
|
history: [],
|
|
rotationCursors: {}
|
|
});
|
|
|
|
await Promise.all([save, replace]);
|
|
const config = store.load();
|
|
assert.equal(maximumActiveWrites, 1);
|
|
assert.equal(config.hosters['byse.sx'][0].apiKey, 'imported-key');
|
|
assert.equal(config.hosterSettings['byse.sx'].retries, 9);
|
|
assert.equal(config.globalSettings.alwaysOnTop, false);
|
|
assert.deepEqual(config.globalSettings.pendingQueue, { savedAt: 123, queueJobs: [{ id: 'local' }] });
|
|
assert.deepEqual(config.rotationCursors, {});
|
|
});
|
|
|
|
it('patches the pending queue after an import without reverting imported settings', async () => {
|
|
await store.save({
|
|
globalSettings: {
|
|
alwaysOnTop: true,
|
|
webhookUrl: 'https://before.invalid',
|
|
pendingQueue: { savedAt: 1, queueJobs: [{ id: 'before' }] }
|
|
}
|
|
});
|
|
|
|
const replace = store.replaceSettings({
|
|
hosters: { 'byse.sx': [{ id: 'imported', enabled: true, authType: 'api', apiKey: 'imported-key' }] },
|
|
hosterSettings: { 'byse.sx': { retries: 9 } },
|
|
globalSettings: {
|
|
alwaysOnTop: false,
|
|
webhookUrl: 'https://imported.invalid',
|
|
pendingQueue: null
|
|
},
|
|
history: [],
|
|
rotationCursors: {}
|
|
});
|
|
const pendingQueue = {
|
|
savedAt: 2,
|
|
queueJobs: [{ id: 'live', status: 'done' }]
|
|
};
|
|
const saveQueue = store.savePendingQueue(pendingQueue);
|
|
|
|
await Promise.all([replace, saveQueue]);
|
|
const config = store.load();
|
|
assert.equal(config.hosters['byse.sx'][0].id, 'imported');
|
|
assert.equal(config.globalSettings.alwaysOnTop, false);
|
|
assert.equal(config.globalSettings.webhookUrl, 'https://imported.invalid');
|
|
assert.deepEqual(config.globalSettings.pendingQueue, pendingQueue);
|
|
});
|
|
|
|
it('preserves main-owned global state when saving a renderer snapshot', async () => {
|
|
await store.save({
|
|
globalSettings: {
|
|
alwaysOnTop: false,
|
|
pendingQueue: { savedAt: 3, queueJobs: [{ id: 'local' }] },
|
|
diagnostics: { enabled: true, port: 7777 },
|
|
historyRetention: '7d',
|
|
remote: { enabled: true, port: 9100, token: 'main-token', allowInput: true }
|
|
}
|
|
});
|
|
|
|
await store.saveRendererGlobalSettings({
|
|
alwaysOnTop: true,
|
|
pendingQueue: null,
|
|
diagnostics: { enabled: false, port: 1 },
|
|
historyRetention: 'all',
|
|
remote: { enabled: true, port: 9200, token: '', allowInput: false }
|
|
});
|
|
|
|
const config = store.load();
|
|
assert.equal(config.globalSettings.alwaysOnTop, true);
|
|
assert.deepEqual(config.globalSettings.pendingQueue, { savedAt: 3, queueJobs: [{ id: 'local' }] });
|
|
assert.equal(config.globalSettings.diagnostics.enabled, true);
|
|
assert.equal(config.globalSettings.diagnostics.port, 7777);
|
|
assert.equal(config.globalSettings.historyRetention, '7d');
|
|
assert.deepEqual(config.globalSettings.remote, { enabled: true, port: 9200, token: 'main-token', allowInput: false });
|
|
});
|
|
|
|
it('persists the last browse directory across stale renderer settings saves', async () => {
|
|
const selectedDirectory = path.join(tmpDir, 'selected');
|
|
|
|
await store.saveLastBrowseDirectory(selectedDirectory);
|
|
await store.saveRendererGlobalSettings({
|
|
alwaysOnTop: true,
|
|
lastBrowseDirectory: ''
|
|
});
|
|
|
|
assert.equal(store.load().globalSettings.lastBrowseDirectory, selectedDirectory);
|
|
});
|
|
|
|
it('merges the fallback log path after earlier queued settings without reverting them', async () => {
|
|
assert.equal(typeof store.saveFallbackLogPath, 'function');
|
|
await store.save({
|
|
globalSettings: {
|
|
alwaysOnTop: false,
|
|
webhookUrl: 'https://before.invalid',
|
|
logFilePath: ''
|
|
}
|
|
});
|
|
|
|
const originalAtomicWrite = store._atomicWrite.bind(store);
|
|
let releaseSettingsWrite;
|
|
let signalSettingsWriteStarted;
|
|
const settingsWriteStarted = new Promise(resolve => { signalSettingsWriteStarted = resolve; });
|
|
store._atomicWrite = (data) => {
|
|
const settings = JSON.parse(data).globalSettings;
|
|
if (!releaseSettingsWrite && settings.webhookUrl === 'https://concurrent.invalid') {
|
|
signalSettingsWriteStarted();
|
|
return new Promise((resolve, reject) => {
|
|
releaseSettingsWrite = () => originalAtomicWrite(data).then(resolve, reject);
|
|
});
|
|
}
|
|
return originalAtomicWrite(data);
|
|
};
|
|
|
|
const current = store.load();
|
|
const settingsSave = store.save({
|
|
globalSettings: {
|
|
...current.globalSettings,
|
|
alwaysOnTop: true,
|
|
webhookUrl: 'https://concurrent.invalid'
|
|
}
|
|
});
|
|
await settingsWriteStarted;
|
|
const fallbackPath = path.join(tmpDir, 'fallback', 'fileuploader.log');
|
|
const fallbackSave = store.saveFallbackLogPath(fallbackPath);
|
|
releaseSettingsWrite();
|
|
await Promise.all([settingsSave, fallbackSave]);
|
|
|
|
const saved = store.load().globalSettings;
|
|
assert.equal(saved.alwaysOnTop, true);
|
|
assert.equal(saved.webhookUrl, 'https://concurrent.invalid');
|
|
assert.equal(saved.logFilePath, fallbackPath);
|
|
});
|
|
|
|
it('merges remote settings in the write queue and returns the canonical token', async () => {
|
|
await store.save({
|
|
globalSettings: {
|
|
remote: { enabled: false, port: 9100, token: 'canonical-token', allowInput: true }
|
|
}
|
|
});
|
|
|
|
const rendererSave = store.saveRendererGlobalSettings({
|
|
alwaysOnTop: true,
|
|
remote: { enabled: false, port: 9200, token: '', allowInput: false }
|
|
});
|
|
const remoteSave = store.saveRemoteSettings(
|
|
{ enabled: true, port: 9300, token: '', allowInput: false },
|
|
() => 'generated-token'
|
|
);
|
|
|
|
const [, canonical] = await Promise.all([rendererSave, remoteSave]);
|
|
assert.deepEqual(canonical, { enabled: true, port: 9300, token: 'canonical-token', allowInput: false });
|
|
assert.deepEqual(store.load().globalSettings.remote, canonical);
|
|
});
|
|
|
|
it('rejects ordinary writes while quiesced and permits only the final pending queue snapshot', async () => {
|
|
store.setWritesQuiesced(true);
|
|
|
|
await assert.rejects(store.save({ globalSettings: { alwaysOnTop: true } }), /beendet/);
|
|
await assert.rejects(store.appendHistory({ id: 'late-history', files: [] }), /beendet/);
|
|
await store.savePendingQueue({ savedAt: 4, queueJobs: [{ id: 'final' }] }, { allowDuringQuiesce: true });
|
|
|
|
assert.equal(store.load().globalSettings.alwaysOnTop, false);
|
|
assert.deepEqual(store.load().globalSettings.pendingQueue, { savedAt: 4, queueJobs: [{ id: 'final' }] });
|
|
store.setWritesQuiesced(false);
|
|
await store.save({ globalSettings: { ...store.load().globalSettings, alwaysOnTop: true } });
|
|
assert.equal(store.load().globalSettings.alwaysOnTop, true);
|
|
});
|
|
|
|
it('load() returns independent clones — mutating one result must not leak into the cache', () => {
|
|
store.load(); // warm the cache
|
|
const a = store.load();
|
|
a.globalSettings.alwaysOnTop = true;
|
|
a.hosters['voe.sx'].push({ id: 'mutant' });
|
|
a.history.push({ id: 'ghost' });
|
|
const b = store.load();
|
|
assert.equal(b.globalSettings.alwaysOnTop, false, 'mutating a prior load() result must not corrupt the cache');
|
|
assert.equal(b.hosters['voe.sx'].length, 0);
|
|
assert.equal(b.history.length, 0);
|
|
});
|
|
|
|
it('load() reflects an external file change (mtime/size cache invalidation)', () => {
|
|
store.load(); // warm cache on the no-file defaults
|
|
fs.writeFileSync(store.filePath, JSON.stringify({ globalSettings: { alwaysOnTop: true } }), 'utf-8');
|
|
assert.equal(store.load().globalSettings.alwaysOnTop, true, 'an external write must invalidate the cache');
|
|
fs.writeFileSync(store.filePath, JSON.stringify({ globalSettings: { alwaysOnTop: false } }), 'utf-8');
|
|
assert.equal(store.load().globalSettings.alwaysOnTop, false, 'a second external write must be seen too');
|
|
});
|
|
|
|
it('save() invalidates the cache so the next load() sees the new value', async () => {
|
|
assert.equal(store.load().globalSettings.alwaysOnTop, false);
|
|
await store.save({ globalSettings: { alwaysOnTop: true } });
|
|
assert.equal(store.load().globalSettings.alwaysOnTop, true, 'load() after save() must reflect the write');
|
|
});
|
|
|
|
it('backup recovery when main file is corrupted', () => {
|
|
// Write valid config first
|
|
fs.writeFileSync(store.filePath, JSON.stringify({
|
|
hosters: { 'doodstream.com': [{ id: 'bak-1', authType: 'api', apiKey: 'from-backup' }] },
|
|
hosterSettings: {}, globalSettings: {}, history: []
|
|
}), 'utf-8');
|
|
// Copy to backup
|
|
fs.copyFileSync(store.filePath, store.filePath + '.bak');
|
|
// Corrupt main file
|
|
fs.writeFileSync(store.filePath, 'CORRUPTED!!!', 'utf-8');
|
|
const config = store.load();
|
|
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'from-backup');
|
|
});
|
|
|
|
it('strict diagnostics config loading rejects primary failures while normal loading keeps recovery', () => {
|
|
assert.equal(typeof store.loadDiagnosticsConfig, 'function');
|
|
|
|
fs.writeFileSync(store.filePath + '.bak', JSON.stringify({
|
|
hosters: { 'doodstream.com': [{ id: 'bak-1', authType: 'api', apiKey: 'from-backup' }] },
|
|
hosterSettings: {},
|
|
globalSettings: {},
|
|
history: []
|
|
}), 'utf-8');
|
|
fs.writeFileSync(store.filePath, '{broken-config', 'utf-8');
|
|
|
|
assert.equal(store.load().hosters['doodstream.com'][0].apiKey, 'from-backup');
|
|
assert.throws(() => store.loadDiagnosticsConfig());
|
|
|
|
fs.rmSync(store.filePath);
|
|
assert.equal(store.load().globalSettings.language, 'en');
|
|
assert.throws(() => store.loadDiagnosticsConfig());
|
|
});
|
|
|
|
it('strict diagnostics config errors never expose malformed JSON or file paths', () => {
|
|
const opaque = 'opaque-config-42';
|
|
fs.writeFileSync(store.filePath, opaque, 'utf-8');
|
|
|
|
let error = thrownBy(() => store.loadDiagnosticsConfig());
|
|
assert.equal(error.code, 'DIAGNOSTIC_CONFIG_INVALID');
|
|
assert.equal(error.message, 'Die Diagnosekonfiguration ist ungültig');
|
|
assert.ok(!error.message.includes(opaque));
|
|
|
|
fs.writeFileSync(store.filePath, '{}', 'utf-8');
|
|
error = thrownBy(() => store.loadDiagnosticsConfig());
|
|
assert.equal(error.code, 'DIAGNOSTIC_CONFIG_INVALID');
|
|
assert.equal(error.message, 'Die Diagnosekonfiguration ist ungültig');
|
|
|
|
fs.rmSync(store.filePath);
|
|
error = thrownBy(() => store.loadDiagnosticsConfig());
|
|
assert.equal(error.code, 'DIAGNOSTIC_CONFIG_READ_FAILED');
|
|
assert.equal(error.message, 'Die Diagnosekonfiguration konnte nicht gelesen werden');
|
|
assert.ok(!error.message.includes(store.filePath));
|
|
});
|
|
|
|
it('strict diagnostics config loading decrypts current credentials and propagates decryption failures', () => {
|
|
assert.equal(typeof store.loadDiagnosticsConfig, 'function');
|
|
|
|
const encrypted = `enc:v1:${Buffer.from('test-protected:diagnostic-secret').toString('base64')}`;
|
|
fs.writeFileSync(store.filePath, JSON.stringify({
|
|
hosters: { 'doodstream.com': [{ id: 'diag-1', authType: 'api', apiKey: encrypted }] },
|
|
hosterSettings: {},
|
|
globalSettings: {},
|
|
history: []
|
|
}), 'utf-8');
|
|
|
|
assert.equal(store.loadDiagnosticsConfig().hosters['doodstream.com'][0].apiKey, 'diagnostic-secret');
|
|
|
|
const originalDecryptString = safeStorage.decryptString;
|
|
safeStorage.decryptString = () => { throw new Error('diagnostic decrypt failure'); };
|
|
try {
|
|
assert.throws(() => store.loadDiagnosticsConfig(), /Gespeicherte Zugangsdaten konnten nicht entschlüsselt werden/);
|
|
} finally {
|
|
safeStorage.decryptString = originalDecryptString;
|
|
}
|
|
});
|
|
|
|
it('wipe-guard: a settings-only save recovers accounts from .bak when the live config validly has none', async () => {
|
|
// Post-wipe state: live config parses fine but has empty hosters; a backup still holds the accounts.
|
|
fs.writeFileSync(store.filePath, JSON.stringify({ hosters: {}, hosterSettings: {}, globalSettings: {}, history: [] }), 'utf-8');
|
|
fs.writeFileSync(store.filePath + '.bak', JSON.stringify({
|
|
hosters: { 'voe.sx': [{ id: 'v1', authType: 'api', apiKey: 'survive-key' }] },
|
|
hosterSettings: {}, globalSettings: {}, history: []
|
|
}), 'utf-8');
|
|
await store.save({ globalSettings: { alwaysOnTop: true } });
|
|
const cfg = store.load();
|
|
assert.ok(cfg.hosters['voe.sx'] && cfg.hosters['voe.sx'].length === 1, 'guard must restore accounts from .bak, not persist the wipe');
|
|
assert.equal(cfg.hosters['voe.sx'][0].apiKey, 'survive-key');
|
|
assert.equal(cfg.globalSettings.alwaysOnTop, true);
|
|
});
|
|
|
|
it('wipe-guard: an explicit save({hosters:{}}) (user deleted all) is NOT blocked', async () => {
|
|
await store.save({ hosters: { 'doodstream.com': [{ id: 'd1', authType: 'api', apiKey: 'k' }] } });
|
|
await store.save({ hosters: {} });
|
|
const cfg = store.load();
|
|
assert.equal((cfg.hosters['doodstream.com'] || []).length, 0, 'an intentional hosters write must be allowed to empty them');
|
|
});
|
|
});
|
|
|
|
describe('ConfigStore history split (electron-history.json)', () => {
|
|
let dir;
|
|
let s;
|
|
|
|
function makeStore() {
|
|
const st = new ConfigStore({ isPackaged: false, getPath: () => dir });
|
|
st.filePath = path.join(dir, 'electron-config.json');
|
|
st.historyPath = path.join(dir, 'electron-history.json');
|
|
return st;
|
|
}
|
|
|
|
function writeConfigWithHistory(n) {
|
|
const history = [];
|
|
for (let i = 0; i < n; i++) history.push({ id: `batch-${i}`, timestamp: 1750000000000 + i, total: 3, files: [{ name: `f${i}.mkv` }] });
|
|
fs.writeFileSync(path.join(dir, 'electron-config.json'), JSON.stringify({
|
|
hosters: { 'byse.sx': [{ id: 'a1', authType: 'api', apiKey: 'k' }] },
|
|
hosterSettings: {}, globalSettings: { historyRetention: 'all' }, history
|
|
}), 'utf-8');
|
|
}
|
|
|
|
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-hist-')); s = makeStore(); });
|
|
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
|
|
|
it('migration moves history into electron-history.json, preserving every entry', () => {
|
|
writeConfigWithHistory(50);
|
|
s._migrateHistory();
|
|
assert.equal(s._historyMigrated, true);
|
|
assert.ok(fs.existsSync(s.historyPath));
|
|
const hist = JSON.parse(fs.readFileSync(s.historyPath, 'utf-8'));
|
|
assert.equal(hist.length, 50);
|
|
assert.equal(hist[0].id, 'batch-0');
|
|
assert.equal(hist[49].id, 'batch-49');
|
|
assert.ok(fs.existsSync(s.filePath + '.pre-history-split.bak'), 'a permanent pre-split backup is kept');
|
|
});
|
|
|
|
it('after migration load() excludes history (cheap hot path) but loadHistory() returns the real data', () => {
|
|
writeConfigWithHistory(30);
|
|
s._migrateHistory();
|
|
assert.deepEqual(s.load().history, [], 'history is not carried in the always-loaded config');
|
|
assert.equal(s.loadHistory().length, 30);
|
|
});
|
|
|
|
it('strict diagnostics history accepts valid empty history and rejects failed or corrupt dedicated reads', () => {
|
|
assert.equal(typeof s.loadDiagnosticsHistory, 'function');
|
|
writeConfigWithHistory(7);
|
|
s._migrateHistory();
|
|
|
|
fs.writeFileSync(s.historyPath, '[]', 'utf-8');
|
|
assert.deepEqual(s.loadDiagnosticsHistory(), []);
|
|
|
|
for (const invalidHistory of ['null', '{}', '{"history":null}', '{broken-history']) {
|
|
fs.writeFileSync(s.historyPath, invalidHistory, 'utf-8');
|
|
const error = thrownBy(() => s.loadDiagnosticsHistory());
|
|
assert.equal(error.code, 'DIAGNOSTIC_HISTORY_INVALID');
|
|
assert.equal(error.message, 'Die Diagnoseverlaufsdatei ist ungültig');
|
|
assert.ok(!error.message.includes(invalidHistory));
|
|
}
|
|
|
|
fs.rmSync(s.historyPath);
|
|
fs.mkdirSync(s.historyPath);
|
|
const error = thrownBy(() => s.loadDiagnosticsHistory());
|
|
assert.equal(error.code, 'DIAGNOSTIC_HISTORY_READ_FAILED');
|
|
assert.equal(error.message, 'Die Diagnoseverlaufsdatei konnte nicht gelesen werden');
|
|
assert.ok(!error.message.includes(s.historyPath));
|
|
});
|
|
|
|
it('real diagnostics agent never returns opaque corrupt dedicated history content', () => {
|
|
const opaque = 'opaque42';
|
|
writeConfigWithHistory(0);
|
|
fs.writeFileSync(s.historyPath, opaque, 'utf-8');
|
|
const agent = createDiagnosticsAgent(s, dir);
|
|
|
|
for (const operation of ['get_history', 'list_errors', 'server_health']) {
|
|
const response = agent.handle(operation, {});
|
|
assert.deepEqual(response, { ok: false, error: 'Die Diagnoseverlaufsdatei ist ungültig' });
|
|
assert.ok(!JSON.stringify(response).includes(opaque));
|
|
}
|
|
});
|
|
|
|
it('strict diagnostics history never falls back to stale config history when a dedicated file exists', () => {
|
|
assert.equal(typeof s.loadDiagnosticsHistory, 'function');
|
|
writeConfigWithHistory(7);
|
|
fs.writeFileSync(s.historyPath, 'null', 'utf-8');
|
|
|
|
assert.equal(s._historyMigrated, false);
|
|
assert.equal(s.loadHistory().length, 7);
|
|
assert.throws(() => s.loadDiagnosticsHistory());
|
|
});
|
|
|
|
it('strict diagnostics history preserves the valid pre-migration history path', () => {
|
|
assert.equal(typeof s.loadDiagnosticsHistory, 'function');
|
|
writeConfigWithHistory(7);
|
|
|
|
assert.equal(s.loadDiagnosticsHistory().length, 7);
|
|
|
|
const config = JSON.parse(fs.readFileSync(s.filePath, 'utf-8'));
|
|
config.history = null;
|
|
fs.writeFileSync(s.filePath, JSON.stringify(config), 'utf-8');
|
|
assert.throws(() => s.loadDiagnosticsHistory());
|
|
});
|
|
|
|
it('appendHistory writes to history.json; the next config write strips stale history from the config file', async () => {
|
|
writeConfigWithHistory(10);
|
|
s._migrateHistory();
|
|
await s.appendHistory({ id: 'new-batch', timestamp: 1750000099999, total: 1, files: [{ name: 'x.mkv' }] });
|
|
assert.equal(s.loadHistory().length, 11, 'append goes to history.json');
|
|
await s.save({ globalSettings: { alwaysOnTop: true } });
|
|
const onDisk = JSON.parse(fs.readFileSync(s.filePath, 'utf-8'));
|
|
assert.ok(!onDisk.history || onDisk.history.length === 0, 'a config write strips stale history from the config file');
|
|
assert.equal(s.loadHistory().length, 11, 'history.json is unaffected by the config write');
|
|
});
|
|
|
|
it('save({globalSettings}) after migration NEVER loses history (data-loss invariant)', async () => {
|
|
writeConfigWithHistory(40);
|
|
s._migrateHistory();
|
|
await s.save({ globalSettings: { alwaysOnTop: true } });
|
|
assert.equal(s.loadHistory().length, 40, 'a settings write must not touch history');
|
|
assert.equal(s.load().globalSettings.alwaysOnTop, true);
|
|
});
|
|
|
|
it('clearHistory empties history.json only', async () => {
|
|
writeConfigWithHistory(20);
|
|
s._migrateHistory();
|
|
await s.clearHistory();
|
|
assert.equal(s.loadHistory().length, 0);
|
|
});
|
|
|
|
it('migration is idempotent — re-running with history.json present does not re-derive or clobber', () => {
|
|
writeConfigWithHistory(15);
|
|
s._migrateHistory();
|
|
const after = makeStore();
|
|
after._migrateHistory();
|
|
assert.equal(after._historyMigrated, true);
|
|
assert.equal(after.loadHistory().length, 15);
|
|
});
|
|
|
|
it('crash-window fallback: not migrated + no history.json → loadHistory reads config.history', () => {
|
|
writeConfigWithHistory(7);
|
|
assert.equal(s._historyMigrated, false);
|
|
assert.equal(s.loadHistory().length, 7, 'legacy path still serves history if migration never ran');
|
|
});
|
|
|
|
it('migrated prune refuses to overwrite a corrupted history file', async () => {
|
|
writeConfigWithHistory(12);
|
|
s._migrateHistory();
|
|
fs.writeFileSync(s.historyPath, '{broken-history', 'utf-8');
|
|
const historyBefore = fs.readFileSync(s.historyPath, 'utf-8');
|
|
|
|
await assert.rejects(s.pruneHistory('7d', { dryRun: false }), /Verlaufsdatei ist beschädigt/);
|
|
|
|
assert.equal(fs.readFileSync(s.historyPath, 'utf-8'), historyBefore);
|
|
assert.equal(JSON.parse(fs.readFileSync(s.filePath, 'utf-8')).globalSettings.historyRetention, 'all');
|
|
});
|
|
|
|
it('appendHistory rejects when the migrated history file is corrupted', async () => {
|
|
writeConfigWithHistory(12);
|
|
s._migrateHistory();
|
|
fs.writeFileSync(s.historyPath, '{broken-history', 'utf-8');
|
|
|
|
await assert.rejects(s.appendHistory({ id: 'unsafe-delete-barrier', files: [] }), /Verlaufsdatei ist beschädigt/);
|
|
});
|
|
|
|
it('pruneHistory trims history.json and persists the retention setting', async () => {
|
|
writeConfigWithHistory(12);
|
|
s._migrateHistory();
|
|
const res = await s.pruneHistory('all', { dryRun: false });
|
|
assert.equal(s.loadHistory().length, 12);
|
|
assert.ok(res.keptBatches === 12);
|
|
});
|
|
|
|
it('migrated prune leaves history unchanged when the retention commit fails', async () => {
|
|
writeConfigWithHistory(12);
|
|
s._migrateHistory();
|
|
const historyBefore = fs.readFileSync(s.historyPath, 'utf-8');
|
|
s._atomicWrite = () => Promise.reject(new Error('retention commit failed'));
|
|
|
|
await assert.rejects(s.pruneHistory('7d', { dryRun: false }), /retention commit failed/);
|
|
|
|
assert.equal(fs.readFileSync(s.historyPath, 'utf-8'), historyBefore);
|
|
assert.equal(JSON.parse(fs.readFileSync(s.filePath, 'utf-8')).globalSettings.historyRetention, 'all');
|
|
});
|
|
|
|
it('migrated prune restores the previous retention when the history write fails', async () => {
|
|
writeConfigWithHistory(12);
|
|
s._migrateHistory();
|
|
const historyBefore = fs.readFileSync(s.historyPath, 'utf-8');
|
|
const originalAtomicWrite = s._atomicWrite.bind(s);
|
|
const retentionWrites = [];
|
|
s._atomicWrite = (data) => {
|
|
retentionWrites.push(JSON.parse(data).globalSettings.historyRetention);
|
|
return originalAtomicWrite(data);
|
|
};
|
|
s._writeHistoryFileAtomic = () => Promise.reject(new Error('history prune failed'));
|
|
|
|
await assert.rejects(s.pruneHistory('7d', { dryRun: false }), /history prune failed/);
|
|
|
|
assert.deepEqual(retentionWrites, ['7d', 'all']);
|
|
assert.equal(JSON.parse(fs.readFileSync(s.filePath, 'utf-8')).globalSettings.historyRetention, 'all');
|
|
assert.equal(fs.readFileSync(s.historyPath, 'utf-8'), historyBefore);
|
|
});
|
|
|
|
it('migrated prune serializes surrounding global settings saves across an internal rollback', async () => {
|
|
writeConfigWithHistory(12);
|
|
s._migrateHistory();
|
|
const historyBefore = fs.readFileSync(s.historyPath, 'utf-8');
|
|
const originalAtomicWrite = s._atomicWrite.bind(s);
|
|
const configWrites = [];
|
|
let priorWriteStarted = false;
|
|
let releasePriorWrite;
|
|
s._atomicWrite = (data) => {
|
|
const settings = JSON.parse(data).globalSettings;
|
|
configWrites.push({ webhookUrl: settings.webhookUrl || '', alwaysOnTop: !!settings.alwaysOnTop, historyRetention: settings.historyRetention });
|
|
if (!priorWriteStarted && settings.webhookUrl === 'https://prune-race.invalid/prior') {
|
|
priorWriteStarted = true;
|
|
return new Promise((resolve, reject) => {
|
|
releasePriorWrite = () => originalAtomicWrite(data).then(resolve, reject);
|
|
});
|
|
}
|
|
return originalAtomicWrite(data);
|
|
};
|
|
|
|
let rejectHistoryWrite;
|
|
s._writeHistoryFileAtomic = () => new Promise((_resolve, reject) => { rejectHistoryWrite = reject; });
|
|
const priorSettings = { ...s.load().globalSettings, alwaysOnTop: true, webhookUrl: 'https://prune-race.invalid/prior', historyRetention: 'all' };
|
|
const priorSave = s.save({ globalSettings: priorSettings });
|
|
while (!releasePriorWrite) await new Promise(resolve => setImmediate(resolve));
|
|
|
|
let pruneError = null;
|
|
const pruning = s.pruneHistory('7d', { dryRun: false }).catch(error => { pruneError = error; });
|
|
releasePriorWrite();
|
|
await priorSave;
|
|
while (!rejectHistoryWrite) await new Promise(resolve => setImmediate(resolve));
|
|
|
|
const laterSettings = { ...priorSettings, alwaysOnTop: false, webhookUrl: 'https://prune-race.invalid/later', historyRetention: 'all' };
|
|
const laterSave = s.save({ globalSettings: laterSettings });
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
const laterCommittedBeforeRollback = configWrites.some(write => write.webhookUrl === 'https://prune-race.invalid/later');
|
|
rejectHistoryWrite(new Error('history prune race failed'));
|
|
await pruning;
|
|
await laterSave;
|
|
|
|
assert.match(pruneError?.message || '', /history prune race failed/);
|
|
assert.equal(laterCommittedBeforeRollback, false);
|
|
assert.deepEqual(configWrites.map(write => `${write.webhookUrl}:${write.historyRetention}`), [
|
|
'https://prune-race.invalid/prior:all',
|
|
'https://prune-race.invalid/prior:7d',
|
|
'https://prune-race.invalid/prior:all',
|
|
'https://prune-race.invalid/later:all'
|
|
]);
|
|
const finalSettings = JSON.parse(fs.readFileSync(s.filePath, 'utf-8')).globalSettings;
|
|
assert.equal(finalSettings.webhookUrl, 'https://prune-race.invalid/later');
|
|
assert.equal(finalSettings.alwaysOnTop, false);
|
|
assert.equal(finalSettings.historyRetention, 'all');
|
|
assert.equal(fs.readFileSync(s.historyPath, 'utf-8'), historyBefore);
|
|
});
|
|
|
|
it('renderer snapshots cannot revert retention after a successful prune', async () => {
|
|
writeConfigWithHistory(12);
|
|
s._migrateHistory();
|
|
const originalHistoryWrite = s._writeHistoryFileAtomic.bind(s);
|
|
let releaseHistoryWrite;
|
|
s._writeHistoryFileAtomic = (history) => new Promise((resolve, reject) => {
|
|
releaseHistoryWrite = () => originalHistoryWrite(history).then(resolve, reject);
|
|
});
|
|
|
|
const pruning = s.pruneHistory('7d', { dryRun: false });
|
|
while (!releaseHistoryWrite) await new Promise(resolve => setImmediate(resolve));
|
|
const staleRendererSave = s.saveRendererGlobalSettings({
|
|
...s.load().globalSettings,
|
|
alwaysOnTop: true,
|
|
historyRetention: 'all'
|
|
});
|
|
releaseHistoryWrite();
|
|
await Promise.all([pruning, staleRendererSave]);
|
|
|
|
assert.equal(s.load().globalSettings.historyRetention, '7d');
|
|
assert.equal(s.load().globalSettings.alwaysOnTop, true);
|
|
});
|
|
});
|