// Integration test for the persistent download queue (download_jobs table) // Run with: npm run test:downloads import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; // Create isolated temp DB file const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-dl-test-')); const dbPath = path.join(tmpDir, 'test.db'); process.env.NEWTUBE_DB_FILE = dbPath; // Dynamically import DB after setting env const dbMod = await import('../db.mjs'); const { insertUser, cryptoRandomUUID, insertDownloadJob, getDownloadJob, listDownloadJobs, updateDownloadJob, deleteDownloadJob, resetActiveDownloadJobs, countActiveDownloadJobs, sumCompletedDownloadBytes, } = dbMod; function expect(cond, msg) { if (!cond) { throw new Error(`Assertion failed: ${msg}`); } } function logOk(msg) { console.log(`✓ ${msg}`); } const userA = cryptoRandomUUID(); const userB = cryptoRandomUUID(); insertUser({ id: userA, username: 'dl-a', email: null, passwordHash: 'x' }); insertUser({ id: userB, username: 'dl-b', email: null, passwordHash: 'y' }); // 1) Insert + read back const job1 = insertDownloadJob({ id: 'job-1', userId: userA, provider: 'peertube', videoId: 'v123', title: 'Video One', formatId: '18', audioOnly: false, url: 'https://example.com/v123' }); expect(job1 && job1.id === 'job-1', 'insertDownloadJob returns the job'); expect(job1.state === 'queued', 'new job starts queued'); expect(job1.progress === 0, 'new job progress is 0'); logOk('insertDownloadJob + getDownloadJob round-trip'); // 2) Ownership: userB cannot see userA's job via list const forB = listDownloadJobs({ userId: userB, limit: 50 }); expect(forB.length === 0, 'userB list is empty'); const forA = listDownloadJobs({ userId: userA, limit: 50 }); expect(forA.length === 1 && forA[0].id === 'job-1', 'userA sees own job'); logOk('Jobs are scoped per user'); // 3) Update: progress monotonic + completion updateDownloadJob('job-1', { state: 'running', progress: 42 }); let j = getDownloadJob('job-1'); expect(j.state === 'running' && j.progress === 42, 'update to running/42 works'); // progress must never go backwards updateDownloadJob('job-1', { state: 'running', progress: 10 }); j = getDownloadJob('job-1'); expect(j.progress === 42, 'progress never decreases'); // complete it updateDownloadJob('job-1', { state: 'completed', progress: 100, fileName: 'Video One.mp4', fileExt: 'mp4', fileSize: 1048576, filePath: '/tmp/x/Video One.mp4', completedAt: Date.now() }); j = getDownloadJob('job-1'); expect(j.state === 'completed' && j.fileSize === 1048576 && j.completedAt, 'completion stores file info'); logOk('updateDownloadJob progress monotonic + completion fields'); // 4) Active count only counts queued/running/merging const job2 = insertDownloadJob({ id: 'job-2', userId: userA, provider: 'odysee', videoId: 'v456', title: 'Video Two', formatId: null, audioOnly: true, url: 'https://example.com/v456' }); expect(countActiveDownloadJobs(userA) === 1, 'completed job not counted as active'); updateDownloadJob('job-2', { state: 'merging' }); expect(countActiveDownloadJobs(userA) === 1, 'merging job counted as active'); logOk('countActiveDownloadJobs counts only active states'); // 5) Quota sums completed sizes within window const total = sumCompletedDownloadBytes(userA, Date.now() - 1000); expect(total === 1048576, `sumCompletedDownloadBytes sums completed files (got ${total})`); const empty = sumCompletedDownloadBytes(userB, 0); expect(empty === 0, 'userB has zero bytes used'); logOk('sumCompletedDownloadBytes quota accounting'); // 6) resetActiveDownloadJobs marks running jobs as interrupted updateDownloadJob('job-2', { state: 'running' }); resetActiveDownloadJobs(); j = getDownloadJob('job-2'); expect(j.state === 'interrupted', 'running job becomes interrupted after restart'); expect(getDownloadJob('job-1').state === 'completed', 'completed job untouched by reset'); logOk('resetActiveDownloadJobs marks active jobs interrupted'); // 7) Delete scoped to owner const removedB = deleteDownloadJob({ userId: userB, id: 'job-1' }); expect(removedB === false, 'userB cannot delete userA job'); const removedA = deleteDownloadJob({ userId: userA, id: 'job-1' }); expect(removedA === true, 'owner can delete own job'); expect(getDownloadJob('job-1') == null, 'deleted job is gone'); logOk('deleteDownloadJob enforces ownership'); // 8) State filter on list const interruptedOnly = listDownloadJobs({ userId: userA, limit: 50, state: 'interrupted' }); expect(interruptedOnly.length === 1 && interruptedOnly[0].id === 'job-2', 'list by state filter works'); logOk('listDownloadJobs state filter'); console.log('\nAll download jobs tests passed.');