Verify update artifacts with exact metadata and SHA-512, require host-confirmed upload completion before cleanup, harden credentials and backups, improve queue recovery and skipped-state reporting, expand Windows path coverage, and add CI packaging checks.
798 lines
36 KiB
JavaScript
798 lines
36 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 ConfigStore = require('../lib/config-store');
|
|
|
|
let tmpDir;
|
|
let store;
|
|
|
|
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, { allowPlaintextCredentialStorage: true });
|
|
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}`);
|
|
}
|
|
}, { allowPlaintextCredentialStorage: true });
|
|
|
|
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(config.globalSettings.parallelUploadCount, 0);
|
|
assert.equal(config.globalSettings.scaleParallelUploads, false);
|
|
assert.equal(config.globalSettings.lastBrowseDirectory, '');
|
|
assert.equal(config.globalSettings.pendingQueue, null);
|
|
assert.deepEqual(config.history, []);
|
|
});
|
|
|
|
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('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('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 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('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 }, { allowPlaintextCredentialStorage: true });
|
|
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('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);
|
|
});
|
|
});
|