Files
Multi-Hoster-Upload/tests/automation-control.test.js
T
Sucukdeluxe d9c66fe20f
CI / verify (push) Canceled after 0s
fix: make automation completion evidence durable
Persist successful watched-file uploads in a dedicated fsync-backed ledger before exposing completion to the renderer. Match entries by normalized full path, hoster, size, and modification time so restart reconciliation skips unchanged completed files while changed files and explicit manual retries remain available.

Remove restored queue ghosts from the ledger even when history and user upload logging are unavailable. Preserve per-hoster partial completion, capture missing file metadata asynchronously, fail closed on corrupted or unwritable evidence, and keep local persistence failures outside automatic upload retries.

Stream managed upload logs with bounded lines, bytes, files, directories, and result counts. Include numbered rotations, reject unconfirmed rows, share concurrent scans through a generation-safe cache, invalidate after successful appends, avoid synchronous configuration and directory reads, and close streams on every path.
2026-08-27 15:14:43 +02:00

454 lines
17 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const {
normalizeAutomationSettings,
countAutomaticQueueJobs,
planAtomicAdmissions,
rollDailyTelemetry,
applyTelemetryDelta,
deriveAutomationState,
isPathWithinAutomationFolder,
classifyProcessedCandidates,
classifyAutomationCompletionLedger,
createAutomationCompletionWriter,
mergeAutomationCompletions,
removeAutomationCompletions
} = require('../lib/automation-control');
test('automation defaults use 15000 jobs and a five minute reconciliation interval', () => {
assert.deepEqual(normalizeAutomationSettings({}), {
queueLimitJobs: 15000,
reconcileIntervalMinutes: 5,
paused: false,
pausedAt: null
});
});
test('automation settings normalize invalid limits intervals and pause timestamps', () => {
assert.deepEqual(normalizeAutomationSettings({
queueLimitJobs: -1,
reconcileIntervalMinutes: 10,
paused: true,
pausedAt: '1700'
}), {
queueLimitJobs: 15000,
reconcileIntervalMinutes: 5,
paused: true,
pausedAt: 1700
});
assert.deepEqual(normalizeAutomationSettings({
queueLimitJobs: 42.9,
reconcileIntervalMinutes: '15',
pausedAt: 1700
}), {
queueLimitJobs: 42,
reconcileIntervalMinutes: 5,
paused: false,
pausedAt: null
});
assert.deepEqual(normalizeAutomationSettings(null), {
queueLimitJobs: 15000,
reconcileIntervalMinutes: 5,
paused: false,
pausedAt: null
});
assert.equal(normalizeAutomationSettings({ reconcileIntervalMinutes: 15 }).reconcileIntervalMinutes, 15);
});
test('automation settings allow only numeric and trimmed string zero to disable the queue limit', () => {
const invalidLimits = [null, '', ' ', false, true, {}, undefined, NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, '0.0', '00', '+0', '-0'];
assert.deepEqual(
invalidLimits.map((queueLimitJobs) => normalizeAutomationSettings({ queueLimitJobs }).queueLimitJobs),
invalidLimits.map(() => 15000)
);
assert.equal(normalizeAutomationSettings({ queueLimitJobs: 0 }).queueLimitJobs, 0);
assert.equal(normalizeAutomationSettings({ queueLimitJobs: ' 0 ' }).queueLimitJobs, 0);
});
test('automation settings clamp positive queue limit fractions to one', () => {
assert.deepEqual(
[0.5, '0.5', 0.125].map((queueLimitJobs) => normalizeAutomationSettings({ queueLimitJobs }).queueLimitJobs),
[1, 1, 1]
);
});
test('capacity counts only executable and running queue jobs', () => {
const statuses = ['preview', 'queued', 'getting-server', 'uploading', 'retrying', 'done', 'error', 'aborted', 'skipped'];
assert.equal(countAutomaticQueueJobs(statuses.map((status, index) => ({ id: String(index), status }))), 5);
assert.equal(countAutomaticQueueJobs(null), 0);
});
test('admission keeps every eligible host job for a file atomic', () => {
const plan = planAtomicAdmissions({
candidates: [
{ path: 'C:\\watch\\a.mkv', mtimeMs: 1000, eligibleJobCount: 4 },
{ path: 'C:\\watch\\b.mkv', mtimeMs: 2000, eligibleJobCount: 2 }
],
currentJobCount: 14997,
queueLimitJobs: 15000
});
assert.deepEqual(plan.admittedPaths, ['C:\\watch\\b.mkv']);
assert.deepEqual(plan.deferredPaths, ['C:\\watch\\a.mkv']);
assert.equal(plan.plannedJobs, 2);
assert.equal(plan.availableSlots, 1);
});
test('admission uses stable mtime and path ordering without mutating candidates', () => {
const candidates = [
{ path: 'b', mtimeMs: 10, eligibleJobCount: 1 },
{ path: 'c', mtimeMs: 5, eligibleJobCount: 1 },
{ path: 'a', mtimeMs: 10, eligibleJobCount: 1 }
];
const snapshot = structuredClone(candidates);
const plan = planAtomicAdmissions({ candidates, currentJobCount: 0, queueLimitJobs: 2 });
assert.deepEqual(plan.admittedPaths, ['c', 'a']);
assert.deepEqual(plan.deferredPaths, ['b']);
assert.deepEqual(candidates, snapshot);
});
test('unlimited admission accepts all eligible files', () => {
const plan = planAtomicAdmissions({
candidates: [{ path: 'a', mtimeMs: 1, eligibleJobCount: 20000 }],
currentJobCount: 50000,
queueLimitJobs: 0
});
assert.deepEqual(plan.admittedPaths, ['a']);
assert.deepEqual(plan.deferredPaths, []);
assert.equal(plan.availableSlots, null);
});
test('admission tolerates malformed candidates and numeric inputs', () => {
assert.deepEqual(planAtomicAdmissions({ candidates: null, currentJobCount: -4, queueLimitJobs: '3' }), {
admittedPaths: [],
deferredPaths: [],
currentJobCount: 0,
plannedJobs: 0,
availableSlots: 3
});
assert.deepEqual(planAtomicAdmissions(null), {
admittedPaths: [],
deferredPaths: [],
currentJobCount: 0,
plannedJobs: 0,
availableSlots: 15000
});
});
test('admission allows only numeric and trimmed string zero to disable the queue limit', () => {
const invalidLimits = [null, '', ' ', false, true, {}, undefined, NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, '0.0', '00', '+0', '-0'];
const candidate = { path: 'large.mkv', mtimeMs: 1, eligibleJobCount: 16000 };
assert.deepEqual(
invalidLimits.map((queueLimitJobs) => planAtomicAdmissions({ candidates: [candidate], queueLimitJobs })),
invalidLimits.map(() => ({
admittedPaths: [],
deferredPaths: ['large.mkv'],
currentJobCount: 0,
plannedJobs: 0,
availableSlots: 15000
}))
);
assert.deepEqual(
[0, ' 0 '].map((queueLimitJobs) => planAtomicAdmissions({ candidates: [candidate], queueLimitJobs })),
[0, ' 0 '].map(() => ({
admittedPaths: ['large.mkv'],
deferredPaths: [],
currentJobCount: 0,
plannedJobs: 16000,
availableSlots: null
}))
);
});
test('admission keeps positive queue limit fractions bounded to one job', () => {
const candidate = { path: 'two-jobs.mkv', mtimeMs: 1, eligibleJobCount: 2 };
assert.deepEqual(
[0.5, '0.5', 0.125].map((queueLimitJobs) => planAtomicAdmissions({ candidates: [candidate], queueLimitJobs })),
[0.5, '0.5', 0.125].map(() => ({
admittedPaths: [],
deferredPaths: ['two-jobs.mkv'],
currentJobCount: 0,
plannedJobs: 0,
availableSlots: 1
}))
);
});
test('daily telemetry resets atomically on the local calendar day boundary', () => {
const before = { dateKey: '2026-08-25', detected: 8, queued: 4, skipped: 2, deferred: 1 };
const now = new Date(2026, 7, 26, 0, 0, 1).getTime();
assert.deepEqual(rollDailyTelemetry(before, now), {
dateKey: '2026-08-26',
detected: 0,
queued: 0,
skipped: 0,
deferred: 0,
lastDetectedName: '',
lastDetectedAt: null,
lastError: '',
lastErrorAt: null
});
});
test('daily telemetry normalizes malformed same-day counters without mutating input', () => {
const now = new Date(2026, 7, 26, 12, 0, 0).getTime();
const telemetry = { dateKey: '2026-08-26', detected: -2, queued: '3', lastError: 'network' };
const snapshot = structuredClone(telemetry);
assert.deepEqual(rollDailyTelemetry(telemetry, now), {
dateKey: '2026-08-26',
detected: 0,
queued: 3,
skipped: 0,
deferred: 0,
lastDetectedName: '',
lastDetectedAt: null,
lastError: 'network',
lastErrorAt: null
});
assert.deepEqual(telemetry, snapshot);
assert.equal(rollDailyTelemetry(null, now).dateKey, '2026-08-26');
});
test('telemetry deltas increment counters and update event details immutably', () => {
const now = new Date(2026, 7, 26, 13, 14, 15).getTime();
const telemetry = {
dateKey: '2026-08-26',
detected: 1,
queued: 2,
skipped: 3,
deferred: 4,
lastDetectedName: '',
lastDetectedAt: null,
lastError: 'old',
lastErrorAt: 10
};
const result = applyTelemetryDelta(telemetry, {
detected: 2,
queued: 3,
skipped: -1,
deferred: '2',
lastDetectedName: 'episode.mkv',
lastError: ''
}, now);
assert.deepEqual(result, {
dateKey: '2026-08-26',
detected: 3,
queued: 5,
skipped: 3,
deferred: 6,
lastDetectedName: 'episode.mkv',
lastDetectedAt: now,
lastError: '',
lastErrorAt: null
});
assert.equal(telemetry.detected, 1);
assert.equal(telemetry.lastError, 'old');
});
test('telemetry and deltas normalize every counter to a finite nonnegative integer', () => {
const now = new Date(2026, 7, 26, 13, 14, 15).getTime();
const telemetry = rollDailyTelemetry({
dateKey: '2026-08-26',
detected: Number.POSITIVE_INFINITY,
queued: Number.NaN,
skipped: -4,
deferred: 3.9
}, now);
assert.deepEqual({
detected: telemetry.detected,
queued: telemetry.queued,
skipped: telemetry.skipped,
deferred: telemetry.deferred
}, { detected: 0, queued: 0, skipped: 0, deferred: 3 });
const changed = applyTelemetryDelta(telemetry, {
detected: Number.POSITIVE_INFINITY,
queued: Number.NaN,
skipped: -2,
deferred: 2.8
}, now);
assert.deepEqual({
detected: changed.detected,
queued: changed.queued,
skipped: changed.skipped,
deferred: changed.deferred
}, { detected: 0, queued: 0, skipped: 0, deferred: 5 });
assert.equal(Object.values(changed).filter(value => typeof value === 'number').every(value => Number.isFinite(value)), true);
});
test('pause has higher display priority than disconnect error and queue limit', () => {
assert.equal(deriveAutomationState({ paused: true, enabled: true, folderPath: 'C:\\watch', reachable: false, error: 'x', queueLimited: true }), 'paused');
});
test('automation state follows inactive disconnected error queue-limited and active priority', () => {
assert.equal(deriveAutomationState({ enabled: false, folderPath: 'C:\\watch', reachable: false, error: 'x', queueLimited: true }), 'inactive');
assert.equal(deriveAutomationState({ enabled: true, folderPath: '', reachable: false, error: 'x', queueLimited: true }), 'inactive');
assert.equal(deriveAutomationState({ enabled: true, folderPath: 'C:\\watch', reachable: false, error: 'x', queueLimited: true }), 'disconnected');
assert.equal(deriveAutomationState({ enabled: true, folderPath: 'C:\\watch', reachable: true, error: 'x', queueLimited: true }), 'error');
assert.equal(deriveAutomationState({ enabled: true, folderPath: 'C:\\watch', reachable: true, queueLimited: true }), 'queue-limited');
assert.equal(deriveAutomationState({ enabled: true, folderPath: 'C:\\watch', reachable: true }), 'active');
assert.equal(deriveAutomationState(null), 'inactive');
});
test('watched-folder membership respects Windows casing and recursive scope', () => {
assert.equal(isPathWithinAutomationFolder('C:\\Watch\\episode.mkv', 'c:/watch', false), true);
assert.equal(isPathWithinAutomationFolder('C:\\Watch\\Season 1\\episode.mkv', 'c:/watch', false), false);
assert.equal(isPathWithinAutomationFolder('C:\\Watch\\Season 1\\episode.mkv', 'c:/watch', true), true);
assert.equal(isPathWithinAutomationFolder('C:\\Watcher\\episode.mkv', 'c:/watch', true), false);
assert.equal(isPathWithinAutomationFolder('', 'c:/watch', true), false);
});
test('exact queue and history paths mark candidates processed case-insensitively', () => {
const result = classifyProcessedCandidates({
candidates: [
{ path: 'C:\\Watch\\queue.mkv' },
{ path: 'C:\\Watch\\history.mkv' },
{ path: 'C:\\Watch\\new.mkv' }
],
queuePaths: ['c:/watch/QUEUE.mkv'],
historyRows: [{ file: 'c:/watch/HISTORY.mkv' }],
uploadLogRows: []
});
assert.deepEqual(result, {
processedPaths: ['C:\\Watch\\queue.mkv', 'C:\\Watch\\history.mkv'],
ambiguousPaths: [],
unprocessedPaths: ['C:\\Watch\\new.mkv']
});
});
test('unique basename evidence marks one candidate processed', () => {
const result = classifyProcessedCandidates({
candidates: [{ path: 'C:\\watch\\unique.mkv' }, { path: 'C:\\watch\\other.mkv' }],
uploadLogRows: [{ filename: 'UNIQUE.MKV' }]
});
assert.deepEqual(result.processedPaths, ['C:\\watch\\unique.mkv']);
assert.deepEqual(result.unprocessedPaths, ['C:\\watch\\other.mkv']);
});
test('ambiguous same-name log evidence never marks a candidate processed', () => {
const result = classifyProcessedCandidates({
candidates: [
{ path: 'C:\\one\\episode.mkv', name: 'episode.mkv' },
{ path: 'D:\\two\\episode.mkv', name: 'episode.mkv' }
],
queuePaths: [],
historyRows: [],
uploadLogRows: [{ fileName: 'episode.mkv', hoster: 'doodstream.com' }]
});
assert.deepEqual(result.processedPaths, []);
assert.deepEqual(result.ambiguousPaths.sort(), ['C:\\one\\episode.mkv', 'D:\\two\\episode.mkv'].sort());
});
test('processed classification tolerates malformed collections and does not mutate candidates', () => {
const candidates = [{ path: 'C:\\watch\\episode.mkv', name: 'episode.mkv' }];
const snapshot = structuredClone(candidates);
assert.deepEqual(classifyProcessedCandidates({
candidates,
queuePaths: null,
historyRows: null,
uploadLogRows: null
}), {
processedPaths: [],
ambiguousPaths: [],
unprocessedPaths: ['C:\\watch\\episode.mkv']
});
assert.deepEqual(candidates, snapshot);
assert.deepEqual(classifyProcessedCandidates(null), {
processedPaths: [],
ambiguousPaths: [],
unprocessedPaths: []
});
});
test('durable completion ledger excludes only unchanged completed hosters', () => {
const candidate = {
path: 'C:\\Watch\\Episode.mkv',
size: 1048576,
mtimeMs: 1787828400123.75,
eligibleHosters: ['doodstream.com', 'voe.sx', 'byse.sx']
};
const completionRows = [
{ path: 'c:/watch/episode.mkv', size: 1048576, mtimeMs: 1787828400123, hoster: 'DOODSTREAM.COM', completedAt: 10 },
{ path: 'C:\\WATCH\\EPISODE.MKV', size: 1048576, mtimeMs: 1787828400123.9, hoster: 'voe.sx', completedAt: 20 }
];
assert.deepEqual(classifyAutomationCompletionLedger({ candidates: [candidate], completionRows }), {
processedPaths: [],
completedByPath: [{ path: candidate.path, hosters: ['doodstream.com', 'voe.sx'] }],
remainingByPath: [{ path: candidate.path, hosters: ['byse.sx'] }]
});
const complete = classifyAutomationCompletionLedger({
candidates: [candidate],
completionRows: completionRows.concat({
path: candidate.path,
size: candidate.size,
mtimeMs: candidate.mtimeMs,
hoster: 'byse.sx',
completedAt: 30
})
});
assert.deepEqual(complete.processedPaths, [candidate.path]);
assert.deepEqual(complete.remainingByPath, [{ path: candidate.path, hosters: [] }]);
const changed = classifyAutomationCompletionLedger({
candidates: [{ ...candidate, mtimeMs: candidate.mtimeMs + 1 }],
completionRows
});
assert.deepEqual(changed.completedByPath, [{ path: candidate.path, hosters: [] }]);
assert.deepEqual(changed.remainingByPath, [{ path: candidate.path, hosters: candidate.eligibleHosters }]);
});
test('completion ledger replaces only the same path and hoster without evicting unrelated entries', () => {
const existing = [
{ path: 'C:\\watch\\a.mkv', size: 1, mtimeMs: 1, hoster: 'voe.sx', completedAt: 10 },
{ path: 'C:\\watch\\b.mkv', size: 2, mtimeMs: 2, hoster: 'voe.sx', completedAt: 20 }
];
const merged = mergeAutomationCompletions(existing, [
{ path: 'c:/WATCH/a.mkv', size: 3, mtimeMs: 3, hoster: 'VOE.SX', completedAt: 30 },
{ path: 'C:\\watch\\c.mkv', size: 4, mtimeMs: 4, hoster: 'byse.sx', completedAt: 40 }
]);
assert.deepEqual(merged, [
{ path: 'C:\\watch\\b.mkv', size: 2, mtimeMs: 2, hoster: 'voe.sx', completedAt: 20 },
{ path: 'c:/WATCH/a.mkv', size: 3, mtimeMs: 3, hoster: 'voe.sx', completedAt: 30 },
{ path: 'C:\\watch\\c.mkv', size: 4, mtimeMs: 4, hoster: 'byse.sx', completedAt: 40 }
]);
assert.deepEqual(removeAutomationCompletions(merged, [{ path: 'C:\\WATCH\\A.MKV', hoster: 'voe.sx' }]), [merged[0], merged[2]]);
assert.deepEqual(removeAutomationCompletions(merged, [{ path: 'c:/watch/c.mkv' }]), [merged[0], merged[1]]);
assert.throws(() => mergeAutomationCompletions(existing, [
{ path: 'C:\\watch\\c.mkv', size: 4, mtimeMs: 4, hoster: 'byse.sx', completedAt: 40 }
], 2), /zu viele Einträge/);
});
test('completion writer coalesces successful jobs and retains a failed batch for retry', async () => {
const scheduled = [];
const writes = [];
const persisted = [];
const failures = [];
let fail = true;
const writer = createAutomationCompletionWriter({
schedule: callback => scheduled.push(callback),
onPersisted: entries => persisted.push(structuredClone(entries)),
onError: (error, entries) => failures.push({ message: error.message, entries: structuredClone(entries) }),
save: async entries => {
if (fail) throw new Error('disk unavailable');
writes.push(structuredClone(entries));
}
});
const first = { path: 'C:\\watch\\episode.mkv', size: 1, mtimeMs: 2, hoster: 'voe.sx', completedAt: 3 };
const newer = { ...first, completedAt: 4 };
writer.add(first);
writer.add(newer);
assert.equal(scheduled.length, 1);
await assert.rejects(writer.flush(), /disk unavailable/);
assert.deepEqual(persisted, []);
assert.deepEqual(failures, [{ message: 'disk unavailable', entries: [newer] }]);
fail = false;
await writer.flush();
assert.deepEqual(writes, [[newer]]);
assert.deepEqual(persisted, [[newer]]);
assert.equal(writer.pendingCount(), 0);
});