Replace session-long account failure pauses with classified 15, 30, 60, and 120 minute cooldowns for temporary account problems. Keep credential, OTP, banned, and disabled states manual while ignoring unknown, file, network, hoster, and bare WAF errors. Reset escalation after confirmed uploads, deduplicate parallel failures, publish revisioned pause snapshots, and show a stable localized countdown with automatic reactivation.
263 lines
13 KiB
JavaScript
263 lines
13 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert');
|
|
const {
|
|
classifyAccountFailure,
|
|
createAccountCooldownController,
|
|
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']);
|
|
});
|
|
|
|
test('temporary account failures escalate through 15, 30, 60, and 120 minute cooldowns', () => {
|
|
let now = 1_000_000;
|
|
const scheduled = [];
|
|
const controller = createAccountCooldownController({
|
|
now: () => now,
|
|
setTimer: (callback, delay) => { scheduled.push({ callback, delay }); return scheduled.length; },
|
|
clearTimer: () => {}
|
|
});
|
|
|
|
const expectedMinutes = [15, 30, 60, 120, 120];
|
|
for (let index = 0; index < expectedMinutes.length; index++) {
|
|
const record = controller.markFailure({ hoster: 'byse.sx', accountId: 'acc-1', mode: 'cooldown' });
|
|
assert.equal(record.failures, index + 1);
|
|
assert.equal(record.pausedUntil, now + expectedMinutes[index] * 60_000);
|
|
assert.equal(scheduled.at(-1).delay, expectedMinutes[index] * 60_000);
|
|
now = record.pausedUntil;
|
|
assert.deepEqual(controller.releaseExpired(), [`byse.sx:acc-1`]);
|
|
}
|
|
});
|
|
|
|
test('parallel duplicate failures count as one strike until the active cooldown expires', () => {
|
|
let now = 50_000;
|
|
const controller = createAccountCooldownController({
|
|
now: () => now,
|
|
setTimer: () => 1,
|
|
clearTimer: () => {}
|
|
});
|
|
const first = controller.markFailure({ hoster: 'byse.sx', accountId: 'acc-1', mode: 'cooldown' });
|
|
const duplicate = controller.markFailure({ hoster: 'byse.sx', accountId: 'acc-1', mode: 'cooldown' });
|
|
now = first.pausedUntil;
|
|
controller.releaseExpired();
|
|
const next = controller.markFailure({ hoster: 'byse.sx', accountId: 'acc-1', mode: 'cooldown' });
|
|
|
|
assert.equal(duplicate.failures, 1);
|
|
assert.equal(duplicate.pausedUntil, first.pausedUntil);
|
|
assert.equal(next.failures, 2);
|
|
assert.equal(next.pausedUntil, now + 30 * 60_000);
|
|
});
|
|
|
|
test('a successful upload resets cooldown escalation to the first level', () => {
|
|
let now = 5_000;
|
|
const controller = createAccountCooldownController({
|
|
now: () => now,
|
|
setTimer: () => 1,
|
|
clearTimer: () => {}
|
|
});
|
|
const first = controller.markFailure({ hoster: 'voe.sx', accountId: 'acc-1', mode: 'cooldown' });
|
|
now = first.pausedUntil;
|
|
controller.releaseExpired();
|
|
controller.markFailure({ hoster: 'voe.sx', accountId: 'acc-1', mode: 'cooldown' });
|
|
|
|
assert.equal(controller.markSuccess('voe.sx', 'acc-1'), true);
|
|
const afterSuccess = controller.markFailure({ hoster: 'voe.sx', accountId: 'acc-1', mode: 'cooldown' });
|
|
assert.equal(afterSuccess.failures, 1);
|
|
assert.equal(afterSuccess.pausedUntil, now + 15 * 60_000);
|
|
});
|
|
|
|
test('manual account pauses never expire and can be reset explicitly', () => {
|
|
let now = 10_000;
|
|
const controller = createAccountCooldownController({
|
|
now: () => now,
|
|
setTimer: () => { throw new Error('manual pauses must not schedule timers'); },
|
|
clearTimer: () => {}
|
|
});
|
|
const record = controller.markFailure({ hoster: 'doodstream.com', accountId: 'acc-1', mode: 'manual' });
|
|
now += 24 * 60 * 60_000;
|
|
|
|
assert.equal(record.pausedUntil, null);
|
|
assert.deepEqual(controller.releaseExpired(), []);
|
|
assert.deepEqual(controller.activeKeys(), ['doodstream.com:acc-1']);
|
|
assert.equal(controller.reset('doodstream.com', 'acc-1'), true);
|
|
assert.deepEqual(controller.activeKeys(), []);
|
|
});
|
|
|
|
test('automatic expiry clears the runtime account and publishes the remaining state', () => {
|
|
let now = 2_000;
|
|
let scheduled;
|
|
const cleared = [];
|
|
const published = [];
|
|
const controller = createAccountCooldownController({
|
|
now: () => now,
|
|
setTimer: (callback, delay) => { scheduled = { callback, delay }; return 1; },
|
|
clearTimer: () => {},
|
|
onClearAccount: (hoster, accountId) => cleared.push(`${hoster}:${accountId}`),
|
|
onChange: (records, cause) => published.push({ records, cause })
|
|
});
|
|
const record = controller.markFailure({ hoster: 'byse.sx', accountId: 'acc-1', mode: 'cooldown' });
|
|
now = record.pausedUntil;
|
|
scheduled.callback();
|
|
|
|
assert.deepEqual(cleared, ['byse.sx:acc-1']);
|
|
assert.deepEqual(published.at(-1), { records: [], cause: 'expired' });
|
|
assert.deepEqual(controller.activeKeys(), []);
|
|
});
|
|
|
|
test('account failure classification keeps credential and OTP errors manual', () => {
|
|
const otp = new Error('Doodstream Login: OTP required');
|
|
otp.otpRequired = true;
|
|
assert.equal(classifyAccountFailure(otp), 'manual');
|
|
assert.equal(classifyAccountFailure(new Error('VOE Login fehlgeschlagen: Falscher Username oder Passwort')), 'manual');
|
|
assert.equal(classifyAccountFailure(new Error('HTTP 401 Unauthorized')), 'manual');
|
|
assert.equal(classifyAccountFailure(new Error('Account banned')), 'manual');
|
|
});
|
|
|
|
test('account failure classification cools down quota and rate-limit errors without pausing transient failures', () => {
|
|
const quota = new Error('not enough disk space on your account');
|
|
quota.accountError = true;
|
|
assert.equal(classifyAccountFailure(quota), 'cooldown');
|
|
assert.equal(classifyAccountFailure(new Error('HTTP 429 Too Many Requests')), 'cooldown');
|
|
const transient = new Error('HTTP 503 Service Unavailable');
|
|
transient.transientNetwork = true;
|
|
assert.equal(classifyAccountFailure(transient), 'none');
|
|
});
|
|
|
|
test('account failure classification does not punish unknown, confirmation, or bare WAF errors', () => {
|
|
assert.equal(classifyAccountFailure(new Error('Upload wurde nicht bestätigt')), 'none');
|
|
assert.equal(classifyAccountFailure(new Error('Unbekannter Parserfehler')), 'none');
|
|
const waf = new Error('HTTP 403');
|
|
waf.status = 403;
|
|
assert.equal(classifyAccountFailure(waf), 'none');
|
|
});
|
|
|
|
test('account failure classification cools down stale sessions and explicit account errors', () => {
|
|
assert.equal(classifyAccountFailure(new Error('CSRF-Token nicht gefunden')), 'cooldown');
|
|
assert.equal(classifyAccountFailure(new Error('Session expired')), 'cooldown');
|
|
const accountError = new Error('Provider account unavailable');
|
|
accountError.accountError = true;
|
|
assert.equal(classifyAccountFailure(accountError), 'cooldown');
|
|
assert.equal(classifyAccountFailure(new Error('Doodstream: sess_id nicht gefunden nach Login')), 'cooldown');
|
|
});
|