Release Multi-Hoster Uploader 3.3.108

This commit is contained in:
Sucukdeluxe
2026-08-01 17:46:29 +02:00
commit 6d0ad84d2d
90 changed files with 29450 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { selectUploadAuth } = require('../lib/account-auth');
test('doodstream prefers the API key even when username/password are also set', () => {
const auth = selectUploadAuth('doodstream.com', {
apiKey: 'KEY123', username: 'u', password: 'p'
});
assert.deepEqual(auth, { apiKey: 'KEY123' }); // API path — no username leaks through
});
test('doodstream with only username/password uses web login (keyless fallback)', () => {
const auth = selectUploadAuth('doodstream.com', { username: 'u', password: 'p' });
assert.deepEqual(auth, { username: 'u', password: 'p' });
});
test('doodstream with empty apiKey + creds falls back to web login (no false API route)', () => {
const auth = selectUploadAuth('doodstream.com', { apiKey: '', username: 'u', password: 'p' });
assert.deepEqual(auth, { username: 'u', password: 'p' });
});
test('doodstream with nothing usable returns empty', () => {
assert.deepEqual(selectUploadAuth('doodstream.com', { apiKey: '', username: '', password: '' }), {});
});
test('voe.sx is unaffected by the doodstream special-case: username/password wins', () => {
// voe also supports both, but the empty-form bug is doodstream-specific; do
// not change voe routing.
const auth = selectUploadAuth('voe.sx', { apiKey: 'VKEY', username: 'u', password: 'p' });
assert.deepEqual(auth, { username: 'u', password: 'p' });
});
test('authType=api forces the API key for any hoster', () => {
assert.deepEqual(selectUploadAuth('voe.sx', { authType: 'api', apiKey: 'K', username: 'u', password: 'p' }), { apiKey: 'K' });
});
test('api-key-only account (no creds) uses the key', () => {
assert.deepEqual(selectUploadAuth('byse.sx', { apiKey: 'BKEY' }), { apiKey: 'BKEY' });
});
test('null / non-object account does not throw', () => {
assert.deepEqual(selectUploadAuth('doodstream.com', null), {});
assert.deepEqual(selectUploadAuth('doodstream.com', undefined), {});
});
+126
View File
@@ -0,0 +1,126 @@
const test = require('node:test');
const assert = require('node:assert');
const { createAccountPicker, enabledAccountsFor } = require('../lib/account-rotation');
const hasCreds = (hoster, a) => !!(a && a.creds !== false);
function acc(id, opts = {}) { return { id, enabled: opts.enabled, creds: opts.creds }; }
function picks(pick, hoster, n) { return Array.from({ length: n }, () => { const a = pick(hoster); return a ? a.id : null; }); }
test('rotate OFF: always the first enabled account (primary, unchanged behavior)', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2'), acc('a3')] };
const pick = createAccountPicker({ hosters, hosterSettings: {}, hasCreds });
assert.deepStrictEqual(picks(pick, 'byse.sx', 4), ['a1', 'a1', 'a1', 'a1']);
});
test('rotate ON, 3 accounts: round-robin per call and wraps around', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2'), acc('a3')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
assert.deepStrictEqual(picks(pick, 'byse.sx', 7), ['a1', 'a2', 'a3', 'a1', 'a2', 'a3', 'a1']);
});
test('rotate ON, single enabled account: no-op (length must be > 1 to rotate)', () => {
const hosters = { 'byse.sx': [acc('a1')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
assert.deepStrictEqual(picks(pick, 'byse.sx', 3), ['a1', 'a1', 'a1']);
});
test('rotate ON skips a disabled account, keeps the rest in order', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2', { enabled: false }), acc('a3')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
assert.deepStrictEqual(picks(pick, 'byse.sx', 4), ['a1', 'a3', 'a1', 'a3']);
});
test('rotate ON skips an account without credentials', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2', { creds: false }), acc('a3')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
assert.deepStrictEqual(picks(pick, 'byse.sx', 4), ['a1', 'a3', 'a1', 'a3']);
});
test('no usable account → null (disabled hoster or missing hoster)', () => {
const hosters = { 'byse.sx': [acc('a1', { enabled: false })] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
assert.strictEqual(pick('byse.sx'), null);
assert.strictEqual(pick('voe.sx'), null);
});
test('rotation index is independent per hoster (interleaved calls)', () => {
const hosters = { 'byse.sx': [acc('b1'), acc('b2')], 'voe.sx': [acc('v1'), acc('v2')] };
const settings = { 'byse.sx': { rotateAccounts: true }, 'voe.sx': { rotateAccounts: true } };
const pick = createAccountPicker({ hosters, hosterSettings: settings, hasCreds });
assert.strictEqual(pick('byse.sx').id, 'b1');
assert.strictEqual(pick('voe.sx').id, 'v1');
assert.strictEqual(pick('byse.sx').id, 'b2');
assert.strictEqual(pick('voe.sx').id, 'v2');
assert.strictEqual(pick('byse.sx').id, 'b1');
});
test('rotate ON for byse only: voe still uses its primary', () => {
const hosters = { 'byse.sx': [acc('b1'), acc('b2')], 'voe.sx': [acc('v1'), acc('v2')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
assert.deepStrictEqual(picks(pick, 'voe.sx', 2), ['v1', 'v1']);
assert.deepStrictEqual(picks(pick, 'byse.sx', 2), ['b1', 'b2']);
});
test('user scenario: 100 files across 2 active accounts → even 50/50 alternating split', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
const ids = picks(pick, 'byse.sx', 100);
assert.strictEqual(ids.filter(x => x === 'a1').length, 50);
assert.strictEqual(ids.filter(x => x === 'a2').length, 50);
assert.deepStrictEqual(ids.slice(0, 5), ['a1', 'a2', 'a1', 'a2', 'a1']);
});
test('enabledAccountsFor filters disabled + no-creds and preserves order', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2', { enabled: false }), acc('a3', { creds: false }), acc('a4')] };
assert.deepStrictEqual(enabledAccountsFor(hosters, 'byse.sx', hasCreds).map(a => a.id), ['a1', 'a4']);
assert.deepStrictEqual(enabledAccountsFor(hosters, 'missing', hasCreds), []);
});
test('seeded index resumes mid-cycle (restart / persisted cursor)', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2'), acc('a3')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds, indices: { 'byse.sx': 1 } });
assert.deepStrictEqual(picks(pick, 'byse.sx', 4), ['a2', 'a3', 'a1', 'a2']);
});
test('drip-feed: fresh picker per call seeded from prior indices keeps rotating (no per-batch reset)', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2'), acc('a3')] };
const settings = { 'byse.sx': { rotateAccounts: true } };
let cursors = {};
const landed = [];
for (let i = 0; i < 6; i++) {
const pick = createAccountPicker({ hosters, hosterSettings: settings, hasCreds, indices: cursors });
landed.push(pick('byse.sx').id);
cursors = pick.indices();
}
assert.deepStrictEqual(landed, ['a1', 'a2', 'a3', 'a1', 'a2', 'a3']);
});
test('dirty() is true only after an actual rotation advance', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2')], 'voe.sx': [acc('v1'), acc('v2')] };
const offPick = createAccountPicker({ hosters, hosterSettings: {}, hasCreds });
offPick('byse.sx'); offPick('voe.sx');
assert.strictEqual(offPick.dirty(), false);
const singlePick = createAccountPicker({ hosters: { 'byse.sx': [acc('a1')] }, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
singlePick('byse.sx');
assert.strictEqual(singlePick.dirty(), false);
const onPick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
onPick('voe.sx');
assert.strictEqual(onPick.dirty(), false);
onPick('byse.sx');
assert.strictEqual(onPick.dirty(), true);
});
test('indices() carries forward unrotated seeded hosters alongside advanced ones', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2')], 'voe.sx': [acc('v1'), acc('v2')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds, indices: { 'voe.sx': 5 } });
pick('byse.sx');
assert.deepStrictEqual(pick.indices(), { 'voe.sx': 5, 'byse.sx': 1 });
});
test('persisted cursor wraps correctly after the enabled-account count shrinks', () => {
const hosters = { 'byse.sx': [acc('a1'), acc('a2')] };
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds, indices: { 'byse.sx': 7 } });
assert.deepStrictEqual(picks(pick, 'byse.sx', 3), ['a2', 'a1', 'a2']);
});
+73
View File
@@ -0,0 +1,73 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { encrypt, decrypt } = require('../lib/backup-crypto');
describe('backup-crypto', () => {
const sampleConfig = {
hosters: { 'doodstream.com': { enabled: true, apiKey: 'test-key-123' } },
hosterSettings: { 'doodstream.com': { retries: 3 } },
globalSettings: { alwaysOnTop: false },
history: [{ file: 'test.mkv', link: 'https://example.com/abc' }]
};
it('encrypt then decrypt round-trips', () => {
const buf = encrypt(sampleConfig);
const result = decrypt(buf);
assert.deepStrictEqual(result, sampleConfig);
});
it('decrypt with corrupted data throws', () => {
const buf = encrypt(sampleConfig);
buf[buf.length - 1] ^= 0xff; // flip last byte
// With no password: app-key fails → needsPassword surfaces.
assert.throws(() => decrypt(buf), (err) => err.needsPassword === true);
// With a password: both app-key and password fail → Falsches Passwort.
assert.throws(() => decrypt(buf, 'anything'), /Falsches Passwort/);
});
it('decrypt with invalid magic throws', () => {
// Buffer must be long enough to pass the length check (>= 4+16+12+16+1 = 49)
const buf = Buffer.alloc(60, 0x41); // 60 bytes of 'A'
assert.throws(() => decrypt(buf), /Keine gültige/);
});
it('decrypt with too-short buffer throws', () => {
assert.throws(() => decrypt(Buffer.alloc(10)), /Ungültiges Backup-Format/);
});
it('handles empty config gracefully', () => {
const empty = { hosters: {}, hosterSettings: {}, globalSettings: {}, history: [] };
const buf = encrypt(empty);
assert.deepStrictEqual(decrypt(buf), empty);
});
it('decrypts legacy password-encrypted buffer when password is provided', () => {
// Reproduce the old format: same envelope, but key derived from user password.
const crypto = require('crypto');
const plaintext = Buffer.from(JSON.stringify(sampleConfig), 'utf-8');
const salt = crypto.randomBytes(16);
const iv = crypto.randomBytes(12);
const key = crypto.pbkdf2Sync('oldUserPw', salt, 100_000, 32, 'sha512');
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const enc = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
const legacyBuf = Buffer.concat([Buffer.from('MHU1'), salt, iv, tag, enc]);
// Without password → should throw needsPassword
assert.throws(() => decrypt(legacyBuf), (err) => err.needsPassword === true);
// With correct password → should decrypt
assert.deepStrictEqual(decrypt(legacyBuf, 'oldUserPw'), sampleConfig);
// With wrong password → should throw (not needsPassword)
assert.throws(() => decrypt(legacyBuf, 'wrongPw'), /Falsches Passwort/);
});
it('each encryption produces different output (random salt/iv)', () => {
const a = encrypt(sampleConfig);
const b = encrypt(sampleConfig);
assert.ok(!a.equals(b), 'two encryptions should differ');
// but both decrypt to same result
assert.deepStrictEqual(decrypt(a), decrypt(b));
});
});
+234
View File
@@ -0,0 +1,234 @@
const { test, before, after } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
let requestRouter = async () => ({ statusCode: 200, headers: {}, body: { text: async () => '{}' } });
const undici = require('undici');
const _origUndiciRequest = undici.request;
undici.request = (...a) => requestRouter(...a);
delete require.cache[require.resolve('../lib/hosters')];
const hostersMod = require('../lib/hosters');
const { uploadFile } = hostersMod;
let tmpFile;
let origFetch;
before(() => {
tmpFile = path.join(os.tmpdir(), `byse-itest-${process.pid}.mkv`);
fs.writeFileSync(tmpFile, Buffer.alloc(2048, 7));
origFetch = global.fetch;
});
after(() => {
global.fetch = origFetch;
undici.request = _origUndiciRequest;
delete require.cache[require.resolve('../lib/hosters')];
try { fs.unlinkSync(tmpFile); } catch {}
});
function stubByseUploadServer() {
global.fetch = async (url) => {
if (/upload\/server/.test(String(url))) {
return { status: 200, text: async () => JSON.stringify({ status: 200, result: 'https://node1.byse.sx/upload/01' }) };
}
return { status: 200, text: async () => '{"status":200}' };
};
}
test('byse "Not video file format" (suspect) DOES poll recovery and claims the async-registered file', async () => {
stubByseUploadServer();
let listCalls = 0;
requestRouter = async (url, opts) => {
const u = String(url);
if (/\/file\/list/.test(u)) {
listCalls++;
const body = listCalls === 1
? '{"status":200,"result":{"files":[]}}'
: JSON.stringify({ status: 200, result: { files: [{ file_code: 'BIGMKV77', title: path.basename(tmpFile) }] } });
return { statusCode: 200, headers: {}, body: { text: async () => body } };
}
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
}
return {
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK', files: [{ filecode: '', filename: 'x.mkv', status: 'Not video file format' }] }) }
};
};
const res = await uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null);
assert.strictEqual(res.file_code, 'BIGMKV77');
assert.ok(listCalls >= 2, 'suspect rejection must still run the recovery poll (live 2026-06-09: >2.7GB MKVs got this status while registering fine)');
});
test('byse "Not video file format" with empty poll throws err.suspectReject so rotation can try other accounts', async () => {
stubByseUploadServer();
const abort = new AbortController();
let listCalls = 0;
requestRouter = async (url, opts) => {
const u = String(url);
if (/\/file\/list/.test(u)) {
listCalls++;
if (listCalls >= 2) abort.abort();
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
}
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
}
return {
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK', files: [{ filecode: '', filename: 'x.mkv', status: 'Not video file format' }] }) }
};
};
await assert.rejects(
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, abort.signal, null),
(err) => err.fileRejected === true && err.suspectReject === true && /Not video file format/i.test(err.message)
);
assert.ok(listCalls >= 2, 'poll must have started before giving up');
});
test('byse "Not video file format" with probe-confirmed NON-video skips the recovery poll (genuine rejection)', async () => {
stubByseUploadServer();
let listCalls = 0;
requestRouter = async (url, opts) => {
const u = String(url);
if (/\/file\/list/.test(u)) {
listCalls++;
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
}
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
}
return {
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK', files: [{ filecode: '', filename: 'x.mkv', status: 'Not video file format' }] }) }
};
};
await assert.rejects(
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null, { probeIsVideoLike: false }),
(err) => err.fileRejected === true && /Not video file format/i.test(err.message)
);
assert.strictEqual(listCalls, 1, 'probe says non-video → the rejection is genuine, no 15-attempt poll');
});
test('byse explicit "Duplicate" rejection still throws fast WITHOUT recovery polling', async () => {
stubByseUploadServer();
let listCalls = 0;
requestRouter = async (url, opts) => {
const u = String(url);
if (/\/file\/list/.test(u)) {
listCalls++;
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
}
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
}
return {
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK', files: [{ filecode: '', filename: 'x.mkv', status: 'Duplicate' }] }) }
};
};
await assert.rejects(
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
(err) => err.fileRejected === true && err.suspectReject !== true && /Duplicate/i.test(err.message)
);
assert.strictEqual(listCalls, 1, 'file/list should be hit ONCE (baseline only) — no 15-attempt recovery poll on a genuine rejection');
});
test('byse empty filecode WITHOUT explicit rejection still polls recovery', async () => {
stubByseUploadServer();
let listCalls = 0;
requestRouter = async (url, opts) => {
const u = String(url);
if (/\/file\/list/.test(u)) {
listCalls++;
const body = listCalls === 1
? '{"status":200,"result":{"files":[]}}'
: JSON.stringify({ status: 200, result: { files: [{ file_code: 'RECOVERED99', title: path.basename(tmpFile) }] } });
return { statusCode: 200, headers: {}, body: { text: async () => body } };
}
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
}
return {
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK' }) }
};
};
const res = await uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null);
assert.strictEqual(res.file_code, 'RECOVERED99');
assert.ok(listCalls >= 2, 'recovery polling must run when there is no explicit rejection');
});
function stubBysePost(response) {
requestRouter = async (url, opts) => {
const u = String(url);
if (/\/file\/list/.test(u)) {
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
}
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
}
return response();
};
}
test('byse upload POST 502 (HTML gateway body) is tagged transientNetwork', async () => {
stubByseUploadServer();
stubBysePost(() => ({
statusCode: 502,
headers: { 'content-type': 'text/html' },
body: { text: async () => '<!doctype html><html><head><title>502 Bad Gateway</title></head><body>502 Bad Gateway</body></html>' }
}));
await assert.rejects(
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
(err) => err.transientNetwork === true && /kein JSON \(HTTP 502\)/.test(err.message)
);
});
test('byse upload POST non-2xx JSON 503 is tagged transientNetwork', async () => {
stubByseUploadServer();
stubBysePost(() => ({
statusCode: 503,
headers: { 'content-type': 'application/json' },
body: { text: async () => JSON.stringify({ status: 503, msg: 'Service Unavailable' }) }
}));
await assert.rejects(
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
(err) => err.transientNetwork === true
);
});
test('byse upload POST 2xx envelope {status:500} is transient; {status:403} stays account-level', async () => {
stubByseUploadServer();
stubBysePost(() => ({
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: { text: async () => JSON.stringify({ status: 500, msg: 'Internal Server Error' }) }
}));
await assert.rejects(
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
(err) => err.transientNetwork === true
);
stubBysePost(() => ({
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: { text: async () => JSON.stringify({ status: 403, msg: 'Forbidden' }) }
}));
await assert.rejects(
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
(err) => err.transientNetwork !== true
);
});
+144
View File
@@ -0,0 +1,144 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { makeCoalescedSet } = require('../lib/coalesced-set');
// Synchronous scheduler stand-in: collects callbacks instead of running
// them, so tests can drive the timing explicitly.
function makeManualScheduler() {
const queue = [];
const fn = (cb) => queue.push(cb);
fn.flush = () => {
while (queue.length) {
const cb = queue.shift();
cb();
}
};
fn.queueLength = () => queue.length;
return fn;
}
test('throws if apply callback missing', () => {
assert.throws(() => makeCoalescedSet());
assert.throws(() => makeCoalescedSet({}));
assert.throws(() => makeCoalescedSet({ apply: 'not-a-fn' }));
});
test('multiple adds in one tick coalesce into one apply call', () => {
const sched = makeManualScheduler();
const calls = [];
const cs = makeCoalescedSet({
apply: (drop) => calls.push([...drop].sort()),
scheduler: sched
});
cs.add('a'); cs.add('b'); cs.add('c');
assert.equal(sched.queueLength(), 1, 'only one microtask scheduled');
assert.equal(cs.pendingSize(), 3);
sched.flush();
assert.deepEqual(calls, [['a', 'b', 'c']]);
assert.equal(cs.pendingSize(), 0);
});
test('duplicate adds are deduplicated', () => {
const sched = makeManualScheduler();
const calls = [];
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]), scheduler: sched });
cs.add('a'); cs.add('a'); cs.add('a');
sched.flush();
assert.deepEqual(calls, [['a']]);
});
test('two batches in series stay independent', () => {
const sched = makeManualScheduler();
const calls = [];
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]), scheduler: sched });
cs.add('x'); cs.add('y');
sched.flush();
cs.add('z');
sched.flush();
assert.deepEqual(calls, [['x', 'y'], ['z']]);
});
test('add after flush re-schedules a new microtask', () => {
const sched = makeManualScheduler();
const cs = makeCoalescedSet({ apply: () => {}, scheduler: sched });
cs.add('a');
assert.equal(sched.queueLength(), 1);
sched.flush();
assert.equal(sched.queueLength(), 0);
assert.equal(cs.isScheduled(), false);
cs.add('b');
assert.equal(sched.queueLength(), 1, 'new add → new microtask');
});
test('drainSync flushes synchronously without waiting for scheduler', () => {
const sched = makeManualScheduler();
const calls = [];
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]), scheduler: sched });
cs.add('p'); cs.add('q');
cs.drainSync();
assert.deepEqual(calls, [['p', 'q']]);
assert.equal(cs.pendingSize(), 0);
// Pending microtask was for the same ids — when it runs, pending is empty
// → apply NOT called twice.
sched.flush();
assert.equal(calls.length, 1, 'queued microtask is a no-op after drainSync');
});
test('drainSync on empty set is a no-op', () => {
let called = 0;
const cs = makeCoalescedSet({ apply: () => called++ });
cs.drainSync();
assert.equal(called, 0);
});
test('throwing apply does not lock out subsequent batches', () => {
const sched = makeManualScheduler();
let attempt = 0;
const cs = makeCoalescedSet({
apply: () => { attempt++; if (attempt === 1) throw new Error('boom'); },
scheduler: sched
});
cs.add('a');
// First flush throws inside apply but is swallowed; coalescer must still work.
sched.flush();
cs.add('b');
sched.flush();
assert.equal(attempt, 2, 'second batch still ran despite first throwing');
});
test('default scheduler is queueMicrotask (or Promise fallback) — runs eventually', async () => {
const calls = [];
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]) });
cs.add('z');
// Wait one microtask
await Promise.resolve();
assert.deepEqual(calls, [['z']]);
});
test('no-op tick: scheduler fires while pending is empty (e.g. drained)', () => {
const sched = makeManualScheduler();
let called = 0;
const cs = makeCoalescedSet({ apply: () => called++, scheduler: sched });
cs.add('a');
cs.drainSync();
assert.equal(called, 1);
// Pending microtask still in queue → flush; pending is empty → apply NOT called again.
sched.flush();
assert.equal(called, 1);
});
test('large burst of 5000 adds coalesces to one apply call', () => {
const sched = makeManualScheduler();
const calls = [];
const cs = makeCoalescedSet({ apply: (d) => calls.push(d.size), scheduler: sched });
for (let i = 0; i < 5000; i++) cs.add('id-' + i);
assert.equal(sched.queueLength(), 1);
sched.flush();
assert.deepEqual(calls, [5000]);
});
+409
View File
@@ -0,0 +1,409 @@
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);
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('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.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.pendingQueue, null);
assert.deepEqual(config.history, []);
});
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('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 });
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('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);
});
});
+62
View File
@@ -0,0 +1,62 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { createAgent } = require('../lib/diagnostics-agent');
function stubCollectors() {
const calls = [];
const mk = (name) => (a) => { calls.push([name, a]); return { name, a }; };
return {
calls,
getSystemInfo: mk('getSystemInfo'),
serverHealth: mk('serverHealth'),
getConfigRedacted: mk('getConfigRedacted'),
listLogs: mk('listLogs'),
readLog: mk('readLog'),
getAppEvents: mk('getAppEvents'),
listErrors: mk('listErrors'),
getQueueState: mk('getQueueState'),
getHistory: mk('getHistory'),
getRotationState: mk('getRotationState'),
getHealth: mk('getHealth')
};
}
test('agent rejects unknown ops and any write/exec-shaped op', () => {
const agent = createAgent(stubCollectors());
for (const bad of ['delete_log', 'write_config', 'run_health_check', 'exec', 'eval', '__proto__', 'set_setting', 'restart']) {
const r = agent.handle(bad, {});
assert.equal(r.ok, false, `${bad} must be rejected`);
assert.match(r.error, /unknown or non-readonly/);
}
});
test('agent rejects inherited Object.prototype members (no whitelist bypass via the prototype chain)', () => {
const agent = createAgent(stubCollectors());
for (const proto of ['constructor', 'toString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf', 'toLocaleString']) {
const r = agent.handle(proto, {});
assert.equal(r.ok, false, `${proto} (inherited) must NOT be treated as an op`);
}
for (const bad of [null, undefined, 42, {}, ['read_log']]) {
assert.equal(agent.handle(bad, {}).ok, false, `non-string op ${JSON.stringify(bad)} must be rejected`);
}
});
test('agent maps each whitelisted op to its collector and is read-only only', () => {
const stub = stubCollectors();
const agent = createAgent(stub);
assert.equal(agent.handle('server_health', { errorLimit: 5 }).ok, true);
assert.equal(agent.handle('read_log', { name: 'debug' }).ok, true);
assert.equal(agent.handle('tail_log', { name: 'debug' }).ok, true, 'tail_log aliases read_log');
assert.equal(agent.handle('get_config_redacted', {}).ok, true);
const ops = new Set(agent.ops);
assert.ok(!ops.has('run_health_check'), 'no live probe op in this build');
for (const op of agent.ops) assert.ok(!/write|delete|set_|exec|restart|cancel|retry/.test(op), `${op} must be read-only`);
});
test('agent surfaces a collector ok:false verbatim and never throws', () => {
const agent = createAgent({ readLog: () => ({ ok: false, error: 'unknown or non-readable log: x' }), getSystemInfo: () => { throw new Error('boom'); } });
assert.equal(agent.handle('read_log', { name: 'x' }).ok, false);
const thrown = agent.handle('get_system_info', {});
assert.equal(thrown.ok, false);
assert.match(thrown.error, /boom/);
});
+166
View File
@@ -0,0 +1,166 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const support = require('../lib/support-bundle');
const stats = require('../lib/stats');
const { createCollectors } = require('../lib/diagnostics-collectors');
const { createAgent } = require('../lib/diagnostics-agent');
const fixtureSecrets = {
diagnosticToken: ['fixture', 'diagnostic', 'token', '123456'].join('-'),
bearerToken: ['fixture', 'bearer', 'token', '123456'].join('-'),
doodstreamKey: ['fixture', 'doodstream', 'key', '99999'].join('-'),
password: ['fixture', 'password', 'not', 'real'].join('-'),
apiKey: ['fixture', 'api', 'key', '1234567'].join('-'),
webhookToken: ['fixture', 'webhook', 'token', '123456'].join('-')
};
function makeFixture() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-diag-'));
const paths = {
fileuploader: path.join(dir, 'fileuploader.log'),
debug: path.join(dir, 'debug.log'),
accountRotation: path.join(dir, 'account-rotation.log'),
doodstreamDebug: path.join(dir, 'doodstream-debug.log'),
crashLog: path.join(dir, 'crash.log'),
logDir: dir
};
fs.writeFileSync(paths.debug, `boot ok\nuploading file with token ${fixtureSecrets.diagnosticToken} inline\nAuthorization: Bearer ${fixtureSecrets.bearerToken}\n`);
fs.writeFileSync(paths.doodstreamDebug, `api_key=${fixtureSecrets.doodstreamKey} sess=abc\n`);
fs.writeFileSync(paths.crashLog, 'CRASH at 12:00\n');
const config = {
hosters: { 'voe.sx': [{ id: 'a1', username: 'u', password: fixtureSecrets.password }], 'byse.sx': [{ id: 'b1', apiKey: fixtureSecrets.apiKey }] },
hosterSettings: {},
globalSettings: {
webhookUrl: `https://discord.com/api/webhooks/12345/${fixtureSecrets.webhookToken}`,
diagnostics: { enabled: true, port: 9110, token: fixtureSecrets.diagnosticToken, bindAddress: '127.0.0.1' },
pendingQueue: { savedAt: 1, selectedUploadHosters: ['voe.sx'], selectedFiles: [{ path: 'C:/a.mkv' }], queueJobs: [{ file: 'C:/a.mkv', fileName: 'a.mkv', hoster: 'voe.sx', status: 'error', error: 'timeout' }] }
},
history: [{ timestamp: new Date(2026, 0, 1).toISOString(), files: [{ name: 'x.mkv', results: [{ hoster: 'voe.sx', status: 'error', error: 'Not video file format' }, { hoster: 'byse.sx', status: 'done', url: 'https://byse.sx/x' }] }] }],
rotationCursors: { 'voe.sx': 1 }
};
const collectors = createCollectors({
loadConfig: () => JSON.parse(JSON.stringify(config)),
getAllLogPaths: () => paths,
support, stats,
appInfo: () => ({ name: 'mhu', version: '9.9.9' }),
systemInfo: () => ({ platform: 'win32', hostname: 'srv' }),
agentInfo: () => ({ version: '9.9.9', port: 9110, clientCount: 0, lastAccess: null })
});
return { dir, paths, config, collectors };
}
test('getConfigRedacted strips password/apiKey/token/webhookUrl and value-scrubs the token mid-string', () => {
const { collectors } = makeFixture();
const out = collectors.getConfigRedacted({ section: 'all' });
const json = JSON.stringify(out);
assert.ok(!json.includes(fixtureSecrets.password), 'password must be redacted');
assert.ok(!json.includes(fixtureSecrets.apiKey), 'apiKey must be redacted');
assert.ok(!json.includes(fixtureSecrets.diagnosticToken), 'diag token must be redacted');
assert.ok(!json.includes(fixtureSecrets.webhookToken), 'webhook secret must be redacted');
});
test('getHistory reads loadHistory (migrated mode: loadConfig().history is empty)', () => {
const c = createCollectors({
loadConfig: () => ({ hosters: {}, globalSettings: {}, history: [] }),
loadHistory: () => [
{ timestamp: '2026-01-01T00:00:00.000Z', files: [{ name: 'a.mkv', results: [{ hoster: 'voe.sx', status: 'done', url: 'https://voe.sx/a' }] }] },
{ timestamp: '2026-01-02T00:00:00.000Z', files: [{ name: 'b.mkv', results: [{ hoster: 'byse.sx', status: 'done', url: 'https://byse.sx/b' }] }] }
],
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
support, stats,
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
});
const out = c.getHistory({ limit: 10 });
assert.equal(out.totalBatches, 2, 'must report real history from loadHistory, not the empty load().history');
assert.equal(out.returned, 2);
});
test('getHistory falls back to loadConfig().history when loadHistory is absent (legacy mode)', () => {
const c = createCollectors({
loadConfig: () => ({ hosters: {}, globalSettings: {}, history: [{ timestamp: '2026-01-01T00:00:00.000Z', files: [] }] }),
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
support, stats,
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
});
assert.equal(c.getHistory({ limit: 10 }).totalBatches, 1, 'legacy path reads load().history when loadHistory not injected');
});
test('readLog redacts a planted token and a Bearer line; doodstream is NOT readable; unknown name rejected', () => {
const { collectors } = makeFixture();
const dbg = collectors.readLog({ name: 'debug', tailKb: 64 });
assert.ok(!dbg.content.includes(fixtureSecrets.diagnosticToken), 'value-scrub removes the live diag token from logs');
assert.ok(!dbg.content.includes(fixtureSecrets.bearerToken), 'pattern-scrub removes Authorization Bearer');
assert.equal(collectors.readLog({ name: 'doodstreamDebug' }).ok, false, 'doodstream-debug.log is not in the readable allowlist');
assert.equal(collectors.readLog({ name: '../../etc/passwd' }).ok, false, 'arbitrary names are rejected (no path traversal)');
assert.equal(collectors.readLog({ name: 'crash' }).name, 'crash');
});
test('readLog grep is case-insensitive substring with | alternation, and is ReDoS-safe', () => {
const { paths } = makeFixture();
const fs2 = require('fs');
fs2.writeFileSync(paths.debug, ['ERROR upload failed', 'info all good', 'WARN timeout hit', 'a'.repeat(120) + '! catastrophic bait'].join('\n'));
const { collectors } = (() => {
const support2 = require('../lib/support-bundle');
const stats2 = require('../lib/stats');
const c = require('../lib/diagnostics-collectors').createCollectors({
loadConfig: () => ({ hosters: {}, globalSettings: {}, history: [] }),
getAllLogPaths: () => paths, support: support2, stats: stats2,
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
});
return { collectors: c };
})();
const alt = collectors.readLog({ name: 'debug', grep: 'error|timeout' });
assert.equal(alt.matchedLines, 2, 'matches the ERROR and timeout lines case-insensitively');
assert.ok(alt.content.includes('ERROR upload failed') && alt.content.includes('WARN timeout hit'));
assert.ok(!alt.content.includes('info all good'), 'non-matching line excluded');
const t0 = Date.now();
const redos = collectors.readLog({ name: 'debug', grep: '(a+)+$' });
assert.ok(Date.now() - t0 < 1000, 'catastrophic-looking grep must return promptly (literal substring, no backtracking)');
assert.equal(redos.matchedLines, 0, '"(a+)+$" is treated as a literal substring, matching nothing here');
});
test('getQueueState flags stale=true for the persisted snapshot and counts by status', () => {
const { collectors } = makeFixture();
const q = collectors.getQueueState({});
assert.equal(q.source, 'persisted');
assert.equal(q.stale, true);
assert.equal(q.counts.error, 1);
});
test('getQueueState (includeJobs default) pattern-scrubs an opaque token in a job error that is NOT a config secret', () => {
const opaqueToken = ['fixture', 'opaque', 'token', '9988'].join('_');
const config = {
hosters: {}, hosterSettings: {},
globalSettings: { pendingQueue: { savedAt: 1, selectedUploadHosters: [], selectedFiles: [], queueJobs: [
{ file: 'C:/b.mkv', fileName: 'b.mkv', hoster: 'streamtape', status: 'error', error: `upload rejected: token=${opaqueToken}` }
] } },
history: [], rotationCursors: {}
};
const collectors = createCollectors({
loadConfig: () => JSON.parse(JSON.stringify(config)),
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
support, stats,
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
});
const q = collectors.getQueueState({});
const json = JSON.stringify(q);
assert.ok(!json.includes(opaqueToken), 'opaque token in a job error must be pattern-scrubbed even on the default includeJobs path');
});
test('listErrors classifies via stats.classifyErrorCategory and redacts error text', () => {
const { collectors } = makeFixture();
const e = collectors.listErrors({});
assert.equal(e.total, 1, 'only the non-done result is an error');
assert.equal(e.byCategory['file-rejected'], 1, '"Not video file format" -> file-rejected');
});
test('serverHealth assembles the one-shot hub without leaking secrets', () => {
const { collectors } = makeFixture();
const h = collectors.serverHealth({});
const json = JSON.stringify(h);
assert.ok(h.server && h.queue && h.errors && h.logs, 'hub has all sections');
assert.ok(!json.includes(fixtureSecrets.password) && !json.includes(fixtureSecrets.diagnosticToken) && !json.includes(fixtureSecrets.webhookToken), 'no secret leaks in server_health');
});
+120
View File
@@ -0,0 +1,120 @@
const { test } = require('node:test');
const assert = require('node:assert');
const os = require('os');
const WebSocket = require('ws');
const RemoteServer = require('../lib/remote-server');
const TOKEN = 'a'.repeat(64);
function firstLanIpv4() {
for (const entry of Object.values(os.networkInterfaces())) {
for (const net of (entry || [])) {
if (net && net.family === 'IPv4' && !net.internal && net.address) return net.address;
}
}
return null;
}
function startAgent(onDiagnosticRequest, extra) {
const srv = new RemoteServer();
return srv.start({ port: 0, host: '127.0.0.1', token: TOKEN, diagnosticMode: true, onDiagnosticRequest, ...(extra || {}) })
.then(() => srv);
}
function connect(port) {
return new WebSocket(`ws://127.0.0.1:${port}`);
}
function once(ws, type) {
return new Promise((resolve, reject) => {
ws.on('message', (raw) => { const m = JSON.parse(raw); if (m.type === type) resolve(m); });
ws.on('close', (code) => reject(new Error('closed ' + code)));
ws.on('error', reject);
});
}
test('diagnostic client: auth -> diag-request -> reqId-correlated diag-response', async () => {
const agent = await startAgent((msg, _client, reply) => {
assert.equal(msg.op, 'server_health');
reply({ ok: true, data: { hello: 'world', echo: msg.args } });
});
const port = agent.getPort();
const ws = connect(port);
await new Promise((r) => ws.on('open', r));
ws.send(JSON.stringify({ type: 'auth', token: TOKEN, role: 'diagnostic' }));
const ok = await once(ws, 'auth-ok');
assert.ok(ok.clientId);
ws.send(JSON.stringify({ type: 'diag-request', reqId: 'r1', op: 'server_health', args: { errorLimit: 3 } }));
const resp = await once(ws, 'diag-response');
assert.equal(resp.reqId, 'r1');
assert.equal(resp.ok, true);
assert.equal(resp.data.hello, 'world');
assert.equal(resp.data.echo.errorLimit, 3);
assert.equal(agent.getLastAccess() !== null, true, 'access timestamp recorded');
ws.close(); agent.stop();
});
test('a diagnostic client NEVER triggers the screen-capture window', async () => {
let captureCreated = false;
const agent = await startAgent(() => {}, { onCreateCaptureWindow: () => { captureCreated = true; } });
const ws = connect(agent.getPort());
await new Promise((r) => ws.on('open', r));
ws.send(JSON.stringify({ type: 'auth', token: TOKEN, role: 'diagnostic' }));
await once(ws, 'auth-ok');
await new Promise((r) => setTimeout(r, 50));
assert.equal(captureCreated, false, 'diagnosticMode must not spawn the capture window');
ws.close(); agent.stop();
});
test('allowlist gate (wiring): a non-loopback peer is closed 4005 when not allowlisted (fail-closed)', () => {
const srv = new RemoteServer();
const closeCodeFor = (remoteAddress, allowlist) => {
srv._config = { allowlist, token: TOKEN, diagnosticMode: true };
let closed = null;
srv._handleConnection({ close: (c) => { closed = c; }, on: () => {} }, { socket: { remoteAddress } });
return closed;
};
assert.equal(closeCodeFor('100.64.0.9', []), 4005, 'empty allowlist => non-loopback rejected (fail-closed)');
assert.equal(closeCodeFor('203.0.113.5', ['100.64.0.0/10']), 4005, 'peer outside the allowlist CIDR rejected');
});
test('a loopback diagnostic client connects even with a non-matching allowlist (loopback is always allowed)', async () => {
const agent = await startAgent(() => {}, { allowlist: ['100.64.0.0/10'] });
const ws = connect(agent.getPort());
await new Promise((r) => ws.on('open', r));
ws.send(JSON.stringify({ type: 'auth', token: TOKEN, role: 'diagnostic' }));
const ok = await once(ws, 'auth-ok');
assert.ok(ok.clientId);
ws.close(); agent.stop();
});
test('network bind (0.0.0.0): an allowlisted non-loopback peer connects over a real socket (the Tailscale path)', async (t) => {
const lan = firstLanIpv4();
if (!lan) { t.skip('no non-internal IPv4 interface available'); return; }
const agent = await startAgent(() => {}, { host: '0.0.0.0', allowlist: [lan] });
const port = agent.getPort();
const ws = new WebSocket(`ws://${lan}:${port}`);
try {
await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); });
ws.send(JSON.stringify({ type: 'auth', token: TOKEN, role: 'diagnostic' }));
const ok = await once(ws, 'auth-ok');
assert.ok(ok.clientId, 'allowlisted LAN peer authed over the 0.0.0.0 bind');
} finally {
ws.close(); agent.stop();
}
});
test('wrong token is rejected and the ip is locked out after 5 attempts', async () => {
const agent = await startAgent(() => {});
const port = agent.getPort();
for (let i = 0; i < 5; i++) {
const ws = connect(port);
await new Promise((r) => ws.on('open', r));
ws.send(JSON.stringify({ type: 'auth', token: 'wrong', role: 'diagnostic' }));
await new Promise((r) => ws.on('close', r));
}
const ws = connect(port);
const closeCode = await new Promise((resolve) => ws.on('close', (c) => resolve(c)));
assert.equal(closeCode, 4003, 'locked out after 5 failed attempts');
agent.stop();
});
+105
View File
@@ -0,0 +1,105 @@
const { test, before, after } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
// Mock the undici transport BEFORE requiring hosters so the destructured
// `request` picks up our stub. apiGet (getUploadServer) uses global fetch, which
// we override per-test. This exercises the FULL doodstream API upload + recovery
// orchestration against the doc-verified response shapes — the gap between the
// already-tested parseDoodstreamResult helper and the real uploadFile path.
// (mock.module needs an experimental flag npm test doesn't pass, so we reassign
// undici.request on the module object and refresh the hosters cache instead.)
let requestRouter = async () => ({ statusCode: 200, headers: {}, body: { text: async () => '{}' } });
const undici = require('undici');
const _origUndiciRequest = undici.request;
undici.request = (...a) => requestRouter(...a);
delete require.cache[require.resolve('../lib/hosters')];
const hostersMod = require('../lib/hosters');
const { uploadFile } = hostersMod;
let tmpFile;
let origFetch;
before(() => {
tmpFile = path.join(os.tmpdir(), `dood-itest-${process.pid}.mkv`);
fs.writeFileSync(tmpFile, Buffer.alloc(2048, 7));
origFetch = global.fetch;
// Keep the "never appears" recovery test fast (real default is 12 × 2.5 s).
hostersMod.__test.DOODSTREAM_POLL.attempts = 3;
hostersMod.__test.DOODSTREAM_POLL.delayMs = 5;
});
after(() => {
global.fetch = origFetch;
undici.request = _origUndiciRequest; // restore real transport for other test files
delete require.cache[require.resolve('../lib/hosters')];
try { fs.unlinkSync(tmpFile); } catch {}
});
// getUploadServer hits /api/upload/server via global fetch.
function stubUploadServer() {
global.fetch = async (url) => {
if (/upload\/server/.test(String(url))) {
return { status: 200, text: async () => JSON.stringify({ status: 200, result: 'https://node1.cloudatacdn.com/upload/01' }) };
}
return { status: 200, text: async () => '{"status":200}' };
};
}
// Build an undici-style router. uploadBody is the POST result; listBodies is a
// queue consumed by successive /api/file/list calls (baseline, then polls).
function routeWith(uploadBody, listBodies = []) {
return async (url, opts) => {
const u = String(url);
if (/\/api\/file\/list/.test(u)) {
const body = listBodies.length ? listBodies.shift() : '{"status":200,"result":{"files":[]}}';
return { statusCode: 200, headers: {}, body: { text: async () => body } };
}
// Upload POST: drain the streamed body so the file handle closes.
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
}
return { statusCode: uploadBody.status, headers: { 'content-type': 'application/json' }, body: { text: async () => uploadBody.body } };
};
}
test('doodstream API upload: filecode returned directly is used', async () => {
stubUploadServer();
requestRouter = routeWith({
status: 200,
body: JSON.stringify({ status: 200, result: [{ filecode: 'DOODCODE1234', download_url: 'https://doodstream.com/d/DOODCODE1234', protected_embed: 'https://doodstream.com/e/DOODCODE1234' }] })
});
const res = await uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null);
assert.equal(res.file_code, 'DOODCODE1234');
assert.equal(res.download_url, 'https://doodstream.com/d/DOODCODE1234');
});
test('doodstream API upload: codeless result recovered via file-list name match', async () => {
stubUploadServer();
const fileName = path.basename(tmpFile).replace(/\.[^.]+$/, ''); // title doodstream stores
requestRouter = routeWith(
{ status: 200, body: JSON.stringify({ status: 200, msg: 'OK' }) }, // codeless upload
[
'{"status":200,"result":{"files":[]}}', // baseline (pre-upload)
`{"status":200,"result":{"files":[{"file_code":"RECOVER9999","title":"${fileName}"}]}}` // poll finds it
]
);
const res = await uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null);
assert.equal(res.file_code, 'RECOVER9999');
assert.equal(res.download_url, 'https://doodstream.com/d/RECOVER9999');
});
test('doodstream API upload: codeless + file never appears → throws hosterTransient (no account poison)', async () => {
stubUploadServer();
requestRouter = routeWith(
{ status: 200, body: JSON.stringify({ status: 200, msg: 'OK' }) },
[] // every file/list returns empty
);
await assert.rejects(
() => uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null),
(err) => {
assert.equal(err.hosterTransient, true, 'codeless result must be tagged hosterTransient');
return true;
}
);
});
+221
View File
@@ -0,0 +1,221 @@
const { test } = require('node:test');
const assert = require('node:assert');
const DoodstreamUploader = require('../lib/doodstream-upload');
// The CDN hands back an XFileSharing form. `fn` is the filecode, `st` is the
// status ("OK" on success, an error string when the backend refuses the file).
// These tests pin the parse/error behaviour of _parseUploadResponse without
// touching the network — _fetch is stubbed to return the upload_result page.
function cdnForm({ fn = '', st = 'OK' } = {}) {
return `<HTML><BODY><Form name='F1' action='https://cdn.example/' method='POST'>` +
`<textarea name="op">upload_result</textarea>` +
`<textarea name="fn">${fn}</textarea>` +
`<textarea name="st">${st}</textarea>` +
`</Form></BODY></HTML>`;
}
const EMPTY_RESULT = '<textarea id="copy_dl" readonly class="form-control" rows="5"></textarea>';
const LINK_RESULT = (code) => `<textarea id="copy_dl" readonly class="form-control" rows="5">https://myvidplay.com/d/${code}</textarea>`;
function uploaderWithResult(resultHtml) {
const up = new DoodstreamUploader();
up._lastUploadUrl = 'https://cdn.example/upload/01';
// Stub the second-step submit so no real request goes out.
up._fetch = async () => ({ text: async () => resultHtml });
return up;
}
test('rejected file: empty fn + non-OK st surfaces the real status', async () => {
const up = uploaderWithResult(EMPTY_RESULT);
await assert.rejects(
() => up._parseUploadResponse(cdnForm({ fn: '', st: 'Error: file already exists' })),
(err) => {
assert.match(err.message, /lehnt Datei ab/);
assert.match(err.message, /file already exists/);
return true;
}
);
});
test('empty fn + st OK: generic error still reports st, fn-state and CDN node', async () => {
const up = uploaderWithResult(EMPTY_RESULT);
await assert.rejects(
() => up._parseUploadResponse(cdnForm({ fn: '', st: 'OK' })),
(err) => {
assert.match(err.message, /kein Filecode/);
assert.match(err.message, /st=OK/);
assert.match(err.message, /fehlt\/leer/);
assert.match(err.message, /cdn\.example/);
return true;
}
);
});
test('valid fn but empty result page: still resolves via fn (no regression)', async () => {
const up = uploaderWithResult(EMPTY_RESULT);
const res = await up._parseUploadResponse(cdnForm({ fn: '7mnp8xna3123', st: 'OK' }));
assert.equal(res.file_code, '7mnp8xna3123');
assert.equal(res.download_url, 'https://doodstream.com/d/7mnp8xna3123');
});
test('happy path: link in result page wins', async () => {
const up = uploaderWithResult(LINK_RESULT('jjsuhr931ds9'));
const res = await up._parseUploadResponse(cdnForm({ fn: 'jjsuhr931ds9', st: 'OK' }));
assert.equal(res.file_code, 'jjsuhr931ds9');
});
// --- _parseUploadFormFields: replicate the current upload form faithfully ---
test('_parseUploadFormFields extracts the real form fields and excludes the file input', () => {
const up = new DoodstreamUploader();
const html = `
<form name="file" enctype="multipart/form-data" action="https://uxg.cloudatacdn.com/upload/01?TOK" method="post">
<input type="hidden" name="sess_id" value="TOK">
<input name="file" type="file" size="30" id="filepc">
<input name="fakefilepc" class="d-none" type="text" id="fakefilepc">
<input type="text" name="file_title" class="form-control">
<button type="submit" name="submit_btn" class="btn">Upload</button>
</form>`;
const f = up._parseUploadFormFields(html);
assert.equal(f.sess_id, 'TOK');
assert.equal(f.fakefilepc, '');
assert.equal(f.file_title, '');
assert.ok('submit_btn' in f);
assert.ok(!('file' in f), 'the file input must be excluded (streamed separately)');
});
test('_parseUploadFormFields returns {} for markup without a form', () => {
const up = new DoodstreamUploader();
assert.deepEqual(up._parseUploadFormFields('<div>no form here</div>'), {});
assert.deepEqual(up._parseUploadFormFields(''), {});
});
// --- deriveApiKey: pull + validate the account API key from the web session ---
test('_extractApiKeyCandidates finds the key in an input value and ranks api-context first', () => {
const up = new DoodstreamUploader();
const csrfCandidate = ['fixture', 'csrf', 'candidate', '00000001'].join('');
const apiCandidate = ['fixture', 'api', 'candidate', '0000000001'].join('');
const html = `
<input type="text" name="csrf" value="${csrfCandidate}">
<div class="panel">API Key <input readonly value="${apiCandidate}"></div>
`;
const cands = up._extractApiKeyCandidates(html);
// The token whose preceding context mentions "API" must rank first.
assert.equal(cands[0], apiCandidate);
assert.ok(cands.includes(csrfCandidate));
});
test('_extractApiKeyCandidates handles textarea + api_key: "x" shapes and empty input', () => {
const up = new DoodstreamUploader();
assert.deepEqual(up._extractApiKeyCandidates(''), []);
const textareaCandidate = ['fixture', 'textarea', 'candidate', '000001'].join('');
const objectCandidate = ['fixture', 'object', 'candidate', '00000001'].join('');
const ta = up._extractApiKeyCandidates(`<textarea id="k">${textareaCandidate}</textarea>`);
assert.ok(ta.includes(textareaCandidate));
const js = up._extractApiKeyCandidates(`var x = {"api_key":"${objectCandidate}"};`);
assert.ok(js.includes(objectCandidate));
});
test('deriveApiKey returns the candidate that validates against the API', async () => {
const up = new DoodstreamUploader();
const acceptedCandidate = ['fixture', 'accepted', 'candidate', '1234567890'].join('');
const rejectedCandidate = ['fixture', 'rejected', 'candidate', '0987654321'].join('');
up._fetch = async () => ({ text: async () => `<div>API Key <input value="${acceptedCandidate}"></div><input value="${rejectedCandidate}">` });
up._validateApiKey = async (key) => key === acceptedCandidate;
const key = await up.deriveApiKey();
assert.equal(key, acceptedCandidate);
assert.equal(up.apiKey, acceptedCandidate); // cached on the instance
});
test('deriveApiKey returns null when no candidate validates (→ caller uses web fallback)', async () => {
const up = new DoodstreamUploader();
const rejectedCandidate = ['fixture', 'rejected', 'candidate', '0000000000'].join('');
up._fetch = async () => ({ text: async () => `<input value="${rejectedCandidate}">` });
up._validateApiKey = async () => false;
assert.equal(await up.deriveApiKey(), null);
assert.equal(up.apiKey, '');
});
test('deriveApiKey short-circuits when a key is already set', async () => {
const up = new DoodstreamUploader();
up.apiKey = 'PRESET';
let fetched = false;
up._fetch = async () => { fetched = true; return { text: async () => '' }; };
assert.equal(await up.deriveApiKey(), 'PRESET');
assert.equal(fetched, false);
});
// --- _fetch: transient network blips on the small requests self-heal ---
test('_fetch retries a transient network failure then succeeds', async () => {
const up = new DoodstreamUploader();
const origFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls++;
if (calls === 1) throw new TypeError('fetch failed');
return { status: 200, headers: { getSetCookie: () => [], get: () => null }, text: async () => 'ok' };
};
try {
const res = await up._fetch('https://example.test/x');
assert.equal(calls, 2); // failed once, retried, succeeded
assert.equal(await res.text(), 'ok');
} finally {
globalThis.fetch = origFetch;
}
});
// --- _getUploadServer: discovery must never fall back to a hardcoded node ---
function fakeRes(body, { status = 200, ctype = 'text/html' } = {}) {
return { status, headers: { get: (h) => (h.toLowerCase() === 'content-type' ? ctype : null) }, text: async () => body };
}
test('getUploadServer: returns JSON result when present', async () => {
const up = new DoodstreamUploader();
up._fetch = async (url) => {
assert.match(url, /op=upload_server/);
return fakeRes(JSON.stringify({ result: 'https://node42.cloudatacdn.com/upload/01' }), { ctype: 'application/json' });
};
assert.equal(await up._getUploadServer(), 'https://node42.cloudatacdn.com/upload/01');
});
test('getUploadServer: falls back to srv_url in upload-page HTML', async () => {
const up = new DoodstreamUploader();
up._fetch = async (url) => {
if (/op=upload_server/.test(url)) return fakeRes('<html>not json</html>');
return fakeRes('<script>var srv_url: "https://node7.cloudatacdn.com/upload/01";</script>');
};
assert.equal(await up._getUploadServer(), 'https://node7.cloudatacdn.com/upload/01');
});
test('getUploadServer: parses current form-action node and refreshes sess_id from the same page', async () => {
const up = new DoodstreamUploader();
up.sessId = 'stale-from-login';
up._fetch = async (url) => {
if (/op=upload_server/.test(url)) return fakeRes('<html>not json</html>');
return fakeRes('<form name="file" enctype="multipart/form-data" action="https://n9.cloudatacdn.com/upload/01?FRESH123" method="post"><input type="hidden" name="sess_id" value="FRESH123"></form>');
};
const url = await up._getUploadServer();
assert.equal(url, 'https://n9.cloudatacdn.com/upload/01?FRESH123');
assert.equal(up.sessId, 'FRESH123'); // critical: form-field token must match the node URL token
});
test('getUploadServer: un-escapes &amp; in the form-action query string', async () => {
const up = new DoodstreamUploader();
up._fetch = async (url) => {
if (/op=upload_server/.test(url)) return fakeRes('<html>not json</html>');
return fakeRes('<form name="file" enctype="multipart/form-data" action="https://n9.cloudatacdn.com/upload/01?a=1&amp;b=2" method="post"></form>');
};
assert.equal(await up._getUploadServer(), 'https://n9.cloudatacdn.com/upload/01?a=1&b=2');
});
test('getUploadServer: throws (no silent dead fallback) when discovery fails', async () => {
const up = new DoodstreamUploader();
up._fetch = async () => fakeRes('<html><body>login required</body></html>', { status: 200 });
await assert.rejects(
() => up._getUploadServer(),
(err) => {
assert.match(err.message, /konnte Upload-Server nicht ermitteln/);
assert.doesNotMatch(err.message, /tr1128ve\.cloudatacdn\.com/); // never the hardcoded node
return true;
}
);
});
+115
View File
@@ -0,0 +1,115 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { detectKind, isVideoLikeKind, probeFileHead, summarizeFileStat } = require('../lib/file-probe');
function tmpWrite(name, buf) {
const p = path.join(os.tmpdir(), `mhu-probe-${Date.now()}-${name}`);
fs.writeFileSync(p, buf);
return p;
}
test('detectKind recognizes ISO-MP4 (ftyp box at offset 4)', () => {
const buf = Buffer.concat([Buffer.from([0x00, 0x00, 0x00, 0x20]), Buffer.from('ftypisom', 'ascii'), Buffer.alloc(8, 0)]);
assert.strictEqual(detectKind(buf), 'mp4-iso');
assert.strictEqual(isVideoLikeKind('mp4-iso'), true);
});
test('detectKind recognizes Matroska / WebM EBML header', () => {
const buf = Buffer.from([0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00]);
assert.strictEqual(detectKind(buf), 'matroska');
assert.strictEqual(isVideoLikeKind('matroska'), true);
});
test('detectKind recognizes AVI (RIFF...AVI )', () => {
const buf = Buffer.concat([Buffer.from('RIFF', 'ascii'), Buffer.from([0x00, 0x00, 0x00, 0x00]), Buffer.from('AVI ', 'ascii')]);
assert.strictEqual(detectKind(buf), 'avi');
});
test('detectKind recognizes FLV', () => {
const buf = Buffer.concat([Buffer.from('FLV', 'ascii'), Buffer.from([0x01])]);
assert.strictEqual(detectKind(buf), 'flv');
});
test('detectKind recognizes ASF (WMV)', () => {
const buf = Buffer.from([0x30, 0x26, 0xB2, 0x75, 0x00, 0x00]);
assert.strictEqual(detectKind(buf), 'asf-wmv');
});
test('detectKind recognizes MPEG-PS (00 00 01 BA)', () => {
const buf = Buffer.from([0x00, 0x00, 0x01, 0xBA, 0x00]);
assert.strictEqual(detectKind(buf), 'mpeg-ps');
});
test('detectKind recognizes JPEG (non-video)', () => {
const buf = Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]);
assert.strictEqual(detectKind(buf), 'jpeg');
assert.strictEqual(isVideoLikeKind('jpeg'), false);
});
test('detectKind recognizes HTML response (non-video)', () => {
const buf = Buffer.from('<!DOCTYPE html><html><head>', 'ascii');
assert.strictEqual(detectKind(buf), 'html');
assert.strictEqual(isVideoLikeKind('html'), false);
});
test('detectKind returns empty for zero-length and unknown for noise', () => {
assert.strictEqual(detectKind(Buffer.alloc(0)), 'empty');
assert.strictEqual(detectKind(Buffer.from([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF])), 'unknown');
});
test('probeFileHead reads first bytes and returns hex + kind for an MP4-like file', async () => {
const mp4Head = Buffer.concat([Buffer.from([0x00, 0x00, 0x00, 0x20]), Buffer.from('ftypisom', 'ascii'), Buffer.alloc(16, 0xAA)]);
const p = tmpWrite('fake.mp4', mp4Head);
try {
const res = await probeFileHead(p, 64);
assert.strictEqual(res.ok, true);
assert.strictEqual(res.kind, 'mp4-iso');
assert.strictEqual(res.isVideoLike, true);
assert.ok(res.headHex.startsWith('0000002066747970'));
assert.strictEqual(res.bytesRead, mp4Head.length);
} finally {
fs.unlinkSync(p);
}
});
test('probeFileHead returns ok:false with kind=unreadable for missing file', async () => {
const res = await probeFileHead(path.join(os.tmpdir(), `does-not-exist-${Date.now()}.mp4`), 32);
assert.strictEqual(res.ok, false);
assert.strictEqual(res.kind, 'unreadable');
assert.ok(res.error);
});
test('summarizeFileStat returns size + mtime for a real file', () => {
const p = tmpWrite('stat.bin', Buffer.alloc(123, 0xCC));
try {
const stat = summarizeFileStat(p);
assert.strictEqual(stat.size, 123);
assert.strictEqual(stat.isFile, true);
assert.ok(stat.mtime);
} finally {
fs.unlinkSync(p);
}
});
test('summarizeFileStat returns error for missing file', () => {
const stat = summarizeFileStat(path.join(os.tmpdir(), `does-not-exist-${Date.now()}.bin`));
assert.ok(stat.error);
});
test('detectKind requires TS sync-byte periodicity — GIF and G-prefixed text are NOT mpeg-ts', () => {
const ts = Buffer.alloc(377, 0xFF);
ts[0] = 0x47; ts[188] = 0x47; ts[376] = 0x47;
assert.strictEqual(detectKind(ts), 'mpeg-ts');
assert.strictEqual(isVideoLikeKind('mpeg-ts'), true);
const gif = Buffer.concat([Buffer.from('GIF89a', 'ascii'), Buffer.alloc(400, 0x00)]);
assert.strictEqual(detectKind(gif), 'gif');
assert.strictEqual(isVideoLikeKind('gif'), false);
const gText = Buffer.concat([Buffer.from('Gewinnerliste 2026\n', 'ascii'), Buffer.alloc(400, 0x20)]);
assert.notStrictEqual(detectKind(gText), 'mpeg-ts');
assert.strictEqual(isVideoLikeKind(detectKind(gText)), false);
});
+84
View File
@@ -0,0 +1,84 @@
const test = require('node:test');
const assert = require('node:assert');
const { applyHistoryRetention, countHistoryRows } = require('../lib/config-store');
function batch(timestamp, okRows, extras = {}) {
const results = [];
for (let i = 0; i < okRows; i++) results.push({ status: 'success', hoster: 'voe.sx', download_url: `https://voe.sx/${i}` });
if (extras.aborted) for (let i = 0; i < extras.aborted; i++) results.push({ status: 'aborted', hoster: 'voe.sx' });
if (extras.error) for (let i = 0; i < extras.error; i++) results.push({ status: 'error', hoster: 'voe.sx' });
return { timestamp, files: [{ name: 'clip.mp4', results }] };
}
const DAY = 86400000;
test('countHistoryRows counts only non-aborted, non-error results', () => {
const h = [batch('2026-01-01', 3, { aborted: 2, error: 1 })];
assert.strictEqual(countHistoryRows(h), 3);
});
test('retention "all" returns the array unchanged', () => {
const h = [batch('2026-01-01', 5), batch('2026-01-02', 5)];
assert.strictEqual(applyHistoryRetention(h, 'all', Date.parse('2026-06-01')), h);
});
test('count policy keeps newest whole batches up to the row target', () => {
const h = [batch('2026-01-01', 400), batch('2026-01-02', 400), batch('2026-01-03', 400)];
const pruned = applyHistoryRetention(h, '1000', Date.parse('2026-06-01'));
assert.strictEqual(pruned.length, 3);
assert.strictEqual(countHistoryRows(pruned), 1200);
});
test('count policy drops older batches once target reached (newest first)', () => {
const h = [batch('2026-01-01', 600), batch('2026-01-02', 600), batch('2026-01-03', 600)];
const pruned = applyHistoryRetention(h, '1000', Date.parse('2026-06-01'));
assert.strictEqual(pruned.length, 2);
assert.deepStrictEqual(pruned.map(b => b.timestamp), ['2026-01-02', '2026-01-03']);
});
test('count policy always keeps the newest batch even if it alone exceeds N', () => {
const h = [batch('2026-01-01', 50), batch('2026-01-02', 5000)];
const pruned = applyHistoryRetention(h, '100', Date.parse('2026-06-01'));
assert.strictEqual(pruned.length, 1);
assert.strictEqual(pruned[0].timestamp, '2026-01-02');
});
test('time policy drops batches older than the cutoff', () => {
const now = Date.parse('2026-06-15T00:00:00Z');
const h = [
batch(new Date(now - 10 * DAY).toISOString(), 5),
batch(new Date(now - 3 * DAY).toISOString(), 5),
batch(new Date(now - 1 * DAY).toISOString(), 5)
];
const pruned = applyHistoryRetention(h, '7d', now);
assert.strictEqual(pruned.length, 2);
});
test('time policy keeps batches with missing or invalid timestamp', () => {
const now = Date.parse('2026-06-15T00:00:00Z');
const h = [
batch(undefined, 5),
batch('not-a-date', 5),
batch(new Date(now - 99 * DAY).toISOString(), 5),
batch(new Date(now - 1 * DAY).toISOString(), 5)
];
const pruned = applyHistoryRetention(h, '30d', now);
assert.strictEqual(pruned.length, 3);
assert.ok(pruned.includes(h[0]));
assert.ok(pruned.includes(h[1]));
assert.ok(!pruned.includes(h[2]));
});
test('count policy shrinks a realistic 41-batch / >1000-row history', () => {
const h = [];
for (let i = 0; i < 41; i++) h.push(batch(`2026-04-${String((i % 28) + 1).padStart(2, '0')}`, 1300));
assert.strictEqual(countHistoryRows(h), 41 * 1300);
const pruned = applyHistoryRetention(h, '1000', Date.parse('2026-06-01'));
assert.strictEqual(pruned.length, 1);
assert.strictEqual(countHistoryRows(pruned), 1300);
});
test('empty history is returned as-is for any policy', () => {
assert.deepStrictEqual(applyHistoryRetention([], '7d', Date.now()), []);
assert.deepStrictEqual(applyHistoryRetention([], '100', Date.now()), []);
});
+95
View File
@@ -0,0 +1,95 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { __test } = require('../lib/hosters');
describe('hosters helpers', () => {
it('extracts VOE file_code from nested result payloads', () => {
assert.deepEqual(__test.parseVoeResult({ result: { file: { file_code: 'abc123' } } }), {
download_url: 'https://voe.sx/abc123',
embed_url: 'https://voe.sx/e/abc123',
file_code: 'abc123'
});
});
it('extracts VOE file_code from flat fallback payloads', () => {
assert.deepEqual(__test.parseVoeResult({ file_code: 'xyz789' }), {
download_url: 'https://voe.sx/xyz789',
embed_url: 'https://voe.sx/e/xyz789',
file_code: 'xyz789'
});
});
it('extracts upload server URLs from nested API responses', () => {
const url = __test.extractUploadServerUrl({
result: {
server: {
upload_url: 'https://delivery-hydra.voe-network.net/upload/01'
}
}
}, 'https://voe.sx');
assert.equal(url, 'https://delivery-hydra.voe-network.net/upload/01');
});
it('parseDoodstreamResult tolerates null/non-object payload without throwing', () => {
// Direct callers may bypass uploadFile's normalisation. The parser must
// never throw on bad input — empty fields are the contract.
for (const bad of [null, undefined, 'string', 42, true]) {
const r = __test.parseDoodstreamResult(bad);
assert.equal(r.file_code, null);
assert.equal(r.download_url, null);
assert.equal(r.embed_url, null);
}
});
it('parseDoodstreamResult handles result-as-array and result-as-object', () => {
const arr = __test.parseDoodstreamResult({ result: [{ filecode: 'AB1', protected_dl: 'https://x/1', protected_embed: 'https://x/e/1' }] });
assert.equal(arr.file_code, 'AB1');
assert.equal(arr.download_url, 'https://x/1');
assert.equal(arr.embed_url, 'https://x/e/1');
const obj = __test.parseDoodstreamResult({ result: { filecode: 'OBJ1', download_url: 'https://x/2' } });
assert.equal(obj.file_code, 'OBJ1');
assert.equal(obj.download_url, 'https://x/2');
});
it('parseByseResult tolerates null/non-object payload without throwing', () => {
for (const bad of [null, undefined, 'string', 42, []]) {
const r = __test.parseByseResult(bad);
assert.equal(r.file_code, null);
assert.equal(r.download_url, null);
assert.equal(r.embed_url, null);
}
});
it('parseByseResult handles malformed files entries (null, missing fields)', () => {
// Files array with a null first element (server returned [null])
const a = __test.parseByseResult({ files: [null] });
assert.equal(a.file_code, null);
// Files array with object missing both filecode and status
const b = __test.parseByseResult({ files: [{}] });
assert.equal(b.file_code, null);
});
it('parseByseResult throws fileRejected for non-OK status with empty filecode', () => {
assert.throws(
() => __test.parseByseResult({ files: [{ status: 'Not video file format' }] }),
(err) => err.fileRejected === true && /Not video file format/i.test(err.message)
);
});
it('parseByseResult flips to accountError for storage-exhausted phrasing', () => {
assert.throws(
() => __test.parseByseResult({ files: [{ status: 'not enough disk space on your account' }] }),
(err) => err.accountError === true
);
});
it('parseByseResult succeeds with valid filecode in files[0]', () => {
const r = __test.parseByseResult({ files: [{ filecode: 'GOOD123', status: 'OK' }] });
assert.equal(r.file_code, 'GOOD123');
assert.equal(r.download_url, 'https://byse.sx/d/GOOD123');
assert.equal(r.embed_url, 'https://byse.sx/e/GOOD123');
});
});
+52
View File
@@ -0,0 +1,52 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { normalizeIp, isLoopbackIp, matchIpRule, evaluateClientAllowed } = require('../lib/ip-allowlist');
test('normalizeIp strips ::ffff: and lowercases', () => {
assert.equal(normalizeIp('::ffff:100.64.0.5'), '100.64.0.5');
assert.equal(normalizeIp('::FFFF:127.0.0.1'), '127.0.0.1');
assert.equal(normalizeIp(' 100.64.0.5 '), '100.64.0.5');
});
test('loopback is always allowed, even with a non-matching allowlist', () => {
for (const ip of ['127.0.0.1', '::1', '::ffff:127.0.0.1', '', 'localhost', '127.5.5.5']) {
assert.equal(evaluateClientAllowed(ip, ['203.0.113.5']), true, `${ip} loopback`);
}
});
test('fail-closed: empty allowlist rejects every non-loopback peer', () => {
for (const ip of ['100.64.0.5', '203.0.113.5', '10.0.0.2', '::ffff:192.168.1.9']) {
assert.equal(evaluateClientAllowed(ip, []), false, `${ip} must be rejected with empty allowlist`);
}
});
test('exact IP allow + reject', () => {
assert.equal(evaluateClientAllowed('203.0.113.5', ['203.0.113.5']), true);
assert.equal(evaluateClientAllowed('203.0.113.6', ['203.0.113.5']), false);
});
test('CIDR matching incl. the Tailscale CGNAT range 100.64.0.0/10', () => {
assert.equal(evaluateClientAllowed('100.64.0.5', ['100.64.0.0/10']), true);
assert.equal(evaluateClientAllowed('100.127.255.254', ['100.64.0.0/10']), true);
assert.equal(evaluateClientAllowed('100.128.0.1', ['100.64.0.0/10']), false, 'just outside the /10');
assert.equal(evaluateClientAllowed('::ffff:100.64.0.5', ['100.64.0.0/10']), true, 'mapped v4 in CIDR');
assert.equal(evaluateClientAllowed('10.0.0.5', ['10.0.0.0/24']), true);
assert.equal(evaluateClientAllowed('10.0.1.5', ['10.0.0.0/24']), false);
});
test('wildcard rules allow everything', () => {
assert.equal(evaluateClientAllowed('8.8.8.8', ['*']), true);
assert.equal(evaluateClientAllowed('8.8.8.8', ['0.0.0.0/0']), true);
});
test('matchIpRule rejects malformed rules and out-of-range octets', () => {
assert.equal(matchIpRule('1.2.3.4', 'not-an-ip'), false);
assert.equal(matchIpRule('1.2.3.4', '1.2.3.0/33'), false);
assert.equal(matchIpRule('1.2.3.999', '1.2.3.0/24'), false);
});
test('isLoopbackIp recognizes loopback forms', () => {
assert.equal(isLoopbackIp('127.0.0.1'), true);
assert.equal(isLoopbackIp('::1'), true);
assert.equal(isLoopbackIp('100.64.0.1'), false);
});
+150
View File
@@ -0,0 +1,150 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { normalizeLogMode, resolveLogFileName, formatDateStamp, formatSessionStamp } = require('../lib/log-mode');
// --- normalizeLogMode ---
test('normalizeLogMode: default for empty/null/undefined is "single"', () => {
assert.equal(normalizeLogMode(), 'single');
assert.equal(normalizeLogMode(null), 'single');
assert.equal(normalizeLogMode({}), 'single');
});
test('normalizeLogMode: explicit logMode wins for all three valid values', () => {
assert.equal(normalizeLogMode({ logMode: 'single' }), 'single');
assert.equal(normalizeLogMode({ logMode: 'daily' }), 'daily');
assert.equal(normalizeLogMode({ logMode: 'session' }), 'session');
});
test('regression: legacy sessionLog:true maps to "daily", NOT "session"', () => {
// The legacy boolean field was named after a misnomer — it actually toggled
// per-day logging. Mapping it to "session" would silently flip every existing
// per-day user onto per-session, which is exactly the bug the migration trap
// exists to prevent.
assert.equal(normalizeLogMode({ sessionLog: true }), 'daily');
});
test('normalizeLogMode: sessionLog:false / missing maps to "single"', () => {
assert.equal(normalizeLogMode({ sessionLog: false }), 'single');
});
test('normalizeLogMode: explicit logMode beats the legacy sessionLog field', () => {
// Once a user picks a mode in 3.3.35+, the legacy boolean must NOT override.
assert.equal(normalizeLogMode({ logMode: 'session', sessionLog: true }), 'session');
assert.equal(normalizeLogMode({ logMode: 'single', sessionLog: true }), 'single');
});
test('normalizeLogMode: invalid logMode strings fall through to single (or legacy if present)', () => {
assert.equal(normalizeLogMode({ logMode: 'lolnope' }), 'single');
assert.equal(normalizeLogMode({ logMode: '' }), 'single');
assert.equal(normalizeLogMode({ logMode: 'lolnope', sessionLog: true }), 'daily');
});
// --- resolveLogFileName ---
test('resolveLogFileName: single mode → bare basename + ext', () => {
assert.equal(
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'single' }),
'fileuploader.log'
);
});
test('resolveLogFileName: daily mode → fileuploader-YYYY-MM-DD.log', () => {
const d = new Date(2026, 4, 28); // May 28, 2026 — month is 0-indexed
assert.equal(
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'daily', date: d }),
'fileuploader-2026-05-28.log'
);
});
test('resolveLogFileName: session mode → <sessionId>.log (baseName ignored)', () => {
assert.equal(
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session', sessionId: '26-05-2026-mdu-session-22-44' }),
'26-05-2026-mdu-session-22-44.log'
);
});
test('formatSessionStamp: DD-MM-YYYY-mdu-session-HH-MM', () => {
const { formatSessionStamp } = require('../lib/log-mode');
assert.equal(formatSessionStamp(new Date(2026, 5, 26, 6, 2, 36)), '26-06-2026-mdu-session-06-02');
});
test('formatSessionStamp: appends a 6-digit suffix when a rand is supplied', () => {
assert.equal(formatSessionStamp(new Date(2026, 5, 26, 6, 2, 36), '847581'), '26-06-2026-mdu-session-06-02-847581');
assert.equal(formatSessionStamp(new Date(2026, 5, 26, 6, 2, 36), 847581), '26-06-2026-mdu-session-06-02-847581');
});
test('resolveLogFileName: session mode with missing sessionId falls back to single (never emits malformed name)', () => {
assert.equal(
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session' }),
'fileuploader.log'
);
});
test('resolveLogFileName: unknown mode is treated as single', () => {
assert.equal(
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'lolnope' }),
'fileuploader.log'
);
});
// --- stripModeStampFromFileName ---
const { stripModeStampFromFileName } = require('../lib/log-mode');
test('stripModeStampFromFileName: leaves bare names alone', () => {
assert.equal(stripModeStampFromFileName('fileuploader.log'), 'fileuploader.log');
assert.equal(stripModeStampFromFileName('fileuploader'), 'fileuploader');
});
test('stripModeStampFromFileName: strips a daily YYYY-MM-DD suffix', () => {
assert.equal(stripModeStampFromFileName('fileuploader-2026-06-03.log'), 'fileuploader.log');
});
test('stripModeStampFromFileName: strips a session-stamp suffix (with and without pid)', () => {
assert.equal(
stripModeStampFromFileName('fileuploader-session-2026-06-03_18-16-20-8132.log'),
'fileuploader.log'
);
assert.equal(
stripModeStampFromFileName('fileuploader-session-2026-06-03_18-16-20.log'),
'fileuploader.log'
);
});
test('stripModeStampFromFileName: new DD-MM-YYYY-mdu-session-HH-MM resets to the default base', () => {
assert.equal(stripModeStampFromFileName('26-06-2026-mdu-session-06-02.log'), 'fileuploader.log');
assert.equal(stripModeStampFromFileName('26-06-2026-mdu-session-06-02-847581.log'), 'fileuploader.log');
});
test('regression: resolveLogFileName(stripModeStampFromFileName(...)) is idempotent — persisting then re-resolving never compounds stamps', () => {
// This is the exact bug shape: persist the resolved path, then on next call
// re-resolve from the saved base — must produce the same file, not a doubled
// session-stamped one. The fix is the strip; this test guards against
// regressing _persistFallbackLogPath into the 3.3.35 bug.
const sessionId = '03-06-2026-mdu-session-18-16';
const dailyDate = new Date(2026, 5, 3);
for (const mode of ['daily', 'session']) {
const date = mode === 'daily' ? dailyDate : new Date();
const initial = resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode, date, sessionId });
const stripped = stripModeStampFromFileName(initial);
// After strip, the base should be back to the bare name.
assert.equal(stripped, 'fileuploader.log', `${mode}: strip should produce bare base`);
// Re-resolving from the bare base gives the same final filename — no doubling.
const reBase = stripped.replace(/\.log$/, '');
const second = resolveLogFileName({ baseName: reBase, ext: '.log', mode, date, sessionId });
assert.equal(second, initial, `${mode}: round-trip must be idempotent`);
}
});
// --- format helpers ---
test('formatDateStamp: zero-pads month and day', () => {
assert.equal(formatDateStamp(new Date(2026, 0, 3)), '2026-01-03');
assert.equal(formatDateStamp(new Date(2026, 11, 31)), '2026-12-31');
});
test('formatSessionStamp: DD-MM-YYYY-mdu-session-HH-MM (no seconds/pid)', () => {
assert.equal(formatSessionStamp(new Date(2026, 4, 28, 7, 9, 5)), '28-05-2026-mdu-session-07-09');
assert.equal(formatSessionStamp(new Date(2026, 4, 28, 22, 44, 52)), '28-05-2026-mdu-session-22-44');
});
+52
View File
@@ -0,0 +1,52 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { hosterLogToFileEnabled } = require('../lib/log-policy');
test('enabled by default when settings missing entirely', () => {
assert.equal(hosterLogToFileEnabled(null, 'voe.sx'), true);
assert.equal(hosterLogToFileEnabled(undefined, 'voe.sx'), true);
assert.equal(hosterLogToFileEnabled('not-an-object', 'voe.sx'), true);
});
test('enabled when hoster has no settings entry', () => {
assert.equal(hosterLogToFileEnabled({}, 'voe.sx'), true);
assert.equal(hosterLogToFileEnabled({ 'byse.sx': { logToFile: false } }, 'voe.sx'), true);
});
test('enabled when hoster entry has no logToFile key (back-compat with old configs)', () => {
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { retries: 3 } }, 'voe.sx'), true);
});
test('enabled when logToFile is explicitly true', () => {
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: true } }, 'voe.sx'), true);
});
test('DISABLED only when logToFile is explicitly false', () => {
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: false } }, 'voe.sx'), false);
});
test('truthy-but-not-true values do not accidentally disable', () => {
// Only the strict boolean false disables — guards against e.g. a stored 0/""
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: 0 } }, 'voe.sx'), true);
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: '' } }, 'voe.sx'), true);
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: null } }, 'voe.sx'), true);
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: undefined } }, 'voe.sx'), true);
});
test('per-hoster independence: one off, others on', () => {
const settings = {
'voe.sx': { logToFile: false },
'byse.sx': { logToFile: true },
'doodstream.com': { retries: 3 }
};
assert.equal(hosterLogToFileEnabled(settings, 'voe.sx'), false);
assert.equal(hosterLogToFileEnabled(settings, 'byse.sx'), true);
assert.equal(hosterLogToFileEnabled(settings, 'doodstream.com'), true);
assert.equal(hosterLogToFileEnabled(settings, 'clouddrop.cc'), true); // not present → on
});
test('malformed hoster entry (string/number) defaults to on', () => {
assert.equal(hosterLogToFileEnabled({ 'voe.sx': 'broken' }, 'voe.sx'), true);
assert.equal(hosterLogToFileEnabled({ 'voe.sx': 42 }, 'voe.sx'), true);
});
+134
View File
@@ -0,0 +1,134 @@
const { test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { maybeRotateLogFile } = require('../lib/log-rotation');
let tmpDir;
let logFile;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-log-rotation-'));
logFile = path.join(tmpDir, 'fileuploader.log');
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
});
function writeBytes(p, n, fill = 'a') {
fs.writeFileSync(p, fill.repeat(n), 'utf-8');
}
test('returns false and skips rotation when file does not exist', () => {
const result = maybeRotateLogFile(logFile, 100);
assert.equal(result, false);
assert.equal(fs.existsSync(logFile), false);
});
test('returns false when file is below the size cap', () => {
writeBytes(logFile, 50);
const result = maybeRotateLogFile(logFile, 100);
assert.equal(result, false);
assert.equal(fs.statSync(logFile).size, 50, 'live file untouched');
assert.equal(fs.existsSync(logFile + '.1'), false, 'no .1 created');
});
test('rotates live file to .1 when over cap', () => {
writeBytes(logFile, 200, 'X');
const result = maybeRotateLogFile(logFile, 100, 3);
assert.equal(result, true);
assert.equal(fs.existsSync(logFile), false, 'live file moved away');
const expectedBackup = path.join(tmpDir, 'fileuploader.1.log');
assert.equal(fs.existsSync(expectedBackup), true, '.1 backup exists');
assert.equal(fs.statSync(expectedBackup).size, 200);
});
test('shifts existing backups up: .1 → .2, .2 → .3 on rotation', () => {
writeBytes(path.join(tmpDir, 'fileuploader.2.log'), 10, 'B');
writeBytes(path.join(tmpDir, 'fileuploader.1.log'), 20, 'A');
writeBytes(logFile, 200, 'L');
const result = maybeRotateLogFile(logFile, 100, 3);
assert.equal(result, true);
// Live file → .1 (latest live data)
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.1.log')).size, 200);
// Old .1 → .2
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.2.log')).size, 20);
// Old .2 → .3
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.3.log')).size, 10);
});
test('drops oldest backup when at maxBackups limit', () => {
// Pre-populate all three backup slots.
writeBytes(path.join(tmpDir, 'fileuploader.3.log'), 5, 'C'); // oldest, will be dropped
writeBytes(path.join(tmpDir, 'fileuploader.2.log'), 10, 'B');
writeBytes(path.join(tmpDir, 'fileuploader.1.log'), 20, 'A');
writeBytes(logFile, 200, 'L');
const result = maybeRotateLogFile(logFile, 100, 3);
assert.equal(result, true);
// Old .3 (5 bytes 'C') gone, replaced by old .2.
const f3 = fs.statSync(path.join(tmpDir, 'fileuploader.3.log'));
assert.equal(f3.size, 10, 'old .2 became new .3 (the C-file was dropped)');
// .2 = old .1
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.2.log')).size, 20);
// .1 = the live file we just rotated
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.1.log')).size, 200);
});
test('is idempotent — second call on still-large file rotates again', () => {
writeBytes(logFile, 200, 'X');
maybeRotateLogFile(logFile, 100, 3);
// Simulate fresh writes after the first rotation
writeBytes(logFile, 200, 'Y');
const result = maybeRotateLogFile(logFile, 100, 3);
assert.equal(result, true);
// The .Y file is now .1, the .X file moved to .2
assert.equal(fs.readFileSync(path.join(tmpDir, 'fileuploader.1.log'), 'utf-8')[0], 'Y');
assert.equal(fs.readFileSync(path.join(tmpDir, 'fileuploader.2.log'), 'utf-8')[0], 'X');
});
test('maxBackups=1: only keeps a single .1 backup, never .2', () => {
writeBytes(logFile, 200, 'L');
maybeRotateLogFile(logFile, 100, 1);
writeBytes(logFile, 200, 'M');
maybeRotateLogFile(logFile, 100, 1);
// .1 holds the latest rotated content (M)
assert.equal(fs.readFileSync(path.join(tmpDir, 'fileuploader.1.log'), 'utf-8')[0], 'M');
// .2 must NOT exist
assert.equal(fs.existsSync(path.join(tmpDir, 'fileuploader.2.log')), false);
});
test('invalid maxBytes (0, negative, NaN) is a no-op', () => {
writeBytes(logFile, 1000, 'X');
for (const max of [0, -1, NaN]) {
const r = maybeRotateLogFile(logFile, max);
assert.equal(r, false, `maxBytes=${max} should be no-op`);
}
assert.equal(fs.existsSync(logFile), true);
assert.equal(fs.existsSync(logFile + '.1'), false);
});
test('logs through provided debug callback on rotation', () => {
writeBytes(logFile, 200, 'X');
const messages = [];
maybeRotateLogFile(logFile, 100, 3, (m) => messages.push(m));
assert.ok(messages.length >= 1, 'at least one log message');
assert.ok(messages.some(m => m.includes('rotated')), `expected "rotated" in: ${messages.join(' | ')}`);
});
test('handles file without extension correctly', () => {
const noExtFile = path.join(tmpDir, 'plainlog');
writeBytes(noExtFile, 200, 'P');
const result = maybeRotateLogFile(noExtFile, 100, 3);
assert.equal(result, true);
// base = the full path, ext = '', so backup name is "plainlog.1"
assert.equal(fs.existsSync(path.join(tmpDir, 'plainlog.1')), true);
assert.equal(fs.existsSync(noExtFile), false);
});
+56
View File
@@ -0,0 +1,56 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { selectOrphanTmps } = require('../lib/orphan-tmp');
const BASE = 'electron-config.json';
const aliveSet = new Set([100, 200]);
const isAlive = (pid) => aliveSet.has(pid);
test('selects only dead-pid <base>.<pid>.tmp orphans', () => {
const files = [
'electron-config.json',
'electron-config.json.bak',
'electron-config.json.tmp',
'electron-config.json.100.tmp',
'electron-config.json.200.tmp',
'electron-config.json.999.tmp',
'electron-config.json.4242.tmp',
'something-else.500.tmp',
'electron-config.json.abc.tmp'
];
const orphans = selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive });
assert.deepEqual(orphans.sort(), ['electron-config.json.4242.tmp', 'electron-config.json.999.tmp']);
});
test('never selects the current process tmp', () => {
const files = ['electron-config.json.7.tmp'];
assert.deepEqual(selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive: () => false }), []);
});
test('never selects the FIXED <base>.tmp (used by async _atomicWrite)', () => {
const files = ['electron-config.json.tmp'];
assert.deepEqual(selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive: () => false }), []);
});
test('never selects the live config or its .bak', () => {
const files = ['electron-config.json', 'electron-config.json.bak'];
assert.deepEqual(selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive: () => false }), []);
});
test('alive pid (incl. EPERM-as-alive) is skipped, preventing deletion of a concurrent instance tmp', () => {
const files = ['electron-config.json.100.tmp', 'electron-config.json.300.tmp'];
const orphans = selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive: (p) => p === 100 });
assert.deepEqual(orphans, ['electron-config.json.300.tmp']);
});
test('robust to junk / missing inputs', () => {
assert.deepEqual(selectOrphanTmps(null, { baseName: BASE, currentPid: 1, isAlive }), []);
assert.deepEqual(selectOrphanTmps(['x', 42, null, undefined], { baseName: BASE, currentPid: 1, isAlive }), []);
assert.deepEqual(selectOrphanTmps(['electron-config.json.5.tmp'], {}), []);
assert.deepEqual(selectOrphanTmps(['electron-config.json.5.tmp'], { baseName: '', currentPid: 1, isAlive }), []);
});
test('does not match a different base that shares a prefix', () => {
const files = ['electron-config.json.backup.5.tmp'];
assert.deepEqual(selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive: () => false }), []);
});
+114
View File
@@ -0,0 +1,114 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { partitionRestoredJobsByLog } = require('../lib/queue-dedup');
function lcg(seed) {
let s = seed >>> 0;
return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };
}
function key(f, h) { return `${String(f).toLowerCase()}|${String(h).toLowerCase()}`; }
test('property: removed iff (done && key in log) OR (savedAt finite && key unambiguous && newest matching log ts >= floor(savedAt/1000)*1000)', () => {
const rnd = lcg(0x9e3779b1);
const statuses = ['preview', 'done', 'error', 'aborted', 'queued', 'skipped'];
const hosters = ['voe.sx', 'byse.sx', 'doodstream.com'];
const names = ['a.mkv', 'b.mp4', 'A.MKV', 'c.mov'];
const folders = ['C:/A/', 'C:/B/', 'D:/down/'];
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
for (let iter = 0; iter < 3000; iter++) {
const useSavedAt = rnd() < 0.7;
const savedAt = useSavedAt ? Math.floor(rnd() * 2_000_000_000_000) : undefined;
const jobs = [];
const nJobs = 1 + Math.floor(rnd() * 6);
for (let i = 0; i < nJobs; i++) {
const name = pick(names);
// Mix shared and distinct paths so ambiguous keys (same name+hoster,
// different folder) actually occur and exercise the guard.
jobs.push({ id: `j${i}`, fileName: name, hoster: pick(hosters), status: pick(statuses), file: `${pick(folders)}${name}` });
}
const log = [];
const nLog = Math.floor(rnd() * 5);
for (let i = 0; i < nLog; i++) {
const hasTs = rnd() < 0.8;
log.push({ fileName: pick(names), hoster: pick(hosters), ts: hasTs ? Math.floor(rnd() * 2_000_000_000_000) : undefined });
}
const logKeys = new Set();
const maxTs = new Map();
for (const e of log) {
const k = key(e.fileName, e.hoster);
logKeys.add(k);
if (typeof e.ts === 'number' && isFinite(e.ts)) {
const prev = maxTs.get(k);
if (prev === undefined || e.ts > prev) maxTs.set(k, e.ts);
}
}
const filesPerKey = new Map();
for (const job of jobs) {
const k = key(job.fileName, job.hoster);
if (!filesPerKey.has(k)) filesPerKey.set(k, new Set());
filesPerKey.get(k).add(job.file || '');
}
const floor = (typeof savedAt === 'number' && isFinite(savedAt)) ? Math.floor(savedAt / 1000) * 1000 : null;
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(kept.length + removed.length, jobs.length, `iter ${iter}: partition must cover every job exactly once`);
const keptIds = new Set(kept.map(j => j.id));
const removedIds = new Set(removed.map(j => j.id));
assert.equal(keptIds.size + removedIds.size, jobs.length, `iter ${iter}: no job in both partitions`);
for (const job of jobs) {
const k = key(job.fileName, job.hoster);
const doneInLog = job.status === 'done' && logKeys.has(k);
const unambiguous = filesPerKey.get(k).size <= 1;
const afterSnap = floor !== null && unambiguous && maxTs.has(k) && maxTs.get(k) >= floor;
const shouldRemove = doneInLog || afterSnap;
assert.equal(removedIds.has(job.id), shouldRemove,
`iter ${iter}: job ${job.id} (status=${job.status} key=${k} unambig=${unambiguous}) expected removed=${shouldRemove}`);
}
}
});
test('property: a genuinely-pending job is NEVER lost to a same-basename sibling completing after the snapshot', () => {
const rnd = lcg(0x1234abcd);
for (let iter = 0; iter < 500; iter++) {
const savedAt = 1_000_000_000_000 + Math.floor(rnd() * 1_000_000);
// X completed after the snapshot (logged); Y is a DIFFERENT file, same
// basename + hoster, genuinely pending. Y must survive.
const jobs = [
{ id: 'X', fileName: 'clip.mp4', hoster: 'voe.sx', status: 'preview', file: 'C:/A/clip.mp4' },
{ id: 'Y', fileName: 'clip.mp4', hoster: 'voe.sx', status: 'preview', file: 'C:/B/clip.mp4' }
];
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: savedAt + 1000 + Math.floor(rnd() * 1000) }];
const { kept } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.ok(kept.some(j => j.id === 'Y'), `iter ${iter}: pending Y must never be silently dropped`);
}
});
test('property: 2-arg legacy call NEVER removes a non-done job (the v3.3.80 canary, fuzzed)', () => {
const rnd = lcg(0xdeadbeef);
const statuses = ['preview', 'error', 'aborted', 'queued', 'skipped'];
const hosters = ['voe.sx', 'byse.sx'];
const names = ['a.mkv', 'b.mp4'];
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
for (let iter = 0; iter < 1000; iter++) {
const jobs = [];
const nJobs = 1 + Math.floor(rnd() * 5);
for (let i = 0; i < nJobs; i++) {
jobs.push({ id: `j${i}`, fileName: pick(names), hoster: pick(hosters), status: pick(statuses), file: `C:/x/${i}` });
}
const log = [];
const nLog = Math.floor(rnd() * 4);
for (let i = 0; i < nLog; i++) {
log.push({ fileName: pick(names), hoster: pick(hosters), ts: Math.floor(rnd() * 2_000_000_000_000) });
}
const { removed } = partitionRestoredJobsByLog(jobs, log);
assert.ok(removed.every(j => j.status === 'done'), `iter ${iter}: legacy 2-arg call must never drop a non-done job`);
}
});
+253
View File
@@ -0,0 +1,253 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { partitionRestoredJobsByLog, completedSelectionKeys } = require('../lib/queue-dedup');
function job(status, fileName, hoster) {
return { status, fileName, hoster, file: `C:/dl/${fileName}` };
}
test('regression: pending preview jobs are NEVER dropped, even when all match the log', () => {
// Exact shape of the reproduced bug: 4 preview jobs for one file across 4
// hosters, every fileName|hoster present in the lifetime upload log.
const jobs = [
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'doodstream.com'),
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'voe.sx'),
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'vidmoly.me'),
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'byse.sx')
];
const log = [
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'doodstream.com' },
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'voe.sx' },
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'vidmoly.me' },
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'byse.sx' }
];
const { kept, removed } = partitionRestoredJobsByLog(jobs, log);
assert.equal(removed.length, 0, 'no pending job may be removed');
assert.equal(kept.length, 4, 'all 4 pending jobs survive restart/update');
});
test('done jobs in the log are dropped (declutter); pending/error/aborted kept', () => {
const jobs = [
job('done', 'a.mkv', 'doodstream.com'),
job('preview', 'a.mkv', 'voe.sx'),
job('error', 'b.mkv', 'doodstream.com'),
job('aborted', 'c.mkv', 'doodstream.com')
];
const log = [
{ fileName: 'a.mkv', hoster: 'doodstream.com' },
{ fileName: 'a.mkv', hoster: 'voe.sx' },
{ fileName: 'b.mkv', hoster: 'doodstream.com' },
{ fileName: 'c.mkv', hoster: 'doodstream.com' }
];
const { kept, removed } = partitionRestoredJobsByLog(jobs, log);
assert.equal(removed.length, 1);
assert.equal(removed[0].status, 'done');
assert.equal(removed[0].hoster, 'doodstream.com');
// The preview a.mkv|voe.sx, error b.mkv, aborted c.mkv all survive.
assert.equal(kept.length, 3);
assert.ok(kept.some(j => j.status === 'preview' && j.hoster === 'voe.sx'));
assert.ok(kept.some(j => j.status === 'error'));
assert.ok(kept.some(j => j.status === 'aborted'));
});
test('done job NOT in the log is kept (e.g. hoster had logToFile disabled)', () => {
const jobs = [job('done', 'd.mkv', 'doodstream.com')];
const { kept, removed } = partitionRestoredJobsByLog(jobs, []);
assert.equal(removed.length, 0);
assert.equal(kept.length, 1);
});
test('case-insensitive match on fileName and hoster', () => {
const jobs = [job('done', 'Movie.MKV', 'DoodStream.com')];
const log = [{ fileName: 'movie.mkv', hoster: 'doodstream.com' }];
const { removed } = partitionRestoredJobsByLog(jobs, log);
assert.equal(removed.length, 1);
});
test('empty/missing inputs do not throw', () => {
assert.deepEqual(partitionRestoredJobsByLog([], []), { kept: [], removed: [] });
assert.deepEqual(partitionRestoredJobsByLog(null, null), { kept: [], removed: [] });
const jobs = [job('done', 'x.mkv', 'voe.sx')];
assert.equal(partitionRestoredJobsByLog(jobs, undefined).kept.length, 1);
});
const T = (s) => Date.parse(s.replace(' ', 'T'));
test('ts-gate: preview job uploaded AFTER the snapshot is dropped (the ghost bug)', () => {
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
const savedAt = T('2026-06-19 12:00:00');
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(removed.length, 1, 'completed-after-snapshot preview is a ghost → drop');
assert.equal(kept.length, 0);
});
test('ts-gate: preview job whose only log entry PREDATES the snapshot is kept (intentional re-upload)', () => {
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 11:00:00') }];
const savedAt = T('2026-06-19 12:00:00');
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(removed.length, 0, 'old upload + freshly-queued re-upload must survive');
assert.equal(kept.length, 1);
});
test('ts-gate: same-second completion is dropped (savedAt floored to the second)', () => {
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:00') }];
const savedAt = T('2026-06-19 12:00:00') + 800;
const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(removed.length, 1, 'log second-granularity must not let same-second ghosts slip through');
});
test('ts-gate: uses the MAX log ts per key (re-upload after a stale earlier entry)', () => {
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
const log = [
{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 11:00:00') },
{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }
];
const savedAt = T('2026-06-19 12:00:00');
const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(removed.length, 1, 'newest matching log entry decides');
});
test('ts-gate inactive without savedAt → legacy behavior (preview kept even if ts newer)', () => {
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
const { kept, removed } = partitionRestoredJobsByLog(jobs, log);
assert.equal(removed.length, 0);
assert.equal(kept.length, 1);
});
test('ts-gate inactive when log entry lacks ts → legacy behavior', () => {
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx' }];
const savedAt = T('2026-06-19 12:00:00');
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(removed.length, 0);
assert.equal(kept.length, 1);
});
test('ts-gate: done job uploaded after snapshot is dropped via either rule', () => {
const jobs = [job('done', 'a.mkv', 'voe.sx')];
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
const savedAt = T('2026-06-19 12:00:00');
const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(removed.length, 1);
});
test('ts-gate ambiguity guard: a pending same-basename file in a DIFFERENT folder is NOT lost when a sibling completes after the snapshot', () => {
// X (C:/A/clip.mp4) was uploaded after the snapshot and logged. Y is a
// genuinely-different file (C:/B/clip.mp4), same basename + hoster, still
// pending. The log records only basenames, so the ts-rule must not drop Y.
const jobs = [
{ status: 'preview', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/A/clip.mp4' },
{ status: 'preview', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/B/clip.mp4' }
];
const savedAt = T('2026-06-19 12:00:00');
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(removed.length, 0, 'ambiguous key -> ts-rule suppressed, no pending file lost');
assert.equal(kept.length, 2);
});
test('ts-gate ambiguity guard: the done-in-log rule still applies on an ambiguous key', () => {
// Even when the key is ambiguous, a job that is actually 'done' and in the log
// is still decluttered (pre-existing rule, unchanged by the guard).
const jobs = [
{ status: 'done', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/A/clip.mp4' },
{ status: 'preview', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/B/clip.mp4' }
];
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
const savedAt = T('2026-06-19 12:00:00');
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(removed.length, 1);
assert.equal(removed[0].status, 'done');
assert.equal(removed[0].file, 'C:/A/clip.mp4');
assert.ok(kept.some(j => j.file === 'C:/B/clip.mp4'), 'the distinct pending file survives');
});
test('ts-gate: a unique-path ghost still drops (guard does not weaken the common case)', () => {
const jobs = [{ status: 'preview', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/A/clip.mp4' }];
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
const savedAt = T('2026-06-19 12:00:00');
const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(removed.length, 1, 'single job for the key -> unambiguous -> ghost dropped as before');
});
test('ts-gate: multi-hoster partial completion — the reported bug shape (drop only the completed hosters)', () => {
// One file queued to 4 hosters; close mid-upload. After the snapshot, 2 hosters
// completed (logged), 2 never started. On restart all 4 restore as 'preview'.
// Must drop EXACTLY the 2 that completed and keep the 2 still-pending. This also
// pins per-hoster keying: a fileName-only gate would wrongly drop all 4.
const f = 'Einfach mal die Fresse halten!!!.mp4';
const jobs = [
job('preview', f, 'doodstream.com'),
job('preview', f, 'voe.sx'),
job('preview', f, 'vidmoly.me'),
job('preview', f, 'byse.sx')
];
const savedAt = T('2026-06-19 12:00:00');
const log = [
{ fileName: f, hoster: 'doodstream.com', ts: T('2026-06-19 12:00:08') },
{ fileName: f, hoster: 'voe.sx', ts: T('2026-06-19 12:00:11') }
];
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
assert.equal(removed.length, 2, 'only the 2 completed-after-snapshot hosters drop');
assert.ok(removed.every(j => j.hoster === 'doodstream.com' || j.hoster === 'voe.sx'));
assert.equal(kept.length, 2, 'the 2 never-started hosters survive');
assert.ok(kept.some(j => j.hoster === 'vidmoly.me'));
assert.ok(kept.some(j => j.hoster === 'byse.sx'));
});
test('completedSelectionKeys: a selectedFile that completed after the snapshot yields its full-path|hoster key', () => {
const selectedFiles = [{ path: 'C:/dl/done.mp4', name: 'done.mp4' }, { path: 'C:/dl/pending.mp4', name: 'pending.mp4' }];
const hosters = ['voe.sx'];
const savedAt = T('2026-06-19 12:00:00');
const log = [{ fileName: 'done.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
const keys = completedSelectionKeys(selectedFiles, hosters, log, savedAt);
assert.deepEqual(keys, ['C:/dl/done.mp4|voe.sx'], 'only the completed file is seeded; pending is not');
});
test('completedSelectionKeys: per-hoster — a file done on voe but not byse only seeds the voe key', () => {
const selectedFiles = [{ path: 'C:/dl/a.mp4', name: 'a.mp4' }];
const hosters = ['voe.sx', 'byse.sx'];
const savedAt = T('2026-06-19 12:00:00');
const log = [{ fileName: 'a.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
const keys = completedSelectionKeys(selectedFiles, hosters, log, savedAt);
assert.deepEqual(keys, ['C:/dl/a.mp4|voe.sx'], 'the still-pending byse upload is NOT seeded');
});
test('completedSelectionKeys: ambiguous basename across folders seeds NOTHING (no lost re-preview)', () => {
const selectedFiles = [{ path: 'C:/A/clip.mp4', name: 'clip.mp4' }, { path: 'C:/B/clip.mp4', name: 'clip.mp4' }];
const hosters = ['voe.sx'];
const savedAt = T('2026-06-19 12:00:00');
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
const keys = completedSelectionKeys(selectedFiles, hosters, log, savedAt);
assert.deepEqual(keys, [], 'ambiguous -> neither path is suppressed, both re-preview (safe direction)');
});
test('completedSelectionKeys: an OLDER completion (pre-snapshot re-queue) is NOT seeded', () => {
const selectedFiles = [{ path: 'C:/dl/reup.mp4', name: 'reup.mp4' }];
const hosters = ['voe.sx'];
const savedAt = T('2026-06-19 12:00:00');
const log = [{ fileName: 'reup.mp4', hoster: 'voe.sx', ts: T('2026-06-19 11:00:00') }];
assert.deepEqual(completedSelectionKeys(selectedFiles, hosters, log, savedAt), []);
});
test('completedSelectionKeys: no savedAt / junk inputs -> empty (legacy + robustness)', () => {
const sf = [{ path: 'C:/dl/a.mp4', name: 'a.mp4' }];
const log = [{ fileName: 'a.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
assert.deepEqual(completedSelectionKeys(sf, ['voe.sx'], log, undefined), []);
assert.deepEqual(completedSelectionKeys(null, ['voe.sx'], log, 1), []);
assert.deepEqual(completedSelectionKeys(sf, null, log, 1), []);
assert.deepEqual(completedSelectionKeys([], [], log, 1), []);
assert.deepEqual(completedSelectionKeys([{ name: 'x' }], ['voe.sx'], log, 1), [], 'entry without path is skipped');
});
test('completedSelectionKeys: derives basename from path when name is missing', () => {
const selectedFiles = [{ path: 'C:/dl/sub/movie.mp4' }];
const hosters = ['voe.sx'];
const savedAt = T('2026-06-19 12:00:00');
const log = [{ fileName: 'movie.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
assert.deepEqual(completedSelectionKeys(selectedFiles, hosters, log, savedAt), ['C:/dl/sub/movie.mp4|voe.sx']);
});
+83
View File
@@ -0,0 +1,83 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { formatUploadLogLine, parseUploadLogLine } = require('../lib/upload-log');
const { partitionRestoredJobsByLog } = require('../lib/queue-dedup');
function makeJobs(n, hoster, status, offset = 0) {
const jobs = [];
for (let i = 0; i < n; i++) {
const fileName = `clip_${String(i + offset).padStart(4, '0')}.mp4`;
jobs.push({ id: `j-${hoster}-${i + offset}`, file: `D:/inbox/${fileName}`, fileName, hoster, status });
}
return jobs;
}
test('user report: 300 queued, ~200 finished mid-session before a hard kill — only the finished drop', () => {
const hoster = 'byse.sx';
const snapshot = new Date(2026, 5, 19, 22, 0, 0);
const savedAt = snapshot.getTime();
const restoredJobs = makeJobs(300, hoster, 'preview');
const completionBase = new Date(2026, 5, 19, 22, 5, 0).getTime();
const logEntries = [];
for (let i = 0; i < 200; i++) {
const d = new Date(completionBase + i * 1000);
logEntries.push(parseUploadLogLine(
formatUploadLogLine(d, hoster, `https://byse.sx/d/x${i}`, `clip_${String(i).padStart(4, '0')}.mp4`)
));
}
const { kept, removed } = partitionRestoredJobsByLog(restoredJobs, logEntries, savedAt);
assert.equal(removed.length, 200, 'the 200 completed-after-snapshot files are dropped as ghosts');
assert.equal(kept.length, 100, 'the 100 never-finished files stay queued');
assert.ok(kept.every(j => Number(j.fileName.slice(5, 9)) >= 200), 'kept are exactly indices 200..299');
const keptNames = new Set(kept.map(j => j.fileName));
assert.ok(removed.every(j => !keptNames.has(j.fileName)));
});
test('multi-hoster batch: per-hoster completion is independent (a file done on voe but not byse keeps byse)', () => {
const savedAt = new Date(2026, 5, 19, 22, 0, 0).getTime();
const done = new Date(2026, 5, 19, 22, 3, 0);
const jobs = [
...makeJobs(3, 'voe.sx', 'preview'),
...makeJobs(3, 'byse.sx', 'preview')
];
const logEntries = [
parseUploadLogLine(formatUploadLogLine(done, 'voe.sx', 'l', 'clip_0000.mp4')),
parseUploadLogLine(formatUploadLogLine(done, 'voe.sx', 'l', 'clip_0001.mp4')),
parseUploadLogLine(formatUploadLogLine(done, 'byse.sx', 'l', 'clip_0000.mp4'))
];
const { kept, removed } = partitionRestoredJobsByLog(jobs, logEntries, savedAt);
assert.equal(removed.length, 3);
assert.ok(removed.some(j => j.hoster === 'voe.sx' && j.fileName === 'clip_0000.mp4'));
assert.ok(removed.some(j => j.hoster === 'voe.sx' && j.fileName === 'clip_0001.mp4'));
assert.ok(removed.some(j => j.hoster === 'byse.sx' && j.fileName === 'clip_0000.mp4'));
assert.ok(kept.some(j => j.hoster === 'byse.sx' && j.fileName === 'clip_0001.mp4'), 'byse clip_0001 not logged -> kept');
});
test('clean idle close (snapshot AFTER completion) keeps an intentional re-queue of an old file', () => {
const hoster = 'voe.sx';
const yesterday = new Date(2026, 5, 18, 12, 0, 0);
const logEntries = [parseUploadLogLine(formatUploadLogLine(yesterday, hoster, 'link', 'reupload_me.mp4'))];
const savedAt = new Date(2026, 5, 19, 9, 0, 0).getTime();
const jobs = [{ id: 'r1', file: 'D:/x/reupload_me.mp4', fileName: 'reupload_me.mp4', hoster, status: 'preview' }];
const { kept, removed } = partitionRestoredJobsByLog(jobs, logEntries, savedAt);
assert.equal(removed.length, 0, 'an upload older than the snapshot is a deliberate re-queue and survives');
assert.equal(kept.length, 1);
});
test('legacy snapshot without savedAt (pre-v3.3.80 config) falls back to done-only dedup', () => {
const hoster = 'voe.sx';
const logEntries = [
parseUploadLogLine(formatUploadLogLine(new Date(2026, 5, 19, 12, 0, 0), hoster, 'l', 'done.mp4')),
parseUploadLogLine(formatUploadLogLine(new Date(2026, 5, 19, 12, 1, 0), hoster, 'l', 'preview.mp4'))
];
const jobs = [
{ id: 'a', file: 'D:/x/done.mp4', fileName: 'done.mp4', hoster, status: 'done' },
{ id: 'b', file: 'D:/x/preview.mp4', fileName: 'preview.mp4', hoster, status: 'preview' }
];
const { kept, removed } = partitionRestoredJobsByLog(jobs, logEntries);
assert.equal(removed.length, 1, 'only the done job is decluttered when no savedAt is available');
assert.equal(removed[0].id, 'a');
assert.ok(kept.some(j => j.id === 'b'), 'the preview survives the legacy path');
});
+115
View File
@@ -0,0 +1,115 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { pruneOldestTerminalJobs, TERMINAL_STATUSES } = require('../lib/queue-prune');
const j = (id, status) => ({ id, status });
test('returns null on empty / non-array input', () => {
assert.equal(pruneOldestTerminalJobs([], 5), null);
assert.equal(pruneOldestTerminalJobs(null, 5), null);
assert.equal(pruneOldestTerminalJobs(undefined, 5), null);
});
test('returns null when all jobs are non-terminal regardless of limit', () => {
const jobs = [j('a', 'queued'), j('b', 'uploading'), j('c', 'preview')];
assert.equal(pruneOldestTerminalJobs(jobs, 0), null);
assert.equal(pruneOldestTerminalJobs(jobs, 100), null);
});
test('returns null when terminal count is at or under the limit', () => {
const jobs = [j('a', 'done'), j('b', 'done'), j('c', 'queued')];
assert.equal(pruneOldestTerminalJobs(jobs, 2), null, 'terminal=2, limit=2 → no-op');
assert.equal(pruneOldestTerminalJobs(jobs, 3), null, 'terminal=2, limit=3 → no-op');
});
test('drops oldest terminal jobs when over the limit, keeps non-terminal', () => {
const jobs = [
j('t1', 'done'), // oldest terminal — should be dropped
j('t2', 'done'), // should be dropped
j('queued1', 'queued'),
j('t3', 'error'), // newest of the dropped block
j('uploading1', 'uploading'),
j('t4', 'done'), // kept (within limit window)
j('t5', 'skipped'), // kept
j('t6', 'aborted'), // kept
];
// 6 terminal, limit 3 → drop 3 oldest (t1, t2, t3)
const result = pruneOldestTerminalJobs(jobs, 3);
assert.notEqual(result, null);
const droppedIds = result.dropped.map(x => x.id).sort();
assert.deepEqual(droppedIds, ['t1', 't2', 't3']);
// Non-terminal jobs always kept; surviving terminals are the newest 3
const keptIds = result.kept.map(x => x.id);
assert.deepEqual(keptIds, ['queued1', 'uploading1', 't4', 't5', 't6']);
});
test('respects insertion order (oldest by index, not by status)', () => {
const jobs = [
j('older-error', 'error'),
j('newer-done', 'done'),
j('newest-aborted', 'aborted'),
];
const result = pruneOldestTerminalJobs(jobs, 1);
assert.deepEqual(result.dropped.map(x => x.id), ['older-error', 'newer-done']);
assert.deepEqual(result.kept.map(x => x.id), ['newest-aborted']);
});
test('drops everything terminal when limit is 0', () => {
const jobs = [
j('q', 'queued'),
j('d1', 'done'),
j('d2', 'done'),
j('e1', 'error'),
];
const result = pruneOldestTerminalJobs(jobs, 0);
assert.deepEqual(result.dropped.map(x => x.id), ['d1', 'd2', 'e1']);
assert.deepEqual(result.kept.map(x => x.id), ['q']);
});
test('rejects negative or non-finite limits', () => {
const jobs = [j('a', 'done'), j('b', 'done')];
assert.equal(pruneOldestTerminalJobs(jobs, -1), null);
assert.equal(pruneOldestTerminalJobs(jobs, NaN), null);
assert.equal(pruneOldestTerminalJobs(jobs, Infinity), null,
'Infinity is technically not finite; safer to treat as no-op');
});
test('TERMINAL_STATUSES set covers all 4 terminal kinds', () => {
assert.ok(TERMINAL_STATUSES.has('done'));
assert.ok(TERMINAL_STATUSES.has('skipped'));
assert.ok(TERMINAL_STATUSES.has('error'));
assert.ok(TERMINAL_STATUSES.has('aborted'));
assert.equal(TERMINAL_STATUSES.size, 4);
// Non-terminal must not be in the set
for (const s of ['queued', 'preview', 'uploading', 'retrying', 'getting-server']) {
assert.equal(TERMINAL_STATUSES.has(s), false, `${s} must not be terminal`);
}
});
test('handles malformed entries (null / missing status) without throwing', () => {
const jobs = [
null,
j('a', 'done'),
{ id: 'no-status' }, // no status
j('b', 'done'),
];
// 2 terminal, limit 1 → drop oldest (a). null and no-status entries stay
// because they aren't terminal. The function must not throw on them.
const result = pruneOldestTerminalJobs(jobs, 1);
assert.notEqual(result, null);
assert.deepEqual(result.dropped.map(x => x && x.id), ['a']);
assert.equal(result.kept.length, 3);
});
test('large queue: keeps the newest `limit` terminals', () => {
const jobs = [];
for (let i = 0; i < 5000; i++) jobs.push(j(`done-${i}`, 'done'));
const result = pruneOldestTerminalJobs(jobs, 500);
assert.notEqual(result, null);
assert.equal(result.dropped.length, 4500);
assert.equal(result.kept.length, 500);
// First kept = done-4500 (the 4501st original entry)
assert.equal(result.kept[0].id, 'done-4500');
assert.equal(result.kept[result.kept.length - 1].id, 'done-4999');
});
+53
View File
@@ -0,0 +1,53 @@
const { describe, it } = require('node:test');
const assert = require('node:assert');
const path = require('path');
const fs = require('fs');
const os = require('os');
// Minimal app mock for ConfigStore
function createTestConfigStore() {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-test-'));
const mockApp = {
isPackaged: false,
getPath: (name) => tmpDir,
getPath: () => tmpDir
};
const ConfigStore = require('../lib/config-store');
const store = new ConfigStore(mockApp);
store.filePath = path.join(tmpDir, 'test-config.json');
return { store, tmpDir };
}
describe('remote config defaults', () => {
it('should include remote settings in defaults', () => {
const { store } = createTestConfigStore();
const config = store.load();
const remote = config.globalSettings.remote;
assert.strictEqual(remote.enabled, false);
assert.strictEqual(remote.port, 9100);
assert.strictEqual(typeof remote.token, 'string');
assert.strictEqual(remote.token, '');
assert.strictEqual(remote.allowInput, true);
});
it('should deep-merge remote settings with existing config', async () => {
const { store } = createTestConfigStore();
// Save config with partial remote settings
await store.save({
globalSettings: {
remote: { enabled: true, port: 9200 }
}
});
const config = store.load();
const remote = config.globalSettings.remote;
// Saved values preserved
assert.strictEqual(remote.enabled, true);
assert.strictEqual(remote.port, 9200);
// Defaults merged in
assert.strictEqual(remote.allowInput, true);
assert.strictEqual(remote.token, '');
});
});
+41
View File
@@ -0,0 +1,41 @@
const { describe, it, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
// Test the module can be required and has the expected API
describe('RemoteServer', () => {
it('should export a class with start/stop methods', () => {
const RemoteServer = require('../lib/remote-server');
assert.strictEqual(typeof RemoteServer, 'function');
assert.strictEqual(typeof RemoteServer.prototype.start, 'function');
assert.strictEqual(typeof RemoteServer.prototype.stop, 'function');
assert.strictEqual(typeof RemoteServer.prototype.getClientCount, 'function');
});
it('should start and stop without errors', async () => {
const RemoteServer = require('../lib/remote-server');
const server = new RemoteServer();
// Mock mainWindow
const mockMainWindow = {
isDestroyed: () => false,
getTitle: () => 'Test Window',
getContentBounds: () => ({ x: 0, y: 0, width: 1920, height: 1080 }),
webContents: {
sendInputEvent: () => {}
}
};
await server.start({
port: 0, // random available port
token: 'test-token-123',
allowInput: true,
mainWindow: mockMainWindow,
onSignalingToCapture: () => {},
onCreateCaptureWindow: () => {},
onDestroyCaptureWindow: () => {}
});
assert.strictEqual(server.getClientCount(), 0);
server.stop();
});
});
+174
View File
@@ -0,0 +1,174 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const Semaphore = require('../lib/semaphore');
describe('Semaphore', () => {
it('clamps limit to at least 1', () => {
assert.equal(new Semaphore(0).limit, 1);
assert.equal(new Semaphore(-5).limit, 1);
assert.equal(new Semaphore(undefined).limit, 1);
assert.equal(new Semaphore(3).limit, 3);
});
it('acquire resolves immediately when slots available', async () => {
const sem = new Semaphore(2);
await sem.acquire();
await sem.acquire();
assert.equal(sem.active, 2);
});
it('acquire blocks when all slots taken', async () => {
const sem = new Semaphore(1);
await sem.acquire();
let resolved = false;
const p = sem.acquire().then(() => { resolved = true; });
// Give microtask a chance to resolve
await new Promise(r => setTimeout(r, 10));
assert.equal(resolved, false, 'should not resolve while slot is taken');
assert.equal(sem.pending, 1);
sem.release();
await p;
assert.equal(resolved, true);
});
it('FIFO ordering', async () => {
const sem = new Semaphore(1);
await sem.acquire(); // take the one slot
const order = [];
const p1 = sem.acquire().then(() => order.push(1));
const p2 = sem.acquire().then(() => order.push(2));
const p3 = sem.acquire().then(() => order.push(3));
assert.equal(sem.pending, 3);
sem.release(); await p1;
sem.release(); await p2;
sem.release(); await p3;
assert.deepEqual(order, [1, 2, 3]);
});
it('release with no waiters decrements active', async () => {
const sem = new Semaphore(2);
await sem.acquire();
assert.equal(sem.active, 1);
sem.release();
assert.equal(sem.active, 0);
});
it('release never goes below 0', () => {
const sem = new Semaphore(2);
sem.release();
assert.equal(sem.active, 0);
sem.release();
assert.equal(sem.active, 0);
});
it('acquire rejects immediately if signal already aborted', async () => {
const sem = new Semaphore(2);
const ac = new AbortController();
ac.abort();
await assert.rejects(sem.acquire(ac.signal), /Aborted/);
assert.equal(sem.active, 0, 'no slot should be acquired');
});
it('abort while waiting in queue removes entry and rejects', async () => {
const sem = new Semaphore(1);
await sem.acquire(); // take the slot
const ac = new AbortController();
const p = sem.acquire(ac.signal);
assert.equal(sem.pending, 1);
ac.abort();
await assert.rejects(p, /Aborted/);
assert.equal(sem.pending, 0, 'entry should be removed from queue');
// Release original slot - should not cause issues
sem.release();
assert.equal(sem.active, 0);
});
it('abort listener is cleaned up when slot is granted via release', async () => {
const sem = new Semaphore(1);
await sem.acquire();
const ac = new AbortController();
let rejected = false;
const p = sem.acquire(ac.signal).catch(() => { rejected = true; });
sem.release(); // grants slot to the waiter
await p;
// Now abort after the slot was already granted
ac.abort();
await new Promise(r => setTimeout(r, 10));
assert.equal(rejected, false, 'reject should not fire after slot was granted');
});
it('updateLimit wakes waiters', async () => {
const sem = new Semaphore(1);
await sem.acquire();
const resolved = [];
const p1 = sem.acquire().then(() => resolved.push(1));
const p2 = sem.acquire().then(() => resolved.push(2));
sem.updateLimit(3);
await Promise.all([p1, p2]);
assert.deepEqual(resolved, [1, 2]);
});
it('updateLimit to lower value does not kill active slots', async () => {
const sem = new Semaphore(3);
await sem.acquire();
await sem.acquire();
await sem.acquire();
assert.equal(sem.active, 3);
sem.updateLimit(1);
assert.equal(sem.active, 3, 'existing active slots should not be evicted');
sem.release();
sem.release();
sem.release();
assert.equal(sem.active, 0);
// Now only 1 slot should be available
await sem.acquire();
let blocked = false;
const p = sem.acquire().then(() => { blocked = true; });
await new Promise(r => setTimeout(r, 10));
assert.equal(blocked, false, 'should block at limit 1');
sem.release();
await p;
});
it('pending getter tracks queue size', async () => {
const sem = new Semaphore(1);
assert.equal(sem.pending, 0);
await sem.acquire();
sem.acquire(); // blocked
sem.acquire(); // blocked
assert.equal(sem.pending, 2);
sem.release();
await new Promise(r => setTimeout(r, 5));
assert.equal(sem.pending, 1);
});
it('release without acquire clamps active to 0', () => {
const sem = new Semaphore(2);
assert.equal(sem.active, 0);
sem.release();
assert.equal(sem.active, 0, 'should not go negative');
sem.release();
assert.equal(sem.active, 0, 'should still be 0');
});
});
+132
View File
@@ -0,0 +1,132 @@
const test = require('node:test');
const assert = require('node:assert');
const {
summarizePerHoster,
classifyErrorCategory,
summarizeBatchErrors,
isRetryableCategory
} = require('../lib/stats');
function makeBatch(timestamp, results) {
return {
id: 'b-' + timestamp,
timestamp: new Date(timestamp).toISOString(),
files: [{ name: 'foo.mp4', size: 1, results }]
};
}
test('summarizePerHoster counts ok and fail per hoster across all batches', () => {
const history = [
makeBatch(1, [
{ hoster: 'voe.sx', status: 'done' },
{ hoster: 'byse.sx', status: 'error', error: 'Not video file format' }
]),
makeBatch(2, [
{ hoster: 'voe.sx', status: 'done' },
{ hoster: 'voe.sx', status: 'error', error: 'CSRF' },
{ hoster: 'byse.sx', status: 'done' }
])
];
const s = summarizePerHoster(history);
assert.strictEqual(s['voe.sx'].ok, 2);
assert.strictEqual(s['voe.sx'].fail, 1);
assert.strictEqual(s['voe.sx'].total, 3);
assert.strictEqual(Math.round(s['voe.sx'].rate * 100), 67);
assert.strictEqual(s['byse.sx'].ok, 1);
assert.strictEqual(s['byse.sx'].fail, 1);
assert.strictEqual(s['byse.sx'].rate, 0.5);
});
test('summarizePerHoster honors sinceMs cutoff', () => {
const history = [
makeBatch(1000, [{ hoster: 'voe.sx', status: 'done' }]),
makeBatch(5000, [{ hoster: 'voe.sx', status: 'error', error: 'x' }])
];
const s = summarizePerHoster(history, { sinceMs: 3000 });
assert.strictEqual(s['voe.sx'].ok, 0);
assert.strictEqual(s['voe.sx'].fail, 1);
});
test('summarizePerHoster honors lastNBatches (newest first)', () => {
const history = [
makeBatch(1000, [{ hoster: 'voe.sx', status: 'done' }]),
makeBatch(2000, [{ hoster: 'voe.sx', status: 'done' }]),
makeBatch(3000, [{ hoster: 'voe.sx', status: 'error', error: 'x' }])
];
const s = summarizePerHoster(history, { lastNBatches: 1 });
assert.strictEqual(s['voe.sx'].ok, 0);
assert.strictEqual(s['voe.sx'].fail, 1);
});
test('summarizePerHoster handles empty / malformed input', () => {
assert.deepStrictEqual(summarizePerHoster(null), {});
assert.deepStrictEqual(summarizePerHoster([]), {});
assert.deepStrictEqual(summarizePerHoster([{ id: 'x', files: null }]), {});
});
test('classifyErrorCategory: file-rejected phrases', () => {
assert.strictEqual(classifyErrorCategory('Byse lehnte Datei ab: Not video file format'), 'file-rejected');
assert.strictEqual(classifyErrorCategory('Duplicate file already exists'), 'file-rejected');
assert.strictEqual(classifyErrorCategory('Datei zu groß (Max: 5 GB)'), 'file-rejected');
});
test('classifyErrorCategory: account-error phrases', () => {
assert.strictEqual(classifyErrorCategory('Quota exceeded'), 'account-error');
assert.strictEqual(classifyErrorCategory('account banned'), 'account-error');
assert.strictEqual(classifyErrorCategory('not enough disk space'), 'account-error');
});
test('classifyErrorCategory: hoster-transient phrases', () => {
assert.strictEqual(classifyErrorCategory('CSRF-Token nicht gefunden'), 'hoster-transient');
assert.strictEqual(classifyErrorCategory('Kein Upload-Server erhalten: server busy'), 'hoster-transient');
assert.strictEqual(classifyErrorCategory('Kein Filecode'), 'hoster-transient');
});
test('classifyErrorCategory: network phrases', () => {
assert.strictEqual(classifyErrorCategory('socket hang up'), 'network');
assert.strictEqual(classifyErrorCategory('fetch failed'), 'network');
assert.strictEqual(classifyErrorCategory('Timeout while reading'), 'network');
});
test('classifyErrorCategory: aborted is its own bucket (not retryable)', () => {
assert.strictEqual(classifyErrorCategory('Abgebrochen'), 'aborted');
assert.strictEqual(isRetryableCategory('aborted'), false);
});
test('classifyErrorCategory: unknown for everything else', () => {
assert.strictEqual(classifyErrorCategory(''), 'unknown');
assert.strictEqual(classifyErrorCategory(null), 'unknown');
assert.strictEqual(classifyErrorCategory('Some weird thing'), 'unknown');
});
test('summarizeBatchErrors buckets results by category', () => {
const summary = {
files: [
{ name: 'a.mp4', results: [
{ hoster: 'voe.sx', status: 'done' },
{ hoster: 'byse.sx', status: 'error', error: 'Not video file format' }
] },
{ name: 'b.mp4', results: [
{ hoster: 'voe.sx', status: 'error', error: 'CSRF' },
{ hoster: 'doodstream.com', status: 'error', error: 'socket hang up' }
] }
]
};
const buckets = summarizeBatchErrors(summary);
assert.strictEqual(buckets['file-rejected'].length, 1);
assert.strictEqual(buckets['file-rejected'][0].hoster, 'byse.sx');
assert.strictEqual(buckets['hoster-transient'].length, 1);
assert.strictEqual(buckets['hoster-transient'][0].hoster, 'voe.sx');
assert.strictEqual(buckets['network'].length, 1);
assert.strictEqual(buckets['network'][0].hoster, 'doodstream.com');
assert.strictEqual(buckets['account-error'].length, 0);
});
test('isRetryableCategory: only transient + network + unknown retry-worthy', () => {
assert.strictEqual(isRetryableCategory('hoster-transient'), true);
assert.strictEqual(isRetryableCategory('network'), true);
assert.strictEqual(isRetryableCategory('unknown'), true);
assert.strictEqual(isRetryableCategory('file-rejected'), false);
assert.strictEqual(isRetryableCategory('account-error'), false);
assert.strictEqual(isRetryableCategory('aborted'), false);
});
+160
View File
@@ -0,0 +1,160 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { sanitizeConfig, collectFile, buildSupportBundleText, redactLogText, REDACTED } = require('../lib/support-bundle');
const artificialSecret = (...fragments) => fragments.join('');
test('sanitizeConfig redacts known credential keys at any nesting depth', () => {
const input = {
hosters: {
'voe.sx': [{ username: 'u', password: 'p1', apiKey: 'k1', enabled: true }],
'byse.sx': [{ apiKey: 'k2' }, { apiKey: 'k3', token: 't1', label: 'main' }]
},
globalSettings: { remote: { token: 'remT' }, scramble: { active: false } }
};
const out = sanitizeConfig(input);
assert.strictEqual(out.hosters['voe.sx'][0].password, REDACTED);
assert.strictEqual(out.hosters['voe.sx'][0].apiKey, REDACTED);
assert.strictEqual(out.hosters['voe.sx'][0].username, 'u');
assert.strictEqual(out.hosters['voe.sx'][0].enabled, true);
assert.strictEqual(out.hosters['byse.sx'][1].apiKey, REDACTED);
assert.strictEqual(out.hosters['byse.sx'][1].token, REDACTED);
assert.strictEqual(out.hosters['byse.sx'][1].label, 'main');
assert.strictEqual(out.globalSettings.remote.token, REDACTED);
});
test('redactLogText scrubs opaque tokens that are NOT stored config secrets', () => {
const secrets = [
artificialSecret('fixture_token_', 'qwerty', '12345'),
artificialSecret('fixture_auth_', 'value', '123456'),
artificialSecret('fixture_refresh_', 'value', '123456'),
artificialSecret('fixture_bearer_', 'value', '123456'),
artificialSecret('fixture_authorization_', 'value', '123456')
];
const cases = [
`boom token=${secrets[0]}`,
`response auth_token: ${secrets[1]}`,
`refresh_token = ${secrets[2]}`,
`using Bearer ${secrets[3]}`,
`Authorization: Bearer ${secrets[4]}`
];
for (const [index, line] of cases.entries()) {
const out = redactLogText(line, []);
assert.ok(out.includes(REDACTED), `expected redaction in: ${line} -> ${out}`);
assert.ok(!out.includes(secrets[index]), `secret survived: ${out}`);
}
});
test('redactLogText leaves benign "token" prose alone', () => {
const benign = 'token bucket refill rate is 5 per second';
assert.equal(redactLogText(benign, []), benign);
});
test('redactLogText scrubs the password from a basic-auth URL but keeps host:port', () => {
const password = artificialSecret('fixture', 'Proxy', 'Password');
const out = redactLogText(`proxy https://admin:${password}@proxy.internal:8080/path`, []);
assert.ok(!out.includes(password), 'basic-auth password must be redacted');
assert.ok(out.includes('proxy.internal:8080'), 'host:port preserved');
assert.ok(out.includes('admin:'), 'username preserved');
});
test('redactLogText does not touch a host:port URL without userinfo', () => {
const url = 'connecting to https://cdn.voe.sx:8080/upload now';
assert.equal(redactLogText(url, []), url);
});
test('redactLogText scrubs Basic auth, JWTs and bare session= values (defense in depth)', () => {
const basicValue = artificialSecret('dXNlcjpw', 'YXNzd29y', 'ZDEyMw');
const jwtValue = artificialSecret('eyJhbGciOiJIUzI1NiJ9', '.', 'eyJzdWIiOiIxMjM0NTY3ODkwIn0', '.', 'dozjgNryP4J3jVmNHl0w5N');
const jwtSecret = artificialSecret('eyJhbGciOiJIUzI1NiJ9', '.', 'eyJzdWIiOiIxMjM0NTY3ODkwIn0');
const sessionValue = artificialSecret('fixture', 'Session', 'Value', '99887766');
const jsonSessionValue = artificialSecret('fixture', 'Json', 'Session', '123456');
const cases = [
{ line: `Authorization: Basic ${basicValue}==`, secret: basicValue },
{ line: `jwt ${jwtValue}`, secret: jwtSecret },
{ line: `session=${sessionValue}`, secret: sessionValue },
{ line: `"session":"${jsonSessionValue}"`, secret: jsonSessionValue },
];
for (const c of cases) {
const out = redactLogText(c.line, []);
assert.ok(!out.includes(c.secret), `must redact: ${c.line} -> ${out}`);
assert.ok(out.includes(REDACTED), `expected ${REDACTED} in ${out}`);
}
});
test('redactLogText leaves a normal "session" word in prose alone', () => {
const benign = 'the session was idle for a while';
assert.equal(redactLogText(benign, []), benign);
});
test('sanitizeConfig does not mutate input', () => {
const input = { hosters: { 'voe.sx': [{ password: 'secret' }] } };
const clone = JSON.parse(JSON.stringify(input));
sanitizeConfig(input);
assert.deepStrictEqual(input, clone);
});
test('sanitizeConfig leaves empty/missing credentials alone', () => {
const input = { hosters: { 'voe.sx': [{ password: '', apiKey: null }] } };
const out = sanitizeConfig(input);
assert.strictEqual(out.hosters['voe.sx'][0].password, '');
assert.strictEqual(out.hosters['voe.sx'][0].apiKey, null);
});
test('sanitizeConfig handles null/undefined input', () => {
assert.strictEqual(sanitizeConfig(null), null);
assert.strictEqual(sanitizeConfig(undefined), undefined);
});
test('collectFile tails when file exceeds maxBytes', () => {
const tmp = path.join(os.tmpdir(), `mhu-bundle-${Date.now()}.log`);
const bigLine = 'x'.repeat(1000) + '\n';
fs.writeFileSync(tmp, bigLine.repeat(100));
try {
const section = collectFile(tmp, 'big.log', 5000);
assert.match(section, /truncated: skipped first \d+ bytes/);
assert.ok(section.length < bigLine.length * 100, 'section should be truncated');
} finally {
fs.unlinkSync(tmp);
}
});
test('collectFile returns placeholder for missing file', () => {
const section = collectFile(path.join(os.tmpdir(), `does-not-exist-${Date.now()}.log`), 'missing');
assert.match(section, /<file does not exist yet>/);
});
test('collectFile returns placeholder for null path', () => {
const section = collectFile(null, 'no-path');
assert.match(section, /<no path configured>/);
});
test('buildSupportBundleText produces structured output with header + config + file sections', () => {
const tmp = path.join(os.tmpdir(), `mhu-bundle-text-${Date.now()}.log`);
fs.writeFileSync(tmp, 'line one\nline two\n');
try {
const text = buildSupportBundleText({
header: { Version: '3.3.41', Platform: 'win32' },
sanitizedConfig: { hosters: { 'voe.sx': [{ apiKey: '<redacted>' }] } },
files: [{ label: 'debug.log', path: tmp }]
});
assert.match(text, /^=== Multi-Hoster-Upload Support Bundle ===/);
assert.match(text, /Version: 3\.3\.41/);
assert.match(text, /Platform: win32/);
assert.match(text, /=== Config \(sanitized/);
assert.match(text, /"apiKey": "<redacted>"/);
assert.match(text, /=== debug\.log/);
assert.match(text, /line one\nline two/);
} finally {
fs.unlinkSync(tmp);
}
});
test('buildSupportBundleText handles empty file list and missing header', () => {
const text = buildSupportBundleText({ sanitizedConfig: {}, files: [] });
assert.match(text, /=== Multi-Hoster-Upload Support Bundle ===/);
assert.match(text, /=== Config/);
});
+286
View File
@@ -0,0 +1,286 @@
const { describe, it, beforeEach, mock } = require('node:test');
const assert = require('node:assert/strict');
describe('suspect-reject alternate accounts', () => {
let UploadManager;
let mockUploadFile;
let mockProbe;
function suspectErr() {
const e = new Error('Byse lehnte Datei ab: Not video file format');
e.fileRejected = true;
e.suspectReject = true;
return e;
}
beforeEach(() => {
delete require.cache[require.resolve('../lib/upload-manager')];
const hosters = require('../lib/hosters');
mockUploadFile = mock.fn(async () => ({ download_url: 'https://byse.sx/d/ok', embed_url: null, file_code: 'ok' }));
hosters.uploadFile = (...a) => mockUploadFile(...a);
hosters.prefetchBaseline = async () => null;
const fileProbe = require('../lib/file-probe');
mockProbe = mock.fn(async () => ({ ok: true, kind: 'matroska', isVideoLike: true, headHex: '1a45dfa3' }));
fileProbe.probeFileHead = (...a) => mockProbe(...a);
const fs = require('fs');
const fakeSize = (p) => {
const m = /-(\d+)gb/i.exec(p);
return { size: (m ? parseInt(m[1], 10) : 3) * 1024 * 1024 * 1024 };
};
const origStatSync = fs.statSync;
fs.statSync = function (p) {
if (typeof p === 'string' && p.startsWith('/test/')) return fakeSize(p);
return origStatSync.call(this, p);
};
const origStat = fs.promises.stat;
fs.promises.stat = async function (p) {
if (typeof p === 'string' && p.startsWith('/test/')) return fakeSize(p);
return origStat.call(this, p);
};
UploadManager = require('../lib/upload-manager');
});
function poolMgr(pool, settings) {
return new UploadManager({ 'byse.sx': { retries: 0, ...(settings || {}) } }, {}, { 'byse.sx': pool });
}
it('tries the file on the next pool account after a suspect rejection and succeeds without blacklisting', async () => {
const mgr = poolMgr([
{ id: 'acc1', apiKey: 'key1' },
{ id: 'acc2', apiKey: 'key2' }
]);
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
if (apiKey === 'key1') throw suspectErr();
return { download_url: 'https://byse.sx/d/alt', embed_url: null, file_code: 'alt' };
});
const rotEvents = [];
mgr.on('rot-log', (e) => rotEvents.push(e.event));
let summary = null;
mgr.on('batch-done', (s) => { summary = s; });
await mgr.startBatch([{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
assert.equal(summary.succeeded, 1);
assert.equal(summary.failed, 0);
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
assert.deepEqual(keys, ['key1', 'key2']);
assert.ok(rotEvents.includes('suspect-reject-alt'));
assert.equal(mgr.getFailedAccountKeys().length, 0, 'suspect rejection must not blacklist any account');
});
it('fails the file when every pool account gives the suspect rejection — each tried exactly once, none blacklisted', async () => {
const mgr = poolMgr([
{ id: 'acc1', apiKey: 'key1' },
{ id: 'acc2', apiKey: 'key2' },
{ id: 'acc3', apiKey: 'key3' }
]);
mockUploadFile.mock.mockImplementation(async () => { throw suspectErr(); });
const rotEvents = [];
mgr.on('rot-log', (e) => rotEvents.push(e.event));
let summary = null;
mgr.on('batch-done', (s) => { summary = s; });
await mgr.startBatch([{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
assert.equal(summary.failed, 1);
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
assert.deepEqual(keys, ['key1', 'key2', 'key3']);
assert.ok(rotEvents.includes('suspect-reject-exhausted'));
assert.equal(mgr.getFailedAccountKeys().length, 0);
});
it('skips pool accounts already marked failed and lands on the last one', async () => {
const mgr = poolMgr([
{ id: 'acc1', apiKey: 'key1' },
{ id: 'acc2', apiKey: 'key2' },
{ id: 'acc3', apiKey: 'key3' },
{ id: 'acc4', apiKey: 'key4' }
]);
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
if (apiKey === 'key3') throw suspectErr();
return { download_url: 'https://byse.sx/d/four', embed_url: null, file_code: 'four' };
});
let summary = null;
mgr.on('batch-done', (s) => { summary = s; });
await mgr.startBatch(
[{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key3', accountId: 'acc3' }],
{ primeFailedAccounts: ['byse.sx:acc1', 'byse.sx:acc2'] }
);
assert.equal(summary.succeeded, 1);
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
assert.deepEqual(keys, ['key3', 'key4'], 'failed acc1/acc2 skipped, fourth account finally gets the file');
});
it('does NOT try alternates when the probe says the file is not a video', async () => {
mockProbe.mock.mockImplementation(async () => ({ ok: true, kind: 'rar', isVideoLike: false, headHex: '52617221' }));
const mgr = poolMgr([
{ id: 'acc1', apiKey: 'key1' },
{ id: 'acc2', apiKey: 'key2' }
]);
mockUploadFile.mock.mockImplementation(async () => { throw suspectErr(); });
const rotEvents = [];
mgr.on('rot-log', (e) => rotEvents.push(e.event));
let summary = null;
mgr.on('batch-done', (s) => { summary = s; });
await mgr.startBatch([{ file: '/test/archive.rar', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
assert.equal(summary.failed, 1);
assert.equal(mockUploadFile.mock.calls.length, 1, 'genuine non-video rejection must not burn uploads on other accounts');
assert.ok(rotEvents.includes('skip-rotation-file-rejected'));
});
it('records a user cancel during the alternates walk as aborted, not error', async () => {
const mgr = poolMgr([
{ id: 'acc1', apiKey: 'key1' },
{ id: 'acc2', apiKey: 'key2' }
]);
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
if (apiKey === 'key1') throw suspectErr();
mgr.cancel();
const e = new Error('This operation was aborted');
throw e;
});
let summary = null;
mgr.on('batch-done', (s) => { summary = s; });
await mgr.startBatch([{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
assert.equal(summary.files[0].results[0].status, 'aborted');
});
it('marks an alternate failed on a genuine account error so later suspect files skip it', async () => {
const mgr = poolMgr([
{ id: 'acc1', apiKey: 'key1' },
{ id: 'acc2', apiKey: 'key2' },
{ id: 'acc3', apiKey: 'key3' }
], { parallelCount: 1 });
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
if (apiKey === 'key1') throw suspectErr();
if (apiKey === 'key2') {
const e = new Error('Byse lehnte Datei ab: 0:0:0:not enough disk space on your account');
e.accountError = true;
throw e;
}
return { download_url: 'https://byse.sx/d/three', embed_url: null, file_code: 'three' };
});
let summary = null;
mgr.on('batch-done', (s) => { summary = s; });
await mgr.startBatch([
{ file: '/test/big1.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' },
{ file: '/test/big2.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }
]);
assert.equal(summary.succeeded, 2);
assert.ok(mgr.getFailedAccountKeys().includes('byse.sx:acc2'), 'dead alternate must be remembered');
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
assert.deepEqual(keys, ['key1', 'key2', 'key3', 'key1', 'key3'], 'second same-size file still gets one real attempt on the primary (memo arms only on the 2nd rejection), then skips the dead alternate and lands on the good account');
});
it('size memo short-circuits a later LARGER file once the account has two confirmed rejections', async () => {
const mgr = poolMgr([
{ id: 'acc1', apiKey: 'key1' },
{ id: 'acc2', apiKey: 'key2' },
{ id: 'acc3', apiKey: 'key3' }
], { parallelCount: 1 });
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
if (apiKey === 'key1' || apiKey === 'key2') throw suspectErr();
return { download_url: 'https://byse.sx/d/three', embed_url: null, file_code: 'three' };
});
const rotEvents = [];
mgr.on('rot-log', (e) => rotEvents.push(e.event));
let summary = null;
mgr.on('batch-done', (s) => { summary = s; });
await mgr.startBatch([
{ file: '/test/a-1gb.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' },
{ file: '/test/b-1gb.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' },
{ file: '/test/c-2gb.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }
]);
assert.equal(summary.succeeded, 3);
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
assert.deepEqual(keys, ['key1', 'key2', 'key3', 'key1', 'key3', 'key3'], 'files 1+2 each get a real attempt on the 1GB-rejecting primary (arming the memo at count 2); the larger 3rd file then short-circuits the primary straight to the good account');
assert.ok(rotEvents.includes('suspect-memo-skip'), 'the larger third file must skip its primary via the armed size memo');
});
it('sizeMemoEnabled:false disables the pre-skip — the larger third file still gets a real attempt on its primary', async () => {
const mgr = poolMgr([
{ id: 'acc1', apiKey: 'key1' },
{ id: 'acc2', apiKey: 'key2' },
{ id: 'acc3', apiKey: 'key3' }
], { parallelCount: 1, sizeMemoEnabled: false });
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
if (apiKey === 'key1' || apiKey === 'key2') throw suspectErr();
return { download_url: 'https://byse.sx/d/three', embed_url: null, file_code: 'three' };
});
const rotEvents = [];
mgr.on('rot-log', (e) => rotEvents.push(e.event));
let summary = null;
mgr.on('batch-done', (s) => { summary = s; });
await mgr.startBatch([
{ file: '/test/a-1gb.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' },
{ file: '/test/b-1gb.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' },
{ file: '/test/c-2gb.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }
]);
assert.equal(summary.succeeded, 3);
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
assert.equal(keys.filter(k => k === 'key1').length, 3, 'with the memo off every file gets a real attempt on the primary — including the larger third');
assert.ok(!rotEvents.includes('suspect-memo-skip'), 'the disabled memo must never short-circuit a primary');
});
it('plain fileRejected without suspect flag keeps the old fast-fail behavior', async () => {
const mgr = poolMgr([
{ id: 'acc1', apiKey: 'key1' },
{ id: 'acc2', apiKey: 'key2' }
]);
mockUploadFile.mock.mockImplementation(async () => {
const e = new Error('Byse lehnte Datei ab: Duplicate');
e.fileRejected = true;
throw e;
});
let summary = null;
mgr.on('batch-done', (s) => { summary = s; });
await mgr.startBatch([{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
assert.equal(summary.failed, 1);
assert.equal(mockUploadFile.mock.calls.length, 1);
});
it('a transient 5xx (byse 502) retries the SAME account and fails clean — no blacklist, no failover cascade', async () => {
const mgr = poolMgr([
{ id: 'acc1', apiKey: 'key1' },
{ id: 'acc2', apiKey: 'key2' },
{ id: 'acc3', apiKey: 'key3' }
], { retries: 2 });
mgr._sleep = async () => {};
mockUploadFile.mock.mockImplementation(async () => {
const e = new Error('Upload-Antwort von byse.sx war kein JSON (HTTP 502): <!doctype html>');
e.transientNetwork = true;
throw e;
});
let accountFailed = 0;
mgr.on('account-failed', () => { accountFailed++; });
let summary = null;
mgr.on('batch-done', (s) => { summary = s; });
await mgr.startBatch([{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
assert.equal(summary.failed, 1);
assert.equal(accountFailed, 0, 'a transient 502 must never emit account-failed');
assert.equal(mgr.getFailedAccountKeys().length, 0, 'no account blacklisted on a transient 502');
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
assert.ok(keys.length >= 2, 'the 502 is retried on the same account');
assert.ok(keys.every(k => k === 'key1'), 'every attempt stays on the primary — no cascade to key2/key3');
});
});
+133
View File
@@ -0,0 +1,133 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { makeThrottleTimer } = require('../lib/throttle-timer');
function fakeClock() {
let t = 0;
let timers = [];
return {
now: () => t,
schedule: (cb, ms) => {
const h = { at: t + ms, cb, dead: false };
timers.push(h);
return h;
},
clear: (h) => { if (h) h.dead = true; },
advance: (ms) => {
const target = t + ms;
for (;;) {
let next = null;
for (const h of timers) {
if (!h.dead && h.at <= target && (next === null || h.at < next.at)) next = h;
}
if (!next) break;
t = next.at;
next.dead = true;
next.cb();
}
t = target;
timers = timers.filter(h => !h.dead);
}
};
}
test('idle debounce: fires once after the delay window', () => {
const c = fakeClock();
const tt = makeThrottleTimer(c);
let fired = 0;
tt.request(() => fired++, 500);
c.advance(499);
assert.equal(fired, 0);
c.advance(1);
assert.equal(fired, 1);
});
test('idle debounce: rapid requests reset the timer (last wins)', () => {
const c = fakeClock();
const tt = makeThrottleTimer(c);
let fired = 0;
tt.request(() => fired++, 500);
c.advance(200);
tt.request(() => fired++, 500);
c.advance(300);
assert.equal(fired, 0, 'should not fire at original 500 — was reset');
c.advance(200);
assert.equal(fired, 1, 'fires 500ms after the second request');
});
test('STARVATION repro: continuous requests with no maxWait NEVER fire', () => {
const c = fakeClock();
const tt = makeThrottleTimer(c);
let fired = 0;
for (let i = 0; i < 30; i++) {
tt.request(() => fired++, 500);
c.advance(100);
}
assert.equal(fired, 0, 'this is exactly the bug the maxWait fix addresses');
});
test('maxWait: continuous requests still force a fire within the window', () => {
const c = fakeClock();
const tt = makeThrottleTimer(c);
let fired = 0;
const fireAtTimes = [];
for (let i = 0; i < 30; i++) {
tt.request(() => { fired++; fireAtTimes.push(c.now()); }, 500, 2000);
c.advance(100);
}
assert.ok(fired >= 1, 'maxWait guarantees at least one fire under continuous load');
assert.ok(fireAtTimes.every(t => t > 0), 'fires happened, not starved');
assert.ok(fireAtTimes.some(t => t <= 2000), 'first fire no later than maxWait');
});
test('maxWait: after a forced fire a fresh burst starts (periodic fires)', () => {
const c = fakeClock();
const tt = makeThrottleTimer(c);
let fired = 0;
for (let i = 0; i < 60; i++) {
tt.request(() => fired++, 500, 2000);
c.advance(100);
}
assert.ok(fired >= 2, `~6000ms of continuous load with 2000ms maxWait should fire multiple times, got ${fired}`);
});
test('flushSync fires the pending fn immediately and clears it', () => {
const c = fakeClock();
const tt = makeThrottleTimer(c);
let fired = 0;
tt.request(() => fired++, 5000);
assert.ok(tt.isPending());
tt.flushSync();
assert.equal(fired, 1);
assert.ok(!tt.isPending());
c.advance(10000);
assert.equal(fired, 1, 'no double fire after flushSync');
});
test('cancel drops the pending fn — no fire', () => {
const c = fakeClock();
const tt = makeThrottleTimer(c);
let fired = 0;
tt.request(() => fired++, 500);
tt.cancel();
assert.ok(!tt.isPending());
c.advance(10000);
assert.equal(fired, 0);
});
test('last-write-wins: a later request with a DIFFERENT fn replaces the earlier one', () => {
const c = fakeClock();
const tt = makeThrottleTimer(c);
const fired = [];
tt.request(() => fired.push('persist'), 500, 20000);
c.advance(100);
tt.request(() => fired.push('clear'), 0);
c.advance(100);
assert.deepEqual(fired, ['clear'], 'only the latest fn fires; the persist was dropped');
});
test('flushSync with nothing pending is a no-op', () => {
const c = fakeClock();
const tt = makeThrottleTimer(c);
assert.doesNotThrow(() => tt.flushSync());
});
+101
View File
@@ -0,0 +1,101 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const Throttle = require('../lib/throttle');
describe('Throttle', () => {
it('unlimited mode (0) returns immediately', async () => {
const t = new Throttle(0);
const start = Date.now();
await t.consume(10_000_000);
assert.ok(Date.now() - start < 50, 'should be instant');
});
it('unlimited with falsy values', async () => {
for (const val of [undefined, null, false, 0]) {
const t = new Throttle(val);
const start = Date.now();
await t.consume(1_000_000);
assert.ok(Date.now() - start < 50, `should be instant for ${val}`);
}
});
it('small consume within initial token budget resolves immediately', async () => {
const t = new Throttle(1024 * 1024); // 1 MB/s
const start = Date.now();
await t.consume(100); // 100 bytes, well within 1MB budget
assert.ok(Date.now() - start < 50);
});
it('large consume exceeding tokens introduces delay', async () => {
const t = new Throttle(1000); // 1000 bytes/sec
// Drain initial tokens
await t.consume(1000);
const start = Date.now();
await t.consume(500); // needs ~500ms of refill
const elapsed = Date.now() - start;
assert.ok(elapsed >= 400, `expected >=400ms, got ${elapsed}ms`);
assert.ok(elapsed < 2000, `expected <2000ms, got ${elapsed}ms`);
});
it('aborted signal stops consumption early', async () => {
const t = new Throttle(100); // 100 bytes/sec
await t.consume(100); // drain budget
const ac = new AbortController();
setTimeout(() => ac.abort(), 100);
const start = Date.now();
await t.consume(10000, ac.signal); // would take ~100s without abort
const elapsed = Date.now() - start;
assert.ok(elapsed < 1000, `should abort quickly, took ${elapsed}ms`);
});
it('updateRate changes behavior', async () => {
const t = new Throttle(100);
await t.consume(100); // drain
t.updateRate(0); // switch to unlimited
const start = Date.now();
await t.consume(999999);
assert.ok(Date.now() - start < 50, 'should be instant after switching to unlimited');
});
it('_refill does not exceed maxBps', () => {
const t = new Throttle(1000);
t.tokens = 0;
t.lastRefill = Date.now() - 60000; // simulate 60 seconds elapsed
t._refill();
assert.ok(t.tokens <= 1000, `tokens should not exceed maxBps, got ${t.tokens}`);
});
it('concurrent consume calls share the token pool', async () => {
const t = new Throttle(2000); // 2000 bytes/sec, initial tokens = 2000
// Two concurrent consumes of 1000 each - should both fit in initial budget
const start = Date.now();
await Promise.all([t.consume(1000), t.consume(1000)]);
assert.ok(Date.now() - start < 100, 'both should resolve from initial budget');
// Third consume should need to wait for refill
const start2 = Date.now();
await t.consume(500);
const elapsed = Date.now() - start2;
assert.ok(elapsed >= 150, `third consume should wait for refill, took ${elapsed}ms`);
});
it('consume(0) resolves immediately', async () => {
const t = new Throttle(100);
const start = Date.now();
await t.consume(0);
assert.ok(Date.now() - start < 50);
});
it('updateRate to unlimited (0) makes consume instant', async () => {
const t = new Throttle(100); // very slow
t.updateRate(0); // unlimited
const start = Date.now();
await t.consume(1_000_000);
assert.ok(Date.now() - start < 50, 'unlimited rate should be instant');
});
});
+113
View File
@@ -0,0 +1,113 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { makeThrottledCache } = require('../lib/throttled-cache');
function fakeClock(start = 0) {
let t = start;
const fn = () => t;
fn.advance = (ms) => { t += ms; };
fn.set = (ms) => { t = ms; };
return fn;
}
test('returns undefined when empty', () => {
const c = makeThrottledCache(100);
assert.equal(c.get('any', {}), undefined);
});
test('returns the set value within the window', () => {
const clock = fakeClock();
const c = makeThrottledCache(100, clock);
const input = [1, 2, 3];
c.set('sig-a', input, 'value-1');
assert.equal(c.get('sig-a', input), 'value-1');
clock.advance(50);
assert.equal(c.get('sig-a', input), 'value-1', 'still valid at 50/100 ms');
clock.advance(49);
assert.equal(c.get('sig-a', input), 'value-1', 'still valid at 99/100 ms');
});
test('expires exactly at refreshMs boundary', () => {
const clock = fakeClock();
const c = makeThrottledCache(100, clock);
c.set('s', {}, 'v');
clock.advance(100);
assert.equal(c.get('s', {}), undefined, '>= refreshMs is a miss');
});
test('miss on different signature', () => {
const c = makeThrottledCache(1000, fakeClock());
const input = {};
c.set('sig-a', input, 'v');
assert.equal(c.get('sig-b', input), undefined);
});
test('miss on different input identity even with same signature', () => {
const c = makeThrottledCache(1000, fakeClock());
c.set('sig-a', { a: 1 }, 'v');
// Different object identity — the cache compares by ===, not by contents
assert.equal(c.get('sig-a', { a: 1 }), undefined);
});
test('overwrite by re-setting same signature', () => {
const clock = fakeClock();
const c = makeThrottledCache(100, clock);
const input = [];
c.set('s', input, 'old');
clock.advance(50);
c.set('s', input, 'new');
// The new entry has a fresh timestamp → still valid for another 100 ms
clock.advance(99);
assert.equal(c.get('s', input), 'new');
});
test('clear empties the cache', () => {
const c = makeThrottledCache(1000, fakeClock());
c.set('s', {}, 'v');
c.clear();
assert.equal(c.get('s', {}), undefined);
assert.equal(c.peek(), null);
});
test('peek reports age and signature', () => {
const clock = fakeClock();
const c = makeThrottledCache(1000, clock);
c.set('mysig', {}, 'v');
clock.advance(42);
const p = c.peek();
assert.equal(p.sig, 'mysig');
assert.equal(p.age, 42);
assert.equal(p.ts, 0);
});
test('throws on invalid refreshMs', () => {
assert.throws(() => makeThrottledCache(-1));
assert.throws(() => makeThrottledCache(NaN));
assert.throws(() => makeThrottledCache('100'));
});
test('refreshMs=0 means every call misses', () => {
const clock = fakeClock();
const c = makeThrottledCache(0, clock);
const input = {};
c.set('s', input, 'v');
// Same tick: 0 - 0 = 0 → not less than refreshMs (0) → miss
assert.equal(c.get('s', input), undefined);
});
test('default clock is Date.now when none provided', () => {
const c = makeThrottledCache(10000);
const input = {}; // single ref — get and set must use the SAME identity
c.set('x', input, 'v');
assert.equal(c.get('x', input), 'v');
});
test('large input arrays are tracked by identity, not value', () => {
const c = makeThrottledCache(1000, fakeClock());
const arr1 = new Array(10000).fill(0);
const arr2 = new Array(10000).fill(0);
c.set('s', arr1, 'cached');
assert.equal(c.get('s', arr1), 'cached');
assert.equal(c.get('s', arr2), undefined, 'different array → miss');
});
+322
View File
@@ -0,0 +1,322 @@
/**
* UI smoke test - launches the real app and checks DOM elements via webContents.
* Run with: node tests/ui-smoke.js
* (This spawns Electron as a child process)
*/
if (!process.env.RUN_UI_SMOKE) {
const { test } = require('node:test');
test('ui smoke skipped unless RUN_UI_SMOKE=1', () => {});
return;
}
const { execFileSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const os = require('os');
// Create a temp script that the real Electron app will execute via --eval
const testScript = `
const { app, BrowserWindow } = require('electron');
const path = require('path');
const fs = require('fs');
async function runAfterDelay(win, delayMs) {
await new Promise(r => setTimeout(r, delayMs));
return win;
}
// Wait for app to be ready, then wait for the real window to load
setTimeout(async () => {
const windows = BrowserWindow.getAllWindows();
if (windows.length === 0) { console.log('ERROR: No windows found'); process.exit(1); }
const win = windows[0];
const wc = win.webContents;
// Wait for renderer init
await new Promise(r => setTimeout(r, 2000));
let passed = 0;
let failed = 0;
const results = [];
function check(name, condition) {
if (condition) { passed++; results.push(' PASS: ' + name); }
else { failed++; results.push(' FAIL: ' + name); }
}
try {
console.log('\\n=== Upload View ===');
const isolationRoot = process.env.UI_SMOKE_ISOLATION_ROOT || '';
const isolatedRootReady = path.isAbsolute(isolationRoot) && fs.existsSync(isolationRoot);
const isolatedAppData = isolatedRootReady && path.isAbsolute(process.env.APPDATA || '') && fs.existsSync(process.env.APPDATA) && path.resolve(process.env.APPDATA).toLowerCase() === path.resolve(isolationRoot, 'appdata').toLowerCase();
const isolatedLocalAppData = isolatedRootReady && path.isAbsolute(process.env.LOCALAPPDATA || '') && fs.existsSync(process.env.LOCALAPPDATA) && path.resolve(process.env.LOCALAPPDATA).toLowerCase() === path.resolve(isolationRoot, 'localappdata').toLowerCase();
const isolatedUserData = isolatedRootReady && path.isAbsolute(app.getPath('userData')) && fs.existsSync(app.getPath('userData')) && path.resolve(app.getPath('userData')).toLowerCase() === path.resolve(isolationRoot, 'user-data').toLowerCase();
console.log('Isolation: APPDATA=' + process.env.APPDATA + ' | LOCALAPPDATA=' + process.env.LOCALAPPDATA + ' | userData=' + app.getPath('userData'));
check('APPDATA, LOCALAPPDATA and Electron userData use isolated directories', isolatedAppData && isolatedLocalAppData && isolatedUserData);
check('Forced failure propagation', process.env.UI_SMOKE_FORCE_FAILURE !== '1');
const tabCount = await wc.executeJavaScript('document.querySelectorAll(".tab-bar > .tab").length');
check('4 main tabs exist', tabCount === 4);
const tabLabels = await wc.executeJavaScript('Array.from(document.querySelectorAll(".tab-bar > .tab"), el => el.textContent.trim()).join("|")');
check('Main tabs expose current views', tabLabels === 'Upload|Accounts|Einstellungen|Verlauf');
const activeTab = await wc.executeJavaScript('document.querySelector(".tab.active")?.textContent?.trim()');
check('Upload tab active by default', activeTab === 'Upload');
const dropVisible = await wc.executeJavaScript('document.getElementById("dropZone")?.style.display !== "none"');
check('Drop zone visible (no files)', dropVisible);
const queueHidden = await wc.executeJavaScript('document.getElementById("queueShell")?.style.display');
check('Queue hidden (no files)', queueHidden === 'none');
const queueControlCount = await wc.executeJavaScript('document.querySelectorAll("#queueCommandBar .toolbar-btn").length');
check('10 queue controls exist', queueControlCount === 10);
const hosterSummary = await wc.executeJavaScript('document.getElementById("hosterSummary")?.textContent');
check('Hoster summary reflects empty account state', hosterSummary === 'Keine Upload-Ziele ausgewählt');
const hosterOptionCount = await wc.executeJavaScript('document.querySelectorAll("#hosterModalList .hoster-option").length');
check('No selectable hosters without accounts', hosterOptionCount === 0);
const hosterHint = await wc.executeJavaScript('document.getElementById("hosterModalHint")?.textContent');
check('Hoster selection explains missing credentials', hosterHint && hosterHint.includes('Keine Hoster mit Zugangsdaten'));
const startDisabled = await wc.executeJavaScript('document.getElementById("startUploadBtn")?.disabled');
check('Start button disabled initially', startDisabled === true);
const sbState = await wc.executeJavaScript('document.getElementById("sbState")?.textContent');
check('Statusbar: Bereit', sbState === 'Bereit');
const version = await wc.executeJavaScript('document.getElementById("versionLabel")?.textContent');
check('Product version label present', version === 'v3.3.108');
const ctxHidden = await wc.executeJavaScript('document.getElementById("contextMenu")?.style.display');
check('Context menu hidden', ctxHidden === 'none');
console.log('\\n=== Accounts View ===');
await wc.executeJavaScript('document.querySelector(".tab[data-view=\\'accounts\\']").click()');
await new Promise(r => setTimeout(r, 300));
const accountsActive = await wc.executeJavaScript('document.getElementById("accounts-view")?.classList.contains("active")');
check('Accounts tab active', accountsActive);
const accountsEmpty = await wc.executeJavaScript('document.querySelector("#accountsList .accounts-empty p")?.textContent');
check('Accounts show privacy-safe empty state', accountsEmpty === 'Keine Accounts vorhanden');
await wc.executeJavaScript('document.getElementById("addAccountBtn").click()');
await new Promise(r => setTimeout(r, 200));
const accountModalVisible = await wc.executeJavaScript('document.getElementById("accountModal")?.style.display');
check('Add-account modal opens', accountModalVisible === 'flex');
const accountHosterOptions = await wc.executeJavaScript('document.querySelectorAll("#accountHosterSelect option").length');
check('7 current hoster/auth options exist', accountHosterOptions === 7);
const accountFieldsEmpty = await wc.executeJavaScript('["accField_label","accField_username","accField_password","accField_apiKey"].filter(id => document.getElementById(id)).every(id => document.getElementById(id).value === "")');
check('Account fields start empty', accountFieldsEmpty);
await wc.executeJavaScript('document.getElementById("closeAccountModalBtn").click()');
await new Promise(r => setTimeout(r, 100));
console.log('\\n=== Settings View ===');
await wc.executeJavaScript('document.querySelector(".tab[data-view=\\'settings\\']").click()');
await new Promise(r => setTimeout(r, 300));
const settingsActive = await wc.executeJavaScript('document.getElementById("settings-view")?.classList.contains("active")');
check('Settings tab active', settingsActive);
const settingsSubtabs = await wc.executeJavaScript('document.querySelectorAll(".settings-subtab").length');
check('6 settings subtabs exist', settingsSubtabs === 6);
const parallel = await wc.executeJavaScript('document.getElementById("parallelUploadCountInput")?.value');
check('Global parallel upload default is unlimited', parallel === '0');
const settingsPointer = await wc.executeJavaScript('document.querySelector(".settings-hoster-pointer")?.textContent');
check('Settings points hoster controls to Accounts', settingsPointer && settingsPointer.includes('Accounts'));
console.log('\\n=== History View ===');
await wc.executeJavaScript('document.querySelector(".tab[data-view=\\'history\\']").click()');
await new Promise(r => setTimeout(r, 1000)); // Wait for async loadHistory
const historyActive = await wc.executeJavaScript('document.getElementById("history-view")?.classList.contains("active")');
check('History tab active', historyActive);
const emptyState = await wc.executeJavaScript('document.querySelector("#historyContainer .empty-state")?.textContent');
check('Empty state or history table shown', emptyState === 'Noch keine Uploads.' || emptyState === undefined);
console.log('\\n=== Global UI ===');
const shutdownHidden = await wc.executeJavaScript('document.getElementById("shutdownOverlay")?.style.display');
check('Shutdown overlay hidden', shutdownHidden === 'none');
const toastHidden = await wc.executeJavaScript('!document.getElementById("copyToast")?.classList.contains("show")');
check('Copy toast hidden', toastHidden);
const updateHidden = await wc.executeJavaScript('document.getElementById("updateBanner")?.style.display');
check('Update banner hidden', updateHidden === 'none');
} catch (err) {
console.error('Test error:', err.message);
failed++;
}
console.log('\\n=== Results ===');
results.forEach(r => console.log(r));
console.log('\\nTotal: ' + (passed + failed) + ' | Passed: ' + passed + ' | Failed: ' + failed);
app.exit(failed > 0 ? 1 : 0);
}, 5000);
`;
let injectRoot;
let injectPath;
let isolationRoot;
let runProvenSuccessful = false;
let childStarted = false;
let childStartTimeMs = 0;
let logSnapshots;
const appPath = path.resolve(__dirname, '..');
const protectedLogPaths = [path.join(appPath, 'crash.log'), path.join(appPath, 'upload-debug.log')];
function removeTempTree(target, prefix) {
if (!target) return;
const resolvedTarget = path.resolve(target);
const resolvedTemp = path.resolve(os.tmpdir());
const validParent = path.dirname(resolvedTarget).toLowerCase() === resolvedTemp.toLowerCase();
const validName = path.basename(resolvedTarget).startsWith(prefix);
if (!validParent || !validName) {
throw new Error('Refusing to remove unexpected UI smoke path: ' + resolvedTarget);
}
fs.rmSync(resolvedTarget, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
function captureLogSnapshot(filePath) {
try {
const stats = fs.lstatSync(filePath);
if (!stats.isFile()) throw new Error('UI smoke protected log is not a regular file: ' + filePath);
return {
filePath,
existed: true,
bytes: fs.readFileSync(filePath),
mode: stats.mode,
atimeMs: stats.atimeMs,
mtimeMs: stats.mtimeMs,
};
} catch (err) {
if (err.code === 'ENOENT') return { filePath, existed: false };
throw err;
}
}
function restoreLogSnapshot(snapshot) {
let currentStats;
try {
currentStats = fs.lstatSync(snapshot.filePath);
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
if (snapshot.existed) {
if (currentStats && !currentStats.isFile()) throw new Error('UI smoke cannot restore non-file log path: ' + snapshot.filePath);
fs.writeFileSync(snapshot.filePath, snapshot.bytes, currentStats ? undefined : { flag: 'wx', mode: snapshot.mode });
fs.chmodSync(snapshot.filePath, snapshot.mode);
fs.utimesSync(snapshot.filePath, snapshot.atimeMs / 1000, snapshot.mtimeMs / 1000);
const restoredBytes = fs.readFileSync(snapshot.filePath);
const restoredStats = fs.statSync(snapshot.filePath);
if (!restoredBytes.equals(snapshot.bytes)) throw new Error('UI smoke log byte restoration failed: ' + snapshot.filePath);
if ((restoredStats.mode & 0o777) !== (snapshot.mode & 0o777)) throw new Error('UI smoke log mode restoration failed: ' + snapshot.filePath);
if (Math.abs(restoredStats.mtimeMs - snapshot.mtimeMs) > 1) throw new Error('UI smoke log mtime restoration failed: ' + snapshot.filePath);
return 'restored';
}
if (!currentStats) return 'unchanged';
const writtenDuringChild = childStarted && childStartTimeMs > 0 && currentStats.mtimeMs >= childStartTimeMs - 1000;
if (!writtenDuringChild || !currentStats.isFile()) throw new Error('UI smoke refuses to remove unproven generated log: ' + snapshot.filePath);
fs.unlinkSync(snapshot.filePath);
return 'removed';
}
try {
logSnapshots = protectedLogPaths.map(captureLogSnapshot);
isolationRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-ui-smoke-state-'));
const appDataDir = path.join(isolationRoot, 'appdata');
const localAppDataDir = path.join(isolationRoot, 'localappdata');
const userDataDir = path.join(isolationRoot, 'user-data');
for (const directory of [appDataDir, localAppDataDir, userDataDir]) {
fs.mkdirSync(directory);
if (!path.isAbsolute(directory) || fs.readdirSync(directory).length !== 0) {
throw new Error('UI smoke isolation directory is not new, empty and absolute: ' + directory);
}
}
injectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-ui-smoke-inject-'));
injectPath = path.join(injectRoot, 'ui-inject.js');
fs.writeFileSync(injectPath, testScript, 'utf-8');
if (process.env.UI_SMOKE_FORCE_SETUP_FAILURE === '1') {
throw new Error('Forced UI smoke setup failure');
}
const electronPath = process.env.UI_SMOKE_FORCE_SPAWN_FAILURE === '1'
? path.join(isolationRoot, 'missing-electron.exe')
: require('electron');
const childEnv = {
...process.env,
APPDATA: appDataDir,
LOCALAPPDATA: localAppDataDir,
ELECTRON_USER_DATA_DIR: userDataDir,
UI_SMOKE_ISOLATION_ROOT: isolationRoot,
};
childStartTimeMs = Date.now();
let result;
try {
result = execFileSync(
electronPath,
[`--user-data-dir=${userDataDir}`, '--require', injectPath, appPath],
{ cwd: isolationRoot, env: childEnv, timeout: process.env.UI_SMOKE_FORCE_TIMEOUT === '1' ? 1000 : 20000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
);
childStarted = true;
} catch (err) {
childStarted = (Number.isInteger(err.pid) && err.pid > 0) || Number.isInteger(err.status) || Boolean(err.signal);
throw err;
}
console.log(result);
runProvenSuccessful = true;
} catch (err) {
if (err.stdout) console.log(err.stdout);
if (err.stderr) {
const filtered = err.stderr.split('\n')
.filter(l => !l.includes('cache_util') && !l.includes('disk_cache') && !l.includes('gpu_disk_cache'))
.join('\n');
if (filtered.trim()) console.error(filtered);
}
if (!err.stdout && !err.stderr) console.error(err.message);
process.exitCode = Number.isInteger(err.status) && err.status > 0 && err.status <= 255 ? err.status : 1;
} finally {
if (logSnapshots) {
const cleanupResults = [];
for (const snapshot of logSnapshots) {
try {
cleanupResults.push(path.basename(snapshot.filePath) + '=' + restoreLogSnapshot(snapshot));
} catch (err) {
console.error(err.message);
process.exitCode = 1;
}
}
if (cleanupResults.length) console.log('UI smoke log cleanup: ' + cleanupResults.join(', '));
}
for (const [target, prefix] of [[injectRoot, 'mhu-ui-smoke-inject-'], [isolationRoot, 'mhu-ui-smoke-state-']]) {
try {
removeTempTree(target, prefix);
} catch (err) {
console.error(err.message);
process.exitCode = 1;
}
}
}
if (!runProvenSuccessful && (!process.exitCode || process.exitCode === 0)) process.exitCode = 1;
+80
View File
@@ -0,0 +1,80 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { formatUploadLogLine, parseUploadLogLine } = require('../lib/upload-log');
const { partitionRestoredJobsByLog } = require('../lib/queue-dedup');
function previewJob(fileName, hoster) {
return { status: 'preview', fileName, hoster, file: `C:/dl/${fileName}` };
}
test('writer -> reader round trip: parsed ts is the same epoch frame as the source Date getTime', () => {
const d = new Date(2026, 5, 19, 12, 0, 30);
const line = formatUploadLogLine(d, 'voe.sx', 'https://voe.sx/x', 'a.mkv');
const parsed = parseUploadLogLine(line);
assert.equal(parsed.hoster, 'voe.sx');
assert.equal(parsed.fileName, 'a.mkv');
assert.equal(parsed.ts, d.getTime(), 'parser ts must equal the writer Date epoch (no tz shift)');
});
test('SEAM: a real appendUploadLog-format line drops a preview ghost vs a savedAt taken BEFORE completion', () => {
const completion = new Date(2026, 5, 19, 12, 0, 30);
const line = formatUploadLogLine(completion, 'voe.sx', 'link', 'a.mkv');
const parsed = parseUploadLogLine(line);
const savedAt = completion.getTime() - 5000;
const { removed, kept } = partitionRestoredJobsByLog([previewJob('a.mkv', 'voe.sx')], [parsed], savedAt);
assert.equal(removed.length, 1, 'a file logged after the snapshot is a ghost and must drop');
assert.equal(kept.length, 0);
});
test('SEAM: the same real line is KEPT vs a savedAt taken AFTER completion (intentional re-upload)', () => {
const completion = new Date(2026, 5, 19, 12, 0, 30);
const parsed = parseUploadLogLine(formatUploadLogLine(completion, 'voe.sx', 'link', 'a.mkv'));
const savedAt = completion.getTime() + 5000;
const { removed, kept } = partitionRestoredJobsByLog([previewJob('a.mkv', 'voe.sx')], [parsed], savedAt);
assert.equal(removed.length, 0, 'an older upload than the snapshot is a deliberate re-queue and must survive');
assert.equal(kept.length, 1);
});
test('parseUploadLogLine skips comments, blanks and malformed lines', () => {
assert.equal(parseUploadLogLine('# fileuploader log'), null);
assert.equal(parseUploadLogLine(''), null);
assert.equal(parseUploadLogLine(' '), null);
assert.equal(parseUploadLogLine('only|three|parts|here'), null);
assert.equal(parseUploadLogLine(null), null);
assert.equal(parseUploadLogLine(42), null);
});
test('parseUploadLogLine: missing/garbage timestamp yields ts=undefined (legacy lines still match by name)', () => {
const parsed = parseUploadLogLine('|voe.sx|link||a.mkv|');
assert.equal(parsed.hoster, 'voe.sx');
assert.equal(parsed.fileName, 'a.mkv');
assert.equal(parsed.ts, undefined);
});
test('parseUploadLogLine: a pipe in the link does NOT shift the filename field (entry not lost)', () => {
const line = formatUploadLogLine(new Date(2026, 5, 19, 12, 0, 0), 'byse.sx', 'https://h.io/a|b', 'movie.mkv');
const parsed = parseUploadLogLine(line);
assert.equal(parsed.hoster, 'byse.sx');
assert.equal(parsed.fileName, 'movie.mkv', 'filename is taken as the last non-empty field, robust to link pipes');
});
test('parseUploadLogLine: two pipes in the link still parse the correct filename', () => {
const line = formatUploadLogLine(new Date(2026, 5, 19, 12, 0, 0), 'byse.sx', 'https://h.io/a|b|c', 'movie.mkv');
const parsed = parseUploadLogLine(line);
assert.equal(parsed.fileName, 'movie.mkv');
});
test('parseUploadLogLine: a leading-space filename is preserved (matches the untrimmed queue-job key)', () => {
const line = formatUploadLogLine(new Date(2026, 5, 19, 12, 0, 0), 'voe.sx', 'https://h.io/a', ' movie.mkv');
const parsed = parseUploadLogLine(line);
assert.equal(parsed.fileName, ' movie.mkv', 'filename is NOT trimmed, so it matches the OS basename verbatim');
});
test('SEAM: a leading-space filename round-trips and the gate still drops its ghost', () => {
const completion = new Date(2026, 5, 19, 12, 0, 30);
const parsed = parseUploadLogLine(formatUploadLogLine(completion, 'voe.sx', 'l', ' spaced.mp4'));
const savedAt = completion.getTime() - 5000;
const job = { status: 'preview', fileName: ' spaced.mp4', hoster: 'voe.sx', file: 'C:/dl/ spaced.mp4' };
const { removed } = partitionRestoredJobsByLog([job], [parsed], savedAt);
assert.equal(removed.length, 1, 'leading-space filename now matches end-to-end (was a mismatch before)');
});
File diff suppressed because it is too large Load Diff
+195
View File
@@ -0,0 +1,195 @@
// Pure unit tests for the validate-credentials shape contract — does NOT spin
// up Electron or the real per-hoster checkers. Those need network. We verify
// the SHAPE the ephemeral hosterConfig is built into (which the per-hoster
// checkers consume) plus the snapshot-key/invalidation invariants that the
// renderer relies on to enforce "validated creds only".
//
// The three assertions the advisor called out as the regression guard for the
// user's "mehrfach angelegt" complaint:
// (a) failed validation persists nothing to config.hosters
// (b) a second "Anlegen" click with the guard set persists exactly one entry
// (c) OTP-required path persists nothing
// are exercised at the state-machine level by simulating the renderer's logic
// (re-implemented here as pure functions for testability — the real ones live
// in renderer/app.js which can't run under node:test).
const { test } = require('node:test');
const assert = require('node:assert');
// ---- Re-implementations of the renderer's pure helpers ----
// These mirror the production code exactly so the tests serve as both a guard
// and executable spec for what saveAccount() must do.
function credsSnapshotKey(authType, creds) {
if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`;
return `api:${creds.apiKey || ''}`;
}
function buildEphemeralHosterConfig(payload) {
return {
username: payload.username || '',
password: payload.password || '',
apiKey: payload.apiKey || '',
enabled: true
};
}
// State-machine simulator that mirrors saveAccount() WITHOUT DOM/IPC.
function makeStateMachine({ validateImpl, persistImpl }) {
let busy = false;
let validated = null; // { hosterName, authType, snapshot, status }
const log = []; // log of every persist call, for assertions
async function click(ctx, creds, otp = '') {
if (busy) { log.push({ type: 'click-ignored-busy' }); return; }
const snapshot = credsSnapshotKey(ctx.authType, creds);
// STEP 2: commit if validated matches.
if (validated &&
validated.hosterName === ctx.hosterName &&
validated.authType === ctx.authType &&
validated.snapshot === snapshot) {
busy = true;
try {
await persistImpl(ctx, creds);
log.push({ type: 'persisted', accountId: ctx.accountId || `${ctx.hosterName}-NEW` });
} finally { busy = false; }
return;
}
// STEP 1: ephemeral validate.
busy = true;
let row;
try {
row = await validateImpl({ hoster: ctx.hosterName, authType: ctx.authType, ...creds, otp });
} finally { busy = false; }
if (row && (row.status === 'ok' || row.status === 'warn')) {
validated = { hosterName: ctx.hosterName, authType: ctx.authType, snapshot, status: row.status };
log.push({ type: 'validated', status: row.status });
return;
}
if (row && row.status === 'otp_required') {
log.push({ type: 'otp-required' });
return;
}
log.push({ type: 'validation-failed', message: row && row.message });
}
function editField() { validated = null; log.push({ type: 'invalidated-by-edit' }); }
return { click, editField, log: () => log.slice(), getValidated: () => validated };
}
// ---- Tests ----
test('regression (a): failed validation persists NOTHING to config.hosters', async () => {
const persistCalls = [];
const sm = makeStateMachine({
validateImpl: async () => ({ status: 'error', message: 'Falsches Passwort' }),
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
});
await sm.click({ hosterName: 'doodstream.com', authType: 'login', isEdit: false }, { username: 'u', password: 'wrong' });
assert.equal(persistCalls.length, 0, 'no persist should happen on failed validation');
assert.equal(sm.getValidated(), null);
assert.deepEqual(sm.log().map(e => e.type), ['validation-failed']);
});
test('regression (b): second click with guard set persists exactly ONE entry — no duplication', async () => {
const persistCalls = [];
let validateCount = 0;
const sm = makeStateMachine({
validateImpl: async () => { validateCount++; return { status: 'ok' }; },
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
});
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
const creds = { username: 'u', password: 'p' };
// Click 1 = validate → green.
await sm.click(ctx, creds);
// Click 2 = commit (same creds, validated snapshot matches).
await sm.click(ctx, creds);
// Click 3 = guard prevents a second commit because after persistImpl the
// state-machine in real code closes the modal. In this simulator the
// validated snapshot is still set — but a real double-click WHILE persistImpl
// is in flight would be caught by busy. Simulate that:
const sm2 = makeStateMachine({
validateImpl: async () => ({ status: 'ok' }),
persistImpl: () => new Promise(r => setTimeout(() => { persistCalls.push('slow'); r(); }, 30))
});
await sm2.click(ctx, creds); // validate
const p1 = sm2.click(ctx, creds); // start commit
const p2 = sm2.click(ctx, creds); // racing click — must be ignored
await Promise.all([p1, p2]);
assert.equal(persistCalls.length, 2, 'one persist from the deliberate two-step flow + one from sm2; racing click ignored');
assert.equal(validateCount, 1, 'second click reused the validated snapshot — no re-validate');
// The racing click MUST have been ignored by the busy guard.
assert.ok(sm2.log().some(e => e.type === 'click-ignored-busy'), 'busy guard fired on racing click');
});
test('regression (c): OTP-required persists NOTHING — and a follow-up click with OTP re-validates ephemerally', async () => {
const persistCalls = [];
let calls = 0;
const sm = makeStateMachine({
validateImpl: async (payload) => {
calls++;
if (!payload.otp) return { status: 'otp_required', message: 'OTP sent' };
if (payload.otp === '123456') return { status: 'ok' };
return { status: 'error', message: 'Bad OTP' };
},
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
});
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
const creds = { username: 'u', password: 'p' };
await sm.click(ctx, creds, ''); // first click → otp_required
await sm.click(ctx, creds, '123456'); // retry with otp → ok
await sm.click(ctx, creds); // final click → commit
assert.equal(persistCalls.length, 1, 'exactly one persist after OTP confirmed');
assert.equal(calls, 2, 'validate ran twice (initial + OTP) before commit');
assert.deepEqual(
sm.log().map(e => e.type),
['otp-required', 'validated', 'persisted']
);
});
test('field edit after green check invalidates the snapshot — next click is a re-Prüfen, not a commit', async () => {
const persistCalls = [];
let validateCount = 0;
const sm = makeStateMachine({
validateImpl: async () => { validateCount++; return { status: 'ok' }; },
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
});
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
await sm.click(ctx, { username: 'u', password: 'p' }); // validate → green
sm.editField(); // user edits cred field → snapshot dropped
await sm.click(ctx, { username: 'u', password: 'newpw' }); // creds differ → re-validate
await sm.click(ctx, { username: 'u', password: 'newpw' }); // now commit the NEW creds
assert.equal(persistCalls.length, 1, 'one persist of the new (re-validated) creds');
assert.equal(persistCalls[0].creds.password, 'newpw', 'persisted creds match the re-validated set');
assert.equal(validateCount, 2, 'second validate was forced by the edit-induced invalidation');
});
test('snapshot key is identical for same creds and DIFFERENT for any cred change (excluding label)', () => {
// Label changes must NOT invalidate validation — label is metadata, not a credential.
assert.equal(credsSnapshotKey('login', { username: 'u', password: 'p' }),
credsSnapshotKey('login', { username: 'u', password: 'p', label: 'XYZ' }));
assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }),
credsSnapshotKey('login', { username: 'u', password: 'P' })); // password char-case
assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }),
credsSnapshotKey('login', { username: 'U', password: 'p' })); // username diff
assert.equal(credsSnapshotKey('api', { apiKey: 'KEY' }),
credsSnapshotKey('api', { apiKey: 'KEY', label: 'mein key' }));
assert.notEqual(credsSnapshotKey('api', { apiKey: 'KEY' }),
credsSnapshotKey('api', { apiKey: 'KEY2' }));
});
test('ephemeral hosterConfig shape matches what per-hoster checkers expect', () => {
// The per-hoster checkers in main.js read .username/.password/.apiKey directly.
// This guards the validate-credentials IPC contract from drifting.
const cfg = buildEphemeralHosterConfig({ hoster: 'doodstream.com', username: 'u', password: 'p' });
assert.equal(cfg.username, 'u');
assert.equal(cfg.password, 'p');
assert.equal(cfg.apiKey, '');
assert.equal(cfg.enabled, true);
const cfg2 = buildEphemeralHosterConfig({ hoster: 'byse.sx', apiKey: 'K' });
assert.equal(cfg2.apiKey, 'K');
assert.equal(cfg2.username, '');
});
+156
View File
@@ -0,0 +1,156 @@
const test = require('node:test');
const assert = require('node:assert');
const { isDiscordWebhook, formatDurationShort, summarizePerHosterFromBatch, buildWebhookRequest, resolveDiscordMention, isAllAborted, clampDiscordContent, DISCORD_CONTENT_LIMIT } = require('../lib/webhook-notify');
const SAMPLE_SUMMARY = {
total: 10,
succeeded: 8,
failed: 2,
files: [
{ name: 'a.mkv', results: [
{ hoster: 'voe.sx', status: 'done' },
{ hoster: 'byse.sx', status: 'error', error: 'x' }
] },
{ name: 'b.mkv', results: [
{ hoster: 'voe.sx', status: 'done' },
{ hoster: 'byse.sx', status: 'done' }
] }
]
};
test('isDiscordWebhook recognizes discord URLs incl. ptb/canary/discordapp', () => {
assert.ok(isDiscordWebhook('https://discord.com/api/webhooks/123/abc'));
assert.ok(isDiscordWebhook('https://discordapp.com/api/webhooks/123/abc'));
assert.ok(isDiscordWebhook('https://ptb.discord.com/api/webhooks/123/abc'));
assert.ok(isDiscordWebhook('https://canary.discord.com/api/webhooks/123/abc'));
assert.strictEqual(isDiscordWebhook('https://example.com/hook'), false);
assert.strictEqual(isDiscordWebhook(''), false);
assert.strictEqual(isDiscordWebhook(null), false);
});
test('isDiscordWebhook REJECTS incomplete discord URLs (no id/token)', () => {
assert.strictEqual(isDiscordWebhook('https://discord.com/api/webhooks/'), false);
assert.strictEqual(isDiscordWebhook('https://discord.com/api/webhooks'), false);
assert.strictEqual(isDiscordWebhook('https://discord.com/api/webhooks/123'), false);
assert.strictEqual(isDiscordWebhook('https://discord.com/api/webhooks/123/'), false);
assert.ok(isDiscordWebhook('https://discord.com/api/webhooks/123456789/aBc-_token123'));
});
test('clampDiscordContent caps to the Discord limit with ellipsis', () => {
const short = 'hello';
assert.strictEqual(clampDiscordContent(short), short);
const long = 'x'.repeat(5000);
const clamped = clampDiscordContent(long);
assert.ok(clamped.length <= DISCORD_CONTENT_LIMIT);
assert.ok(clamped.endsWith('…'));
});
test('buildWebhookRequest: many hosters does not exceed Discord limit', () => {
const files = [{ name: 'a.mkv', results: [] }];
for (let i = 0; i < 60; i++) files[0].results.push({ hoster: `hoster-with-a-really-long-name-${i}.example.com`, status: 'done' });
const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', { total: 60, succeeded: 60, failed: 0, files }, { durationSec: 60 });
const body = JSON.parse(req.body);
assert.ok(body.content.length <= DISCORD_CONTENT_LIMIT, `content ${body.content.length} must be <= ${DISCORD_CONTENT_LIMIT}`);
assert.match(body.content, /\+\d+/);
});
test('buildWebhookRequest: aborted meta changes the headline', () => {
const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', { total: 5, succeeded: 0, failed: 5, files: [] }, { aborted: true });
const body = JSON.parse(req.body);
assert.match(body.content, /Batch abgebrochen/);
});
test('isAllAborted: true only when every result is aborted', () => {
assert.strictEqual(isAllAborted({ files: [{ results: [{ status: 'aborted' }, { status: 'aborted' }] }] }), true);
assert.strictEqual(isAllAborted({ files: [{ results: [{ status: 'aborted' }, { status: 'done' }] }] }), false);
assert.strictEqual(isAllAborted({ files: [{ results: [{ status: 'error' }] }] }), false);
assert.strictEqual(isAllAborted({ files: [] }), false);
assert.strictEqual(isAllAborted(null), false);
});
test('formatDurationShort formats h/m/s tiers', () => {
assert.strictEqual(formatDurationShort(45), '45s');
assert.strictEqual(formatDurationShort(125), '2m 5s');
assert.strictEqual(formatDurationShort(3 * 3600 + 12 * 60), '3h 12m');
assert.strictEqual(formatDurationShort(-5), '0s');
assert.strictEqual(formatDurationShort(undefined), '0s');
});
test('summarizePerHosterFromBatch counts ok/fail per hoster', () => {
const s = summarizePerHosterFromBatch(SAMPLE_SUMMARY);
assert.deepStrictEqual(s['voe.sx'], { ok: 2, fail: 0 });
assert.deepStrictEqual(s['byse.sx'], { ok: 1, fail: 1 });
});
test('summarizePerHosterFromBatch handles malformed input', () => {
assert.deepStrictEqual(summarizePerHosterFromBatch(null), {});
assert.deepStrictEqual(summarizePerHosterFromBatch({}), {});
assert.deepStrictEqual(summarizePerHosterFromBatch({ files: [{ results: null }] }), {});
});
test('buildWebhookRequest produces Discord content body for discord URLs', () => {
const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', SAMPLE_SUMMARY, { durationSec: 3700, appVersion: '3.3.59', machineName: 'srv-1' });
assert.strictEqual(req.method, 'POST');
assert.strictEqual(req.headers['Content-Type'], 'application/json');
const body = JSON.parse(req.body);
assert.ok(typeof body.content === 'string');
assert.match(body.content, /Batch fertig/);
assert.match(body.content, /srv-1/);
assert.match(body.content, /8 ok/);
assert.match(body.content, /2 Fehler/);
assert.match(body.content, /1h 1m/);
assert.match(body.content, /voe\.sx: 2\/2/);
});
test('buildWebhookRequest produces raw JSON payload for generic URLs', () => {
const req = buildWebhookRequest('https://example.com/hook', SAMPLE_SUMMARY, { durationSec: 60, appVersion: '3.3.59', timestamp: '2026-06-09T00:00:00Z' });
const body = JSON.parse(req.body);
assert.strictEqual(body.event, 'batch-done');
assert.strictEqual(body.total, 10);
assert.strictEqual(body.succeeded, 8);
assert.strictEqual(body.failed, 2);
assert.strictEqual(body.durationSec, 60);
assert.strictEqual(body.version, '3.3.59');
assert.deepStrictEqual(body.perHoster['byse.sx'], { ok: 1, fail: 1 });
});
test('resolveDiscordMention: @here / @everyone use parse=everyone', () => {
assert.deepStrictEqual(resolveDiscordMention('@here'), { token: '@here', allowed: { parse: ['everyone'] } });
assert.deepStrictEqual(resolveDiscordMention('everyone'), { token: '@everyone', allowed: { parse: ['everyone'] } });
});
test('resolveDiscordMention: bare numeric id → user mention', () => {
assert.deepStrictEqual(resolveDiscordMention('123456789012345'), { token: '<@123456789012345>', allowed: { users: ['123456789012345'] } });
assert.deepStrictEqual(resolveDiscordMention('<@!123456789012345>'), { token: '<@123456789012345>', allowed: { users: ['123456789012345'] } });
});
test('resolveDiscordMention: role:id and <@&id> → role mention', () => {
assert.deepStrictEqual(resolveDiscordMention('role:99887766'), { token: '<@&99887766>', allowed: { roles: ['99887766'] } });
assert.deepStrictEqual(resolveDiscordMention('<@&99887766>'), { token: '<@&99887766>', allowed: { roles: ['99887766'] } });
});
test('resolveDiscordMention: empty / junk → null', () => {
assert.strictEqual(resolveDiscordMention(''), null);
assert.strictEqual(resolveDiscordMention(' '), null);
assert.strictEqual(resolveDiscordMention('not-an-id'), null);
});
test('buildWebhookRequest: discord with mention prepends token + sets allowed_mentions', () => {
const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', SAMPLE_SUMMARY, { durationSec: 60, mention: '123456789012345' });
const body = JSON.parse(req.body);
assert.ok(body.content.startsWith('<@123456789012345> '));
assert.deepStrictEqual(body.allowed_mentions, { users: ['123456789012345'] });
});
test('buildWebhookRequest: discord without mention blocks all pings (allowed_mentions parse empty)', () => {
const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', SAMPLE_SUMMARY, { durationSec: 60 });
const body = JSON.parse(req.body);
assert.deepStrictEqual(body.allowed_mentions, { parse: [] });
});
test('buildWebhookRequest tolerates empty summary', () => {
const req = buildWebhookRequest('https://example.com/hook', null, {});
const body = JSON.parse(req.body);
assert.strictEqual(body.total, 0);
assert.strictEqual(body.succeeded, 0);
});