From 2810ff8d98b3fe58fb64cb21258ab302d8555b13 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Tue, 22 Sep 2026 06:55:29 +0200 Subject: [PATCH] Move regression tests to private repository --- .gitignore | 2 + PROJECT_MEMORY.md | 5 +- README.md | 7 +- package.json | 4 +- scripts/verify-public-release.mjs | 76 +- services/backup-api/package.json | 3 +- services/backup-api/test/server.test.mjs | 499 -- tests/account-auth.test.js | 177 - tests/account-rotation.test.js | 263 - tests/account-status.test.js | 82 - tests/auto-resume.test.js | 82 - tests/automation-control.test.js | 453 -- tests/backup-crypto.test.js | 85 - tests/byse-reject-recovery.test.js | 234 - tests/coalesced-set.test.js | 144 - tests/config-store.test.js | 1048 ---- tests/dev-runner.test.js | 11 - tests/diagnostics-agent.test.js | 62 - tests/diagnostics-collectors.test.js | 179 - tests/diagnostics-protocol.test.js | 120 - tests/doodstream-api-upload.test.js | 105 - tests/doodstream-upload.test.js | 364 -- tests/file-discovery.test.js | 59 - tests/file-probe.test.js | 115 - tests/folder-monitor.test.js | 839 ---- tests/history-retention.test.js | 95 - tests/history-status.test.js | 17 - tests/hosters.test.js | 95 - tests/i18n.test.js | 408 -- tests/import-preflight.test.js | 71 - tests/ip-allowlist.test.js | 52 - tests/log-mode.test.js | 168 - tests/log-policy.test.js | 52 - tests/log-rotation.test.js | 134 - tests/online-backup-keyring.test.js | 896 ---- tests/online-backup-manager.test.js | 504 -- tests/online-backup-recovery.test.js | 103 - tests/online-backup-service.test.js | 71 - tests/online-backup.test.js | 223 - tests/orphan-tmp.test.js | 56 - tests/package-build-files.test.js | 1216 ----- tests/public-release-verifier.test.js | 93 - tests/queue-dedup-property.test.js | 114 - tests/queue-dedup.test.js | 253 - tests/queue-persistence-scenario.test.js | 83 - tests/queue-prune.test.js | 115 - tests/queue-stats.test.js | 38 - tests/remote-config.test.js | 71 - tests/remote-server.test.js | 42 - tests/secret-store.test.js | 158 - tests/semaphore.test.js | 174 - tests/serialized-runner.test.js | 53 - tests/session-report.test.js | 31 - tests/settings-backup.test.js | 83 - tests/settings-import-gate.test.js | 19 - tests/source-cleanup-policy.test.js | 205 - tests/source-delete-journal.test.js | 85 - tests/source-file-cleanup.test.js | 469 -- tests/speed-history.test.js | 40 - tests/startup-renderer.test.js | 5555 ---------------------- tests/stats.test.js | 186 - tests/support-bundle.test.js | 202 - tests/support/ui-network-safety.js | 28 - tests/suspect-reject-alternates.test.js | 286 -- tests/throttle-timer.test.js | 133 - tests/throttle.test.js | 101 - tests/throttled-cache.test.js | 113 - tests/ui-network-safety.test.js | 47 - tests/ui-smoke.js | 2905 ----------- tests/updater-version.test.js | 478 -- tests/upload-audit.test.js | 201 - tests/upload-confirmation.test.js | 88 - tests/upload-diagnostics.test.js | 27 - tests/upload-log.test.js | 220 - tests/upload-manager.test.js | 1752 ------- tests/validate-credentials.test.js | 208 - tests/vidmoly-upload.test.js | 93 - tests/webhook-notify.test.js | 184 - 78 files changed, 11 insertions(+), 24101 deletions(-) delete mode 100644 services/backup-api/test/server.test.mjs delete mode 100644 tests/account-auth.test.js delete mode 100644 tests/account-rotation.test.js delete mode 100644 tests/account-status.test.js delete mode 100644 tests/auto-resume.test.js delete mode 100644 tests/automation-control.test.js delete mode 100644 tests/backup-crypto.test.js delete mode 100644 tests/byse-reject-recovery.test.js delete mode 100644 tests/coalesced-set.test.js delete mode 100644 tests/config-store.test.js delete mode 100644 tests/dev-runner.test.js delete mode 100644 tests/diagnostics-agent.test.js delete mode 100644 tests/diagnostics-collectors.test.js delete mode 100644 tests/diagnostics-protocol.test.js delete mode 100644 tests/doodstream-api-upload.test.js delete mode 100644 tests/doodstream-upload.test.js delete mode 100644 tests/file-discovery.test.js delete mode 100644 tests/file-probe.test.js delete mode 100644 tests/folder-monitor.test.js delete mode 100644 tests/history-retention.test.js delete mode 100644 tests/history-status.test.js delete mode 100644 tests/hosters.test.js delete mode 100644 tests/i18n.test.js delete mode 100644 tests/import-preflight.test.js delete mode 100644 tests/ip-allowlist.test.js delete mode 100644 tests/log-mode.test.js delete mode 100644 tests/log-policy.test.js delete mode 100644 tests/log-rotation.test.js delete mode 100644 tests/online-backup-keyring.test.js delete mode 100644 tests/online-backup-manager.test.js delete mode 100644 tests/online-backup-recovery.test.js delete mode 100644 tests/online-backup-service.test.js delete mode 100644 tests/online-backup.test.js delete mode 100644 tests/orphan-tmp.test.js delete mode 100644 tests/package-build-files.test.js delete mode 100644 tests/public-release-verifier.test.js delete mode 100644 tests/queue-dedup-property.test.js delete mode 100644 tests/queue-dedup.test.js delete mode 100644 tests/queue-persistence-scenario.test.js delete mode 100644 tests/queue-prune.test.js delete mode 100644 tests/queue-stats.test.js delete mode 100644 tests/remote-config.test.js delete mode 100644 tests/remote-server.test.js delete mode 100644 tests/secret-store.test.js delete mode 100644 tests/semaphore.test.js delete mode 100644 tests/serialized-runner.test.js delete mode 100644 tests/session-report.test.js delete mode 100644 tests/settings-backup.test.js delete mode 100644 tests/settings-import-gate.test.js delete mode 100644 tests/source-cleanup-policy.test.js delete mode 100644 tests/source-delete-journal.test.js delete mode 100644 tests/source-file-cleanup.test.js delete mode 100644 tests/speed-history.test.js delete mode 100644 tests/startup-renderer.test.js delete mode 100644 tests/stats.test.js delete mode 100644 tests/support-bundle.test.js delete mode 100644 tests/support/ui-network-safety.js delete mode 100644 tests/suspect-reject-alternates.test.js delete mode 100644 tests/throttle-timer.test.js delete mode 100644 tests/throttle.test.js delete mode 100644 tests/throttled-cache.test.js delete mode 100644 tests/ui-network-safety.test.js delete mode 100644 tests/ui-smoke.js delete mode 100644 tests/updater-version.test.js delete mode 100644 tests/upload-audit.test.js delete mode 100644 tests/upload-confirmation.test.js delete mode 100644 tests/upload-diagnostics.test.js delete mode 100644 tests/upload-log.test.js delete mode 100644 tests/upload-manager.test.js delete mode 100644 tests/validate-credentials.test.js delete mode 100644 tests/vidmoly-upload.test.js delete mode 100644 tests/webhook-notify.test.js diff --git a/.gitignore b/.gitignore index 09a1a20..121b550 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ node_modules/ +/tests/ +/services/backup-api/test/ .dev-runner.lock release/ .artifacts/ diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index 62fc992..6c5bb79 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -11,6 +11,7 @@ Multi-Hoster-Upload ist eine Electron-Desktopanwendung für Windows zum Hochlade - Version `2.1.50` enthält verbesserte Sicherungsmenüs, getrennte Online-Backup-Bereiche, einheitliche Automatik- und Log-Einstellungen sowie korrigierte Such- und Update-Anzeigen. - Ordnerüberwachung lässt sich unabhängig von ihrem Aktivierungszustand mit gespeicherten Regeln schreibgeschützt testen. Testscans starten keine Uploads und verändern keine laufende Überwachung. - Nach dem Release wurden ausschließlich Wartebedingungen zweier Electron-Tests korrigiert. Die Anwendungsversion bleibt unverändert. +- Regressionstests sind in das private Repository `Multi-Hoster-Upload-Tests` ausgelagert. Beide privaten Remotes enthalten Desktop- und Backup-API-Tests. Öffentliche CI führt nur Lint, Abhängigkeitsprüfung und Build aus. Historische Commits und Tags bleiben unverändert. ## Entscheidungen @@ -43,14 +44,12 @@ Bei Problemen mit Shell-Sonderzeichen im Projektpfad können die Prüfungen dire ```powershell node node_modules/eslint/bin/eslint.js . -$testFiles = @((Get-ChildItem tests -Filter '*.test.js').FullName) + (Resolve-Path tests/ui-smoke.js).Path -node --test @testFiles -node --test services/backup-api/test/server.test.mjs npm audit --omit=dev ``` ## Bekannte Probleme und nächste Schritte +- Nach jedem App-Push die private Testsuite mit dem vollständigen App-Commit starten: `gh workflow run tests.yml --repo Sucukdeluxe/Multi-Hoster-Upload-Tests -f source_ref=`. Ein erfolgreicher öffentlicher Build ersetzt diesen Testlauf nicht. Lokale Ausführung ist in der privaten README dokumentiert. - Die DoodStream-Web-Upload-Serverermittlung ist weiterhin gesondert zu prüfen. Ein erfolgreicher Accountcheck bestätigt keinen erfolgreichen Dateitransfer. - Der optionale Langzeit-UI-Smoke hat bekannte Timing-/Fixture-Abweichungen außerhalb der regulären CI. Diese getrennt untersuchen; reguläre Regressionen nicht überspringen. - Die CI meldet eine Laufzeit-Abkündigung für die verwendeten Checkout-/Node-Setup-Actions. Ein Versionswechsel ist separat zu prüfen. diff --git a/README.md b/README.md index b48337f..699eada 100644 --- a/README.md +++ b/README.md @@ -287,13 +287,14 @@ npm install npm start ``` -Run the test suite and lint checks: +Run the public lint and dependency checks: ```powershell -npm test -npm run lint +npm run verify ``` +The regression suite is maintained and executed in a separate private repository against an exact application commit. Public CI runs lint, dependency checks and Windows builds; it does not run the private tests. Maintainers must also verify the private regression run before releasing. Historical commits and release tags have not been rewritten. + Create both Windows release targets without publishing them: ```powershell diff --git a/package.json b/package.json index 80e49cd..e991704 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,7 @@ "scripts": { "start": "electron .", "dev": "node scripts/dev-runner.cjs", - "test": "node --test tests/*.test.js tests/ui-smoke.js", - "test:backup-api": "npm --prefix services/backup-api test", - "verify": "npm run lint && npm test && npm run test:backup-api && npm audit --omit=dev", + "verify": "npm run lint && npm audit --omit=dev", "lint": "eslint .", "dist": "electron-builder --publish never --win", "release:win": "electron-builder --publish never --win nsis portable" diff --git a/scripts/verify-public-release.mjs b/scripts/verify-public-release.mjs index fd64973..65ba332 100644 --- a/scripts/verify-public-release.mjs +++ b/scripts/verify-public-release.mjs @@ -96,78 +96,6 @@ const sourceFiles = [ 'services/backup-api/package.json', 'services/backup-api/src/cli.mjs', 'services/backup-api/src/server.mjs', - 'services/backup-api/test/server.test.mjs', - 'tests/online-backup-recovery.test.js', - 'tests/account-auth.test.js', - 'tests/account-rotation.test.js', - 'tests/account-status.test.js', - 'tests/automation-control.test.js', - 'tests/auto-resume.test.js', - 'tests/backup-crypto.test.js', - 'tests/byse-reject-recovery.test.js', - 'tests/coalesced-set.test.js', - 'tests/config-store.test.js', - 'tests/diagnostics-agent.test.js', - 'tests/diagnostics-collectors.test.js', - 'tests/diagnostics-protocol.test.js', - 'tests/dev-runner.test.js', - 'tests/doodstream-api-upload.test.js', - 'tests/doodstream-upload.test.js', - 'tests/file-probe.test.js', - 'tests/file-discovery.test.js', - 'tests/folder-monitor.test.js', - 'tests/history-status.test.js', - 'tests/history-retention.test.js', - 'tests/import-preflight.test.js', - 'tests/hosters.test.js', - 'tests/i18n.test.js', - 'tests/ip-allowlist.test.js', - 'tests/log-mode.test.js', - 'tests/log-policy.test.js', - 'tests/log-rotation.test.js', - 'tests/online-backup-service.test.js', - 'tests/online-backup.test.js', - 'tests/online-backup-keyring.test.js', - 'tests/online-backup-manager.test.js', - 'tests/orphan-tmp.test.js', - 'tests/package-build-files.test.js', - 'tests/public-release-verifier.test.js', - 'tests/queue-dedup-property.test.js', - 'tests/queue-dedup.test.js', - 'tests/queue-persistence-scenario.test.js', - 'tests/queue-prune.test.js', - 'tests/queue-stats.test.js', - 'tests/remote-config.test.js', - 'tests/remote-server.test.js', - 'tests/semaphore.test.js', - 'tests/secret-store.test.js', - 'tests/serialized-runner.test.js', - 'tests/settings-backup.test.js', - 'tests/settings-import-gate.test.js', - 'tests/speed-history.test.js', - 'tests/source-cleanup-policy.test.js', - 'tests/source-delete-journal.test.js', - 'tests/source-file-cleanup.test.js', - 'tests/startup-renderer.test.js', - 'tests/stats.test.js', - 'tests/support-bundle.test.js', - 'tests/support/ui-network-safety.js', - 'tests/suspect-reject-alternates.test.js', - 'tests/throttle-timer.test.js', - 'tests/throttle.test.js', - 'tests/throttled-cache.test.js', - 'tests/ui-network-safety.test.js', - 'tests/ui-smoke.js', - 'tests/updater-version.test.js', - 'tests/upload-audit.test.js', - 'tests/upload-log.test.js', - 'tests/upload-confirmation.test.js', - 'tests/upload-diagnostics.test.js', - 'tests/upload-manager.test.js', - 'tests/vidmoly-upload.test.js', - 'tests/session-report.test.js', - 'tests/validate-credentials.test.js', - 'tests/webhook-notify.test.js' ]; const screenshotFiles = [ 'assets/product-overview.png', @@ -182,9 +110,7 @@ const binaryExtensions = new Set(['.ico', '.png']); const expectedScripts = { start: 'electron .', dev: 'node scripts/dev-runner.cjs', - test: 'node --test tests/*.test.js tests/ui-smoke.js', - 'test:backup-api': 'npm --prefix services/backup-api test', - verify: 'npm run lint && npm test && npm run test:backup-api && npm audit --omit=dev', + verify: 'npm run lint && npm audit --omit=dev', lint: 'eslint .', dist: 'electron-builder --publish never --win', 'release:win': 'electron-builder --publish never --win nsis portable' diff --git a/services/backup-api/package.json b/services/backup-api/package.json index ff77449..8aeec87 100644 --- a/services/backup-api/package.json +++ b/services/backup-api/package.json @@ -4,8 +4,7 @@ "private": true, "type": "module", "scripts": { - "start": "node src/cli.mjs", - "test": "node --test" + "start": "node src/cli.mjs" }, "engines": { "node": ">=20" diff --git a/services/backup-api/test/server.test.mjs b/services/backup-api/test/server.test.mjs deleted file mode 100644 index 7e0571b..0000000 --- a/services/backup-api/test/server.test.mjs +++ /dev/null @@ -1,499 +0,0 @@ -import assert from 'node:assert/strict' -import { createHash, randomBytes } from 'node:crypto' -import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { createConnection } from 'node:net' -import test from 'node:test' -import lockfile from 'proper-lockfile' -import { createBackupServer } from '../src/server.mjs' - -const allowedOrigin = 'https://uploader.24-music.de' - -function fixture() { - const deleteSecret = randomBytes(32).toString('base64url') - return { - deleteSecret, - payload: { - id: randomBytes(16).toString('base64url'), - blob: randomBytes(96).toString('base64url'), - deleteVerifier: createHash('sha256').update(Buffer.from(deleteSecret, 'base64url')).digest('base64url') - } - } -} - -async function startApi(options = {}) { - const rootDir = await mkdtemp(join(tmpdir(), 'mhu-backup-api-')) - const server = createBackupServer({ - rootDir, - allowedOrigins: [allowedOrigin], - rateLimit: { max: 100, windowMs: 60_000 }, - ...options - }) - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(0, '127.0.0.1', resolve) - }) - return { - rootDir, - server, - baseUrl: `http://127.0.0.1:${server.address().port}`, - async close() { - await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) - await rm(rootDir, { recursive: true, force: true }) - } - } -} - -function request(api, path, options = {}) { - return fetch(`${api.baseUrl}${path}`, options) -} - -test('health reports readiness without storage details and sends security headers', async (t) => { - const api = await startApi() - t.after(() => api.close()) - - const response = await request(api, '/health') - - assert.equal(response.status, 200) - assert.deepEqual(await response.json(), { status: 'ok' }) - assert.equal(response.headers.get('cache-control'), 'no-store') - assert.equal(response.headers.get('x-content-type-options'), 'nosniff') - assert.equal(response.headers.get('content-security-policy'), "default-src 'none'") -}) - -test('creates immutable ciphertext records and restores them after a restart', async (t) => { - const api = await startApi() - const backup = fixture() - t.after(async () => { - if (api.server.listening) await new Promise((resolve) => api.server.close(resolve)) - await rm(api.rootDir, { recursive: true, force: true }) - }) - - const created = await request(api, '/v1/backups', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(backup.payload) - }) - assert.equal(created.status, 201) - assert.equal((await created.json()).sourceIp, '127.0.0.1') - await new Promise((resolve) => api.server.close(resolve)) - - api.server = createBackupServer({ rootDir: api.rootDir, allowedOrigins: [allowedOrigin] }) - await new Promise((resolve, reject) => { - api.server.once('error', reject) - api.server.listen(0, '127.0.0.1', resolve) - }) - api.baseUrl = `http://127.0.0.1:${api.server.address().port}` - - const restored = await request(api, '/v1/backups/restore', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ id: backup.payload.id }) - }) - assert.equal(restored.status, 200) - assert.deepEqual(await restored.json(), { blob: backup.payload.blob }) - - const duplicate = await request(api, '/v1/backups', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ ...backup.payload, blob: randomBytes(96).toString('base64url') }) - }) - assert.equal(duplicate.status, 409) -}) - -test('validates payload shape, content type and decoded blob size', async (t) => { - const api = await startApi() - t.after(() => api.close()) - const valid = fixture() - const invalid = [ - { ...valid.payload, id: 'short' }, - { ...valid.payload, blob: 'not+base64url' }, - { ...valid.payload, deleteVerifier: 'short' }, - { id: valid.payload.id, blob: valid.payload.blob }, - { ...valid.payload, extra: true }, - { ...valid.payload, expiresInSeconds: 0 }, - { ...valid.payload, expiresInSeconds: 30 * 24 * 60 * 60 }, - { ...valid.payload, expiresInSeconds: '604800' } - ] - - for (const body of invalid) { - const response = await request(api, '/v1/backups', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body) - }) - assert.equal(response.status, 400) - } - - const wrongType = await request(api, '/v1/backups', { - method: 'POST', - headers: { 'content-type': 'text/plain' }, - body: JSON.stringify(valid.payload) - }) - assert.equal(wrongType.status, 415) - - const oversized = fixture() - oversized.payload.blob = randomBytes(262_145).toString('base64url') - const tooLarge = await request(api, '/v1/backups', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(oversized.payload) - }) - assert.equal(tooLarge.status, 413) -}) - -test('expires finite backups at the exact deadline and removes their ciphertext', async (t) => { - let nowMs = Date.parse('2026-09-01T10:00:00.000Z') - const api = await startApi({ now: () => nowMs }) - t.after(() => api.close()) - const backup = fixture() - backup.payload.expiresInSeconds = 86_400 - - const created = await request(api, '/v1/backups', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(backup.payload) - }) - assert.equal(created.status, 201) - const storedPath = join(api.rootDir, `${backup.payload.id}.json`) - const stored = JSON.parse(await readFile(storedPath, 'utf8')) - assert.equal(stored.version, 4) - assert.equal(stored.expiresAt, '2026-09-02T10:00:00.000Z') - - const beforeDeadline = await request(api, '/v1/backups/restore', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ id: backup.payload.id }) - }) - assert.equal(beforeDeadline.status, 200) - - nowMs += 86_400_000 - const expired = await request(api, '/v1/backups/restore', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ id: backup.payload.id }) - }) - assert.equal(expired.status, 404) - await assert.rejects(stat(storedPath), error => error.code === 'ENOENT') -}) - -test('keeps legacy records unlimited and reclaims expired records on the next upload', async (t) => { - let nowMs = Date.parse('2026-09-01T10:00:00.000Z') - const api = await startApi({ now: () => nowMs }) - t.after(() => api.close()) - const legacy = fixture() - await writeFile(join(api.rootDir, `${legacy.payload.id}.json`), JSON.stringify({ - version: 1, - blob: legacy.payload.blob, - deleteVerifier: legacy.payload.deleteVerifier, - createdAt: '2026-01-01T00:00:00.000Z' - })) - const finite = fixture() - finite.payload.expiresInSeconds = 86_400 - await request(api, '/v1/backups', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(finite.payload) - }) - - nowMs += 2 * 86_400_000 - const replacement = fixture() - replacement.payload.expiresInSeconds = null - assert.equal((await request(api, '/v1/backups', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(replacement.payload) - })).status, 201) - - await assert.rejects(stat(join(api.rootDir, `${finite.payload.id}.json`)), error => error.code === 'ENOENT') - assert.equal((await request(api, '/v1/backups/restore', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ id: legacy.payload.id }) - })).status, 200) - assert.equal((await request(api, '/v1/backups/restore', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ id: replacement.payload.id }) - })).status, 200) -}) - -test('deletes only with the matching client secret and returns constant not-found responses', async (t) => { - const api = await startApi() - t.after(() => api.close()) - const backup = fixture() - await request(api, '/v1/backups', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(backup.payload) - }) - - const missing = await request(api, '/v1/backups/restore', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ id: randomBytes(16).toString('base64url') }) - }) - const wrong = await request(api, '/v1/backups/delete', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ id: backup.payload.id, deleteSecret: randomBytes(32).toString('base64url') }) - }) - assert.equal(missing.status, 404) - assert.equal(wrong.status, 404) - assert.equal(await missing.text(), await wrong.text()) - - const deleted = await request(api, '/v1/backups/delete', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ id: backup.payload.id, deleteSecret: backup.deleteSecret }) - }) - assert.equal(deleted.status, 204) - assert.equal((await readdir(api.rootDir)).length, 0) -}) - -test('allows only configured origins and supports preflight', async (t) => { - const api = await startApi() - t.after(() => api.close()) - - const allowed = await request(api, '/health', { headers: { origin: allowedOrigin } }) - assert.equal(allowed.headers.get('access-control-allow-origin'), allowedOrigin) - const denied = await request(api, '/health', { headers: { origin: 'https://attacker.example' } }) - assert.equal(denied.status, 403) - const preflight = await request(api, '/v1/backups', { - method: 'OPTIONS', - headers: { - origin: allowedOrigin, - 'access-control-request-method': 'POST', - 'access-control-request-headers': 'content-type' - } - }) - assert.equal(preflight.status, 204) -}) - -test('separately rate limits uploads while restores and health remain available', async (t) => { - const api = await startApi({ uploadRateLimit: { max: 1, windowMs: 60_000 } }) - t.after(() => api.close()) - const first = fixture() - const second = fixture() - const create = (backup) => request(api, '/v1/backups', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(backup.payload) - }) - - assert.equal((await create(first)).status, 201) - assert.equal((await create(second)).status, 429) - const restored = await request(api, '/v1/backups/restore', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ id: first.payload.id }) - }) - assert.equal(restored.status, 200) - assert.equal((await request(api, '/health')).status, 200) -}) - -test('rate limits invalid request bodies before validation', async (t) => { - const api = await startApi({ requestRateLimit: { max: 1, windowMs: 60_000 } }) - t.after(() => api.close()) - const sendInvalid = () => request(api, '/v1/backups', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ invalid: 'x'.repeat(300_000) }) - }) - - assert.equal((await sendInvalid()).status, 400) - assert.equal((await sendInvalid()).status, 429) -}) - -test('ignores forwarded client addresses from untrusted socket peers', async (t) => { - const api = await startApi({ - trustedProxy: true, - trustedProxyAddresses: [], - rateLimit: { max: 1, windowMs: 60_000 } - }) - t.after(() => api.close()) - const body = JSON.stringify({ id: randomBytes(16).toString('base64url') }) - const restore = (forwarded) => request(api, '/v1/backups/restore', { - method: 'POST', - headers: { 'content-type': 'application/json', 'x-forwarded-for': forwarded }, - body - }) - - assert.equal((await restore('198.51.100.1')).status, 404) - assert.equal((await restore('198.51.100.2')).status, 429) - const created = await request(api, '/v1/backups', { - method: 'POST', - headers: { 'content-type': 'application/json', 'x-forwarded-for': '198.51.100.3' }, - body: JSON.stringify(fixture().payload) - }) - assert.equal(created.status, 201) -}) - -test('uses the last forwarded address from an explicitly trusted proxy', async (t) => { - const api = await startApi({ - trustedProxy: true, - trustedProxyAddresses: ['127.0.0.1'], - rateLimit: { max: 1, windowMs: 60_000 } - }) - t.after(() => api.close()) - const body = JSON.stringify({ id: randomBytes(16).toString('base64url') }) - const restore = (forwarded) => request(api, '/v1/backups/restore', { - method: 'POST', - headers: { 'content-type': 'application/json', 'x-forwarded-for': forwarded }, - body - }) - - assert.equal((await restore('198.51.100.1, 203.0.113.9')).status, 404) - assert.equal((await restore('198.51.100.2, 203.0.113.9')).status, 429) -}) - -test('stores only server-derived source IP and keeps it out of public restore responses', async t => { - for (const [trustedProxy, forwarded, expected] of [ - [false, '198.51.100.2', '127.0.0.1'], - [true, '198.51.100.2, 203.0.113.9', '203.0.113.9'], - [true, '2001:db8::42', '2001:db8::42'], - [true, 'invalid', '127.0.0.1'] - ]) { - const api = await startApi({ trustedProxy, trustedProxyAddresses: ['127.0.0.1'] }) - t.after(() => api.close()) - const backup = fixture() - const create = payload => request(api, '/v1/backups', { method: 'POST', headers: { 'content-type': 'application/json', 'x-forwarded-for': forwarded }, body: JSON.stringify(payload) }) - assert.equal((await create({ ...backup.payload, sourceIp: '192.0.2.1' })).status, 400) - const created = await create(backup.payload) - assert.equal(created.status, 201) - const metadata = await created.json() - assert.equal(metadata.sourceIp, expected) - const file = join(api.rootDir, `${backup.payload.id}.json`) - const stored = JSON.parse(await readFile(file, 'utf8')) - assert.equal(stored.sourceIp, expected) - assert.equal(stored.createdAt, metadata.createdAt) - const restoreRecord = () => request(api, '/v1/backups/restore', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: backup.payload.id }) }) - assert.deepEqual(await (await restoreRecord()).json(), { blob: backup.payload.blob }) - delete stored.sourceIp - stored.version = 2 - await writeFile(file, JSON.stringify(stored)) - assert.equal((await restoreRecord()).status, 200) - } -}) - -test('keeps concurrency leases until storage mutations finish', async (t) => { - const api = await startApi({ maxConcurrentPerClient: 1, maxConcurrentTotal: 1 }) - t.after(() => api.close()) - const release = await lockfile.lock(api.rootDir, { - realpath: false, - lockfilePath: join(api.rootDir, '.storage.lock') - }) - const create = (backup) => request(api, '/v1/backups', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(backup.payload) - }) - const first = create(fixture()) - await new Promise((resolve) => setTimeout(resolve, 40)) - const second = create(fixture()) - let secondStatus - try { - secondStatus = await Promise.race([ - second.then((response) => response.status), - new Promise((resolve) => setTimeout(() => resolve('pending'), 100)) - ]) - } finally { - await release() - } - assert.equal((await first).status, 201) - if (secondStatus === 'pending') await second - assert.equal(secondStatus, 429) -}) - -test('times out incomplete request bodies', async (t) => { - const api = await startApi({ bodyTimeoutMs: 30 }) - t.after(() => api.close()) - const response = await new Promise((resolve, reject) => { - const socket = createConnection(new URL(api.baseUrl).port, '127.0.0.1') - let data = '' - socket.setEncoding('utf8') - socket.once('error', reject) - socket.on('data', (chunk) => { data += chunk }) - socket.on('end', () => resolve(data)) - socket.once('connect', () => { - socket.write('POST /v1/backups HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: 10\r\nConnection: close\r\n\r\n{') - }) - }) - - assert.match(response, /^HTTP\/1\.1 408 /) - assert.match(response, /request_timeout/) -}) - -test('caches health readiness instead of writing on every request', async (t) => { - const api = await startApi({ healthCacheMs: 60_000 }) - t.after(() => api.close()) - - assert.equal((await request(api, '/health')).status, 200) - await rm(api.rootDir, { recursive: true, force: true }) - assert.equal((await request(api, '/health')).status, 200) - await assert.rejects(stat(api.rootDir), { code: 'ENOENT' }) -}) - -test('cleans orphaned temporary files and enforces a record limit', async (t) => { - const api = await startApi({ maxRecords: 1 }) - t.after(() => api.close()) - const orphan = join(api.rootDir, `.${randomBytes(16).toString('hex')}.tmp`) - await writeFile(orphan, 'orphan') - const create = (backup) => request(api, '/v1/backups', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(backup.payload) - }) - - assert.equal((await create(fixture())).status, 201) - assert.equal((await create(fixture())).status, 507) - const files = await readdir(api.rootDir) - assert.equal(files.some((name) => name.endsWith('.tmp')), false) - assert.equal(files.filter((name) => name.endsWith('.json')).length, 1) -}) - -test('enforces atomic storage capacity without blocking existing restores', async (t) => { - const api = await startApi({ maxStorageBytes: 420 }) - const secondServer = createBackupServer({ rootDir: api.rootDir, allowedOrigins: [allowedOrigin], maxStorageBytes: 420 }) - await new Promise((resolve, reject) => { - secondServer.once('error', reject) - secondServer.listen(0, '127.0.0.1', resolve) - }) - t.after(() => api.close()) - t.after(() => new Promise((resolve) => secondServer.close(resolve))) - const first = fixture() - const second = fixture() - const create = (backup, baseUrl = api.baseUrl) => fetch(`${baseUrl}/v1/backups`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(backup.payload) - }) - - const results = await Promise.all([create(first), create(second, `http://127.0.0.1:${secondServer.address().port}`)]) - - assert.deepEqual(results.map((response) => response.status).sort(), [201, 507]) - assert.equal((await readdir(api.rootDir)).length, 1) -}) - -test('never accepts record ids in URLs and stores no client delete secret', async (t) => { - const api = await startApi() - t.after(() => api.close()) - const backup = fixture() - - assert.equal((await request(api, `/v1/backups/${backup.payload.id}`)).status, 404) - assert.equal((await request(api, `/v1/backups?backup=${backup.payload.id}`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(backup.payload) - })).status, 404) - - await request(api, '/v1/backups', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(backup.payload) - }) - const files = await readdir(api.rootDir) - const stored = await readFile(join(api.rootDir, files[0]), 'utf8') - assert.equal(stored.includes(backup.deleteSecret), false) -}) diff --git a/tests/account-auth.test.js b/tests/account-auth.test.js deleted file mode 100644 index 4ad69a8..0000000 --- a/tests/account-auth.test.js +++ /dev/null @@ -1,177 +0,0 @@ -const { test } = require('node:test'); -const assert = require('node:assert'); -const { createDoodstreamOtpCoordinator, 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), {}); -}); - -function otpRequired(message = 'OTP erforderlich') { - const error = new Error(message); - error.otpRequired = true; - return error; -} - -test('explicit web login is not overridden by a stored API key', () => { - assert.deepEqual(selectUploadAuth('doodstream.com', { authType: 'login', username: 'user', password: 'secret', apiKey: 'KEY' }), { username: 'user', password: 'secret' }); -}); - -test('upload reuses the confirmed OTP session without another login', async () => { - let logins = 0; - const uploadSession = {}; - const coordinator = createDoodstreamOtpCoordinator({ - createUploader: () => ({ async login() { logins++; }, cloneSession: () => uploadSession }) - }); - const health = await coordinator.check({ username: 'user', password: 'secret' }); - assert.equal(health.status, 'ok'); - assert.equal(await coordinator.acquire({ username: 'user', password: 'secret' }), uploadSession); - assert.equal(logins, 1); -}); - -test('concurrent and repeated Doodstream checks request only one OTP', async () => { - let loginCalls = 0; - let releaseLogin; - const coordinator = createDoodstreamOtpCoordinator({ - createUploader: () => ({ - login: () => new Promise((resolve, reject) => { - loginCalls++; - releaseLogin = () => reject(otpRequired('OTP gesendet')); - }) - }) - }); - const first = coordinator.check({ username: 'user', password: 'secret' }); - const second = coordinator.check({ username: 'user', password: 'secret' }); - assert.equal(loginCalls, 1); - releaseLogin(); - assert.deepEqual(await first, { status: 'otp_required', message: 'OTP gesendet' }); - assert.deepEqual(await second, { status: 'otp_required', message: 'OTP gesendet' }); - assert.deepEqual(await coordinator.check({ username: 'user', password: 'secret' }), { status: 'otp_required', message: 'OTP gesendet' }); - assert.equal(loginCalls, 1); -}); - -test('Doodstream OTP verification reuses the challenged uploader session', async () => { - let created = 0; - const calls = []; - const coordinator = createDoodstreamOtpCoordinator({ - createUploader: () => { - created++; - return { - async login(_username, _password, otp) { - calls.push(otp || ''); - if (!otp) throw otpRequired('OTP gesendet'); - } - }; - } - }); - assert.equal((await coordinator.check({ username: 'user', password: 'secret' })).status, 'otp_required'); - assert.deepEqual(await coordinator.check({ username: 'user', password: 'secret', otp: '123456' }), { - status: 'ok', - message: 'Login erfolgreich' - }); - assert.equal(created, 1); - assert.deepEqual(calls, ['', '123456']); -}); - -test('a rejected OTP remains pending without requesting another code', async () => { - let created = 0; - let calls = 0; - const coordinator = createDoodstreamOtpCoordinator({ - createUploader: () => { - created++; - return { - async login(_username, _password, otp) { - calls++; - if (!otp) throw otpRequired('OTP gesendet'); - throw new Error('Code ungültig'); - } - }; - } - }); - await coordinator.check({ username: 'user', password: 'secret' }); - assert.deepEqual(await coordinator.check({ username: 'user', password: 'secret', otp: '000000' }), { - status: 'otp_required', - message: 'Code ungültig' - }); - assert.deepEqual(await coordinator.check({ username: 'user', password: 'secret' }), { - status: 'otp_required', - message: 'Code ungültig' - }); - assert.equal(created, 1); - assert.equal(calls, 2); -}); - -test('explicit OTP resend is rate-limited and starts one fresh session', async () => { - let currentTime = 1000; - let created = 0; - const coordinator = createDoodstreamOtpCoordinator({ - now: () => currentTime, - resendCooldownMs: 60000, - createUploader: () => { - created++; - return { login: async () => { throw otpRequired('OTP gesendet'); } }; - } - }); - await coordinator.check({ username: 'user', password: 'secret' }); - await coordinator.check({ username: 'user', password: 'secret', requestNewChallenge: true }); - assert.equal(created, 1); - currentTime += 60000; - await coordinator.check({ username: 'user', password: 'secret', requestNewChallenge: true }); - assert.equal(created, 2); -}); - -test('expired OTP submission cannot create a replacement challenge implicitly', async () => { - let currentTime = 1000; - let created = 0; - const coordinator = createDoodstreamOtpCoordinator({ - now: () => currentTime, - challengeTtlMs: 1000, - createUploader: () => { - created++; - return { login: async () => { throw otpRequired('OTP gesendet'); } }; - } - }); - await coordinator.check({ username: 'user', password: 'secret' }); - currentTime += 1001; - assert.deepEqual(await coordinator.check({ username: 'user', password: 'secret', otp: '123456' }), { - status: 'otp_required', - message: 'OTP-Anfrage ist abgelaufen. Bitte einen neuen Code anfordern.' - }); - assert.equal(created, 1); -}); diff --git a/tests/account-rotation.test.js b/tests/account-rotation.test.js deleted file mode 100644 index 88cd816..0000000 --- a/tests/account-rotation.test.js +++ /dev/null @@ -1,263 +0,0 @@ -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('Maximum storage space of the account used up.')), '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'); -}); diff --git a/tests/account-status.test.js b/tests/account-status.test.js deleted file mode 100644 index dbe836f..0000000 --- a/tests/account-status.test.js +++ /dev/null @@ -1,82 +0,0 @@ -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const { - getAccountGroupStatus, - getAccountPausePresentation, - getAccountStatusPresentation, - formatAccountPauseRemaining, - subscribeAccountPauseSnapshots -} = require('../renderer/account-status'); - -test('mixed account results use warning group status', () => { - assert.equal(getAccountGroupStatus({ total: 3, disabled: 0, ok: 2, error: 1, checking: 0, unchecked: 0 }), 'warn'); -}); - -test('group is red only when every active account has an error', () => { - assert.equal(getAccountGroupStatus({ total: 3, disabled: 0, ok: 0, error: 3, checking: 0, unchecked: 0 }), 'error'); -}); - -test('group is green when every active account is ready', () => { - assert.equal(getAccountGroupStatus({ total: 3, disabled: 0, ok: 3, error: 0, checking: 0, unchecked: 0 }), 'ok'); -}); - -test('warning-only accounts keep the group orange', () => { - assert.equal(getAccountGroupStatus({ total: 2, disabled: 0, ok: 0, warn: 2, error: 0, checking: 0, unchecked: 0 }), 'warn'); -}); - -test('OTP-required account exposes warning presentation', () => { - assert.deepEqual(getAccountStatusPresentation('otp_required'), { - statusClass: 'warn', - label: 'OTP erforderlich', - requiresOtp: true - }); -}); - -test('timed account pause presentation exposes a stable countdown', () => { - assert.deepEqual(getAccountPausePresentation({ mode: 'cooldown', pausedUntil: 905_000 }, 5_000), { - mode: 'cooldown', - remainingSeconds: 900, - expired: false - }); -}); - -test('manual account pause presentation requires action and timed pauses expire at zero', () => { - assert.deepEqual(getAccountPausePresentation({ mode: 'manual', pausedUntil: null }, 5_000), { - mode: 'manual', - remainingSeconds: null, - expired: false - }); - assert.deepEqual(getAccountPausePresentation({ mode: 'cooldown', pausedUntil: 5_000 }, 5_000), { - mode: 'cooldown', - remainingSeconds: 0, - expired: true - }); -}); - -test('account pause countdown uses fixed two-digit seconds and supports two-hour cooldowns', () => { - assert.equal(formatAccountPauseRemaining(900), '15:00'); - assert.equal(formatAccountPauseRemaining(3599), '59:59'); - assert.equal(formatAccountPauseRemaining(7200), '120:00'); -}); - -test('account pause subscription is installed before the initial snapshot is requested', async () => { - const order = []; - let push; - let resolveInitial; - const initial = new Promise(resolve => { resolveInitial = resolve; }); - const applied = []; - const api = { - onSessionFailedAccountsChanged: callback => { order.push('subscribe'); push = callback; }, - getSessionFailedAccountStates: () => { order.push('load'); return initial; } - }; - const pending = subscribeAccountPauseSnapshots(api, snapshot => applied.push(snapshot)); - push({ revision: 2, accounts: [{ accountId: 'live' }] }); - resolveInitial({ revision: 1, accounts: [] }); - await pending; - - assert.deepEqual(order, ['subscribe', 'load']); - assert.deepEqual(applied, [ - { revision: 2, accounts: [{ accountId: 'live' }] }, - { revision: 1, accounts: [] } - ]); -}); diff --git a/tests/auto-resume.test.js b/tests/auto-resume.test.js deleted file mode 100644 index 7043ac8..0000000 --- a/tests/auto-resume.test.js +++ /dev/null @@ -1,82 +0,0 @@ -const test = require('node:test'); -const assert = require('node:assert/strict'); - -const { getAutoResumeJobs, createAutoResumeController } = require('../renderer/auto-resume'); - -test('auto resume includes only restored waiting jobs', () => { - const jobs = [ - { id: 'queued', status: 'queued', file: 'a', hoster: 'h' }, - { id: 'preview', status: 'preview', file: 'b', hoster: 'h' }, - { id: 'error', status: 'error', file: 'c', hoster: 'h' }, - { id: 'skipped', status: 'skipped', file: 'd', hoster: 'h' }, - { id: 'done', status: 'done', file: 'e', hoster: 'h' }, - { id: 'invalid', status: 'queued', file: '', hoster: 'h' } - ]; - assert.deepEqual(getAutoResumeJobs(jobs).map(job => job.id), ['queued', 'preview']); -}); - -test('countdown is visible, cancelable, and starts exactly once', () => { - let tick; - let cleared = 0; - let starts = 0; - const updates = []; - const controller = createAutoResumeController({ - delaySeconds: 2, - setIntervalFn: callback => { tick = callback; return 7; }, - clearIntervalFn: id => { assert.equal(id, 7); cleared++; }, - onTick: (seconds, count) => updates.push([seconds, count]), - onStart: jobIds => { assert.deepEqual(jobIds, ['one', 'two', 'three']); starts++; } - }); - assert.equal(controller.schedule(['one', 'two', 'three']), true); - assert.deepEqual(updates, [[2, 3]]); - tick(); - assert.deepEqual(updates, [[2, 3], [1, 3]]); - tick(); - tick(); - assert.equal(starts, 1); - assert.equal(cleared, 1); - assert.equal(controller.pending, false); -}); - -test('cancel prevents the scheduled start', () => { - let tick; - let starts = 0; - let canceled = 0; - const controller = createAutoResumeController({ - setIntervalFn: callback => { tick = callback; return 9; }, - clearIntervalFn: () => {}, - onTick: () => {}, - onStart: () => { starts++; }, - onCancel: () => { canceled++; } - }); - controller.schedule(['restored']); - assert.equal(controller.cancel(), true); - tick(); - assert.equal(starts, 0); - assert.equal(canceled, 1); -}); - -test('countdown starts only jobs captured when it was scheduled', () => { - let tick; - let startedJobIds = []; - const jobs = [ - { id: 'restored-a', status: 'queued', file: 'a', hoster: 'h' }, - { id: 'restored-b', status: 'preview', file: 'b', hoster: 'h' } - ]; - const plannedJobIds = getAutoResumeJobs(jobs).map(job => job.id); - const controller = createAutoResumeController({ - delaySeconds: 1, - setIntervalFn: callback => { tick = callback; return 11; }, - clearIntervalFn: () => {}, - onTick: () => {}, - onStart: jobIds => { - startedJobIds = getAutoResumeJobs(jobs, jobIds).map(job => job.id); - } - }); - - assert.equal(controller.schedule(plannedJobIds), true); - jobs.push({ id: 'added-during-countdown', status: 'queued', file: 'c', hoster: 'h' }); - tick(); - - assert.deepEqual(startedJobIds, ['restored-a', 'restored-b']); -}); diff --git a/tests/automation-control.test.js b/tests/automation-control.test.js deleted file mode 100644 index 789913f..0000000 --- a/tests/automation-control.test.js +++ /dev/null @@ -1,453 +0,0 @@ -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); -}); diff --git a/tests/backup-crypto.test.js b/tests/backup-crypto.test.js deleted file mode 100644 index 476f6b4..0000000 --- a/tests/backup-crypto.test.js +++ /dev/null @@ -1,85 +0,0 @@ -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)); - }); - - it('password-protected backups require the exact password', () => { - const buf = encrypt(sampleConfig, 'correct horse battery staple'); - assert.equal(buf.subarray(0, 4).toString('ascii'), 'MHU2'); - assert.throws(() => decrypt(buf), (error) => error.needsPassword === true); - assert.throws(() => decrypt(buf, 'wrong password'), /Falsches Passwort/); - assert.deepStrictEqual(decrypt(buf, 'correct horse battery staple'), sampleConfig); - }); - - it('rejects an empty password instead of silently using the built-in key', () => { - assert.throws(() => encrypt(sampleConfig, ' '), /Passwort/); - }); -}); diff --git a/tests/byse-reject-recovery.test.js b/tests/byse-reject-recovery.test.js deleted file mode 100644 index 9740478..0000000 --- a/tests/byse-reject-recovery.test.js +++ /dev/null @@ -1,234 +0,0 @@ -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 () => '502 Bad Gateway502 Bad Gateway' } - })); - 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 - ); -}); diff --git a/tests/coalesced-set.test.js b/tests/coalesced-set.test.js deleted file mode 100644 index a2be092..0000000 --- a/tests/coalesced-set.test.js +++ /dev/null @@ -1,144 +0,0 @@ -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]); -}); diff --git a/tests/config-store.test.js b/tests/config-store.test.js deleted file mode 100644 index 0a43c30..0000000 --- a/tests/config-store.test.js +++ /dev/null @@ -1,1048 +0,0 @@ -const { describe, it, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const Module = require('node:module'); -const originalLoad = Module._load; -const safeStorage = { - isEncryptionAvailable: () => true, - encryptString: value => Buffer.from(`test-protected:${value}`), - decryptString: value => value.toString().replace(/^test-protected:/, '') -}; -Module._load = function load(request, parent, isMain) { - if (request === 'electron') return { safeStorage }; - return originalLoad.call(this, request, parent, isMain); -}; -const ConfigStore = require('../lib/config-store'); -require('../lib/secret-store').encryptField('test-initialization'); -Module._load = originalLoad; - -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'); - store.automationCompletionPath = path.join(tmpDir, 'automation-completions.json'); - return store; -} - -function createStoreAt(filePath) { - const configuredStore = new ConfigStore({ - isPackaged: false, - getPath: () => path.dirname(filePath) - }); - configuredStore.filePath = filePath; - configuredStore.historyPath = path.join(path.dirname(filePath), 'electron-history.json'); - configuredStore.automationCompletionPath = path.join(path.dirname(filePath), 'automation-completions.json'); - return configuredStore; -} - -describe('ConfigStore', () => { - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-test-')); - store = createStore(); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('explicit user-data-dir isolates development config writes from the project', async () => { - const isolatedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-user-data-')); - const projectConfigPath = path.join(__dirname, '..', 'electron-config.json'); - const projectConfigExisted = fs.existsSync(projectConfigPath); - const projectConfigBefore = projectConfigExisted ? fs.readFileSync(projectConfigPath) : null; - const explicitStore = new ConfigStore({ - isPackaged: false, - commandLine: { - hasSwitch: (name) => name === 'user-data-dir' - }, - getPath: (name) => { - if (name === 'userData') return isolatedDir; - if (name === 'exe') return path.join(isolatedDir, 'Multi-Hoster-Upload.exe'); - throw new Error(`Unexpected app path: ${name}`); - } - }); - - try { - assert.equal(explicitStore.filePath, path.join(isolatedDir, 'electron-config.json')); - await explicitStore.save({ globalSettings: { alwaysOnTop: true } }); - assert.equal(fs.existsSync(path.join(isolatedDir, 'electron-config.json')), true); - assert.equal(fs.existsSync(projectConfigPath), projectConfigExisted); - if (projectConfigBefore) { - assert.equal(fs.readFileSync(projectConfigPath).equals(projectConfigBefore), true); - } - } finally { - fs.rmSync(isolatedDir, { recursive: true, force: true }); - } - }); - - it('load returns defaults when file does not exist', () => { - const config = store.load(); - assert.ok(config.hosters); - assert.ok(config.hosters['doodstream.com']); - assert.ok(config.hosters['voe.sx']); - assert.ok(config.hosters['vidmoly.me']); - assert.ok(config.hosters['byse.sx']); - assert.ok(config.hosterSettings); - assert.equal(config.hosterSettings['doodstream.com'].retries, 3); - assert.equal(config.hosterSettings['doodstream.com'].parallelCount, 2); - assert.equal(config.globalSettings.alwaysOnTop, false); - assert.equal(config.globalSettings.language, 'en'); - assert.equal(config.globalSettings.shutdownAfterFinish, 'nothing'); - assert.equal(config.globalSettings.logFilePath, ''); - assert.equal(config.globalSettings.resumeQueueOnLaunch, true); - assert.equal(Object.hasOwn(config.globalSettings, 'allowPlaintextCredentialStorage'), false); - assert.equal(config.globalSettings.parallelUploadCount, 0); - assert.equal(config.globalSettings.scaleParallelUploads, false); - assert.equal(config.globalSettings.lastBrowseDirectory, ''); - assert.equal(config.globalSettings.pendingQueue, null); - assert.deepEqual(config.history, []); - }); - - it('automation defaults persist a 15000 job limit and five minute interval', () => { - const settings = store.load().globalSettings.folderMonitor; - - assert.equal(settings.queueLimitJobs, 15000); - assert.equal(settings.reconcileIntervalMinutes, 5); - assert.equal(settings.paused, false); - assert.equal(settings.pausedAt, null); - }); - - it('automation pause survives a save and reload', async () => { - await store.save({ globalSettings: { folderMonitor: { paused: true, pausedAt: 1787712000000 } } }); - - const reloaded = createStoreAt(store.filePath).load(); - - assert.equal(reloaded.globalSettings.folderMonitor.paused, true); - assert.equal(reloaded.globalSettings.folderMonitor.pausedAt, 1787712000000); - }); - - it('automation completions survive queue clearing and can be removed explicitly', async () => { - const completion = { - path: 'C:\\watch\\episode.mkv', - size: 1024, - mtimeMs: 1787828400123, - hoster: 'doodstream.com', - completedAt: 1787828500000 - }; - - await store.saveAutomationCompletions([completion]); - await store.savePendingQueue(null); - await store.appendHistory({ id: 'old', timestamp: '2026-01-01T00:00:00.000Z', files: [] }); - await store.clearHistory(); - - const reloaded = createStoreAt(store.filePath); - assert.deepEqual(await reloaded.loadAutomationCompletions(), [completion]); - - await reloaded.clearAutomationCompletions([{ path: completion.path, hoster: completion.hoster }]); - assert.deepEqual(await reloaded.loadAutomationCompletions(), []); - assert.equal(JSON.parse(fs.readFileSync(reloaded.automationCompletionPath, 'utf8')).version, 1); - }); - - it('automation completion writes remain serialized without evicting older unique paths', async () => { - const first = store.saveAutomationCompletions([ - { path: 'C:\\watch\\old.mkv', size: 1, mtimeMs: 1, hoster: 'voe.sx', completedAt: 1 } - ], { maxEntries: 2 }); - const second = store.saveAutomationCompletions([ - { path: 'C:\\watch\\middle.mkv', size: 2, mtimeMs: 2, hoster: 'voe.sx', completedAt: 2 }, - { path: 'C:\\watch\\new.mkv', size: 3, mtimeMs: 3, hoster: 'byse.sx', completedAt: 3 } - ], { maxEntries: 2 }); - - await Promise.all([first, second]); - await store.drainAutomationCompletionWrites(); - - assert.deepEqual((await store.loadAutomationCompletions()).map(entry => entry.path), [ - 'C:\\watch\\old.mkv', - 'C:\\watch\\middle.mkv', - 'C:\\watch\\new.mkv' - ]); - }); - - it('corrupted automation completion evidence fails closed', async () => { - fs.writeFileSync(store.automationCompletionPath, '{broken', 'utf8'); - await assert.rejects(store.loadAutomationCompletions()); - const reloaded = createStoreAt(store.filePath); - fs.writeFileSync(reloaded.automationCompletionPath, JSON.stringify({ version: 1, entries: [{ path: 'C:\\watch\\invalid.mkv' }] }), 'utf8'); - await assert.rejects(reloaded.loadAutomationCompletions(), /ungültig/); - }); - - it('automation completion drain waits for an active durable write', async () => { - const originalWrite = store._writeAutomationCompletionFile.bind(store); - let releaseWrite; - store._writeAutomationCompletionFile = entries => new Promise((resolve, reject) => { - releaseWrite = () => originalWrite(entries).then(resolve, reject); - }); - const saving = store.saveAutomationCompletions([ - { path: 'C:\\watch\\drain.mkv', size: 1, mtimeMs: 2, hoster: 'voe.sx', completedAt: 3 } - ]); - while (!releaseWrite) await new Promise(resolve => setImmediate(resolve)); - let drained = false; - const draining = store.drainAutomationCompletionWrites().then(() => { drained = true; }); - await new Promise(resolve => setImmediate(resolve)); - assert.equal(drained, false); - releaseWrite(); - await Promise.all([saving, draining]); - assert.equal(drained, true); - }); - - it('drops the retired plaintext credential setting from legacy configurations', () => { - fs.writeFileSync(store.filePath, JSON.stringify({ - hosters: {}, - hosterSettings: {}, - globalSettings: { allowPlaintextCredentialStorage: true } - }), 'utf-8'); - - const config = store.load(); - - assert.equal(Object.hasOwn(config.globalSettings, 'allowPlaintextCredentialStorage'), false); - }); - - it('never writes the retired plaintext credential setting back to disk', async () => { - await store.save({ - globalSettings: { allowPlaintextCredentialStorage: true } - }); - - const saved = JSON.parse(fs.readFileSync(store.filePath, 'utf-8')); - - assert.equal(Object.hasOwn(saved.globalSettings, 'allowPlaintextCredentialStorage'), false); - }); - - it('keeps permanent source deletion disabled by default', () => { - const config = store.load(); - assert.equal(config.globalSettings.deleteSourceAfterSuccessfulUpload, false); - }); - - it('save then load round-trips', async () => { - await store.save({ hosters: { 'doodstream.com': [{ id: 'test-1', enabled: true, authType: 'api', apiKey: 'test-key-123' }] } }); - const config = store.load(); - assert.equal(config.hosters['doodstream.com'][0].apiKey, 'test-key-123'); - }); - - it('default logMode is "single"', () => { - const config = store.load(); - assert.equal(config.globalSettings.logMode, 'single'); - }); - - it('persists pendingQueue incl. savedAt (number) and ts-bearing jobs across save/load', async () => { - // The queue-persistence fix stamps pendingQueue.savedAt and restores it on launch. - // This proves the persistence layer round-trips the new fields untouched (the - // ts-gate is worthless if savedAt does not survive serialization). - const pendingQueue = { - savedAt: 1750000000123, - selectedUploadHosters: ['voe.sx', 'byse.sx'], - selectedFiles: [{ path: 'C:/dl/a.mkv', name: 'a.mkv', size: 4242 }], - queueJobs: [ - { id: 'j1', file: 'C:/dl/a.mkv', fileName: 'a.mkv', hoster: 'voe.sx', status: 'preview', bytesTotal: 4242, maxAttempts: 0 }, - { id: 'j2', file: 'C:/dl/a.mkv', fileName: 'a.mkv', hoster: 'byse.sx', status: 'error', error: 'boom', maxAttempts: 3 } - ] - }; - const current = store.load(); - await store.save({ globalSettings: { ...current.globalSettings, pendingQueue } }); - const loaded = store.load(); - const pq = loaded.globalSettings.pendingQueue; - assert.equal(pq.savedAt, 1750000000123, 'savedAt epoch survives JSON round-trip'); - assert.equal(typeof pq.savedAt, 'number'); - assert.equal(pq.queueJobs.length, 2); - assert.equal(pq.queueJobs[0].fileName, 'a.mkv'); - assert.equal(pq.queueJobs[0].hoster, 'voe.sx'); - assert.equal(pq.queueJobs[1].status, 'error'); - assert.deepEqual(pq.selectedUploadHosters, ['voe.sx', 'byse.sx']); - assert.equal(pq.selectedFiles[0].path, 'C:/dl/a.mkv'); - }); - - it('pendingQueue can be cleared back to null (clearPersistedQueueStateSoon path)', async () => { - const current = store.load(); - await store.save({ globalSettings: { ...current.globalSettings, pendingQueue: { savedAt: 1, queueJobs: [] } } }); - assert.ok(store.load().globalSettings.pendingQueue); - const c2 = store.load(); - await store.save({ globalSettings: { ...c2.globalSettings, pendingQueue: null } }); - assert.equal(store.load().globalSettings.pendingQueue, null); - }); - - it('regression: legacy sessionLog:true on disk normalizes to logMode "daily" (NOT "session")', async () => { - // Write a config with the legacy boolean only (what an existing user has). - await store.save({ globalSettings: { sessionLog: true } }); - const config = store.load(); - // The misnamed legacy field MUST map to daily — mapping to "session" would - // silently change every per-day user's behaviour on upgrade. - assert.equal(config.globalSettings.logMode, 'daily'); - }); - - it('logMode round-trips for all three values', async () => { - for (const mode of ['single', 'daily', 'session']) { - await store.save({ globalSettings: { logMode: mode } }); - const config = store.load(); - assert.equal(config.globalSettings.logMode, mode, `mode ${mode}`); - } - }); - - it('load merges with defaults for missing hosters', () => { - // Write partial config in old single-object format (triggers migration) - fs.writeFileSync(store.filePath, JSON.stringify({ - hosters: { 'doodstream.com': { apiKey: 'abc' } } - }), 'utf-8'); - - const config = store.load(); - // Old format is migrated to array - assert.ok(Array.isArray(config.hosters['doodstream.com'])); - assert.equal(config.hosters['doodstream.com'][0].apiKey, 'abc'); - // Other hosters should still have defaults (empty arrays) - assert.ok(Array.isArray(config.hosters['voe.sx'])); - assert.equal(config.hosters['voe.sx'].length, 0); - }); - - it('hosterSettings merge fills gaps with defaults', () => { - fs.writeFileSync(store.filePath, JSON.stringify({ - hosterSettings: { 'voe.sx': { retries: 5 } } - }), 'utf-8'); - - const config = store.load(); - assert.equal(config.hosterSettings['voe.sx'].retries, 5); - assert.equal(config.hosterSettings['voe.sx'].parallelCount, 2); // default - assert.equal(config.hosterSettings['voe.sx'].maxSpeedKbs, 0); // default - assert.equal(config.hosterSettings['voe.sx'].logToFile, true); // default on - }); - - it('logToFile defaults to true for every hoster', () => { - const config = store.load(); - for (const name of ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc']) { - assert.equal(config.hosterSettings[name].logToFile, true, `${name} should default logToFile=true`); - } - }); - - it('sizeMemoEnabled defaults to true for every hoster and persists when disabled', async () => { - const fresh = store.load(); - for (const name of ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc']) { - assert.equal(fresh.hosterSettings[name].sizeMemoEnabled, true, `${name} should default sizeMemoEnabled=true`); - } - await store.save({ hosterSettings: { 'byse.sx': { sizeMemoEnabled: false } } }); - const config = store.load(); - assert.equal(config.hosterSettings['byse.sx'].sizeMemoEnabled, false, 'explicit false preserved'); - assert.equal(config.hosterSettings['voe.sx'].sizeMemoEnabled, true, 'other hoster still defaults on'); - }); - - it('logToFile=false persists and survives reload', async () => { - await store.save({ hosterSettings: { 'voe.sx': { logToFile: false } } }); - const config = store.load(); - assert.equal(config.hosterSettings['voe.sx'].logToFile, false, 'explicit false preserved'); - assert.equal(config.hosterSettings['byse.sx'].logToFile, true, 'other hoster still defaults on'); - }); - - it('save only updates provided sections', async () => { - // Save hoster settings first - await store.save({ hosterSettings: { 'doodstream.com': { retries: 10, maxSpeedKbs: 0, parallelCount: 2, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } } }); - // Save hosters credentials separately (array format) - await store.save({ hosters: { 'doodstream.com': [{ id: 'test-1', enabled: true, authType: 'api', apiKey: 'key123' }] } }); - - const config = store.load(); - assert.equal(config.hosters['doodstream.com'][0].apiKey, 'key123'); - assert.equal(config.hosterSettings['doodstream.com'].retries, 10); // preserved - }); - - it('appendHistory keeps complete history without truncation', async () => { - for (let i = 0; i < 105; i++) { - await store.appendHistory({ id: `batch-${i}`, timestamp: new Date().toISOString(), files: [] }); - } - const history = store.loadHistory(); - assert.equal(history.length, 105); - assert.equal(history[0].id, 'batch-0'); - assert.equal(history[104].id, 'batch-104'); - }); - - it('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('savePendingQueue does not block the event loop on slow synchronous filesystem methods', async () => { - await store.save({ - hosters: { 'byse.sx': [{ id: 'non-blocking-account', enabled: true, authType: 'api', apiKey: 'test-key' }] }, - globalSettings: { alwaysOnTop: true } - }); - store.load(); - - const syncMethods = ['openSync', 'writeSync', 'fsyncSync', 'readFileSync', 'writeFileSync', 'renameSync']; - const originals = new Map(syncMethods.map(name => [name, fs[name]])); - const waitBuffer = new Int32Array(new SharedArrayBuffer(4)); - const stallMs = 35; - let eventLoopDelayMs; - - for (const name of syncMethods) { - fs[name] = (...args) => { - Atomics.wait(waitBuffer, 0, 0, stallMs); - return originals.get(name)(...args); - }; - } - - try { - const startedAt = performance.now(); - const eventLoopTick = new Promise(resolve => { - setTimeout(() => { - eventLoopDelayMs = performance.now() - startedAt; - resolve(); - }, 0); - }); - const save = store.savePendingQueue({ savedAt: 5, queueJobs: [{ id: 'non-blocking' }] }); - - await eventLoopTick; - await save; - } finally { - for (const [name, original] of originals) fs[name] = original; - } - - assert.ok( - eventLoopDelayMs < stallMs * 3, - `queue save blocked the event loop for ${eventLoopDelayMs.toFixed(1)} ms` - ); - }); - - it('folder monitor runtime saves preserve a concurrently queued pending queue snapshot', async () => { - const pendingQueue = { - savedAt: 1787712000000, - queueJobs: [{ id: 'paused-job', automationPaused: true }] - }; - - const queueSave = store.savePendingQueue(pendingQueue); - const runtimeSave = store.saveFolderMonitorRuntimeState({ paused: false, pausedAt: null }); - await Promise.all([queueSave, runtimeSave]); - - const current = store.load().globalSettings; - assert.deepEqual(current.pendingQueue, pendingQueue); - assert.equal(current.folderMonitor.paused, false); - assert.equal(current.folderMonitor.pausedAt, null); - }); - - it('folder monitor runtime saves cannot revert concurrently queued monitor settings', async () => { - const stale = store.load().globalSettings.folderMonitor; - const currentSettings = { - ...stale, - folderPath: 'D:\\new-watch', - hosters: ['byse.sx'], - filterMode: 'exclude', - autoStart: false - }; - - const settingsSave = store.save({ - globalSettings: { - ...store.load().globalSettings, - folderMonitor: currentSettings - } - }); - const runtimeSave = store.saveFolderMonitorRuntimeState({ - ...stale, - paused: true, - pausedAt: 1787712000000 - }); - await Promise.all([settingsSave, runtimeSave]); - - const folderMonitor = store.load().globalSettings.folderMonitor; - assert.equal(folderMonitor.folderPath, 'D:\\new-watch'); - assert.deepEqual(folderMonitor.hosters, ['byse.sx']); - assert.equal(folderMonitor.filterMode, 'exclude'); - assert.equal(folderMonitor.autoStart, false); - assert.equal(folderMonitor.paused, true); - assert.equal(folderMonitor.pausedAt, 1787712000000); - }); - - it('drainWrites waits for config and history writes appended while draining', async () => { - assert.equal(typeof store.drainWrites, 'function'); - await store.save({ globalSettings: { alwaysOnTop: false } }); - store._historyMigrated = true; - fs.writeFileSync(store.historyPath, '[]', 'utf-8'); - - const originalAtomicWrite = store._atomicWrite.bind(store); - const originalHistoryWrite = store._writeHistoryFileAtomic.bind(store); - const configReleases = []; - const historyReleases = []; - const block = (releases, operation) => new Promise((resolve, reject) => { - releases.push(() => Promise.resolve().then(operation).then(resolve, reject)); - }); - store._atomicWrite = (data) => block(configReleases, () => originalAtomicWrite(data)); - store._writeHistoryFileAtomic = (history) => block(historyReleases, () => originalHistoryWrite(history)); - - const configWrites = store.save({ globalSettings: { alwaysOnTop: true } }) - .then(() => store.save({ hosterSettings: { 'byse.sx': { retries: 8 } } })); - const historyWrites = store.appendHistory({ id: 'first', files: [] }) - .then(() => store.appendHistory({ id: 'second', files: [] })); - - while (configReleases.length < 1 || historyReleases.length < 1) await new Promise(resolve => setImmediate(resolve)); - let drained = false; - const draining = store.drainWrites().then(() => { drained = true; }); - await new Promise(resolve => setImmediate(resolve)); - assert.equal(drained, false); - - configReleases.shift()(); - historyReleases.shift()(); - while (configReleases.length < 1 || historyReleases.length < 1) await new Promise(resolve => setImmediate(resolve)); - await new Promise(resolve => setImmediate(resolve)); - assert.equal(drained, false); - - configReleases.shift()(); - historyReleases.shift()(); - await Promise.all([configWrites, historyWrites, draining]); - assert.equal(store.load().globalSettings.alwaysOnTop, true); - assert.equal(store.load().hosterSettings['byse.sx'].retries, 8); - assert.deepEqual(store.loadHistory().map(entry => entry.id), ['first', 'second']); - }); - - it('drainWrites ignores caller-handled failures but propagates a write failing during the drain', async () => { - const originalAtomicWrite = store._atomicWrite.bind(store); - store._atomicWrite = () => Promise.reject(new Error('config write failed')); - await assert.rejects(store.save({ globalSettings: { alwaysOnTop: true } }), /config write failed/); - await store.drainWrites(); - - store._atomicWrite = originalAtomicWrite; - store._historyMigrated = true; - fs.writeFileSync(store.historyPath, '[]', 'utf-8'); - let rejectHistoryWrite; - store._writeHistoryFileAtomic = () => new Promise((_resolve, reject) => { rejectHistoryWrite = reject; }); - const pendingWrite = store.appendHistory({ id: 'failed', files: [] }); - pendingWrite.catch(() => {}); - while (!rejectHistoryWrite) await new Promise(resolve => setImmediate(resolve)); - const draining = store.drainWrites(); - rejectHistoryWrite(new Error('history write failed')); - await assert.rejects(draining, /history write failed/); - await assert.rejects(pendingWrite, /history write failed/); - await store.drainWrites(); - }); - - it('surfaces a failed queued write and keeps later writes usable', async () => { - const originalAtomicWrite = store._atomicWrite.bind(store); - let failNextWrite = true; - store._atomicWrite = (data) => { - if (failNextWrite) { - failNextWrite = false; - return Promise.reject(new Error('write failed')); - } - return originalAtomicWrite(data); - }; - - await assert.rejects(store.appendHistory({ id: 'failed', files: [] }), /write failed/); - await store.appendHistory({ id: 'saved', files: [] }); - assert.deepEqual(store.loadHistory().map(entry => entry.id), ['saved']); - }); - - it('serializes a complete settings replacement with pending saves', async () => { - const originalAtomicWrite = store._atomicWrite.bind(store); - let activeWrites = 0; - let maximumActiveWrites = 0; - store._atomicWrite = async (data) => { - activeWrites += 1; - maximumActiveWrites = Math.max(maximumActiveWrites, activeWrites); - await new Promise((resolve) => setTimeout(resolve, 15)); - try { - await originalAtomicWrite(data); - } finally { - activeWrites -= 1; - } - }; - - const save = store.save({ globalSettings: { alwaysOnTop: true, pendingQueue: { savedAt: 123, queueJobs: [{ id: 'local' }] } } }); - const replace = store.replaceSettings({ - hosters: { 'byse.sx': [{ id: 'imported', enabled: true, authType: 'api', apiKey: 'imported-key' }] }, - hosterSettings: { 'byse.sx': { retries: 9 } }, - globalSettings: { alwaysOnTop: false, pendingQueue: null }, - history: [], - rotationCursors: {} - }); - - await Promise.all([save, replace]); - const config = store.load(); - assert.equal(maximumActiveWrites, 1); - assert.equal(config.hosters['byse.sx'][0].apiKey, 'imported-key'); - assert.equal(config.hosterSettings['byse.sx'].retries, 9); - assert.equal(config.globalSettings.alwaysOnTop, false); - assert.deepEqual(config.globalSettings.pendingQueue, { savedAt: 123, queueJobs: [{ id: 'local' }] }); - assert.deepEqual(config.rotationCursors, {}); - }); - - it('patches the pending queue after an import without reverting imported settings', async () => { - await store.save({ - globalSettings: { - alwaysOnTop: true, - webhookUrl: 'https://before.invalid', - pendingQueue: { savedAt: 1, queueJobs: [{ id: 'before' }] } - } - }); - - const replace = store.replaceSettings({ - hosters: { 'byse.sx': [{ id: 'imported', enabled: true, authType: 'api', apiKey: 'imported-key' }] }, - hosterSettings: { 'byse.sx': { retries: 9 } }, - globalSettings: { - alwaysOnTop: false, - webhookUrl: 'https://imported.invalid', - pendingQueue: null - }, - history: [], - rotationCursors: {} - }); - const pendingQueue = { - savedAt: 2, - queueJobs: [{ id: 'live', status: 'done' }] - }; - const saveQueue = store.savePendingQueue(pendingQueue); - - await Promise.all([replace, saveQueue]); - const config = store.load(); - assert.equal(config.hosters['byse.sx'][0].id, 'imported'); - assert.equal(config.globalSettings.alwaysOnTop, false); - assert.equal(config.globalSettings.webhookUrl, 'https://imported.invalid'); - assert.deepEqual(config.globalSettings.pendingQueue, pendingQueue); - }); - - it('preserves main-owned global state when saving a renderer snapshot', async () => { - await store.save({ - globalSettings: { - alwaysOnTop: false, - pendingQueue: { savedAt: 3, queueJobs: [{ id: 'local' }] }, - diagnostics: { enabled: true, port: 7777 }, - historyRetention: '7d', - remote: { enabled: true, port: 9100, token: 'main-token', allowInput: true } - } - }); - - await store.saveRendererGlobalSettings({ - alwaysOnTop: true, - pendingQueue: null, - diagnostics: { enabled: false, port: 1 }, - historyRetention: 'all', - remote: { enabled: true, port: 9200, token: '', allowInput: false } - }); - - const config = store.load(); - assert.equal(config.globalSettings.alwaysOnTop, true); - assert.deepEqual(config.globalSettings.pendingQueue, { savedAt: 3, queueJobs: [{ id: 'local' }] }); - assert.equal(config.globalSettings.diagnostics.enabled, true); - assert.equal(config.globalSettings.diagnostics.port, 7777); - assert.equal(config.globalSettings.historyRetention, '7d'); - assert.deepEqual(config.globalSettings.remote, { enabled: true, port: 9200, token: 'main-token', allowInput: false }); - }); - - it('renderer settings saves cannot clear the authoritative automation pause', async () => { - const current = store.load(); - await store.save({ - globalSettings: { - ...current.globalSettings, - folderMonitor: { - ...current.globalSettings.folderMonitor, - enabled: true, - folderPath: 'C:\\watch', - paused: true, - pausedAt: 1787712000000 - } - } - }); - - await store.saveRendererGlobalSettings({ - alwaysOnTop: true, - folderMonitor: { paused: false, pausedAt: null } - }); - - const saved = store.load().globalSettings.folderMonitor; - assert.equal(saved.paused, true); - assert.equal(saved.pausedAt, 1787712000000); - assert.equal(saved.enabled, true); - assert.equal(saved.folderPath, 'C:\\watch'); - }); - - it('persists the last browse directory across stale renderer settings saves', async () => { - const selectedDirectory = path.join(tmpDir, 'selected'); - - await store.saveLastBrowseDirectory(selectedDirectory); - await store.saveRendererGlobalSettings({ - alwaysOnTop: true, - lastBrowseDirectory: '' - }); - - assert.equal(store.load().globalSettings.lastBrowseDirectory, selectedDirectory); - }); - - it('merges remote settings in the write queue and returns the canonical token', async () => { - await store.save({ - globalSettings: { - remote: { enabled: false, port: 9100, token: 'canonical-token', allowInput: true } - } - }); - - const rendererSave = store.saveRendererGlobalSettings({ - alwaysOnTop: true, - remote: { enabled: false, port: 9200, token: '', allowInput: false } - }); - const remoteSave = store.saveRemoteSettings( - { enabled: true, port: 9300, token: '', allowInput: false }, - () => 'generated-token' - ); - - const [, canonical] = await Promise.all([rendererSave, remoteSave]); - assert.deepEqual(canonical, { enabled: true, port: 9300, token: 'canonical-token', allowInput: false }); - assert.deepEqual(store.load().globalSettings.remote, canonical); - }); - - it('rejects ordinary writes while quiesced and permits only the final pending queue snapshot', async () => { - store.setWritesQuiesced(true); - - await assert.rejects(store.save({ globalSettings: { alwaysOnTop: true } }), /beendet/); - await assert.rejects(store.appendHistory({ id: 'late-history', files: [] }), /beendet/); - await store.savePendingQueue({ savedAt: 4, queueJobs: [{ id: 'final' }] }, { allowDuringQuiesce: true }); - - assert.equal(store.load().globalSettings.alwaysOnTop, false); - assert.deepEqual(store.load().globalSettings.pendingQueue, { savedAt: 4, queueJobs: [{ id: 'final' }] }); - store.setWritesQuiesced(false); - await store.save({ globalSettings: { ...store.load().globalSettings, alwaysOnTop: true } }); - assert.equal(store.load().globalSettings.alwaysOnTop, true); - }); - - it('load() returns independent clones — mutating one result must not leak into the cache', () => { - store.load(); // warm the cache - const a = store.load(); - a.globalSettings.alwaysOnTop = true; - a.hosters['voe.sx'].push({ id: 'mutant' }); - a.history.push({ id: 'ghost' }); - const b = store.load(); - assert.equal(b.globalSettings.alwaysOnTop, false, 'mutating a prior load() result must not corrupt the cache'); - assert.equal(b.hosters['voe.sx'].length, 0); - assert.equal(b.history.length, 0); - }); - - it('load() reflects an external file change (mtime/size cache invalidation)', () => { - store.load(); // warm cache on the no-file defaults - fs.writeFileSync(store.filePath, JSON.stringify({ globalSettings: { alwaysOnTop: true } }), 'utf-8'); - assert.equal(store.load().globalSettings.alwaysOnTop, true, 'an external write must invalidate the cache'); - fs.writeFileSync(store.filePath, JSON.stringify({ globalSettings: { alwaysOnTop: false } }), 'utf-8'); - assert.equal(store.load().globalSettings.alwaysOnTop, false, 'a second external write must be seen too'); - }); - - it('save() invalidates the cache so the next load() sees the new value', async () => { - assert.equal(store.load().globalSettings.alwaysOnTop, false); - await store.save({ globalSettings: { alwaysOnTop: true } }); - assert.equal(store.load().globalSettings.alwaysOnTop, true, 'load() after save() must reflect the write'); - }); - - it('backup recovery when main file is corrupted', () => { - // Write valid config first - fs.writeFileSync(store.filePath, JSON.stringify({ - hosters: { 'doodstream.com': [{ id: 'bak-1', authType: 'api', apiKey: 'from-backup' }] }, - hosterSettings: {}, globalSettings: {}, history: [] - }), 'utf-8'); - // Copy to backup - fs.copyFileSync(store.filePath, store.filePath + '.bak'); - // Corrupt main file - fs.writeFileSync(store.filePath, 'CORRUPTED!!!', 'utf-8'); - const config = store.load(); - assert.equal(config.hosters['doodstream.com'][0].apiKey, 'from-backup'); - }); - - it('wipe-guard: a settings-only save recovers accounts from .bak when the live config validly has none', async () => { - // Post-wipe state: live config parses fine but has empty hosters; a backup still holds the accounts. - fs.writeFileSync(store.filePath, JSON.stringify({ hosters: {}, hosterSettings: {}, globalSettings: {}, history: [] }), 'utf-8'); - fs.writeFileSync(store.filePath + '.bak', JSON.stringify({ - hosters: { 'voe.sx': [{ id: 'v1', authType: 'api', apiKey: 'survive-key' }] }, - hosterSettings: {}, globalSettings: {}, history: [] - }), 'utf-8'); - await store.save({ globalSettings: { alwaysOnTop: true } }); - const cfg = store.load(); - assert.ok(cfg.hosters['voe.sx'] && cfg.hosters['voe.sx'].length === 1, 'guard must restore accounts from .bak, not persist the wipe'); - assert.equal(cfg.hosters['voe.sx'][0].apiKey, 'survive-key'); - assert.equal(cfg.globalSettings.alwaysOnTop, true); - }); - - it('wipe-guard: an explicit save({hosters:{}}) (user deleted all) is NOT blocked', async () => { - await store.save({ hosters: { 'doodstream.com': [{ id: 'd1', authType: 'api', apiKey: 'k' }] } }); - await store.save({ hosters: {} }); - const cfg = store.load(); - assert.equal((cfg.hosters['doodstream.com'] || []).length, 0, 'an intentional hosters write must be allowed to empty them'); - }); -}); - -describe('ConfigStore history split (electron-history.json)', () => { - let dir; - let s; - - function makeStore() { - const st = new ConfigStore({ isPackaged: false, getPath: () => dir }); - st.filePath = path.join(dir, 'electron-config.json'); - st.historyPath = path.join(dir, 'electron-history.json'); - return st; - } - - function writeConfigWithHistory(n) { - const history = []; - for (let i = 0; i < n; i++) history.push({ id: `batch-${i}`, timestamp: 1750000000000 + i, total: 3, files: [{ name: `f${i}.mkv` }] }); - fs.writeFileSync(path.join(dir, 'electron-config.json'), JSON.stringify({ - hosters: { 'byse.sx': [{ id: 'a1', authType: 'api', apiKey: 'k' }] }, - hosterSettings: {}, globalSettings: { historyRetention: 'all' }, history - }), 'utf-8'); - } - - beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-hist-')); s = makeStore(); }); - afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); - - it('migration moves history into electron-history.json, preserving every entry', () => { - writeConfigWithHistory(50); - s._migrateHistory(); - assert.equal(s._historyMigrated, true); - assert.ok(fs.existsSync(s.historyPath)); - const hist = JSON.parse(fs.readFileSync(s.historyPath, 'utf-8')); - assert.equal(hist.length, 50); - assert.equal(hist[0].id, 'batch-0'); - assert.equal(hist[49].id, 'batch-49'); - assert.ok(fs.existsSync(s.filePath + '.pre-history-split.bak'), 'a permanent pre-split backup is kept'); - }); - - it('after migration load() excludes history (cheap hot path) but loadHistory() returns the real data', () => { - writeConfigWithHistory(30); - s._migrateHistory(); - assert.deepEqual(s.load().history, [], 'history is not carried in the always-loaded config'); - assert.equal(s.loadHistory().length, 30); - }); - - it('appendHistory writes to history.json; the next config write strips stale history from the config file', async () => { - writeConfigWithHistory(10); - s._migrateHistory(); - await s.appendHistory({ id: 'new-batch', timestamp: 1750000099999, total: 1, files: [{ name: 'x.mkv' }] }); - assert.equal(s.loadHistory().length, 11, 'append goes to history.json'); - await s.save({ globalSettings: { alwaysOnTop: true } }); - const onDisk = JSON.parse(fs.readFileSync(s.filePath, 'utf-8')); - assert.ok(!onDisk.history || onDisk.history.length === 0, 'a config write strips stale history from the config file'); - assert.equal(s.loadHistory().length, 11, 'history.json is unaffected by the config write'); - }); - - it('save({globalSettings}) after migration NEVER loses history (data-loss invariant)', async () => { - writeConfigWithHistory(40); - s._migrateHistory(); - await s.save({ globalSettings: { alwaysOnTop: true } }); - assert.equal(s.loadHistory().length, 40, 'a settings write must not touch history'); - assert.equal(s.load().globalSettings.alwaysOnTop, true); - }); - - it('clearHistory empties history.json only', async () => { - writeConfigWithHistory(20); - s._migrateHistory(); - await s.clearHistory(); - assert.equal(s.loadHistory().length, 0); - }); - - it('migration is idempotent — re-running with history.json present does not re-derive or clobber', () => { - writeConfigWithHistory(15); - s._migrateHistory(); - const after = makeStore(); - after._migrateHistory(); - assert.equal(after._historyMigrated, true); - assert.equal(after.loadHistory().length, 15); - }); - - it('crash-window fallback: not migrated + no history.json → loadHistory reads config.history', () => { - writeConfigWithHistory(7); - assert.equal(s._historyMigrated, false); - assert.equal(s.loadHistory().length, 7, 'legacy path still serves history if migration never ran'); - }); - - it('migrated prune refuses to overwrite a corrupted history file', async () => { - writeConfigWithHistory(12); - s._migrateHistory(); - fs.writeFileSync(s.historyPath, '{broken-history', 'utf-8'); - const historyBefore = fs.readFileSync(s.historyPath, 'utf-8'); - - await assert.rejects(s.pruneHistory('7d', { dryRun: false }), /Verlaufsdatei ist beschädigt/); - - assert.equal(fs.readFileSync(s.historyPath, 'utf-8'), historyBefore); - assert.equal(JSON.parse(fs.readFileSync(s.filePath, 'utf-8')).globalSettings.historyRetention, 'all'); - }); - - it('appendHistory rejects when the migrated history file is corrupted', async () => { - writeConfigWithHistory(12); - s._migrateHistory(); - fs.writeFileSync(s.historyPath, '{broken-history', 'utf-8'); - - await assert.rejects(s.appendHistory({ id: 'unsafe-delete-barrier', files: [] }), /Verlaufsdatei ist beschädigt/); - }); - - it('pruneHistory trims history.json and persists the retention setting', async () => { - writeConfigWithHistory(12); - s._migrateHistory(); - const res = await s.pruneHistory('all', { dryRun: false }); - assert.equal(s.loadHistory().length, 12); - assert.ok(res.keptBatches === 12); - }); - - it('migrated prune leaves history unchanged when the retention commit fails', async () => { - writeConfigWithHistory(12); - s._migrateHistory(); - const historyBefore = fs.readFileSync(s.historyPath, 'utf-8'); - s._atomicWrite = () => Promise.reject(new Error('retention commit failed')); - - await assert.rejects(s.pruneHistory('7d', { dryRun: false }), /retention commit failed/); - - assert.equal(fs.readFileSync(s.historyPath, 'utf-8'), historyBefore); - assert.equal(JSON.parse(fs.readFileSync(s.filePath, 'utf-8')).globalSettings.historyRetention, 'all'); - }); - - it('migrated prune restores the previous retention when the history write fails', async () => { - writeConfigWithHistory(12); - s._migrateHistory(); - const historyBefore = fs.readFileSync(s.historyPath, 'utf-8'); - const originalAtomicWrite = s._atomicWrite.bind(s); - const retentionWrites = []; - s._atomicWrite = (data) => { - retentionWrites.push(JSON.parse(data).globalSettings.historyRetention); - return originalAtomicWrite(data); - }; - s._writeHistoryFileAtomic = () => Promise.reject(new Error('history prune failed')); - - await assert.rejects(s.pruneHistory('7d', { dryRun: false }), /history prune failed/); - - assert.deepEqual(retentionWrites, ['7d', 'all']); - assert.equal(JSON.parse(fs.readFileSync(s.filePath, 'utf-8')).globalSettings.historyRetention, 'all'); - assert.equal(fs.readFileSync(s.historyPath, 'utf-8'), historyBefore); - }); - - it('migrated prune serializes surrounding global settings saves across an internal rollback', async () => { - writeConfigWithHistory(12); - s._migrateHistory(); - const historyBefore = fs.readFileSync(s.historyPath, 'utf-8'); - const originalAtomicWrite = s._atomicWrite.bind(s); - const configWrites = []; - let priorWriteStarted = false; - let releasePriorWrite; - s._atomicWrite = (data) => { - const settings = JSON.parse(data).globalSettings; - configWrites.push({ webhookUrl: settings.webhookUrl || '', alwaysOnTop: !!settings.alwaysOnTop, historyRetention: settings.historyRetention }); - if (!priorWriteStarted && settings.webhookUrl === 'https://prune-race.invalid/prior') { - priorWriteStarted = true; - return new Promise((resolve, reject) => { - releasePriorWrite = () => originalAtomicWrite(data).then(resolve, reject); - }); - } - return originalAtomicWrite(data); - }; - - let rejectHistoryWrite; - s._writeHistoryFileAtomic = () => new Promise((_resolve, reject) => { rejectHistoryWrite = reject; }); - const priorSettings = { ...s.load().globalSettings, alwaysOnTop: true, webhookUrl: 'https://prune-race.invalid/prior', historyRetention: 'all' }; - const priorSave = s.save({ globalSettings: priorSettings }); - while (!releasePriorWrite) await new Promise(resolve => setImmediate(resolve)); - - let pruneError = null; - const pruning = s.pruneHistory('7d', { dryRun: false }).catch(error => { pruneError = error; }); - releasePriorWrite(); - await priorSave; - while (!rejectHistoryWrite) await new Promise(resolve => setImmediate(resolve)); - - const laterSettings = { ...priorSettings, alwaysOnTop: false, webhookUrl: 'https://prune-race.invalid/later', historyRetention: 'all' }; - const laterSave = s.save({ globalSettings: laterSettings }); - await new Promise(resolve => setImmediate(resolve)); - await new Promise(resolve => setImmediate(resolve)); - const laterCommittedBeforeRollback = configWrites.some(write => write.webhookUrl === 'https://prune-race.invalid/later'); - rejectHistoryWrite(new Error('history prune race failed')); - await pruning; - await laterSave; - - assert.match(pruneError?.message || '', /history prune race failed/); - assert.equal(laterCommittedBeforeRollback, false); - assert.deepEqual(configWrites.map(write => `${write.webhookUrl}:${write.historyRetention}`), [ - 'https://prune-race.invalid/prior:all', - 'https://prune-race.invalid/prior:7d', - 'https://prune-race.invalid/prior:all', - 'https://prune-race.invalid/later:all' - ]); - const finalSettings = JSON.parse(fs.readFileSync(s.filePath, 'utf-8')).globalSettings; - assert.equal(finalSettings.webhookUrl, 'https://prune-race.invalid/later'); - assert.equal(finalSettings.alwaysOnTop, false); - assert.equal(finalSettings.historyRetention, 'all'); - assert.equal(fs.readFileSync(s.historyPath, 'utf-8'), historyBefore); - }); - - it('renderer snapshots cannot revert retention after a successful prune', async () => { - writeConfigWithHistory(12); - s._migrateHistory(); - const originalHistoryWrite = s._writeHistoryFileAtomic.bind(s); - let releaseHistoryWrite; - s._writeHistoryFileAtomic = (history) => new Promise((resolve, reject) => { - releaseHistoryWrite = () => originalHistoryWrite(history).then(resolve, reject); - }); - - const pruning = s.pruneHistory('7d', { dryRun: false }); - while (!releaseHistoryWrite) await new Promise(resolve => setImmediate(resolve)); - const staleRendererSave = s.saveRendererGlobalSettings({ - ...s.load().globalSettings, - alwaysOnTop: true, - historyRetention: 'all' - }); - releaseHistoryWrite(); - await Promise.all([pruning, staleRendererSave]); - - assert.equal(s.load().globalSettings.historyRetention, '7d'); - assert.equal(s.load().globalSettings.alwaysOnTop, true); - }); -}); diff --git a/tests/dev-runner.test.js b/tests/dev-runner.test.js deleted file mode 100644 index d92f660..0000000 --- a/tests/dev-runner.test.js +++ /dev/null @@ -1,11 +0,0 @@ -const test = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -test('dev runner cannot let an old child exit clear the current Electron process', () => { - const source = fs.readFileSync(path.join(__dirname, '..', 'scripts', 'dev-runner.cjs'), 'utf8'); - - assert.match(source, /const startedChild = spawn\(electron/u); - assert.match(source, /if \(child !== startedChild\) return;\s*child = null;/u); -}); diff --git a/tests/diagnostics-agent.test.js b/tests/diagnostics-agent.test.js deleted file mode 100644 index e728d6a..0000000 --- a/tests/diagnostics-agent.test.js +++ /dev/null @@ -1,62 +0,0 @@ -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/); -}); diff --git a/tests/diagnostics-collectors.test.js b/tests/diagnostics-collectors.test.js deleted file mode 100644 index 38daaf8..0000000 --- a/tests/diagnostics-collectors.test.js +++ /dev/null @@ -1,179 +0,0 @@ -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'); - -function makeFixture() { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-diag-')); - const fixtureAlpha = ['SECRET', 'TOKEN', '123456'].join(''); - const fixtureBeta = ['abcdef', '123456'].join(''); - const fixtureGamma = ['LIVE', 'KEY', '99999'].join(''); - const fixtureDelta = ['HUNTER', '2', 'SECRET'].join(''); - const fixtureEpsilon = ['BYSE', 'KEY', '1234567'].join(''); - const fixtureZeta = ['WBHOOK', 'SECRET', 'TOKEN'].join(''); - const paths = { - fileuploader: path.join(dir, 'fileuploader.log'), - uploadAudit: path.join(dir, 'upload-audit.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 ${fixtureAlpha} inline\nAuthorization: Bearer ${fixtureBeta}\n`); - fs.writeFileSync(paths.uploadAudit, `# SOURCE-CLEANUP {"token":"${fixtureAlpha}"}\n`); - fs.writeFileSync(paths.doodstreamDebug, `api_key=${fixtureGamma} sess=abc\n`); - fs.writeFileSync(paths.crashLog, 'CRASH at 12:00\n'); - const config = { - hosters: { 'voe.sx': [{ id: 'a1', username: 'u', password: fixtureDelta }], 'byse.sx': [{ id: 'b1', apiKey: fixtureEpsilon }] }, - hosterSettings: {}, - globalSettings: { - webhookUrl: ['https://discord.com/api/webhooks/', '12345', fixtureZeta].join('/'), - diagnostics: { enabled: true, port: 9110, token: fixtureAlpha, 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, fixtureAlpha, fixtureDelta, fixtureEpsilon, fixtureZeta }; -} - -test('getConfigRedacted strips password/apiKey/token/webhookUrl and value-scrubs the token mid-string', () => { - const { collectors, fixtureAlpha, fixtureDelta, fixtureEpsilon, fixtureZeta } = makeFixture(); - const out = collectors.getConfigRedacted({ section: 'all' }); - const json = JSON.stringify(out); - assert.ok(!json.includes(fixtureDelta), 'password must be redacted'); - assert.ok(!json.includes(fixtureEpsilon), 'apiKey must be redacted'); - assert.ok(!json.includes(fixtureAlpha), 'diag token must be redacted'); - assert.ok(!json.includes(fixtureZeta), '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 }); - const audit = collectors.readLog({ name: 'uploadAudit', tailKb: 64 }); - assert.ok(!dbg.content.includes('SECRETTOKEN123456'), 'value-scrub removes the live diag token from logs'); - assert.ok(!/Bearer abcdef123456/.test(dbg.content), 'pattern-scrub removes Authorization Bearer'); - assert.equal(audit.name, 'uploadAudit'); - assert.ok(!audit.content.includes('SECRETTOKEN123456'), 'source cleanup audit is readable only through the redacted diagnostics path'); - 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('rotated audit backups are listed and readable with the rotation naming convention', () => { - const { collectors, paths, fixtureAlpha } = makeFixture(); - const backupPath = path.join(path.dirname(paths.uploadAudit), 'upload-audit.1.log'); - fs.writeFileSync(backupPath, `# SOURCE-CLEANUP {"token":"${fixtureAlpha}"}\n`); - const listed = collectors.listLogs().files.find(file => file.name === 'uploadAudit'); - assert.ok(listed.variants.some(variant => variant.backup === 1)); - assert.ok(!collectors.listLogs().otherLogs.some(file => file.name === 'upload-audit.1.log')); - const backup = collectors.readLog({ name: 'uploadAudit', backup: 1, tailKb: 64 }); - assert.equal(backup.path, backupPath); - assert.ok(!backup.content.includes(fixtureAlpha)); -}); - -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 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=OPAQUE_NONconfig_TOKEN_9988' } - ] } }, - 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('OPAQUE_NONconfig_TOKEN_9988'), '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('HUNTER2SECRET') && !json.includes('SECRETTOKEN123456') && !json.includes('WBHOOKSECRETTOKEN'), 'no secret leaks in server_health'); -}); diff --git a/tests/diagnostics-protocol.test.js b/tests/diagnostics-protocol.test.js deleted file mode 100644 index 6dc7af6..0000000 --- a/tests/diagnostics-protocol.test.js +++ /dev/null @@ -1,120 +0,0 @@ -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(); -}); diff --git a/tests/doodstream-api-upload.test.js b/tests/doodstream-api-upload.test.js deleted file mode 100644 index 1913a53..0000000 --- a/tests/doodstream-api-upload.test.js +++ /dev/null @@ -1,105 +0,0 @@ -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; - } - ); -}); diff --git a/tests/doodstream-upload.test.js b/tests/doodstream-upload.test.js deleted file mode 100644 index 9f53a14..0000000 --- a/tests/doodstream-upload.test.js +++ /dev/null @@ -1,364 +0,0 @@ -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 `
` + - `` + - `` + - `` + - `
`; -} - -const EMPTY_RESULT = ''; -const LINK_RESULT = (code) => ``; - -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 = ` -
- - - - - -
`; - 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('
no form here
'), {}); - 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 html = ` - -
API Key
- `; - const cands = up._extractApiKeyCandidates(html); - // The token whose preceding context mentions "API" must rank first. - assert.equal(cands[0], 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'); - assert.ok(cands.includes('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')); -}); - -test('_extractApiKeyCandidates handles textarea + api_key: "x" shapes and empty input', () => { - const up = new DoodstreamUploader(); - assert.deepEqual(up._extractApiKeyCandidates(''), []); - const ta = up._extractApiKeyCandidates(''); - assert.ok(ta.includes('cccccccccccccccccccccccccccccccc')); - const js = up._extractApiKeyCandidates('var x = {"api_key":"dddddddddddddddddddddddddddddddd"};'); - assert.ok(js.includes('dddddddddddddddddddddddddddddddd')); -}); - -test('deriveApiKey returns the candidate that validates against the API', async () => { - const up = new DoodstreamUploader(); - up._fetch = async () => ({ text: async () => '
API Key
' }); - up._validateApiKey = async (key) => key === 'REALKEY1234567890abcdefGHIJK'; - const key = await up.deriveApiKey(); - assert.equal(key, 'REALKEY1234567890abcdefGHIJK'); - assert.equal(up.apiKey, 'REALKEY1234567890abcdefGHIJK'); // cached on the instance -}); - -test('deriveApiKey returns null when no candidate validates (→ caller uses web fallback)', async () => { - const up = new DoodstreamUploader(); - up._fetch = async () => ({ text: async () => '' }); - 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('OTP verification keeps the challenged cookie session without another bootstrap request', async () => { - const up = new DoodstreamUploader(); - const originalFetch = globalThis.fetch; - let bootstrapCalls = 0; - let loginCalls = 0; - up._fetch = async () => { - bootstrapCalls++; - return fakeRes(bootstrapCalls === 1 ? 'ok' : ``); - }; - globalThis.fetch = async (url, options) => { - loginCalls++; - assert.match(url, /\?op=login_ajax&/u); - assert.equal(options.method, 'GET'); - assert.equal(options.body, undefined); - if (loginCalls === 1) { - assert.equal(options.headers.Cookie, undefined); - return { - status: 200, - headers: { getSetCookie: () => ['otp_session=SESSION123; Path=/'], get: () => null }, - text: async () => JSON.stringify({ status: 'otp_sent', message: 'Verification code has been sent' }) - }; - } - assert.equal(options.headers.Cookie, 'otp_session=SESSION123'); - assert.match(url, /loginotp=123456/u); - return { - status: 200, - headers: { getSetCookie: () => [], get: () => null }, - text: async () => JSON.stringify({ status: 'redirect', message: '/?op=my_account' }) - }; - }; - try { - await assert.rejects(() => up.login('user', 'secret'), error => error.otpRequired === true); - await up.login('user', 'secret', '123456'); - assert.equal(bootstrapCalls, 2); - assert.equal(loginCalls, 2); - assert.equal(up.sessId, 'SESSION_456-X'); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test('_findSessId accepts the current Vue upload data and URL-safe tokens', () => { - const up = new DoodstreamUploader(); - assert.equal( - up._findSessId(``), - 'abc_DEF-123' - ); - assert.equal( - up._findSessId(''), - 'abc_DEF-456' - ); - assert.equal(up._findSessId(``), ''); -}); - -test('login follows the session-establishing redirect before loading the upload page', async (t) => { - const up = new DoodstreamUploader(); - const requests = []; - t.mock.method(globalThis, 'fetch', async (url, options) => { - const parsed = new URL(url); - const route = parsed.pathname + (parsed.searchParams.get('op') || ''); - requests.push(route); - if (parsed.searchParams.get('op') === 'login_ajax') { - return new Response(JSON.stringify({ status: 'redirect', message: '/finish-login?ticket=TEST' })); - } - if (parsed.pathname === '/finish-login') { - return new Response('', { headers: { 'set-cookie': 'session=TEST; Path=/' } }); - } - if (parsed.searchParams.get('op') === 'upload') { - assert.equal(options.headers.Cookie, 'session=TEST'); - return new Response(''); - } - return new Response(''); - }); - await up.login('user', 'password', '123456'); - assert.deepEqual(requests, ['/', '/login_ajax', '/finish-login', '/upload']); - assert.equal(up.sessId, 'TEST_SESSION'); -}); - -test('login cannot report success when the redirected session is still a guest', async (t) => { - const up = new DoodstreamUploader(); - t.mock.method(globalThis, 'fetch', async url => new Response( - new URL(url).searchParams.get('op') === 'login_ajax' - ? JSON.stringify({ status: 'redirect', message: '/dashboard' }) - : `` - )); - await assert.rejects(up.login('user', 'password'), /sess_id nicht gefunden/); - assert.equal(up.sessId, ''); -}); - -test('login redirect chains cannot forward session cookies to another origin', async (t) => { - const up = new DoodstreamUploader(); - let calls = 0; - t.mock.method(globalThis, 'fetch', async () => { - calls++; - return new Response('', { status: 302, headers: { location: 'https://other.example/' } }); - }); - await assert.rejects(up._fetch('https://doodstream.com/dashboard', { allowedOrigin: 'https://doodstream.com' }), /Weiterleitungsziel/); - assert.equal(calls, 1); -}); - -test('authenticated dashboard verifies web login without an upload token or API calls', async (t) => { - const up = new DoodstreamUploader(); - t.mock.method(globalThis, 'fetch', async (url) => { - const target = new URL(url); - assert.equal(target.hostname, 'doodstream.com'); - if (target.searchParams.get('op') === 'login_ajax') { - return new Response(JSON.stringify({ status: 'redirect', message: '/dashboard' }), { - headers: { 'set-cookie': 'xfsts=SESSION; Path=/' } - }); - } - return new Response('SettingsVideosLogout'); - }); - await up.login('user', 'secret', '123456'); - assert.equal(up.apiKey, ''); - assert.equal(up.sessId, ''); - assert.equal(up.cookies.get('xfsts'), 'SESSION'); -}); - -test('an unverified settings token cannot make a missing-session login pass', async (t) => { - const up = new DoodstreamUploader(); - t.mock.method(globalThis, 'fetch', async url => { - const target = new URL(url); - if (target.hostname === 'doodapi.co') return new Response(JSON.stringify({ status: 403 })); - if (target.searchParams.get('op') === 'login_ajax') return new Response(JSON.stringify({ status: 'success' })); - if (target.pathname === '/settings') return new Response(''); - return new Response(''); - }); - await assert.rejects(up.login('user', 'secret'), /sess_id nicht gefunden/); - assert.equal(up.apiKey, ''); -}); - -test('web server unavailability is a hoster failure rather than a missing login session', async () => { - const up = new DoodstreamUploader(); - up._fetch = async () => new Response(JSON.stringify({ success: false, message: 'No servers available for uploads' })); - await assert.rejects(up._getUploadServer(), error => error.hosterTransient === true && /No servers available/.test(error.message)); -}); - -test('parallel web uploads receive independent cookie and upload-session state', () => { - const up = new DoodstreamUploader(); - up.cookies.set('xfsts', 'AUTH_SESSION'); - up.sessId = 'UPLOAD_SESSION'; - const first = up.cloneSession(); - const second = up.cloneSession(); - first.cookies.set('xfsts', 'CHANGED'); - first.sessId = 'CHANGED'; - assert.equal(second.cookies.get('xfsts'), 'AUTH_SESSION'); - assert.equal(second.sessId, 'UPLOAD_SESSION'); - assert.equal(up.cookies.get('xfsts'), 'AUTH_SESSION'); -}); - -test('getUploadServer: parses the current upload_get_srv response', async () => { - const up = new DoodstreamUploader(); - up._fetch = async (url) => { - assert.match(url, /op=upload_get_srv/); - return fakeRes(JSON.stringify({ success: true, server: { srv_url: 'https://node42.cloudatacdn.com', disk_id: '01' } }), { ctype: 'application/json' }); - }; - assert.match(await up._getUploadServer(), /^https:\/\/node42\.cloudatacdn\.com\/upload\/01\?t=\d+$/u); -}); - -test('getUploadServer: falls back to srv_url in upload-page HTML', async () => { - const up = new DoodstreamUploader(); - up._fetch = async (url) => { - if (/op=upload_get_srv/.test(url)) return fakeRes('not json'); - return fakeRes(''); - }; - 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_get_srv/.test(url)) return fakeRes('not json'); - return fakeRes('
'); - }; - const url = await up._getUploadServer(); - assert.equal(url, 'https://n9.cloudatacdn.com/upload/01?FRESH_123-X'); - assert.equal(up.sessId, 'FRESH_123-X'); // critical: form-field token must match the node URL token -}); - -test('getUploadServer: un-escapes & in the form-action query string', async () => { - const up = new DoodstreamUploader(); - up._fetch = async (url) => { - if (/op=upload_get_srv/.test(url)) return fakeRes('not json'); - return fakeRes('
'); - }; - 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('login required', { 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; - } - ); -}); diff --git a/tests/file-discovery.test.js b/tests/file-discovery.test.js deleted file mode 100644 index ca3dd4a..0000000 --- a/tests/file-discovery.test.js +++ /dev/null @@ -1,59 +0,0 @@ -const test = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); -const { walkFolderAsync } = require('../lib/file-discovery'); - -function directory(name) { - return { name, isDirectory: () => true, isFile: () => false }; -} - -function file(name) { - return { name, isDirectory: () => false, isFile: () => true }; -} - -test('folder discovery preserves UNC and Unicode paths', async () => { - const root = '\\\\server\\share\\Übertragungen'; - const child = path.win32.join(root, 'Staffel 1'); - const target = path.win32.join(child, 'Folge äöü.mkv'); - const fsPromises = { - readdir: async dir => dir === root ? [directory('Staffel 1')] : [file('Folge äöü.mkv')], - stat: async value => ({ size: value === target ? 1234 : 0 }) - }; - const result = await walkFolderAsync(root, { fsPromises, pathImpl: path.win32 }); - assert.deepEqual(result, [{ path: target, name: 'Folge äöü.mkv', size: 1234 }]); -}); - -test('folder discovery does not truncate long absolute paths', async () => { - const root = `C:\\${'sehr-langer-ordner\\'.repeat(18)}ziel`; - const target = path.win32.join(root, 'video.mp4'); - const result = await walkFolderAsync(root, { - fsPromises: { - readdir: async () => [file('video.mp4')], - stat: async () => ({ size: 77 }) - }, - pathImpl: path.win32 - }); - assert.equal(result[0].path, target); - assert.ok(result[0].path.length > 260); -}); - -test('nonrecursive folder discovery never reads child directories', async () => { - const root = 'C:\\watch'; - const child = path.win32.join(root, 'nested'); - const reads = []; - const result = await walkFolderAsync(root, { - recursive: false, - fsPromises: { - readdir: async (dir) => { - reads.push(dir); - if (dir === root) return [file('root.mkv'), directory('nested')]; - if (dir === child) return [file('nested.mkv')]; - return []; - }, - stat: async () => ({ size: 1 }) - }, - pathImpl: path.win32 - }); - assert.deepEqual(reads, [root]); - assert.deepEqual(result.map((entry) => entry.name), ['root.mkv']); -}); diff --git a/tests/file-probe.test.js b/tests/file-probe.test.js deleted file mode 100644 index c792a52..0000000 --- a/tests/file-probe.test.js +++ /dev/null @@ -1,115 +0,0 @@ -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('', '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); -}); diff --git a/tests/folder-monitor.test.js b/tests/folder-monitor.test.js deleted file mode 100644 index 9f1a453..0000000 --- a/tests/folder-monitor.test.js +++ /dev/null @@ -1,839 +0,0 @@ -const test = require('node:test'); -const assert = require('node:assert/strict'); -const { EventEmitter } = require('node:events'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const FolderMonitor = require('../lib/folder-monitor'); -const { classifyProcessedCandidates, planAtomicAdmissions } = require('../lib/automation-control'); - -function createWatcherHarness() { - const calls = []; - const timers = createManualTimers(); - const watch = (folderPath, options) => { - const watcher = new EventEmitter(); - watcher.close = async () => {}; - calls.push({ folderPath, options, watcher }); - return watcher; - }; - return { calls, monitor: new FolderMonitor({ watch, ...timers }) }; -} - -test('test scan works while inactive, active or paused without changing monitor state', async () => { - const folderPath = path.join(os.tmpdir(), 'monitor-read-only-test'); - for (const state of ['inactive', 'active', 'paused']) { - const { monitor, events } = createScanHarness({ files: [ - { path: path.join(folderPath, 'video.mkv'), size: 12, mtimeMs: 1 }, - { path: path.join(folderPath, 'note.txt'), size: 2, mtimeMs: 2 } - ] }); - if (state === 'active') monitor.start({ folderPath: 'different-live-folder' }); - if (state === 'paused') monitor.configure({ folderPath: 'different-paused-folder', paused: true }); - const before = monitor.status(); - const generation = monitor._generation; - const settings = monitor._settings; - const statuses = events.statuses.length; - const seen = [...monitor._seenFiles]; - const result = await monitor.testScan({ enabled: false, folderPath, extensions: 'mkv', filterMode: 'include', recursive: true }); - assert.equal(result.reachable, true); - assert.deepEqual(result.files.map(file => file.filterMatched), [true, false]); - assert.deepEqual(monitor.status(), before); - assert.equal(monitor._generation, generation); - assert.equal(monitor._settings, settings); - assert.deepEqual([...monitor._seenFiles], seen); - assert.equal(events.statuses.length, statuses); - assert.equal(events.newFiles.length, 0); - monitor.stop(); - } -}); - -test('test scan uses independent settings for concurrent read-only requests', async () => { - const visited = []; - const monitor = new FolderMonitor({ - access: async () => {}, - walkFolder: async (folder, options) => { visited.push([folder, options.recursive]); return []; }, - watch: () => { throw new Error('No watcher may be started'); }, - setIntervalFn: () => { throw new Error('No timer may be started'); } - }); - await Promise.all([ - monitor.testScan({ folderPath: 'first', recursive: false }), - monitor.testScan({ folderPath: 'second', recursive: true }) - ]); - assert.deepEqual(visited, [['first', false], ['second', true]]); - assert.equal(monitor.running, false); - assert.equal(monitor.status().folderPath, ''); -}); - -test('test scan distinguishes missing, unavailable and unreadable folders safely', async () => { - const missing = await new FolderMonitor().testScan({}); - assert.equal(missing.error, 'Kein Ordnerpfad angegeben'); - const unavailable = await new FolderMonitor({ access: async () => { throw new Error('private path'); } }).testScan({ folderPath: 'test' }); - assert.equal(unavailable.error, 'Ordner nicht erreichbar'); - const failed = await new FolderMonitor({ access: async () => {}, walkFolder: async () => { throw new Error('private path'); } }).testScan({ folderPath: 'test' }); - assert.equal(failed.error, 'Ordnerscan fehlgeschlagen'); -}); - -test('test scan IPC reads persisted settings instead of depending on the active watcher', async () => { - const vm = require('node:vm'); - const source = fs.readFileSync(path.join(__dirname, '../main.js'), 'utf8'); - const start = source.indexOf("ipcMain.handle('folder-monitor:test-scan'"); - const end = source.indexOf('\n});', start) + 4; - const settings = { enabled: false, folderPath: 'test-folder' }; - let handler; - const expected = { files: [], reachable: true }; - vm.runInNewContext(source.slice(start, end), { - ipcMain: { handle: (_name, callback) => { handler = callback; } }, - configStore: { load: () => ({ globalSettings: { folderMonitor: settings } }) }, - folderMonitor: { testScan: snapshot => { assert.equal(snapshot, settings); return expected; } } - }); - assert.equal(await handler(), expected); -}); - -function createManualTimers() { - const intervals = new Set(); - const timeouts = new Set(); - const intervalDelays = []; - return { - setIntervalFn(callback, delay) { - intervals.add(callback); - intervalDelays.push(delay); - return callback; - }, - clearIntervalFn(callback) { - intervals.delete(callback); - }, - setTimeoutFn(callback) { - timeouts.add(callback); - return callback; - }, - clearTimeoutFn(callback) { - timeouts.delete(callback); - }, - async runInterval() { - for (const callback of [...intervals]) await callback(); - }, - async runTimeouts() { - for (const callback of [...timeouts]) { - timeouts.delete(callback); - await callback(); - } - }, - intervalDelays - }; -} - -function createSilentWatch() { - return () => { - const watcher = new EventEmitter(); - watcher.close = async () => {}; - return watcher; - }; -} - -function createScanHarness({ files = [] } = {}) { - const events = { newFiles: [], statuses: [] }; - const timers = createManualTimers(); - const stats = new Map(files.map((file) => [file.path, { mtimeMs: file.mtimeMs }])); - const monitor = new FolderMonitor({ - watch: createSilentWatch(), - walkFolder: async () => files.map(({ path: filePath, name, size }) => ({ path: filePath, name, size })), - access: async () => {}, - stat: async (filePath) => stats.get(filePath), - now: () => 1234, - ...timers - }); - monitor.on('new-files', (paths) => events.newFiles.push(paths)); - monitor.on('status', (status) => events.statuses.push(status)); - return { monitor, events, runInterval: timers.runInterval }; -} - -function createDeferredScanHarness() { - let calls = 0; - let releaseFirstScan; - const timers = createManualTimers(); - const firstScan = new Promise((resolve) => { releaseFirstScan = resolve; }); - const monitor = new FolderMonitor({ - watch: createSilentWatch(), - access: async () => {}, - stat: async () => ({ mtimeMs: 1 }), - walkFolder: async () => { - calls++; - if (calls === 1) await firstScan; - return []; - }, - ...timers - }); - monitor.start({ folderPath: 'C:\\incoming', reconcileIntervalMinutes: 5 }); - return { monitor, releaseFirstScan, scanCalls: () => calls }; -} - -function createReachabilityHarness(initiallyReachable) { - let reachable = initiallyReachable; - let calls = 0; - const statusEvents = []; - const timers = createManualTimers(); - const monitor = new FolderMonitor({ - watch: createSilentWatch(), - access: async () => { - if (!reachable) throw new Error('unreachable'); - }, - walkFolder: async () => { - calls++; - return []; - }, - stat: async () => ({ mtimeMs: 1 }), - ...timers - }); - monitor.on('status', (status) => statusEvents.push(status)); - return { - monitor, - setReachable(value) { reachable = value; }, - statusEvents, - scanCalls: () => calls, - runInterval: timers.runInterval - }; -} - -test('existing files are included only on the first start of the same watch scope', () => { - const { calls, monitor } = createWatcherHarness(); - const settings = { folderPath: 'C:\\incoming', includeExisting: true, recursive: false }; - monitor.start(settings); - monitor.start(settings); - assert.equal(calls[0].options.ignoreInitial, false); - assert.equal(calls[1].options.ignoreInitial, true); -}); - -test('existing files remain ignored unless the option is enabled', () => { - const { calls, monitor } = createWatcherHarness(); - monitor.start({ folderPath: 'C:\\incoming', includeExisting: false, recursive: false }); - monitor.start({ folderPath: 'C:\\incoming', includeExisting: true, recursive: false }); - assert.equal(calls[0].options.ignoreInitial, true); - assert.equal(calls[1].options.ignoreInitial, false); -}); - -test('a changed folder or filter creates a new initial scope', () => { - const { calls, monitor } = createWatcherHarness(); - monitor.start({ folderPath: 'C:\\incoming', includeExisting: true, recursive: false, extensions: 'mp4' }); - monitor.start({ folderPath: 'D:\\incoming', includeExisting: true, recursive: false, extensions: 'mp4' }); - monitor.start({ folderPath: 'D:\\incoming', includeExisting: true, recursive: false, extensions: 'mkv' }); - assert.deepEqual(calls.map(call => call.options.ignoreInitial), [false, false, false]); -}); - -test('initial scan completion is exposed so the one-time option can be persisted as consumed', () => { - const { calls, monitor } = createWatcherHarness(); - let completed = 0; - monitor.on('initial-scan-complete', () => { completed++; }); - monitor.start({ folderPath: 'C:\\incoming', includeExisting: true, recursive: false }); - calls[0].watcher.emit('ready'); - calls[0].watcher.emit('ready'); - assert.equal(completed, 1); -}); - -test('dry scan returns every descriptor with a disjoint filter classification without emitting files', async () => { - const { monitor, events } = createScanHarness({ - files: [ - { path: 'C:\\incoming\\a.mkv', name: 'a.mkv', size: 10, mtimeMs: 1 }, - { path: 'C:\\incoming\\b.txt', name: 'b.txt', size: 10, mtimeMs: 2 } - ] - }); - monitor.start({ folderPath: 'C:\\incoming', extensions: 'mkv', filterMode: 'include', recursive: true, reconcileIntervalMinutes: 5 }); - const result = await monitor.scan({ emitFiles: false, trigger: 'test' }); - assert.deepEqual(result.files, [ - { path: 'C:\\incoming\\a.mkv', name: 'a.mkv', size: 10, mtimeMs: 1, filterMatched: true, filterReason: 'matched' }, - { path: 'C:\\incoming\\b.txt', name: 'b.txt', size: 10, mtimeMs: 2, filterMatched: false, filterReason: 'extension' } - ]); - assert.equal(result.files.length, 2); - assert.equal(result.files.filter(file => file.filterMatched).length, 1); - assert.equal(events.newFiles.length, 0); -}); - -test('productive full scans emit every classified descriptor without consuming watcher duplicate reservations', async () => { - const { monitor, events } = createScanHarness({ - files: [ - { path: 'C:\\incoming\\a.mkv', name: 'a.mkv', size: 10, mtimeMs: 1 }, - { path: 'C:\\incoming\\b.txt', name: 'b.txt', size: 10, mtimeMs: 2 } - ] - }); - monitor.start({ folderPath: 'C:\\incoming', extensions: 'mkv', filterMode: 'include', recursive: true, skipDuplicates: true, reconcileIntervalMinutes: 5 }); - - await monitor.scan({ emitFiles: true, trigger: 'startup' }); - await monitor.scan({ emitFiles: true, trigger: 'interval' }); - - assert.deepEqual(events.newFiles.map(files => files.map(file => ({ name: file.name, filterMatched: file.filterMatched }))), [ - [{ name: 'a.mkv', filterMatched: true }, { name: 'b.txt', filterMatched: false }], - [{ name: 'a.mkv', filterMatched: true }, { name: 'b.txt', filterMatched: false }] - ]); - assert.equal(monitor.status().seenCount, 0); -}); - -test('reconciliation intervals accept only finite numeric positive-list values', () => { - const cases = [ - [1, 60000], - [5, 300000], - [15, 900000], - [30, 1800000], - [60, 3600000], - [-1, 300000], - [Number.POSITIVE_INFINITY, 300000], - ['1', 300000], - ['15', 300000], - [2, 300000], - [undefined, 300000] - ]; - - for (const [reconcileIntervalMinutes, expectedDelay] of cases) { - const timers = createManualTimers(); - const monitor = new FolderMonitor({ watch: createSilentWatch(), ...timers }); - monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes }); - assert.equal(timers.intervalDelays[0], expectedDelay, String(reconcileIntervalMinutes)); - monitor.stop(); - } -}); - -test('status owns monitoring start and next reconciliation timestamps across pause resume and intervals', async () => { - let now = 1000; - const timers = createManualTimers(); - const monitor = new FolderMonitor({ - watch: createSilentWatch(), - access: async () => {}, - walkFolder: async () => [], - stat: async () => ({ mtimeMs: 1 }), - now: () => now, - ...timers - }); - - monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 }); - assert.equal(monitor.status().startedAt, 1000); - assert.equal(monitor.status().nextReconcileAt, 301000); - now = 2000; - await monitor.scan({ emitFiles: true, trigger: 'startup' }); - assert.equal(monitor.status().startedAt, 1000); - assert.equal(monitor.status().nextReconcileAt, 301000); - await monitor.pause(); - assert.equal(monitor.status().startedAt, null); - assert.equal(monitor.status().nextReconcileAt, null); - now = 4000; - await monitor.resume({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 }); - assert.equal(monitor.status().startedAt, 4000); - assert.equal(monitor.status().nextReconcileAt, 304000); - now = 304000; - await timers.runInterval(); - assert.equal(monitor.status().startedAt, 4000); - assert.equal(monitor.status().lastScanAt, 304000); - assert.equal(monitor.status().nextReconcileAt, 604000); -}); - -test('paused configuration keeps watcher and interval closed while allowing a read-only scan', async () => { - const timers = createManualTimers(); - let watcherStarts = 0; - const monitor = new FolderMonitor({ - watch: () => { - watcherStarts++; - return createSilentWatch()(); - }, - access: async () => {}, - walkFolder: async () => [{ path: 'C:\\watch\\a.mkv', name: 'a.mkv', size: 1 }], - stat: async () => ({ mtimeMs: 1 }), - ...timers - }); - - const result = monitor.configure({ folderPath: 'C:\\watch', extensions: 'mkv', reconcileIntervalMinutes: 5 }); - const scan = await monitor.scan({ emitFiles: false, trigger: 'test' }); - const productive = await monitor.scan({ emitFiles: true, trigger: 'manual' }); - - assert.deepEqual(result, { includesExisting: false, paused: true }); - assert.equal(watcherStarts, 0); - assert.equal(timers.intervalDelays.length, 0); - assert.equal(monitor.status().paused, true); - assert.equal(monitor.status().folderPath, 'C:\\watch'); - assert.deepEqual(scan.files.map(file => file.name), ['a.mkv']); - assert.equal(productive.cancelled, true); -}); - -test('overlapping reconcile requests serialize and collapse to one follow-up scan', async () => { - const { monitor, releaseFirstScan, scanCalls } = createDeferredScanHarness(); - const first = monitor.scan({ emitFiles: true, trigger: 'interval' }); - const second = monitor.scan({ emitFiles: true, trigger: 'reconnect' }); - const third = monitor.scan({ emitFiles: true, trigger: 'manual' }); - releaseFirstScan(); - await Promise.all([first, second, third]); - assert.equal(scanCalls(), 2); -}); - -test('disconnect preserves configuration and reconnect performs one immediate scan', async () => { - const { monitor, setReachable, statusEvents, scanCalls, runInterval } = createReachabilityHarness(false); - monitor.start({ folderPath: 'Z:\\watch', reconcileIntervalMinutes: 5 }); - await runInterval(); - assert.equal(statusEvents.at(-1).reachable, false); - assert.equal(monitor.status().folderPath, 'Z:\\watch'); - setReachable(true); - await runInterval(); - assert.equal(statusEvents.at(-1).reachable, true); - assert.equal(scanCalls(), 1); -}); - -test('reachable reconciliation intervals scan the configured folder', async () => { - const { monitor, runInterval, scanCalls } = createReachabilityHarness(true); - monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 }); - await runInterval(); - await runInterval(); - assert.equal(scanCalls(), 2); -}); - -test('pause stops watcher and reconciliation until explicit resume', async () => { - const { monitor, runInterval, scanCalls } = createReachabilityHarness(true); - monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 }); - await monitor.pause(); - await runInterval(); - assert.equal(scanCalls(), 0); - assert.equal(monitor.status().paused, true); - await monitor.resume({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 }); - assert.equal(scanCalls(), 1); - assert.equal(monitor.status().paused, false); -}); - -test('resume can activate watcher and interval before a separately gated reconciliation', async () => { - const { monitor, scanCalls } = createReachabilityHarness(true); - const settings = { folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 }; - monitor.start(settings); - await monitor.pause(); - - const result = await monitor.resume(settings, { reconcile: false }); - - assert.equal(scanCalls(), 0); - assert.equal(result.reconciled, false); - assert.equal(monitor.status().running, true); - assert.equal(monitor.status().paused, false); -}); - -test('repeated pause emits one status change and keeps reconciliation stopped', async () => { - const { monitor, statusEvents, runInterval, scanCalls } = createReachabilityHarness(true); - monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 }); - const statusCountBeforePause = statusEvents.length; - - await monitor.pause(); - await monitor.pause(); - await runInterval(); - - assert.equal(statusEvents.length, statusCountBeforePause + 1); - assert.equal(statusEvents.at(-1).paused, true); - assert.equal(scanCalls(), 0); -}); - -test('stop emits an immutable non-running status snapshot', () => { - const { monitor } = createWatcherHarness(); - const statuses = []; - monitor.on('status', (status) => statuses.push(status)); - monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 }); - const statusCount = statuses.length; - monitor.stop(); - assert.equal(statuses.length, statusCount + 1); - assert.equal(statuses.at(-1).running, false); - assert.equal(Object.isFrozen(statuses.at(-1)), true); -}); - -test('pause invalidates a running scan before it can publish late state or files', async () => { - let releaseWalk; - let markWalkStarted; - const walkPending = new Promise((resolve) => { releaseWalk = resolve; }); - const walkStarted = new Promise((resolve) => { markWalkStarted = resolve; }); - const timers = createManualTimers(); - const statuses = []; - const newFiles = []; - const monitor = new FolderMonitor({ - watch: createSilentWatch(), - access: async () => {}, - walkFolder: async () => { - markWalkStarted(); - await walkPending; - return [{ path: 'C:\\watch\\late.mkv', name: 'late.mkv', size: 1 }]; - }, - stat: async () => ({ mtimeMs: 1 }), - ...timers - }); - monitor.on('status', (status) => statuses.push(status)); - monitor.on('new-files', (files) => newFiles.push(files)); - monitor.start({ folderPath: 'C:\\watch', extensions: 'mkv', skipDuplicates: true, reconcileIntervalMinutes: 5 }); - const scan = monitor.scan({ emitFiles: true, trigger: 'manual' }); - await walkStarted; - await monitor.pause(); - const statusCountAfterPause = statuses.length; - releaseWalk(); - const result = await scan; - assert.equal(result.cancelled, true); - assert.equal(statuses.length, statusCountAfterPause); - assert.deepEqual(newFiles, []); - assert.equal(monitor.status().scanning, false); -}); - -test('stop cancels a running scan and its pending follow-up', async () => { - let releaseWalk; - let markWalkStarted; - let walkCalls = 0; - const walkPending = new Promise((resolve) => { releaseWalk = resolve; }); - const walkStarted = new Promise((resolve) => { markWalkStarted = resolve; }); - const timers = createManualTimers(); - const statuses = []; - const monitor = new FolderMonitor({ - watch: createSilentWatch(), - access: async () => {}, - walkFolder: async () => { - walkCalls++; - if (walkCalls === 1) { - markWalkStarted(); - await walkPending; - } - return []; - }, - stat: async () => ({ mtimeMs: 1 }), - ...timers - }); - monitor.on('status', (status) => statuses.push(status)); - monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 }); - const first = monitor.scan({ emitFiles: true, trigger: 'interval' }); - await walkStarted; - const followUp = monitor.scan({ emitFiles: true, trigger: 'manual' }); - monitor.stop(); - const statusCountAfterStop = statuses.length; - releaseWalk(); - const results = await Promise.all([first, followUp]); - assert.equal(walkCalls, 1); - assert.equal(results.every((result) => result.cancelled === true), true); - assert.equal(statuses.length, statusCountAfterStop); -}); - -test('late watcher add callbacks are ignored after pause', async () => { - const timers = createManualTimers(); - const watchers = []; - const newFiles = []; - const monitor = new FolderMonitor({ - watch: () => { - const watcher = new EventEmitter(); - watcher.close = async () => {}; - watchers.push(watcher); - return watcher; - }, - access: async () => {}, - walkFolder: async () => [], - stat: async () => ({ mtimeMs: 1 }), - ...timers - }); - monitor.on('new-files', (files) => newFiles.push(files)); - monitor.start({ folderPath: 'C:\\watch', extensions: 'mkv', reconcileIntervalMinutes: 5 }); - await monitor.pause(); - watchers[0].emit('add', 'C:\\watch\\late.mkv'); - await timers.runTimeouts(); - assert.deepEqual(newFiles, []); -}); - -test('resume full scan redelivers candidates while watcher duplicate history remains reserved', async () => { - const timers = createManualTimers(); - const watchers = []; - const newFiles = []; - const monitor = new FolderMonitor({ - watch: () => { - const watcher = new EventEmitter(); - watcher.close = async () => {}; - watchers.push(watcher); - return watcher; - }, - access: async () => {}, - walkFolder: async () => [{ path: 'C:\\watch\\same.mkv', name: 'same.mkv', size: 1 }], - stat: async () => ({ mtimeMs: 1 }), - ...timers - }); - monitor.on('new-files', (files) => newFiles.push(files)); - const settings = { folderPath: 'C:\\watch', extensions: 'mkv', skipDuplicates: true, reconcileIntervalMinutes: 5 }; - monitor.start(settings); - watchers[0].emit('add', 'C:\\watch\\same.mkv'); - await timers.runTimeouts(); - await monitor.pause(); - await monitor.resume(settings); - watchers[1].emit('add', 'C:\\watch\\same.mkv'); - await timers.runTimeouts(); - assert.deepEqual(newFiles.map(files => files.map(file => typeof file === 'string' ? file : file.path)), [ - ['C:\\watch\\same.mkv'], - ['C:\\watch\\same.mkv'] - ]); - assert.equal(monitor.status().seenCount, 1); -}); - -test('watcher add paused before batch timeout is emitted exactly once by resume scan', async () => { - const timers = createManualTimers(); - const watchers = []; - const newFiles = []; - const filePath = 'C:\\watch\\pending.mkv'; - const monitor = new FolderMonitor({ - watch: () => { - const watcher = new EventEmitter(); - watcher.close = async () => {}; - watchers.push(watcher); - return watcher; - }, - access: async () => {}, - walkFolder: async () => [{ path: filePath, name: 'pending.mkv', size: 1 }], - stat: async () => ({ mtimeMs: 1 }), - ...timers - }); - monitor.on('new-files', (files) => newFiles.push(files)); - const settings = { folderPath: 'C:\\watch', extensions: 'mkv', skipDuplicates: true, reconcileIntervalMinutes: 5 }; - monitor.start(settings); - watchers[0].emit('add', filePath); - assert.deepEqual(newFiles, []); - await monitor.pause(); - assert.deepEqual(newFiles, []); - await monitor.resume(settings); - assert.deepEqual(newFiles.map(files => files.map(file => typeof file === 'string' ? file : file.path)), [[filePath]]); - assert.equal(monitor.status().seenCount, 0); -}); - -test('synchronous pause during watcher delivery keeps its reservation while resume scan redelivers the candidate', async () => { - const timers = createManualTimers(); - const watchers = []; - const newFiles = []; - const filePath = 'C:\\watch\\delivered.mkv'; - let pausePromise; - const monitor = new FolderMonitor({ - watch: () => { - const watcher = new EventEmitter(); - watcher.close = async () => {}; - watchers.push(watcher); - return watcher; - }, - access: async () => {}, - walkFolder: async () => [{ path: filePath, name: 'delivered.mkv', size: 1 }], - stat: async () => ({ mtimeMs: 1 }), - ...timers - }); - monitor.on('new-files', (files) => { - newFiles.push(files); - pausePromise = monitor.pause(); - }); - const settings = { folderPath: 'C:\\watch', extensions: 'mkv', skipDuplicates: true, reconcileIntervalMinutes: 5 }; - monitor.start(settings); - watchers[0].emit('add', filePath); - await timers.runTimeouts(); - await pausePromise; - assert.deepEqual(newFiles, [[filePath]]); - await monitor.resume(settings); - assert.deepEqual(newFiles.map(files => files.map(file => typeof file === 'string' ? file : file.path)), [[filePath], [filePath]]); - assert.equal(monitor.status().seenCount, 1); -}); - -test('pause rollback never deletes historical seen state from a dedupe-off batch', async () => { - const timers = createManualTimers(); - const watchers = []; - const newFiles = []; - const filePath = 'C:\\watch\\historical.mkv'; - let discoverExisting = false; - const monitor = new FolderMonitor({ - watch: () => { - const watcher = new EventEmitter(); - watcher.close = async () => {}; - watchers.push(watcher); - return watcher; - }, - access: async () => {}, - walkFolder: async () => discoverExisting ? [{ path: filePath, name: 'historical.mkv', size: 1 }] : [], - stat: async () => ({ mtimeMs: 1 }), - ...timers - }); - monitor.on('new-files', (files) => newFiles.push(files)); - const dedupeOn = { folderPath: 'C:\\watch', extensions: 'mkv', skipDuplicates: true, reconcileIntervalMinutes: 5 }; - const dedupeOff = { ...dedupeOn, skipDuplicates: false }; - monitor.start(dedupeOn); - watchers[0].emit('add', filePath); - await timers.runTimeouts(); - assert.deepEqual(newFiles, [[filePath]]); - await monitor.pause(); - await monitor.resume(dedupeOff); - watchers[1].emit('add', filePath); - assert.deepEqual(newFiles, [[filePath]]); - await monitor.pause(); - await monitor.resume(dedupeOn); - watchers[2].emit('add', filePath); - await timers.runTimeouts(); - assert.deepEqual(newFiles, [[filePath]]); - assert.equal(monitor.status().seenCount, 1); -}); - -test('dry scan leaves the public status byte-identical and does not consume reconnect state', async () => { - let reachable = false; - const timers = createManualTimers(); - const statuses = []; - const monitor = new FolderMonitor({ - watch: createSilentWatch(), - access: async () => { - if (!reachable) throw new Error('offline'); - }, - walkFolder: async () => [], - stat: async () => ({ mtimeMs: 1 }), - ...timers - }); - monitor.on('status', (status) => statuses.push(status)); - monitor.start({ folderPath: 'Z:\\watch', reconcileIntervalMinutes: 5 }); - await monitor.scan({ emitFiles: true, trigger: 'interval' }); - reachable = true; - const before = JSON.stringify(monitor.status()); - const statusCount = statuses.length; - const dry = await monitor.scan({ emitFiles: false, trigger: 'test' }); - assert.equal(dry.reachable, true); - assert.equal(JSON.stringify(monitor.status()), before); - assert.equal(statuses.length, statusCount); - const productive = await monitor.scan({ emitFiles: true, trigger: 'reconnect' }); - assert.equal(productive.reconnected, true); -}); - -test('walk failure clears scan state, sanitizes the error and preserves one follow-up', async () => { - let releaseFailure; - let walkCalls = 0; - const failurePending = new Promise((resolve) => { releaseFailure = resolve; }); - const timers = createManualTimers(); - const statuses = []; - const monitor = new FolderMonitor({ - watch: createSilentWatch(), - access: async () => {}, - walkFolder: async () => { - walkCalls++; - if (walkCalls === 1) { - await failurePending; - throw new Error('token=secret-value at C:\\private\\file.mkv'); - } - return []; - }, - stat: async () => ({ mtimeMs: 1 }), - ...timers - }); - monitor.on('status', (status) => statuses.push(status)); - monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 }); - const first = monitor.scan({ emitFiles: true, trigger: 'interval' }); - const second = monitor.scan({ emitFiles: true, trigger: 'reconnect' }); - const third = monitor.scan({ emitFiles: true, trigger: 'manual' }); - releaseFailure(); - await Promise.all([first, second, third]); - assert.equal(walkCalls, 2); - assert.equal(statuses.some((status) => status.error === 'Ordnerscan fehlgeschlagen'), true); - assert.equal(statuses.some((status) => status.error.includes('secret-value') || status.error.includes('C:\\private')), false); - assert.equal(monitor.status().scanning, false); -}); - -test('new-files listener failure terminates the productive scan with a sanitized error', async () => { - const { monitor } = createScanHarness({ - files: [{ path: 'C:\\watch\\a.mkv', name: 'a.mkv', size: 1, mtimeMs: 1 }] - }); - monitor.on('new-files', () => { - throw new Error('apiKey=listener-secret'); - }); - monitor.start({ folderPath: 'C:\\watch', extensions: 'mkv', reconcileIntervalMinutes: 5 }); - const result = await monitor.scan({ emitFiles: true, trigger: 'manual' }); - assert.equal(result.error, 'Ordnerscan fehlgeschlagen'); - assert.equal(monitor.status().error, 'Ordnerscan fehlgeschlagen'); - assert.equal(monitor.status().error.includes('listener-secret'), false); - assert.equal(monitor.status().scanning, false); -}); - -test('interval callback contains unexpected scan rejection', async () => { - const timers = createManualTimers(); - const statuses = []; - const monitor = new FolderMonitor({ - watch: createSilentWatch(), - access: async () => {}, - walkFolder: async () => [], - stat: async () => ({ mtimeMs: 1 }), - ...timers - }); - monitor.on('status', (status) => statuses.push(status)); - monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 }); - monitor.scan = async () => { throw new Error('token=interval-secret'); }; - await assert.doesNotReject(() => timers.runInterval()); - assert.equal(statuses.at(-1).error, 'Ordnerscan fehlgeschlagen'); - assert.equal(statuses.at(-1).error.includes('interval-secret'), false); -}); - -test('real temporary folder recovers a file-atomic deferral with default duplicate protection', async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-automation-folder-')); - const detached = `${root}-detached`; - const timers = createManualTimers(); - const queuedPaths = new Set(); - const admissions = []; - let currentJobCount = 14998; - try { - fs.mkdirSync(path.join(root, 'nested')); - const fourJobPath = path.join(root, 'four-jobs.mkv'); - const twoJobPath = path.join(root, 'nested', 'two-jobs.mkv'); - fs.writeFileSync(fourJobPath, Buffer.from('four')); - fs.writeFileSync(path.join(root, 'ignored.txt'), Buffer.from('ignored')); - fs.writeFileSync(twoJobPath, Buffer.from('two')); - fs.writeFileSync(path.join(root, 'nested', 'excluded.mp4'), Buffer.from('excluded')); - fs.utimesSync(fourJobPath, new Date('2020-01-01T00:00:00.000Z'), new Date('2020-01-01T00:00:00.000Z')); - fs.utimesSync(twoJobPath, new Date('2020-01-02T00:00:00.000Z'), new Date('2020-01-02T00:00:00.000Z')); - const monitor = new FolderMonitor({ watch: createSilentWatch(), ...timers }); - monitor.on('new-files', (files) => { - const descriptors = files.map(file => typeof file === 'string' ? { - path: file, - name: path.basename(file), - mtimeMs: fs.statSync(file).mtimeMs - } : file).filter(file => file.filterMatched !== false); - const processed = classifyProcessedCandidates({ candidates: descriptors, queuePaths: [...queuedPaths] }); - const unprocessed = new Set(processed.unprocessedPaths); - const candidates = descriptors - .filter(file => unprocessed.has(file.path)) - .map(file => ({ ...file, eligibleJobCount: file.name === 'four-jobs.mkv' ? 4 : 2 })); - const plan = planAtomicAdmissions({ candidates, currentJobCount, queueLimitJobs: 15000 }); - for (const filePath of plan.admittedPaths) queuedPaths.add(filePath); - currentJobCount += plan.plannedJobs; - admissions.push({ - trigger: monitor.status().lastScanTrigger, - admittedPaths: plan.admittedPaths, - deferredPaths: plan.deferredPaths, - currentJobCount - }); - }); - monitor.start({ - folderPath: root, - recursive: true, - extensions: 'mkv', - filterMode: 'include', - skipDuplicates: true, - reconcileIntervalMinutes: 5 - }); - - const first = await monitor.scan({ emitFiles: true, trigger: 'startup' }); - assert.deepEqual(first.files.filter(file => file.filterMatched).map(file => file.name).sort(), ['four-jobs.mkv', 'two-jobs.mkv']); - assert.equal(first.files.every((file) => Number.isFinite(file.mtimeMs)), true); - assert.deepEqual(admissions[0], { - trigger: 'startup', - admittedPaths: [twoJobPath], - deferredPaths: [fourJobPath], - currentJobCount: 15000 - }); - - currentJobCount = 14996; - await timers.runInterval(); - assert.deepEqual(admissions[1], { - trigger: 'interval', - admittedPaths: [fourJobPath], - deferredPaths: [], - currentJobCount: 15000 - }); - - fs.renameSync(root, detached); - await timers.runInterval(); - assert.equal(monitor.status().reachable, false); - assert.equal(admissions.length, 2); - - fs.renameSync(detached, root); - await timers.runInterval(); - assert.equal(monitor.status().reachable, true); - assert.equal(admissions.at(-1).trigger, 'reconnect'); - assert.deepEqual(admissions.at(-1).admittedPaths, []); - - await timers.runInterval(); - assert.equal(admissions.filter(entry => entry.trigger === 'reconnect').length, 1); - assert.equal(admissions.at(-1).trigger, 'interval'); - assert.deepEqual([...queuedPaths].sort(), [fourJobPath, twoJobPath].sort()); - await monitor.pause(); - } finally { - if (fs.existsSync(detached)) fs.renameSync(detached, root); - fs.rmSync(root, { recursive: true, force: true }); - } -}); diff --git a/tests/history-retention.test.js b/tests/history-retention.test.js deleted file mode 100644 index 525ce95..0000000 --- a/tests/history-retention.test.js +++ /dev/null @@ -1,95 +0,0 @@ -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 every visible history result', () => { - const h = [batch('2026-01-01', 3, { aborted: 2, error: 1 })]; - assert.strictEqual(countHistoryRows(h), 6); -}); - -test('count policy prunes histories made only from failed or aborted uploads', () => { - const h = [ - batch('2026-01-01', 0, { error: 60 }), - batch('2026-01-02', 0, { aborted: 60 }), - batch('2026-01-03', 0, { error: 60 }) - ]; - const pruned = applyHistoryRetention(h, '100', Date.parse('2026-06-01')); - assert.deepStrictEqual(pruned.map(b => b.timestamp), ['2026-01-02', '2026-01-03']); - assert.strictEqual(countHistoryRows(pruned), 120); -}); - -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()), []); -}); diff --git a/tests/history-status.test.js b/tests/history-status.test.js deleted file mode 100644 index 0539f1d..0000000 --- a/tests/history-status.test.js +++ /dev/null @@ -1,17 +0,0 @@ -const test = require('node:test'); -const assert = require('node:assert/strict'); - -const { classifyHistoryStatus, historyDetail } = require('../renderer/history-status'); - -test('history status keeps skipped separate from successful and failed uploads', () => { - assert.equal(classifyHistoryStatus('done'), 'success'); - assert.equal(classifyHistoryStatus('error'), 'error'); - assert.equal(classifyHistoryStatus('aborted'), 'error'); - assert.equal(classifyHistoryStatus('skipped'), 'skipped'); - assert.equal(classifyHistoryStatus('unexpected'), 'error'); -}); - -test('history detail shows a skipped reason instead of a fake link', () => { - assert.equal(historyDetail({ status: 'skipped', error: 'Datei zu groß', download_url: 'https://example.invalid/wrong' }), 'Datei zu groß'); - assert.equal(historyDetail({ status: 'done', download_url: 'https://example.invalid/ok' }), 'https://example.invalid/ok'); -}); diff --git a/tests/hosters.test.js b/tests/hosters.test.js deleted file mode 100644 index 9f41eef..0000000 --- a/tests/hosters.test.js +++ /dev/null @@ -1,95 +0,0 @@ -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'); - }); -}); diff --git a/tests/i18n.test.js b/tests/i18n.test.js deleted file mode 100644 index 9dc1252..0000000 --- a/tests/i18n.test.js +++ /dev/null @@ -1,408 +0,0 @@ -const test = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const espree = require('espree'); - -const { normalizeLanguage, translateText } = require('../renderer/i18n'); - -test('online backup metadata displays source IP without exposing full keys', () => { - assert.equal(translateText('IP unbekannt', 'en'), 'IP unknown'); - assert.equal(translateText('IP unknown', 'de'), 'IP unbekannt'); - const source = fs.readFileSync(path.join(__dirname, '../renderer/app.js'), 'utf8'); - assert.ok(source.includes('`IP: ${entry.sourceIp}`')); - assert.ok(source.includes('key.textContent = entry.displayKey;')); -}); - -test('online backup selection defaults to unlimited in both languages', () => { - assert.equal(translateText('Unbegrenzt (Standard)', 'en'), 'Unlimited (default)'); - assert.equal(translateText('Unlimited (default)', 'de'), 'Unbegrenzt (Standard)'); - assert.equal(translateText('7 Tage', 'en'), '7 days'); - const source = fs.readFileSync(path.join(__dirname, '../renderer/app.js'), 'utf8'); - const select = source.match(/')); - wc.focus(); - const point = await wc.executeJavaScript('(() => { const r = document.getElementById("check").getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()'); - for (const expected of [true, false]) { - wc.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, ...point }); - wc.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, ...point }); - const state = await waitForState('(() => { const c = document.getElementById("check"); const s = getComputedStyle(c); return { checked: c.checked, shadow: s.boxShadow, outline: s.outlineStyle }; })()', state => state.checked === expected, 'Checkbox mouse click'); - assert.equal(state.checked, expected); - assert.equal(state.shadow, 'none'); - assert.equal(state.outline, 'none'); - } - await wc.executeJavaScript('document.getElementById("before").focus()'); - wc.sendInputEvent({ type: 'keyDown', keyCode: 'Tab' }); - wc.sendInputEvent({ type: 'keyUp', keyCode: 'Tab' }); - const keyboard = await waitForState('(() => { const c = document.getElementById("check"); return { active: document.activeElement === c, visible: c.matches(":focus-visible"), outline: getComputedStyle(c).outlineStyle }; })()', state => state.active, 'Checkbox Tab focus'); - assert.equal(keyboard.active, true); - assert.equal(keyboard.visible, true); - assert.notEqual(keyboard.outline, 'none'); - wc.sendInputEvent({ type: 'keyDown', keyCode: 'Space' }); - wc.sendInputEvent({ type: 'keyUp', keyCode: 'Space' }); - assert.equal(await waitForState('document.getElementById("check").checked', checked => checked, 'Checkbox Space toggle'), true); - assert.notEqual(await wc.executeJavaScript('document.getElementById("number").focus(); getComputedStyle(document.getElementById("number")).boxShadow'), 'none'); - win.destroy(); - fs.writeFileSync(process.env.MHU_CHECKBOX_RESULT, JSON.stringify({ ok: true })); - app.exit(0); -}).catch(error => { fs.writeFileSync(process.env.MHU_CHECKBOX_RESULT, JSON.stringify({ error: error.message })); app.exit(1); }); -`); - const execution = spawnSync(path.join(root, 'node_modules/electron/dist/electron.exe'), [probe, '--user-data-dir=' + path.join(directory, 'profile')], { - cwd: root, windowsHide: true, encoding: 'utf8', timeout: 20000, - env: { ...process.env, MHU_CHECKBOX_CSS: path.join(root, 'renderer/styles.css'), MHU_CHECKBOX_RESULT: result } - }); - const data = fs.existsSync(result) ? JSON.parse(fs.readFileSync(result, 'utf8')) : {}; - assert.equal(execution.status, 0, data.error || execution.stderr); - assert.equal(data.ok, true); -}); - -test('backup submenu survives slow gap crossing, hover clicks and keyboard navigation', { skip: process.platform !== 'win32' }, t => { - const root = path.resolve(__dirname, '..'); - const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-menu-hover-')); - t.after(() => fs.rmSync(directory, { recursive: true, force: true })); - const probe = path.join(directory, 'probe.cjs'); - const result = path.join(directory, 'result.json'); - fs.writeFileSync(probe, ` -const { app, BrowserWindow } = require('electron'); -const fs = require('node:fs'); -const path = require('node:path'); -const assert = require('node:assert/strict'); -app.whenReady().then(async () => { - const root = process.env.MHU_MENU_ROOT; - const css = fs.readFileSync(path.join(root, 'renderer/styles.css'), 'utf8'); - const html = fs.readFileSync(path.join(root, 'renderer/index.html'), 'utf8'); - const source = fs.readFileSync(path.join(root, 'renderer/app.js'), 'utf8'); - const init = source.slice(source.indexOf('function initMenuBar()'), source.indexOf('async function _handleMenuAction(')); - const nav = html.match(/