CI / build-and-test (push) Successful in 13m34s
- Strategy Pattern front: ChannelProviderInterface + 6 providers (yt/dm/tw/pt/od/ru) + factory (ids longs/courts) - ChannelContentService: cache par onglet/tri/recherche (TTL 5min), pagination nextPage - ChannelPage: header (banniere/avatar/subs/description ...plus), tabs dynamiques par capabilities, grilles videos/shorts 9:16/playlists/live, recherche debounced, tri recent/populaire, infinite scroll IntersectionObserver, skeletons/empty/error - Backend: GET /api/channels/:provider/:id/content (videos/shorts/playlists/live) natif par provider
107 lines
4.6 KiB
JavaScript
107 lines
4.6 KiB
JavaScript
// 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.');
|