Version 2.0.0 - update of everything
This commit is contained in:
85 files changed
+13065
-2107
No files matched your search
@@ -0,0 +1 @@
|
||||
logs/
|
||||
@@ -0,0 +1,10 @@
|
||||
# Tests de développement (ad hoc)
|
||||
|
||||
Scripts autonomes écrits pendant la refonte YouTube / admin / OAuth (octobre 2026). Ils ne démarrent ni le bot Discord ni le serveur HTTP.
|
||||
Lancer depuis n'importe où : `node backend/tests/dev/<fichier>.js` (code de sortie 1 en cas d'échec).
|
||||
|
||||
- Hors réseau : `cookies.test.js`, `errors.test.js`, `plan.test.js`, `limiter.test.js`, `google-utils.test.js`, `google-flow.test.js`, `admin-youtube.test.js`, `admin-config.test.js`, `server-socket.test.js`, `stream.edge.js`
|
||||
- Réseau (YouTube / GitHub réels) : `stream.e2e.js`, `check.e2e.js`, `metadata.live.test.js`, `avatar.live.test.js`, `update.check.js`
|
||||
- `undef-check.js` : détection d'identifiants non déclarés (nécessite `frontend/node_modules` pour @babel).
|
||||
|
||||
Les données YouTube des tests vont dans `os.tmpdir()/chopin-tests` (jamais `backend/data/`), les logs dans `tests/dev/logs/` (ignoré par git).
|
||||
@@ -0,0 +1,46 @@
|
||||
// Environnement de test : loguix dans le scratchpad, données YouTube isolées (jamais backend/data/youtube)
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const BACKEND = require("path").resolve(__dirname, "../..");
|
||||
// Données jetables hors du dépôt
|
||||
const SCRATCH = path.join(os.tmpdir(), 'chopin-tests');
|
||||
fs.mkdirSync(SCRATCH, { recursive: true });
|
||||
const LOGS = path.join(__dirname, 'logs');
|
||||
fs.mkdirSync(LOGS, { recursive: true });
|
||||
|
||||
const loguix = require(path.join(BACKEND, 'node_modules', 'loguix'));
|
||||
loguix.setup(LOGS, path.join(BACKEND, 'package.json'));
|
||||
|
||||
const { __glob } = require(path.join(BACKEND, 'src', 'utils', 'GlobalVars'));
|
||||
|
||||
function isolateData(name = 'ytdata') {
|
||||
const dir = fs.mkdtempSync(path.join(SCRATCH, `${name}-`));
|
||||
__glob.YOUTUBE_DIR = dir;
|
||||
__glob.YOUTUBE_COOKIES = path.join(dir, 'cookies.txt');
|
||||
__glob.YOUTUBE_STATE = path.join(dir, 'state.json');
|
||||
__glob.YOUTUBE_SETTINGS = path.join(dir, 'settings.json');
|
||||
// Le cache yt-dlp peut rester partagé entre les essais (dans le scratchpad)
|
||||
__glob.YOUTUBE_CACHE = path.join(SCRATCH, 'ytcache');
|
||||
// Par défaut aucun ancien fichier : chaque test choisit
|
||||
__glob.COOKIES = path.join(dir, 'legacy-none.txt');
|
||||
__glob.COOKIES_JSON = path.join(dir, 'legacy-none.json');
|
||||
return dir;
|
||||
}
|
||||
|
||||
let failures = 0, passes = 0;
|
||||
function check(name, cond, extra) {
|
||||
if (cond) { passes++; console.log(` ok ${name}`); }
|
||||
else { failures++; console.log(` FAIL ${name}${extra !== undefined ? ' -> ' + JSON.stringify(extra) : ''}`); }
|
||||
}
|
||||
function done() {
|
||||
console.log(`\n${passes} ok, ${failures} échec(s)`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
}
|
||||
function req(rel) { return require(path.join(BACKEND, rel)); }
|
||||
function chopinTmpDirs() {
|
||||
return fs.readdirSync(os.tmpdir()).filter(e => e.startsWith('chopin-ytc-'));
|
||||
}
|
||||
|
||||
module.exports = { BACKEND, SCRATCH, __glob, isolateData, check, done, req, chopinTmpDirs };
|
||||
@@ -0,0 +1,356 @@
|
||||
// Routes "/ADMIN/CONFIG/*", "/ADMIN/WARP/CONFIG", "/ADMIN/PLAYLISTS/*" (+ quelques correctifs players / comptes)
|
||||
// de backend/src/server/Admin.js, appelées par un faux socket. Configuration.js, Database.js et WarpProxy.js sont les
|
||||
// vrais modules, mais TOUS les chemins de __glob qui pointent vers backend/data sont redirigés vers un dossier
|
||||
// temporaire (config.json synthétique, jamais backend/data). Aucun appel réseau (Spotify simulé, WARP jamais activé).
|
||||
const Module = require('module');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { BACKEND, SCRATCH, __glob, isolateData, check, done, req } = require('./_setup');
|
||||
|
||||
// --- Données isolées : chaque chemin de data/ est redirigé AVANT de charger le moindre module ---
|
||||
const DATA_DIR = path.join(BACKEND, 'data');
|
||||
const tmp = fs.mkdtempSync(path.join(SCRATCH, 'admin-config-'));
|
||||
for (const [key, value] of Object.entries(__glob)) {
|
||||
if (typeof value === 'string' && path.resolve(value).toLowerCase().startsWith(DATA_DIR.toLowerCase())) {
|
||||
__glob[key] = path.join(tmp, path.relative(DATA_DIR, value));
|
||||
}
|
||||
}
|
||||
__glob.DATA = tmp;
|
||||
isolateData('admin-config-yt');
|
||||
const safePaths = Object.entries(__glob).every(([k, v]) => typeof v !== 'string' || !path.resolve(v).toLowerCase().startsWith(DATA_DIR.toLowerCase()));
|
||||
if (!safePaths) { console.error('Chemins non isolés : arrêt'); process.exit(1); }
|
||||
process.env.YTDLP_AUTO_UPDATE = 'false';
|
||||
delete process.env.WARP_PROXY;
|
||||
delete process.env.WARP_FALLBACK_DIRECT;
|
||||
|
||||
// Secrets SYNTHÉTIQUES : aucun ne doit apparaître dans une réponse, une poussée ou un log
|
||||
const SECRETS = {
|
||||
token: 'FAKETOKEN.abcdefghijklmnop.qrstuvwxyz0123',
|
||||
client_secret: 'FAKECLIENTSECRET_9876543210',
|
||||
youtube: 'FAKEYTSECRET_aaaabbbbcccc',
|
||||
spotify: 'FAKESPOTIFYSECRET_ddddeeee',
|
||||
refresh: 'FAKEREFRESHTOKEN_ffffgggg',
|
||||
proxyPass: 'FAKEPROXYPASS_hhhh'
|
||||
};
|
||||
fs.writeFileSync(path.join(tmp, 'config.json'), JSON.stringify({
|
||||
token: SECRETS.token,
|
||||
client_secret: SECRETS.client_secret,
|
||||
report: { channel: '123456789012345678', contact: '' },
|
||||
api: {
|
||||
youtube: { clientId: 'yt-client-id.apps.googleusercontent.com', clientSecret: SECRETS.youtube },
|
||||
spotify: { clientId: 'spotifyclientid', clientSecret: SECRETS.spotify }
|
||||
},
|
||||
website: 'https://chopin.example.org',
|
||||
server_port: 5000,
|
||||
media: { guildId: '', channelId: '' }
|
||||
}, null, 2));
|
||||
|
||||
const SRC = path.join(BACKEND, 'src');
|
||||
const norm = (p) => p.replace(/\\/g, '/').toLowerCase().replace(/\.js$/, '');
|
||||
|
||||
// --- Stubs ---
|
||||
const admin = {
|
||||
identity: { id: '111111', username: 'testadmin' }, labels: ['ADMIN'],
|
||||
isAdmin: () => true, isFullBanned: () => false
|
||||
};
|
||||
const owner = {
|
||||
identity: { id: '333333', username: 'proprio', global_name: 'Propriétaire', avatar: null }, labels: [],
|
||||
isAdmin: () => false, isFullBanned: () => false
|
||||
};
|
||||
const guilds = new Map([['222222', { id: '222222', name: 'Serveur Test' }]]);
|
||||
|
||||
// Player simulé : loop / shuffle comptés pour vérifier l'idempotence
|
||||
const playerCalls = { setLoop: 0, setShuffle: 0 };
|
||||
const fakePlayer = {
|
||||
guildId: '222222', loop: true, queue: { shuffle: false, clearNext() {} },
|
||||
isConnected: () => true,
|
||||
async setLoop() { playerCalls.setLoop++; this.loop = !this.loop; },
|
||||
async setShuffle() { playerCalls.setShuffle++; this.queue.shuffle = !this.queue.shuffle; },
|
||||
getState() {
|
||||
return {
|
||||
guildId: '222222', status: 'playing', loop: this.loop, shuffle: this.queue.shuffle,
|
||||
current: { id: 'song-b', title: 'B' },
|
||||
lastError: { code: 'SOURCE_FAILED', message: 'x', songId: 'song-a', at: Date.now() },
|
||||
next: [], previous: []
|
||||
};
|
||||
}
|
||||
};
|
||||
const AllPlayers = new Map([['222222', fakePlayer]]);
|
||||
const removed = [];
|
||||
|
||||
const stubs = {
|
||||
[norm(SRC + '/server/auth/User')]: {
|
||||
getUserById: (id) => id === '111111' ? admin : id === '333333' ? owner : null,
|
||||
getUsers: () => [admin, owner]
|
||||
},
|
||||
[norm(SRC + '/server/auth/Session')]: { getSessionCount: () => 0 },
|
||||
[norm(SRC + '/player/Player')]: { getAllPlayers: () => [...AllPlayers.values()], AllPlayers },
|
||||
[norm(SRC + '/discord/Bot')]: {
|
||||
getGuilds: () => guilds, getClient: () => null, isReady: () => false,
|
||||
getChannel: () => null
|
||||
},
|
||||
[norm(SRC + '/discord/ServerSettings')]: {},
|
||||
[norm(SRC + '/discord/MediaBase')]: {},
|
||||
[norm(SRC + '/playlists/PlaylistManager')]: {
|
||||
removePlaylist(userId, playlistId) {
|
||||
const list = getDatabase('Playlists').data[userId] || [];
|
||||
const index = list.findIndex(p => String(p.playlistId) === String(playlistId));
|
||||
if (index === -1) return false;
|
||||
list.splice(index, 1);
|
||||
removed.push({ userId, playlistId });
|
||||
return true;
|
||||
}
|
||||
},
|
||||
[norm(SRC + '/utils/SafeMetric')]: { getAll: () => [], get: () => 0 },
|
||||
[norm(SRC + '/utils/LogReader')]: { init() {}, getCurrentName: () => 'x.log', isInterrupted: async () => false },
|
||||
[norm(SRC + '/utils/Maintenance')]: {},
|
||||
[norm(SRC + '/player/Method/Youtube')]: { activeProcesses: new Map() },
|
||||
};
|
||||
|
||||
// Spotify simulé : refus avec le secret recopié dans le message (il doit être masqué)
|
||||
let spotifyMode = 'reject';
|
||||
class FakeSpotify {
|
||||
constructor({ clientId, clientSecret }) { this.clientId = clientId; this.clientSecret = clientSecret; }
|
||||
async clientCredentialsGrant() {
|
||||
if (spotifyMode === 'ok') return { body: { access_token: 'FAKEACCESS', expires_in: 3600 } };
|
||||
throw Object.assign(new Error('Bad Request'), { statusCode: 400, body: { error: 'invalid_client', error_description: 'Invalid client secret ' + this.clientSecret } });
|
||||
}
|
||||
}
|
||||
|
||||
const origLoad = Module._load;
|
||||
Module._load = function (request, parent) {
|
||||
if (request === 'spotify-web-api-node') return FakeSpotify;
|
||||
if (request.startsWith('.') && parent) {
|
||||
const resolved = norm(path.resolve(path.dirname(parent.filename), request));
|
||||
if (stubs[resolved]) return stubs[resolved];
|
||||
}
|
||||
return origLoad.apply(this, arguments);
|
||||
};
|
||||
|
||||
const { Database, getDatabase } = req('src/utils/Database/Database.js');
|
||||
// Bases réelles dans le dossier temporaire
|
||||
const playlistsDb = new Database('Playlists', path.join(tmp, 'playlists.json'), {});
|
||||
playlistsDb.data = {
|
||||
'333333': [
|
||||
{ playlistId: '1', title: 'Perso', type: 'playlist', songs: [{ id: 'a' }, { id: 'b' }], duration: 300 },
|
||||
{ playlistId: '2', title: 'Synchro', type: 'youtube', source: 'google', url: 'https://www.youtube.com/playlist?list=PLx', songs: [{ id: 'c' }], syncedAt: '2026-10-01T10:00:00.000Z' },
|
||||
{ playlistId: '3', title: 'Spotify', type: 'spotify', url: 'https://open.spotify.com/playlist/x', songs: [] }
|
||||
],
|
||||
'444444': []
|
||||
};
|
||||
const googleDb = new Database('google', path.join(tmp, 'google.json'), {});
|
||||
googleDb.data = {
|
||||
'333333': { tokens: { refresh_token: SECRETS.refresh, access_token: SECRETS.refresh + '_a' }, linkedAt: '2026-10-01' },
|
||||
'555555': { tokens: { refresh_token: SECRETS.refresh + '_2' }, invalid: true }
|
||||
};
|
||||
|
||||
const configuration = req('src/utils/Database/Configuration.js');
|
||||
const warp = req('src/utils/WarpProxy.js');
|
||||
const Admin = req('src/server/Admin.js');
|
||||
|
||||
// --- Faux socket / io ---
|
||||
const handlers = new Map();
|
||||
const socket = { id: 'sock1', data: { userId: '111111' }, on: (name, fn) => handlers.set(name, fn), emit() {}, leave() {}, join() {} };
|
||||
const emitted = [];
|
||||
const rooms = new Map([['ADMIN', new Set(['sock1'])]]);
|
||||
const fakeIo = {
|
||||
sockets: { adapter: { rooms }, sockets: new Map() },
|
||||
to: (room) => ({ emit: (ev, data) => emitted.push({ room, ev, data }) }),
|
||||
in: () => ({ socketsLeave() {} }),
|
||||
emit() {},
|
||||
of: () => ({ adapter: { on() {} } })
|
||||
};
|
||||
Admin.attach({ io: fakeIo, socket, socketUser: () => admin });
|
||||
Admin.init(fakeIo);
|
||||
|
||||
function call(name, payload) {
|
||||
return new Promise((resolve) => handlers.get(name)(payload, resolve));
|
||||
}
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
const leaks = (value) => Object.values(SECRETS).filter(s => JSON.stringify(value).includes(s));
|
||||
const configEvents = [];
|
||||
process.on('CONFIG_UPDATE', (payload) => configEvents.push(payload));
|
||||
const playlistEvents = [];
|
||||
process.on('PLAYLISTS_UPDATE', (userId) => playlistEvents.push(userId));
|
||||
|
||||
(async () => {
|
||||
const expected = ['/ADMIN/CONFIG/GET', '/ADMIN/CONFIG/SET', '/ADMIN/CONFIG/SPOTIFY/TEST', '/ADMIN/WARP/CONFIG', '/ADMIN/PLAYLISTS/LIST', '/ADMIN/PLAYLISTS/DELETE'];
|
||||
check('routes enregistrées', expected.every(r => handlers.has(r)), expected.filter(r => !handlers.has(r)));
|
||||
|
||||
console.log('# Configuration : validateur pur');
|
||||
const bad = (key, value) => { try { configuration.validateValue(key, value); return false; } catch (e) { return e.code === 'INVALID_PARAMS'; } };
|
||||
check('clé inconnue refusée', bad('api.discord.secret', 'x'));
|
||||
check('port non numérique refusé', bad('server_port', 'abc'));
|
||||
check('port hors limites refusé', bad('server_port', 70000));
|
||||
check('port en texte accepté', configuration.validateValue('server_port', ' 4000 ') === 4000);
|
||||
check('site javascript: refusé', bad('website', 'javascript:alert(1)'));
|
||||
check('site avec identifiants refusé', bad('website', 'https://user:pass@example.org'));
|
||||
check('site avec paramètres refusé', bad('website', 'https://example.org/?a=1'));
|
||||
check('site normalisé sans / final', configuration.validateValue('website', ' https://Example.org/chopin/ ') === 'https://example.org/chopin');
|
||||
check('snowflake trop court refusé', bad('report.channel', '123'));
|
||||
check('snowflake vide accepté (facultatif)', configuration.validateValue('report.channel', '') === '');
|
||||
check('token vide refusé', bad('token', ' '));
|
||||
check('secret avec espace refusé', bad('api.spotify.clientSecret', 'abc def'));
|
||||
check('retour à la ligne refusé', bad('api.spotify.clientId', 'abc\ndef'));
|
||||
check('type non texte refusé', bad('api.spotify.clientId', 42));
|
||||
|
||||
console.log('# CONFIG/GET');
|
||||
let r = await call('/ADMIN/CONFIG/GET', {});
|
||||
check('GET ok', r.ok, r);
|
||||
check('aucun secret dans la réponse', leaks(r).length === 0, leaks(r));
|
||||
const cfg = r.data.config;
|
||||
check('toutes les clés de la liste blanche', configuration.getKeys().every(k => cfg[k]), Object.keys(cfg));
|
||||
check('token masqué {set,length}', cfg.token.secret && cfg.token.set === true && cfg.token.length === SECRETS.token.length && !('value' in cfg.token), cfg.token);
|
||||
check('secret Spotify masqué', cfg['api.spotify.clientSecret'].set === true && !('value' in cfg['api.spotify.clientSecret']));
|
||||
check('valeur non secrète renvoyée', cfg.website.value === 'https://chopin.example.org' && cfg['api.spotify.clientId'].value === 'spotifyclientid');
|
||||
check('redémarrage requis signalé', cfg.server_port.requiresRestart === true && cfg.server_port.pendingRestart === false && r.data.restartRequired === false);
|
||||
const integ = r.data.integrations;
|
||||
check('URI de redirection Google exacte', integ.google.redirectUri === 'https://chopin.example.org/oauth2callback', integ.google);
|
||||
check('Google configuré + comptes liés', integ.google.configured === true && integ.google.linkedUsers === 2 && integ.google.invalidUsers === 1, integ.google);
|
||||
check('Spotify configuré', integ.spotify.configured === true && integ.spotify.lastTest === null);
|
||||
check('WARP non configuré', integ.warp.configured === false);
|
||||
|
||||
console.log('# CONFIG/SET');
|
||||
r = await call('/ADMIN/CONFIG/SET', { key: 'token.inconnu', value: 'x' });
|
||||
check('clé inconnue -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS', r);
|
||||
r = await call('/ADMIN/CONFIG/SET', { key: '__proto__', value: 'x' });
|
||||
check('__proto__ refusé', !r.ok && r.error === 'INVALID_PARAMS', r);
|
||||
r = await call('/ADMIN/CONFIG/SET', { key: 'website' });
|
||||
check('valeur absente -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS', r);
|
||||
r = await call('/ADMIN/CONFIG/SET', { key: 'website', value: 'ftp://example.org' });
|
||||
check('site ftp -> INVALID_PARAMS + message FR', !r.ok && r.error === 'INVALID_PARAMS' && /site/i.test(r.message), r);
|
||||
check('aucun CONFIG_UPDATE sur refus', configEvents.length === 0, configEvents);
|
||||
|
||||
emitted.length = 0;
|
||||
r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientSecret', value: ' NEWSPOTIFYSECRET_zz ' });
|
||||
check('secret Spotify modifié', r.ok && r.data.result.changed === true && r.data.config['api.spotify.clientSecret'].length === 'NEWSPOTIFYSECRET_zz'.length, r);
|
||||
check('nouveau secret absent de la réponse', !JSON.stringify(r).includes('NEWSPOTIFYSECRET_zz'));
|
||||
check('CONFIG_UPDATE émis avec la clé seule', configEvents.length === 1 && configEvents[0].key === 'api.spotify.clientSecret' && Object.keys(configEvents[0]).length === 1, configEvents);
|
||||
const onDisk = JSON.parse(fs.readFileSync(path.join(tmp, 'config.json'), 'utf-8'));
|
||||
check('config.json (temporaire) enregistré', onDisk.api.spotify.clientSecret === 'NEWSPOTIFYSECRET_zz' && onDisk.token === SECRETS.token);
|
||||
check('getter relu à chaud', configuration.getSpotifyClientSecret() === 'NEWSPOTIFYSECRET_zz');
|
||||
await sleep(450);
|
||||
const pushes = emitted.filter(e => e.ev === '/ADMIN/CONFIG/UPDATE');
|
||||
check('poussée /ADMIN/CONFIG/UPDATE vers ADMIN', pushes.length === 1 && pushes[0].room === 'ADMIN', emitted.map(e => e.ev));
|
||||
check('poussée sans secret', pushes.length === 1 && leaks(pushes[0].data).length === 0 && !JSON.stringify(pushes[0].data).includes('NEWSPOTIFYSECRET_zz'));
|
||||
|
||||
r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientSecret', value: 'NEWSPOTIFYSECRET_zz' });
|
||||
check('même valeur : changed=false, pas d\'événement', r.ok && r.data.result.changed === false && configEvents.length === 1);
|
||||
|
||||
r = await call('/ADMIN/CONFIG/SET', { key: 'server_port', value: 4000 });
|
||||
check('port : redémarrage en attente', r.ok && r.data.result.requiresRestart && r.data.result.pendingRestart && r.data.restartRequired === true, r.data?.result);
|
||||
r = await call('/ADMIN/CONFIG/SET', { key: 'server_port', value: '5000' });
|
||||
check('port remis : plus d\'attente', r.ok && r.data.result.pendingRestart === false && r.data.restartRequired === false);
|
||||
|
||||
r = await call('/ADMIN/CONFIG/SET', { key: 'website', value: 'https://new.example.org/' });
|
||||
check('site normalisé + URI Google mise à jour', r.ok && r.data.config.website.value === 'https://new.example.org' && r.data.integrations.google.redirectUri === 'https://new.example.org/oauth2callback', r.data?.integrations?.google);
|
||||
r = await call('/ADMIN/CONFIG/SET', { key: 'report.contact', value: '' });
|
||||
check('champ facultatif vidé', r.ok && r.data.config['report.contact'].value === '');
|
||||
r = await call('/ADMIN/CONFIG/SET', { key: 'token', value: '' });
|
||||
check('token vide refusé', !r.ok && r.error === 'INVALID_PARAMS');
|
||||
|
||||
console.log('# CONFIG/SPOTIFY/TEST');
|
||||
r = await call('/ADMIN/CONFIG/SPOTIFY/TEST', {});
|
||||
check('test refusé : ok=false, message FR', r.ok && r.data.result.ok === false && /refusés/.test(r.data.result.message), r);
|
||||
check('secret masqué dans le message', !JSON.stringify(r).includes('NEWSPOTIFYSECRET_zz') && r.data.result.message.includes('***'));
|
||||
check('dernier test mémorisé', r.data.integrations.spotify.lastTest?.ok === false);
|
||||
spotifyMode = 'ok';
|
||||
r = await call('/ADMIN/CONFIG/SPOTIFY/TEST', {});
|
||||
check('test réussi', r.ok && r.data.result.ok === true && r.data.result.expiresIn === 3600, r);
|
||||
r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientId', value: 'autreid' });
|
||||
check('dernier test oublié après modification', r.ok && r.data.integrations.spotify.lastTest === null);
|
||||
r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientId', value: '' });
|
||||
r = await call('/ADMIN/CONFIG/SPOTIFY/TEST', {});
|
||||
check('sans identifiants -> UNAVAILABLE', !r.ok && r.error === 'UNAVAILABLE', r);
|
||||
|
||||
console.log('# WARP/CONFIG');
|
||||
const badPatch = (patch, locks) => { try { warp.validateConfigPatch(patch, locks); return false; } catch (e) { return e.code === 'INVALID_PARAMS'; } };
|
||||
const noLocks = { url: false, enabled: false, fallbackDirect: false };
|
||||
check('ftp refusé', badPatch({ url: 'ftp://127.0.0.1:21' }, noLocks));
|
||||
check('socks5 sans port refusé', badPatch({ url: 'socks5://127.0.0.1' }, noLocks));
|
||||
check('chemin refusé', badPatch({ url: 'http://127.0.0.1:1080/x' }, noLocks));
|
||||
check('booléen attendu', badPatch({ fallbackDirect: 'oui' }, noLocks));
|
||||
check('champ inconnu refusé', badPatch({ host: 'x' }, noLocks));
|
||||
check('socks5h accepté', warp.validateConfigPatch({ url: 'socks5h://warp:1080' }, noLocks).url === 'socks5h://warp:1080');
|
||||
check('URL vide = suppression', warp.validateConfigPatch({ url: ' ' }, noLocks).url === null);
|
||||
check('verrou env respecté (pur)', badPatch({ url: 'http://127.0.0.1:1080' }, { ...noLocks, url: true }));
|
||||
|
||||
r = await call('/ADMIN/WARP/CONFIG', {});
|
||||
check('aucune modification -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS');
|
||||
r = await call('/ADMIN/WARP/CONFIG', { url: 'gopher://x' });
|
||||
check('URL invalide -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS' && /http/.test(r.message), r);
|
||||
r = await call('/ADMIN/WARP/CONFIG', { enabled: true });
|
||||
check('activer sans URL refusé', !r.ok && r.error === 'INVALID_PARAMS', r);
|
||||
// Désactivé : aucun contrôle réseau
|
||||
r = await call('/ADMIN/WARP/CONFIG', { enabled: false, url: `http://user:${SECRETS.proxyPass}@127.0.0.1:9`, fallbackDirect: false });
|
||||
check('configuration enregistrée', r.ok && r.data.changes.length >= 2, r);
|
||||
check('identifiants jamais renvoyés', r.ok && !JSON.stringify(r).includes(SECRETS.proxyPass) && r.data.status.file.url === 'http://***@127.0.0.1:9' && r.data.status.file.hasCredentials === true, r.data?.status?.file);
|
||||
check('proxy désactivé : non configuré', r.data.status.configured === false && r.data.status.fallbackDirect === false);
|
||||
const proxyFile = JSON.parse(fs.readFileSync(__glob.PROXY, 'utf-8'));
|
||||
check('proxy.json (temporaire) écrit', proxyFile.enabled === false && proxyFile.url.includes('127.0.0.1:9') && path.dirname(__glob.PROXY) === tmp);
|
||||
r = await call('/ADMIN/WARP/CONFIG', { fallbackDirect: true });
|
||||
check('modifier une option garde l\'URL', r.ok && JSON.parse(fs.readFileSync(__glob.PROXY, 'utf-8')).url.includes(SECRETS.proxyPass) && r.data.status.fallbackDirect === true);
|
||||
|
||||
process.env.WARP_PROXY = 'http://127.0.0.1:9';
|
||||
process.env.WARP_FALLBACK_DIRECT = 'true';
|
||||
r = await call('/ADMIN/WARP/STATUS', {});
|
||||
check('verrous exposés', r.ok && r.data.locked.url && r.data.locked.enabled && r.data.locked.fallbackDirect && r.data.source === 'env', r.data?.locked);
|
||||
r = await call('/ADMIN/WARP/CONFIG', { url: 'http://127.0.0.1:1080' });
|
||||
check('URL verrouillée par WARP_PROXY', !r.ok && r.error === 'INVALID_PARAMS' && r.message.includes('WARP_PROXY'), r);
|
||||
r = await call('/ADMIN/WARP/CONFIG', { fallbackDirect: false });
|
||||
check('repli verrouillé par WARP_FALLBACK_DIRECT', !r.ok && r.error === 'INVALID_PARAMS' && r.message.includes('WARP_FALLBACK_DIRECT'), r);
|
||||
delete process.env.WARP_PROXY;
|
||||
delete process.env.WARP_FALLBACK_DIRECT;
|
||||
r = await call('/ADMIN/WARP/CONFIG', { url: '' });
|
||||
check('URL effacée', r.ok && r.data.status.file.url === null && r.data.status.configured === false, r.data?.status);
|
||||
|
||||
console.log('# PLAYLISTS');
|
||||
r = await call('/ADMIN/PLAYLISTS/LIST', {});
|
||||
check('LIST ok', r.ok && Array.isArray(r.data), r);
|
||||
check('propriétaires sans playlist ignorés', r.data.length === 1 && r.data[0].userId === '333333');
|
||||
const entry = r.data[0];
|
||||
check('forme propriétaire', entry.username === 'proprio' && entry.owner?.id === '333333' && entry.owner.global_name === 'Propriétaire' && entry.owner.isDeleted === false, entry.owner);
|
||||
const lists = entry.playlists;
|
||||
check('forme playlist', lists.length === 3 && lists.every(p => typeof p.playlistId === 'string' && 'title' in p && 'type' in p && 'source' in p && typeof p.songCount === 'number'), lists);
|
||||
check('compte des titres', lists[0].songCount === 2 && lists[1].songCount === 1 && lists[2].songCount === 0);
|
||||
check('synchro Google repérée', lists[1].source === 'google' && lists[1].syncedAt === '2026-10-01T10:00:00.000Z');
|
||||
check('aucune liste de titres renvoyée', lists.every(p => !('songs' in p)));
|
||||
r = await call('/ADMIN/PLAYLISTS/LIST', { userId: '444444' });
|
||||
check('filtre par utilisateur (même vide)', r.ok && r.data.length === 1 && r.data[0].playlists.length === 0);
|
||||
r = await call('/ADMIN/PLAYLISTS/LIST', { userId: 'abc' });
|
||||
check('userId invalide -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS');
|
||||
r = await call('/ADMIN/PLAYLISTS/DELETE', { userId: '333333', playlistId: '99' });
|
||||
check('playlist inconnue -> NOT_FOUND', !r.ok && r.error === 'NOT_FOUND');
|
||||
r = await call('/ADMIN/PLAYLISTS/DELETE', { userId: '333333', playlistId: '2' });
|
||||
check('suppression ok', r.ok && r.data.deleted === true && removed.length === 1 && removed[0].playlistId === '2', r);
|
||||
check('PLAYLISTS_UPDATE émis pour le propriétaire', playlistEvents.includes('333333'), playlistEvents);
|
||||
|
||||
console.log('# Players / comptes (correctifs)');
|
||||
r = await call('/ADMIN/PLAYERS/LIST', {});
|
||||
check('erreur d\'une autre musique marquée résolue', r.ok && r.data[0]?.lastErrorStale === true, r.data?.[0]);
|
||||
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'loop', value: true });
|
||||
check('loop=true déjà actif : aucune bascule', r.ok && playerCalls.setLoop === 0 && fakePlayer.loop === true, r);
|
||||
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'loop', value: false });
|
||||
check('loop=false : une bascule', r.ok && playerCalls.setLoop === 1 && fakePlayer.loop === false);
|
||||
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'shuffle', value: false });
|
||||
check('shuffle=false déjà inactif : aucune bascule', r.ok && playerCalls.setShuffle === 0);
|
||||
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'shuffle' });
|
||||
check('shuffle sans valeur : bascule (ancien client)', r.ok && playerCalls.setShuffle === 1);
|
||||
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'loop', value: 'oui' });
|
||||
check('valeur non booléenne refusée', !r.ok && r.error === 'INVALID_PARAMS');
|
||||
r = await call('/ADMIN/USERS/SET_GUILD_MOD', { userId: '111111', guildId: '222222', value: true });
|
||||
check('SET_GUILD_MOD sur soi-même -> SELF_ACTION', !r.ok && r.error === 'SELF_ACTION', r);
|
||||
|
||||
console.log('# Journaux');
|
||||
await sleep(300);
|
||||
const logDir = path.join(__dirname, 'logs');
|
||||
const logText = fs.readdirSync(logDir).map(f => { try { return fs.readFileSync(path.join(logDir, f), 'utf-8'); } catch (e) { return ''; } }).join('\n');
|
||||
const logged = [...Object.values(SECRETS), 'NEWSPOTIFYSECRET_zz'].filter(s => logText.includes(s));
|
||||
check('aucun secret dans les logs', logged.length === 0, logged);
|
||||
check('backend/data jamais utilisé', !fs.existsSync(path.join(DATA_DIR, 'config.json.tmp')));
|
||||
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
done();
|
||||
setTimeout(() => process.exit(process.exitCode || 0), 50);
|
||||
})().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,247 @@
|
||||
// Routes "/ADMIN/YOUTUBE/*" de backend/src/server/Admin.js, appelées par un faux socket.
|
||||
// Dépendances lourdes (bot Discord, utilisateurs, players, bases...) simulées ; données YouTube isolées
|
||||
// dans le scratchpad (jamais backend/data). Aucun appel réseau : yt-dlp (run/update/check/test) est simulé.
|
||||
const Module = require('module');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { BACKEND, isolateData, check, done, req, chopinTmpDirs } = require('./_setup');
|
||||
const dataDir = isolateData('admin-yt');
|
||||
process.env.YTDLP_AUTO_UPDATE = 'false';
|
||||
process.env.YTDLP_COOKIES_ON_PROXY = 'false'; // verrouillé par l'environnement
|
||||
|
||||
const SRC = path.join(BACKEND, 'src');
|
||||
const norm = (p) => p.replace(/\\/g, '/').toLowerCase().replace(/\.js$/, '');
|
||||
|
||||
// --- Stubs ---
|
||||
const admin = {
|
||||
identity: { id: '111111', username: 'testadmin' }, labels: ['ADMIN'],
|
||||
isAdmin: () => true, isFullBanned: () => false
|
||||
};
|
||||
const guilds = new Map([['222222', { id: '222222', name: 'Serveur Test' }]]);
|
||||
const stubs = {
|
||||
[norm(SRC + '/server/auth/User')]: { getUserById: (id) => id === '111111' ? admin : null, getUsers: () => [admin] },
|
||||
[norm(SRC + '/server/auth/Session')]: { getSessionCount: () => 0 },
|
||||
[norm(SRC + '/player/Player')]: { getAllPlayers: () => [], AllPlayers: new Map() },
|
||||
[norm(SRC + '/discord/Bot')]: { getGuilds: () => guilds, getClient: () => null, isReady: () => false },
|
||||
[norm(SRC + '/discord/ServerSettings')]: {},
|
||||
[norm(SRC + '/playlists/PlaylistManager')]: {},
|
||||
[norm(SRC + '/utils/SafeMetric')]: { getAll: () => [], get: () => 0 },
|
||||
[norm(SRC + '/utils/LogReader')]: { init() {}, getCurrentName: () => 'x.log', isInterrupted: async () => false },
|
||||
[norm(SRC + '/utils/Maintenance')]: {},
|
||||
[norm(SRC + '/utils/Database/Configuration')]: { getMediaGuildId: () => null, getMediaChannelId: () => null },
|
||||
[norm(SRC + '/utils/Database/Database')]: { getDatabase: () => null },
|
||||
[norm(SRC + '/player/Method/Youtube')]: { activeProcesses: new Map([['222222', { pid: 1 }]]) },
|
||||
};
|
||||
const origLoad = Module._load;
|
||||
Module._load = function (request, parent) {
|
||||
if (request.startsWith('.') && parent) {
|
||||
const resolved = norm(path.resolve(path.dirname(parent.filename), request));
|
||||
if (stubs[resolved]) return stubs[resolved];
|
||||
}
|
||||
return origLoad.apply(this, arguments);
|
||||
};
|
||||
|
||||
const Admin = req('src/server/Admin.js');
|
||||
const youtube = req('src/youtube');
|
||||
|
||||
// --- Faux socket / io ---
|
||||
const handlers = new Map();
|
||||
const socket = { id: 'sock1', data: { userId: '111111' }, on: (name, fn) => handlers.set(name, fn), emit() {}, leave() {}, join() {} };
|
||||
const emitted = [];
|
||||
const rooms = new Map([['ADMIN', new Set(['sock1'])]]);
|
||||
const fakeIo = {
|
||||
sockets: { adapter: { rooms }, sockets: new Map() },
|
||||
to: (room) => ({ emit: (ev, data) => emitted.push({ room, ev, data }) }),
|
||||
emit() {},
|
||||
of: () => ({ adapter: { on() {} } })
|
||||
};
|
||||
Admin.attach({ io: fakeIo, socket, socketUser: () => admin });
|
||||
Admin.init(fakeIo);
|
||||
|
||||
function call(name, payload) {
|
||||
return new Promise((resolve) => handlers.get(name)(payload, resolve));
|
||||
}
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
// Cookies SYNTHÉTIQUES (valeurs factices), domaine YouTube + un domaine étranger
|
||||
const SECRET = 'FAKESECRET';
|
||||
const exp = Math.floor(Date.now() / 1000) + 30 * 86400;
|
||||
const line = (d, n, v) => [d, 'TRUE', '/', 'TRUE', String(exp), n, v].join('\t');
|
||||
const COOKIES_OK = ['# Netscape HTTP Cookie File',
|
||||
line('.youtube.com', 'LOGIN_INFO', SECRET + '_login'),
|
||||
line('.youtube.com', 'SAPISID', SECRET + '_sapisid'),
|
||||
line('.youtube.com', '__Secure-3PAPISID', SECRET + '_3p'),
|
||||
line('.youtube.com', 'SID', SECRET + '_sid'),
|
||||
line('.example.org', 'tracker', SECRET + '_other')].join('\r\n');
|
||||
const COOKIES_ANON = ['# Netscape HTTP Cookie File', line('.youtube.com', 'YSC', SECRET + '_ysc')].join('\n');
|
||||
|
||||
(async () => {
|
||||
const expectedRoutes = ['STATUS', 'COOKIES/IMPORT', 'COOKIES/BROWSER', 'COOKIES/CHECK', 'COOKIES/DELETE', 'SETTINGS', 'TEST', 'UPDATE', 'DIAGNOSTICS/CLEAR'];
|
||||
check('routes enregistrées', expectedRoutes.every(r => handlers.has('/ADMIN/YOUTUBE/' + r)));
|
||||
|
||||
// Vérification en direct simulée (pas de réseau)
|
||||
let checkCalls = 0;
|
||||
youtube.check.checkCookies = async () => {
|
||||
checkCalls++;
|
||||
const result = { at: new Date().toISOString(), ok: true, loggedIn: true, premium: false, message: 'Compte reconnu', cls: null, route: 'direct' };
|
||||
youtube.cookies.setLastCheck(result);
|
||||
return { ...result, rotated: false, ms: 5, status: youtube.cookies.getStatus() };
|
||||
};
|
||||
|
||||
console.log('# STATUS');
|
||||
let r = await call('/ADMIN/YOUTUBE/STATUS', {});
|
||||
check('STATUS ok', r.ok, r);
|
||||
check('STATUS forme', r.data && r.data.ytdlp && r.data.cookies && r.data.settings && r.data.diagnostics && Array.isArray(r.data.recent));
|
||||
check('aucun cookie au départ', r.data.cookies.present === false);
|
||||
check('cookiesOnProxy verrouillé', r.data.settings.cookiesOnProxy.locked === true);
|
||||
|
||||
console.log('# IMPORT');
|
||||
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: '' });
|
||||
check('contenu vide refusé', !r.ok && r.error === 'INVALID_PARAMS', r);
|
||||
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: 'x'.repeat(1000001) });
|
||||
check('contenu > 1 000 000 refusé', !r.ok && r.error === 'INVALID_PARAMS');
|
||||
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: COOKIES_OK, source: 'browser:firefox' });
|
||||
check('source interdite refusée', !r.ok && r.error === 'INVALID_PARAMS');
|
||||
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: 'du texte qui ne ressemble à rien' });
|
||||
check('contenu illisible -> INVALID_PARAMS avec message FR', !r.ok && r.error === 'INVALID_PARAMS' && /cookie/i.test(r.message), r);
|
||||
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: COOKIES_ANON, source: 'paste' });
|
||||
check('import sans compte : ok + avertissement', r.ok && r.data.warnings.some(w => /connexion/i.test(w)) && r.data.checking === false, r.data?.warnings);
|
||||
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: COOKIES_OK, source: 'file' });
|
||||
check('import connecté ok', r.ok && r.data.status.cookies.loggedIn && r.data.status.cookies.valid, r);
|
||||
check('avertissement domaine étranger', r.ok && r.data.warnings.some(w => /autres sites/.test(w)), r.data?.warnings);
|
||||
check('vérification lancée', r.data.checking === true);
|
||||
check('source / auteur enregistrés', r.data.status.cookies.source === 'file' && r.data.status.cookies.importedBy?.username === 'testadmin');
|
||||
check('aucune valeur de cookie dans la réponse', !JSON.stringify(r).includes(SECRET));
|
||||
check('fichier maître dans le dossier isolé', fs.existsSync(path.join(dataDir, 'cookies.txt')));
|
||||
await sleep(50);
|
||||
check('checkCookies appelé', checkCalls === 1);
|
||||
|
||||
console.log('# Poussée');
|
||||
emitted.length = 0;
|
||||
await sleep(700);
|
||||
const push = emitted.filter(e => e.ev === '/ADMIN/YOUTUBE/UPDATE');
|
||||
check('poussée /ADMIN/YOUTUBE/UPDATE (coalescée)', push.length === 1 && push[0].room === 'ADMIN', emitted.map(e => e.ev));
|
||||
check('poussée contient lastCheck', push[0]?.data?.cookies?.lastCheck?.ok === true);
|
||||
rooms.set('ADMIN', new Set());
|
||||
emitted.length = 0;
|
||||
process.emit('YOUTUBE_UPDATE');
|
||||
await sleep(700);
|
||||
check('pas de poussée si room ADMIN vide', emitted.length === 0);
|
||||
rooms.set('ADMIN', new Set(['sock1']));
|
||||
|
||||
console.log('# CHECK');
|
||||
r = await call('/ADMIN/YOUTUBE/COOKIES/CHECK', {});
|
||||
check('CHECK ok', r.ok && r.data.result.ok === true && r.data.result.status === undefined && r.data.status.cookies.lastCheck, r);
|
||||
|
||||
console.log('# SETTINGS');
|
||||
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { cookiesMode: 'always', playerClients: 'web_safari, tv' } });
|
||||
check('SETTINGS appliqués', r.ok && r.data.settings.cookiesMode.value === 'always' && r.data.settings.playerClients.value === 'web_safari,tv', r);
|
||||
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { cookiesOnProxy: true } });
|
||||
check('clé verrouillée refusée', !r.ok && r.error === 'INVALID_PARAMS' && /YTDLP_COOKIES_ON_PROXY/.test(r.message), r);
|
||||
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { seekStrategy: 'n importe quoi' } });
|
||||
check('valeur invalide refusée', !r.ok && r.error === 'INVALID_PARAMS');
|
||||
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { inconnu: 1 } });
|
||||
check('clé inconnue refusée', !r.ok && r.error === 'INVALID_PARAMS');
|
||||
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: {} });
|
||||
check('patch vide refusé', !r.ok && r.error === 'INVALID_PARAMS');
|
||||
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: [1] });
|
||||
check('patch tableau refusé', !r.ok && r.error === 'INVALID_PARAMS');
|
||||
// Retour aux valeurs par défaut
|
||||
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { cookiesMode: 'fallback', playerClients: '' } });
|
||||
check('SETTINGS remis par défaut', r.ok && r.data.settings.cookiesMode.value === 'fallback' && r.data.settings.playerClients.value === '');
|
||||
check('settings.json dans le dossier isolé', fs.existsSync(path.join(dataDir, 'settings.json')));
|
||||
|
||||
console.log('# TEST');
|
||||
let testArgs = null;
|
||||
const realTest = youtube.check.testPlayback;
|
||||
youtube.check.testPlayback = async (opts) => { testArgs = opts; return { ok: true, route: 'direct', identity: 'anon', clients: '', cls: null, message: 'Extraction réussie', format: { id: '251' }, ms: 1, version: 'x' }; };
|
||||
r = await call('/ADMIN/YOUTUBE/TEST', { route: 'direct', identity: 'anon', url: '', clients: '' });
|
||||
check('TEST transmis (url / clients vides -> réglages)', r.ok && testArgs.route === 'direct' && testArgs.url === undefined && testArgs.clients === undefined, testArgs);
|
||||
r = await call('/ADMIN/YOUTUBE/TEST', { route: 'moon' });
|
||||
check('route invalide', !r.ok && r.error === 'INVALID_PARAMS');
|
||||
r = await call('/ADMIN/YOUTUBE/TEST', { identity: 'root' });
|
||||
check('identité invalide', !r.ok && r.error === 'INVALID_PARAMS');
|
||||
r = await call('/ADMIN/YOUTUBE/TEST', { url: 'javascript:alert(1)' });
|
||||
check('url non http refusée', !r.ok && r.error === 'INVALID_PARAMS');
|
||||
youtube.check.testPlayback = async () => { const e = new Error('Un test de lecture est déjà en cours'); e.code = 'BUSY'; throw e; };
|
||||
r = await call('/ADMIN/YOUTUBE/TEST', {});
|
||||
check('BUSY -> UNAVAILABLE', !r.ok && r.error === 'UNAVAILABLE', r);
|
||||
youtube.check.testPlayback = realTest;
|
||||
// Vraie fonction, sans réseau : clients invalides -> INVALID_PARAMS
|
||||
r = await call('/ADMIN/YOUTUBE/TEST', { clients: 'web;rm' });
|
||||
check('clients invalides (vrai testPlayback) -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS', r);
|
||||
|
||||
console.log('# UPDATE');
|
||||
youtube.ytdlp.update = async ({ reason }) => ({ ok: true, updatable: true, before: '1', after: '2', message: 'Mise à jour : 1 → 2', output: '', at: new Date().toISOString(), reason });
|
||||
r = await call('/ADMIN/YOUTUBE/UPDATE', {});
|
||||
check('UPDATE ok', r.ok && r.data.result.after === '2' && /testadmin/.test(r.data.result.reason) && r.data.status.ytdlp, r);
|
||||
|
||||
console.log('# BROWSER (yt-dlp simulé)');
|
||||
const realRun = youtube.ytdlp.run;
|
||||
let runArgs = null;
|
||||
youtube.ytdlp.run = async (args) => {
|
||||
runArgs = args;
|
||||
const file = args[args.indexOf('--cookies') + 1];
|
||||
fs.writeFileSync(file, COOKIES_OK + '\n' + line('.bank.example', 'session', SECRET + '_bank'));
|
||||
return { code: 1, stdout: '', stderr: 'ERROR: [youtube] x: Sign in to confirm you’re not a bot', timedOut: false };
|
||||
};
|
||||
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'firefox', profile: 'default-release' });
|
||||
check('BROWSER importe malgré l\'échec d\'extraction', r.ok && r.data.status.cookies.source === 'browser:firefox' && r.data.status.cookies.loggedIn, r);
|
||||
check('arguments --cookies-from-browser', runArgs && runArgs[runArgs.indexOf('--cookies-from-browser') + 1] === 'firefox:default-release' && runArgs.includes('--skip-download'));
|
||||
const tmpDir = path.dirname(runArgs[runArgs.indexOf('--cookies') + 1]);
|
||||
check('fichier temporaire du navigateur supprimé', !fs.existsSync(tmpDir), tmpDir);
|
||||
check('cookies étrangers non stockés', !fs.readFileSync(path.join(dataDir, 'cookies.txt'), 'utf-8').includes('bank'));
|
||||
youtube.ytdlp.run = async () => ({ code: 1, stdout: '', stderr: 'ERROR: could not find firefox cookies database in /x --cookies /secret/path', timedOut: false });
|
||||
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'firefox' });
|
||||
check('échec navigateur -> UNAVAILABLE explicite', !r.ok && r.error === 'UNAVAILABLE' && /même machine/.test(r.message) && !r.message.includes('/secret/path'), r);
|
||||
youtube.ytdlp.run = async (args) => {
|
||||
fs.writeFileSync(args[args.indexOf('--cookies') + 1], '# Netscape HTTP Cookie File\n' + line('.bank.example', 'session', 'x'));
|
||||
return { code: 0, stdout: '', stderr: '' };
|
||||
};
|
||||
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'chrome' });
|
||||
check('navigateur sans cookie YouTube -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS' && /chrome/.test(r.message), r);
|
||||
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'netscape' });
|
||||
check('navigateur inconnu refusé', !r.ok && r.error === 'INVALID_PARAMS');
|
||||
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'firefox', profile: 'a::b' });
|
||||
check('profil invalide refusé', !r.ok && r.error === 'INVALID_PARAMS');
|
||||
youtube.ytdlp.run = realRun;
|
||||
check('aucune copie chopin-ytc-* restante', chopinTmpDirs().length === 0, chopinTmpDirs());
|
||||
|
||||
console.log('# OVERVIEW');
|
||||
r = await call('/ADMIN/OVERVIEW', {});
|
||||
const yt = r.data?.ytdlp;
|
||||
check('overview.ytdlp compatible', r.ok && yt && 'version' in yt && yt.cookies.present === true && yt.cookies.updatedAt === yt.cookies.importedAt && yt.activeProcesses === 1, r.ok ? yt : r);
|
||||
check('overview.ytdlp enrichi', yt && yt.cookies.valid === true && 'recentFailureRate' in yt && 'lastFailure' in yt);
|
||||
|
||||
console.log('# DIAGNOSTICS');
|
||||
youtube.diagnostics.record({ guildId: '222222', songId: 's', title: 'Titre', route: 'direct', identity: 'anon', outcome: 'failed', cls: 'HTTP_403', message: 'HTTP Error 403', ms: 100 });
|
||||
r = await call('/ADMIN/YOUTUBE/STATUS', {});
|
||||
check('recent avec nom du serveur', r.data.recent[0]?.guildName === 'Serveur Test' && r.data.diagnostics.byClass.HTTP_403 === 1, r.data.recent[0]);
|
||||
r = await call('/ADMIN/YOUTUBE/DIAGNOSTICS/CLEAR', {});
|
||||
check('CLEAR', r.ok && r.data.recent.length === 0 && r.data.diagnostics.total === 0);
|
||||
|
||||
console.log('# DELETE');
|
||||
r = await call('/ADMIN/YOUTUBE/COOKIES/DELETE', {});
|
||||
check('DELETE', r.ok && r.data.cookies.present === false && !fs.existsSync(path.join(dataDir, 'cookies.txt')));
|
||||
r = await call('/ADMIN/YOUTUBE/COOKIES/CHECK', {});
|
||||
check('CHECK sans cookies -> UNAVAILABLE', !r.ok && r.error === 'UNAVAILABLE');
|
||||
|
||||
console.log('# Droits');
|
||||
admin.labels = []; admin.isAdmin = () => false;
|
||||
r = await call('/ADMIN/YOUTUBE/STATUS', {});
|
||||
check('non-admin refusé', !r.ok && r.error === 'FORBIDDEN');
|
||||
admin.isAdmin = () => true; admin.labels = ['ADMIN'];
|
||||
|
||||
// Journaux : aucune valeur de cookie
|
||||
await sleep(300);
|
||||
const logs = path.join(__dirname, 'logs');
|
||||
let leaked = false;
|
||||
for (const f of fs.readdirSync(logs)) if (fs.readFileSync(path.join(logs, f), 'utf-8').includes(SECRET)) leaked = f;
|
||||
check('aucune valeur de cookie dans les logs', !leaked, leaked);
|
||||
const auditLines = fs.readdirSync(logs).flatMap(f => fs.readFileSync(path.join(logs, f), 'utf-8').split('\n')).filter(l => l.includes('[ADMIN] testadmin -> ') && /YouTube|yt-dlp/.test(l));
|
||||
check('audits écrits', auditLines.length >= 6, auditLines.length);
|
||||
console.log(auditLines.slice(-12).map(l => ' ' + l.trim()).join('\n'));
|
||||
|
||||
done();
|
||||
setTimeout(() => process.exit(process.exitCode), 100);
|
||||
})().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,22 @@
|
||||
// Test de getYouTubeProfilePicture (non exportée) : module recompilé avec une exportation de test
|
||||
const path = require("path"), fs = require("fs"), Module = require("module"), assert = require("assert");
|
||||
const SRC = require("path").resolve(__dirname, "../../src");
|
||||
require(path.join(SRC, "../node_modules/loguix")).setup(path.join(__dirname, "logs"), path.join(SRC, "../package.json"));
|
||||
const file = path.join(SRC, "media/YoutubeInformation.js");
|
||||
const m = new Module(file, module); m.filename = file; m.paths = Module._nodeModulePaths(path.dirname(file));
|
||||
m._compile(fs.readFileSync(file, "utf8") + "\nmodule.exports.__avatar = getYouTubeProfilePicture;", file);
|
||||
const get = m.exports.__avatar;
|
||||
(async () => {
|
||||
let t = Date.now();
|
||||
const a1 = await get("https://www.youtube.com/@aftkt");
|
||||
console.log("avatar #1:", Date.now() - t, "ms", a1);
|
||||
assert.ok(a1 && a1.startsWith("https://"));
|
||||
t = Date.now();
|
||||
const a2 = await get("https://www.youtube.com/@aftkt");
|
||||
console.log("avatar #2 (cache):", Date.now() - t, "ms");
|
||||
assert.strictEqual(a2, a1); assert.ok(Date.now() - t < 20);
|
||||
assert.strictEqual(await get("https://evil.example.com/@x"), null);
|
||||
assert.strictEqual(await get("https://www.youtube.com/channel/undefined"), null);
|
||||
assert.strictEqual(await get(undefined), null);
|
||||
console.log("OK");
|
||||
})().catch(e => { console.error("ÉCHEC", e); process.exit(1); });
|
||||
@@ -0,0 +1,75 @@
|
||||
// check.js (test de lecture, vérification des cookies), settings (env verrouillé), index (init, getStatus)
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { BACKEND, __glob, isolateData, check, done, req, chopinTmpDirs } = require('./_setup');
|
||||
const dataDir = isolateData('check');
|
||||
process.env.YTDLP_POT_PROVIDER = 'http://user:pw@bgutil-provider:4416';
|
||||
process.env.YTDLP_SEEK_STRATEGY = 'nimportequoi';
|
||||
// Pas de mise à jour automatique pendant ce test (limite de l'API GitHub)
|
||||
process.env.YTDLP_AUTO_UPDATE = 'false';
|
||||
|
||||
const youtube = req('src/youtube');
|
||||
const { settings, cookies, check: ytcheck, ytdlp } = youtube;
|
||||
|
||||
(async () => {
|
||||
console.log('# settings');
|
||||
const pub = settings.getPublic();
|
||||
check('potProviderUrl verrouillé + masqué', pub.potProviderUrl.locked && pub.potProviderUrl.value === 'http://***@bgutil-provider:4416', pub.potProviderUrl);
|
||||
check('env invalide ignoré (seekStrategy)', pub.seekStrategy.locked === false && pub.seekStrategy.value === 'auto');
|
||||
let err = null;
|
||||
try { settings.update({ potProviderUrl: 'http://x' }); } catch (e) { err = e; }
|
||||
check('clé verrouillée refusée', err?.code === 'INVALID_PARAMS', err?.message);
|
||||
err = null;
|
||||
try { settings.update({ playerClients: 'web;rm -rf' }); } catch (e) { err = e; }
|
||||
check('clients invalides refusés', err?.code === 'INVALID_PARAMS');
|
||||
err = null;
|
||||
try { settings.update({ testUrl: 'https://evil.com/watch?v=x' }); } catch (e) { err = e; }
|
||||
check('testUrl non YouTube refusée', err?.code === 'INVALID_PARAMS');
|
||||
err = null;
|
||||
try { settings.update({ inconnu: 1 }); } catch (e) { err = e; }
|
||||
check('clé inconnue refusée', err?.code === 'INVALID_PARAMS');
|
||||
settings.update({ cookiesOnProxy: 'true', playerClients: ' Web , mweb ' }, { username: 'test' });
|
||||
check('normalisation + écriture', settings.get().cookiesOnProxy === true && settings.get().playerClients === 'web,mweb' && JSON.parse(fs.readFileSync(__glob.YOUTUBE_SETTINGS, 'utf8')).playerClients === 'web,mweb');
|
||||
check('env non écrit dans settings.json', !('potProviderUrl' in JSON.parse(fs.readFileSync(__glob.YOUTUBE_SETTINGS, 'utf8'))));
|
||||
settings.update({ playerClients: '', cookiesOnProxy: false });
|
||||
const base = ytdlp.baseArgs({ proxy: 'http://warp:1080', cookiesPath: '/tmp/x', playerClients: 'mweb' });
|
||||
console.log(' baseArgs :', youtube.errors.sanitize(base.join(' ')));
|
||||
check('baseArgs : pot + client + proxy + cookies + -4', base.includes('-4') && base.includes('youtubepot-bgutilhttp:base_url=http://user:pw@bgutil-provider:4416') && base.includes('youtube:player_client=mweb') && base.includes('--proxy') && base.includes('--cookies') && !base.includes('--no-cache-dir'));
|
||||
// Pas de bgutil local pour les essais réels
|
||||
delete process.env.YTDLP_POT_PROVIDER;
|
||||
settings.reload();
|
||||
|
||||
console.log('# init + getStatus');
|
||||
await youtube.init();
|
||||
const st = youtube.getStatus();
|
||||
console.log(' getStatus :', JSON.stringify({ ytdlp: { ...st.ytdlp, path: path.basename(st.ytdlp.path) }, ffmpeg: st.ffmpeg.source, cookies: { present: st.cookies.present, valid: st.cookies.valid }, pot: st.pot, activeProcesses: st.activeProcesses, diagnostics: st.diagnostics.total }));
|
||||
check('getStatus : clés attendues', ['ytdlp', 'ffmpeg', 'cookies', 'settings', 'pot', 'diagnostics', 'activeProcesses'].every(k => k in st));
|
||||
check('dossier cache créé', fs.existsSync(__glob.YOUTUBE_CACHE));
|
||||
|
||||
console.log('# testPlayback (anonyme, directe)');
|
||||
const t = await ytcheck.testPlayback({ route: 'direct', identity: 'anon' });
|
||||
console.log(' ', JSON.stringify(t));
|
||||
check('testPlayback ok + format', t.ok && t.format && t.format.id, t);
|
||||
let busy = null;
|
||||
const p1 = ytcheck.testPlayback({ route: 'direct', identity: 'anon' });
|
||||
try { await ytcheck.testPlayback({}); } catch (e) { busy = e.code; }
|
||||
await p1;
|
||||
check('testPlayback : un seul à la fois (BUSY)', busy === 'BUSY');
|
||||
const bad = await ytcheck.testPlayback({ url: '--exec=calc' });
|
||||
check('testPlayback : URL refusée', bad.ok === false && /URL/.test(bad.message));
|
||||
|
||||
console.log('# checkCookies (sans cookies puis anciens cookies)');
|
||||
const none = await ytcheck.checkCookies();
|
||||
check('checkCookies sans cookies', none.ok === false && /Aucun cookie/.test(none.message));
|
||||
__glob.COOKIES = path.join(BACKEND, 'data', 'cookies.txt');
|
||||
__glob.COOKIES_JSON = path.join(BACKEND, 'data', 'cookies.json');
|
||||
cookies.migrateLegacy();
|
||||
const [c1, c2] = await Promise.all([ytcheck.checkCookies(), ytcheck.checkCookies()]);
|
||||
console.log(' ', JSON.stringify({ ok: c1.ok, loggedIn: c1.loggedIn, rotated: c1.rotated, cls: c1.cls, message: c1.message, route: c1.route, ms: c1.ms }));
|
||||
check('checkCookies : appel unique partagé', c1 === c2);
|
||||
check('checkCookies : lastCheck enregistré', cookies.getStatus().lastCheck?.at && cookies.getStatus().lastCheck.message === c1.message);
|
||||
check('aucune copie chopin-ytc-* restante', chopinTmpDirs().length === 0, chopinTmpDirs());
|
||||
check('aucun processus actif', ytdlp.activeCount() === 0);
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
done();
|
||||
})().catch((e) => { console.error(e); process.exitCode = 1; });
|
||||
@@ -0,0 +1,183 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { BACKEND, __glob, isolateData, check, done, req, chopinTmpDirs } = require('./_setup');
|
||||
const dataDir = isolateData('cookies');
|
||||
const cookies = req('src/youtube/cookies');
|
||||
|
||||
const NOW = 1791360000; // 2026-10-07
|
||||
const FUT = 1830000000; // 2027-12
|
||||
const PAST = 1700000000;
|
||||
const T = (...f) => f.join('\t');
|
||||
let events = 0;
|
||||
process.on('YOUTUBE_UPDATE', () => events++);
|
||||
|
||||
function throwsInvalid(fn) {
|
||||
try { fn(); return false; } catch (e) { return e.code === 'INVALID_COOKIES' && /[a-zé]/.test(e.message); }
|
||||
}
|
||||
|
||||
console.log('# Netscape');
|
||||
{
|
||||
const loggedIn = [
|
||||
'# Netscape HTTP Cookie File',
|
||||
'# commentaire',
|
||||
'',
|
||||
T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'AAA'),
|
||||
T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'SAPISID', 'BBB'),
|
||||
'#HttpOnly_' + T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, '__Secure-3PSID', 'CCC'),
|
||||
T('.youtube.com', 'TRUE', '/', 'FALSE', 0, 'PREF', 'f6=40000000'),
|
||||
T('.youtube.com', 'TRUE', '/', 'TRUE', PAST, 'GPS', '1'),
|
||||
T('.example.com', 'TRUE', '/', 'FALSE', FUT, 'tracker', 'x'),
|
||||
T('accounts.google.com', 'FALSE', '/', 'TRUE', FUT, '__Host-GAPS', 'g'),
|
||||
'ligne cassée sans tabulation',
|
||||
T('.youtube.com', 'MAYBE', '/', 'TRUE', FUT, 'X', 'y'),
|
||||
].join('\r\n');
|
||||
const r = cookies.parseCookies(loggedIn, { now: NOW });
|
||||
const names = r.cookies.map(c => c.name);
|
||||
check('CRLF + en-tête : cookies valides lus', names.includes('LOGIN_INFO') && names.includes('SAPISID') && names.includes('PREF'), names);
|
||||
check('#HttpOnly_ reconnu', r.cookies.find(c => c.name === '__Secure-3PSID')?.httpOnly === true);
|
||||
check('cookie de session (0) gardé', r.cookies.find(c => c.name === 'PREF')?.expires === 0);
|
||||
check('expiré retiré', !names.includes('GPS'));
|
||||
check('domaine étranger retiré', !names.includes('tracker'));
|
||||
check('google.com gardé (hostOnly)', r.cookies.find(c => c.name === '__Host-GAPS')?.includeSubdomains === false);
|
||||
check('avertissements (lignes, domaines, expirés)', r.warnings.length === 3, r.warnings);
|
||||
const a = cookies.analyze(r.cookies);
|
||||
check('analyze : loggedIn', a.loggedIn === true);
|
||||
check('analyze : jamais de valeur', !JSON.stringify(a).includes('AAA') && !JSON.stringify(a).includes('BBB'));
|
||||
check('analyze : authNames', a.authNames.includes('LOGIN_INFO') && a.authNames.includes('SAPISID') && a.authNames.includes('__Secure-3PSID'), a.authNames);
|
||||
check('analyze : earliestAuthExpiry ISO', a.earliestAuthExpiry === new Date(FUT * 1000).toISOString());
|
||||
|
||||
// Sans en-tête, LF, valeur vide, tabulations converties en espaces
|
||||
const noHeader = [
|
||||
T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'AAA'),
|
||||
'.youtube.com TRUE / TRUE ' + FUT + ' __Secure-3PAPISID ZZZ',
|
||||
T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'EMPTY', ''),
|
||||
].join('\n');
|
||||
const r2 = cookies.parseCookies(noHeader, { now: NOW });
|
||||
check('sans en-tête + espaces au lieu de tabulations', r2.cookies.length === 3, r2.cookies.map(c => c.name));
|
||||
check('valeur vide acceptée', r2.cookies.find(c => c.name === 'EMPTY')?.value === '');
|
||||
check('loggedIn via __Secure-3PAPISID', cookies.analyze(r2.cookies).loggedIn === true);
|
||||
|
||||
// Règle yt-dlp : LOGIN_INFO seul ou SAPISID seul ne suffit pas
|
||||
const onlySapisid = T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, '__Secure-3PAPISID', 'x');
|
||||
check('pas connecté sans LOGIN_INFO', cookies.analyze(cookies.parseCookies(onlySapisid, { now: NOW }).cookies).loggedIn === false);
|
||||
const onlyLogin = T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'x');
|
||||
const aLogin = cookies.analyze(cookies.parseCookies(onlyLogin, { now: NOW }).cookies);
|
||||
check('pas connecté sans SAPISID', aLogin.loggedIn === false && aLogin.missing.includes('SAPISID'));
|
||||
// Cookies google.com seuls : ne comptent pas pour youtube.com
|
||||
const googleOnly = [T('.google.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'x'), T('.google.com', 'TRUE', '/', 'TRUE', FUT, 'SAPISID', 'x')].join('\n');
|
||||
check('LOGIN_INFO sur google.com ne compte pas', cookies.analyze(cookies.parseCookies(googleOnly, { now: NOW }).cookies).loggedIn === false);
|
||||
|
||||
// Sérialisation : relue à l'identique, LF, en-tête
|
||||
const text = cookies.serializeCookies(r.cookies);
|
||||
check('sérialisation : en-tête Netscape + LF', text.startsWith('# Netscape HTTP Cookie File\n') && !text.includes('\r'));
|
||||
const back = cookies.parseCookies(text, { now: NOW }).cookies;
|
||||
check('sérialisation : aller-retour', JSON.stringify(back) === JSON.stringify(r.cookies));
|
||||
}
|
||||
|
||||
console.log('# JSON');
|
||||
{
|
||||
const arr = [
|
||||
{ domain: '.youtube.com', expirationDate: FUT + 0.512229, hostOnly: false, httpOnly: true, name: 'LOGIN_INFO', path: '/', sameSite: 'no_restriction', secure: true, session: false, storeId: '0', value: 'AAA', id: 1 },
|
||||
{ domain: '.youtube.com', expirationDate: FUT + 0.9, hostOnly: false, httpOnly: false, name: 'SAPISID', path: '/', secure: true, session: false, value: 'BBB' },
|
||||
{ domain: 'www.youtube.com', hostOnly: true, httpOnly: false, name: 'ST-xyz', path: '/', secure: false, session: true, value: 'S' },
|
||||
{ domain: 'youtube.com', hostOnly: false, name: 'NODOT', path: '/', secure: true, expires: (FUT * 1000), value: 'ms' },
|
||||
{ domain: '.youtube.com', name: 'OLD', expirationDate: PAST, value: 'x' },
|
||||
{ domain: '.facebook.com', name: 'fb', expirationDate: FUT, value: 'x' },
|
||||
{ nope: true },
|
||||
];
|
||||
const r = cookies.parseCookies(JSON.stringify(arr), { now: NOW });
|
||||
const by = Object.fromEntries(r.cookies.map(c => [c.name, c]));
|
||||
check('JSON : expirationDate décimal arrondi', by.LOGIN_INFO?.expires === FUT);
|
||||
check('JSON : session -> 0', by['ST-xyz']?.expires === 0);
|
||||
check('JSON : hostOnly -> sans sous-domaines', by['ST-xyz']?.includeSubdomains === false && by['ST-xyz']?.domain === 'www.youtube.com');
|
||||
check('JSON : domaine non hostOnly sans point -> .youtube.com', by.NODOT?.domain === '.youtube.com' && by.NODOT?.includeSubdomains === true);
|
||||
check('JSON : expires en millisecondes', by.NODOT?.expires === FUT);
|
||||
check('JSON : httpOnly', by.LOGIN_INFO?.httpOnly === true && by.SAPISID?.httpOnly === false);
|
||||
check('JSON : expiré, étranger et invalide retirés', !by.OLD && !by.fb && r.warnings.length === 3, r.warnings);
|
||||
check('JSON : loggedIn', cookies.analyze(r.cookies).loggedIn === true);
|
||||
check('JSON : hasSessionOnly faux (le cookie de session n\'est pas un cookie de connexion)', cookies.analyze(r.cookies).hasSessionOnly === false);
|
||||
|
||||
const wrapped = cookies.parseCookies(JSON.stringify({ url: 'https://www.youtube.com', cookies: arr.slice(0, 2) }), { now: NOW });
|
||||
check('{cookies:[...]} accepté', wrapped.cookies.length === 2 && cookies.analyze(wrapped.cookies).loggedIn);
|
||||
const withBom = cookies.parseCookies('' + JSON.stringify(arr.slice(0, 2)), { now: NOW });
|
||||
check('BOM ignoré', withBom.cookies.length === 2);
|
||||
const expiry = cookies.parseCookies(JSON.stringify([{ domain: '.youtube.com', name: 'A', value: 'b', expiry: FUT }]), { now: NOW });
|
||||
check('champ expiry (Selenium)', expiry.cookies[0].expires === FUT);
|
||||
const sessionAuth = cookies.parseCookies(JSON.stringify([{ domain: '.youtube.com', name: 'LOGIN_INFO', value: 'b', session: true }]), { now: NOW });
|
||||
check('hasSessionOnly vrai pour un cookie de connexion de session', cookies.analyze(sessionAuth.cookies).hasSessionOnly === true);
|
||||
}
|
||||
|
||||
console.log('# Contenus invalides');
|
||||
check('vide', throwsInvalid(() => cookies.parseCookies(' ')));
|
||||
check('non-chaîne', throwsInvalid(() => cookies.parseCookies(null)));
|
||||
check('texte quelconque', throwsInvalid(() => cookies.parseCookies('bonjour\nceci n\'est pas un fichier de cookies')));
|
||||
check('JSON tronqué', throwsInvalid(() => cookies.parseCookies('[{"domain": ".youtube.com", "name": ')));
|
||||
check('JSON objet sans cookies', throwsInvalid(() => cookies.parseCookies('{"a": 1}')));
|
||||
check('JSON liste vide', throwsInvalid(() => cookies.parseCookies('[]')));
|
||||
check('HTML', throwsInvalid(() => cookies.parseCookies('<html><body>Login</body></html>')));
|
||||
check('commentaires seuls', throwsInvalid(() => cookies.parseCookies('# Netscape HTTP Cookie File\n# rien\n')));
|
||||
check('valeur avec retour à la ligne refusée', throwsInvalid(() => cookies.parseCookies(JSON.stringify([{ domain: '.youtube.com', name: 'A', value: 'x\ny' }]))));
|
||||
|
||||
console.log('# Stockage');
|
||||
const goodText = [T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'AAA'), T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'SAPISID', 'BBB')].join('\n');
|
||||
let threw = false;
|
||||
try { cookies.importCookies(T('.google.com', 'TRUE', '/', 'TRUE', FUT, 'SID', 'x')); } catch (e) { threw = e.code === 'INVALID_COOKIES'; }
|
||||
check('import refusé sans cookie youtube.com', threw && !fs.existsSync(__glob.YOUTUBE_COOKIES));
|
||||
|
||||
const before = events;
|
||||
const res = cookies.importCookies(goodText, { source: 'paste', by: { id: '42', username: 'raphix' } });
|
||||
check('import : statut valide', res.status.valid && res.status.loggedIn && res.status.count === 2 && res.status.source === 'paste', res.status);
|
||||
check('import : importedBy', res.status.importedBy?.username === 'raphix');
|
||||
check('import : YOUTUBE_UPDATE émis', events > before);
|
||||
check('import : fichier maître LF + en-tête', fs.readFileSync(__glob.YOUTUBE_COOKIES, 'utf8').startsWith('# Netscape HTTP Cookie File\n'));
|
||||
check('statut : jamais de valeur', !JSON.stringify(cookies.getStatus()).includes('AAA'));
|
||||
|
||||
const notLogged = cookies.importCookies(T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'PREF', 'x'), { source: 'file' });
|
||||
check('import non connecté : stocké + avertissement', notLogged.status.present && !notLogged.status.loggedIn && notLogged.warnings.some(w => /connexion/.test(w)), notLogged.warnings);
|
||||
|
||||
cookies.importCookies(goodText, { source: 'paste' });
|
||||
check('markInvalid', cookies.markInvalid('test') === true && cookies.getStatus().valid === false && cookies.getStatus().invalidReason === 'test');
|
||||
check('markInvalid idempotent', cookies.markInvalid('encore') === false && cookies.getStatus().invalidReason === 'test');
|
||||
cookies.setLastCheck({ at: new Date().toISOString(), ok: true, loggedIn: true, premium: false, message: 'ok' });
|
||||
check('vérification réussie : invalidation levée', cookies.getStatus().valid === true);
|
||||
cookies.reload();
|
||||
check('rechargement depuis le disque', cookies.getStatus().valid === true && cookies.getStatus().count === 2);
|
||||
|
||||
(async () => {
|
||||
const copy = await cookies.acquireCopy();
|
||||
check('acquireCopy : fichier identique au maître', fs.readFileSync(copy.path, 'utf8') === fs.readFileSync(__glob.YOUTUBE_COOKIES, 'utf8'));
|
||||
check('acquireCopy : dossier chopin-ytc-', path.basename(path.dirname(copy.path)).startsWith('chopin-ytc-') && cookies.liveCopyCount() === 1);
|
||||
copy.release(); copy.release();
|
||||
check('release : dossier supprimé', !fs.existsSync(path.dirname(copy.path)) && cookies.liveCopyCount() === 0);
|
||||
|
||||
const del = cookies.deleteCookies();
|
||||
check('deleteCookies', !del.present && !fs.existsSync(__glob.YOUTUBE_COOKIES));
|
||||
check('acquireCopy sans cookies -> null', (await cookies.acquireCopy()) === null);
|
||||
check('aucune copie temporaire restante', chopinTmpDirs().length === 0, chopinTmpDirs());
|
||||
|
||||
console.log('# Fichiers réels (lecture seule, analyse sans valeurs)');
|
||||
const realTxt = path.join(BACKEND, 'data', 'cookies.txt');
|
||||
const realJson = path.join(BACKEND, 'data', 'cookies.json');
|
||||
const mtimes = () => [realTxt, realJson].map(f => fs.existsSync(f) ? fs.statSync(f).mtimeMs : null).join(',');
|
||||
const mtimesBefore = mtimes();
|
||||
for (const file of [realTxt, realJson]) {
|
||||
if (!fs.existsSync(file)) { console.log(` (absent) ${path.basename(file)}`); continue; }
|
||||
try {
|
||||
const r = cookies.parseCookies(fs.readFileSync(file, 'utf8'));
|
||||
console.log(` ${path.basename(file)} :`, JSON.stringify(cookies.analyze(r.cookies)), '| avertissements :', JSON.stringify(r.warnings));
|
||||
} catch (e) {
|
||||
console.log(` ${path.basename(file)} : erreur ${e.code} ${e.message}`);
|
||||
}
|
||||
}
|
||||
// Migration : les anciens fichiers réels servent de source, la destination est le dossier isolé
|
||||
cookies.reload();
|
||||
__glob.COOKIES = realTxt;
|
||||
__glob.COOKIES_JSON = realJson;
|
||||
const mig = cookies.migrateLegacy();
|
||||
const st = cookies.getStatus();
|
||||
console.log(' migration :', JSON.stringify(mig), '| statut :', JSON.stringify({ present: st.present, loggedIn: st.loggedIn, valid: st.valid, source: st.source, count: st.count, authNames: st.authNames, missing: st.missing, earliestAuthExpiry: st.earliestAuthExpiry }));
|
||||
check('migration : fichiers d\'origine non modifiés', mtimes() === mtimesBefore);
|
||||
check('migration : pas de seconde migration', cookies.migrateLegacy().migrated === false);
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
done();
|
||||
})();
|
||||
@@ -0,0 +1,113 @@
|
||||
const { check, done, req } = require('./_setup');
|
||||
const errors = req('src/youtube/errors');
|
||||
|
||||
const ID = 'ERROR: [youtube] jNQXAC9IVRw: ';
|
||||
// Chaque ligne du tableau de research-ytdlp.md §5 (+ variantes réelles)
|
||||
const CASES = [
|
||||
// Bot check (apostrophe typographique et droite)
|
||||
[ID + 'Sign in to confirm you’re not a bot. Use --cookies-from-browser or --cookies for the authentication. See https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp for how to manually pass cookies.', 'BOT_CHECK'],
|
||||
[ID + "Sign in to confirm you're not a bot. This helps protect our community. Learn more", 'BOT_CHECK'],
|
||||
['ERROR: [youtube] abc: Sign in to confirm youre not a bot', 'BOT_CHECK'],
|
||||
// IP bloquée
|
||||
[ID + 'All player responses are invalid. Your IP is likely being blocked by Youtube', 'IP_BLOCKED'],
|
||||
// Captcha
|
||||
[ID + 'YouTube is requiring a captcha challenge before playback', 'CAPTCHA'],
|
||||
// Rate limit
|
||||
[ID + "This content isn't available, try again later. The current session has been rate-limited by YouTube for up to an hour. It is recommended to use `-t sleep` to add a delay between video requests", 'RATE_LIMITED'],
|
||||
[ID + 'This content isn’t available, try again later.', 'RATE_LIMITED'],
|
||||
// 403 au téléchargement
|
||||
['ERROR: unable to download video data: HTTP Error 403: Forbidden', 'HTTP_403'],
|
||||
['[download] Got error: HTTP Error 403: Forbidden. Retrying (1/3)...', 'HTTP_403'],
|
||||
['ERROR: [download] Giving up after 3 retries: HTTP Error 403: Forbidden', 'HTTP_403'],
|
||||
// 429
|
||||
['ERROR: [youtube] abc: Unable to download API page: HTTP Error 429: Too Many Requests', 'HTTP_429'],
|
||||
// Âge
|
||||
[ID + 'Sign in to confirm your age. This video may be inappropriate for some users.', 'AGE_RESTRICTED'],
|
||||
[ID + 'This video is age-restricted and only available on YouTube', 'AGE_RESTRICTED'],
|
||||
// Connexion / membres
|
||||
[ID + 'Login details are needed to download this content', 'LOGIN_REQUIRED'],
|
||||
[ID + "This video is available to this channel's members on level: Member (or any higher level). Join this channel to get access to members-only content like this video, and other exclusive perks.", 'MEMBERS_ONLY'],
|
||||
[ID + 'Join this channel to get access to members-only content like this video, and other exclusive perks.', 'MEMBERS_ONLY'],
|
||||
// Privée
|
||||
[ID + "Private video. Sign in if you've been granted access to this video", 'PRIVATE'],
|
||||
// Indisponible
|
||||
[ID + 'Video unavailable', 'UNAVAILABLE'],
|
||||
[ID + 'Video unavailable. This content isn’t available.', 'UNAVAILABLE'],
|
||||
[ID + 'This video is unavailable', 'UNAVAILABLE'],
|
||||
[ID + 'Video unavailable. This video has been removed by the uploader', 'UNAVAILABLE'],
|
||||
// Géo
|
||||
[ID + 'The uploader has not made this video available in your country', 'GEO_BLOCKED'],
|
||||
// PO token (avertissement seul)
|
||||
['WARNING: [youtube] jNQXAC9IVRw: mweb client https formats require a GVS PO Token which was not provided. They will be skipped as they may yield HTTP Error 403. You can manually pass a GVS PO Token for this client with --extractor-args "youtube:po_token=mweb.gvs+XXX".', 'POT_MISSING'],
|
||||
// SABR
|
||||
['WARNING: [youtube] jNQXAC9IVRw: Some web client https formats have been skipped as they are missing a URL. YouTube may have enabled the SABR-only streaming experiment for your account.', 'SABR'],
|
||||
['WARNING: [youtube] jNQXAC9IVRw: Some tv client https formats have been skipped as they are missing a url. YouTube may have enabled the SABR-only streaming experiment for the current session.', 'SABR'],
|
||||
['[debug] [youtube] YouTube is forcing SABR streaming for this client.', 'SABR'],
|
||||
// Défi JS
|
||||
['WARNING: [youtube] jNQXAC9IVRw: n challenge solving failed: Some formats may be missing. Ensure you have a supported JavaScript runtime and challenge solver script distribution installed.', 'JS_CHALLENGE'],
|
||||
['WARNING: [youtube] jNQXAC9IVRw: Signature solving failed: Some formats may be missing.', 'JS_CHALLENGE'],
|
||||
['WARNING: [youtube] No supported JavaScript runtime could be found. Only deno is enabled by default', 'JS_CHALLENGE'],
|
||||
// Format
|
||||
['ERROR: [youtube] jNQXAC9IVRw: Requested format is not available. Use --list-formats for a list of available formats', 'FORMAT_UNAVAILABLE'],
|
||||
// Page à recharger
|
||||
[ID + 'The page needs to be reloaded.', 'PAGE_RELOAD'],
|
||||
// Cookies tournés
|
||||
['WARNING: [youtube] The provided YouTube account cookies are no longer valid. They have likely been rotated in the browser as a security measure. For tips on how to effectively export YouTube cookies, refer to https://github.com/yt-dlp/yt-dlp/wiki/Extractors#exporting-youtube-cookies .', 'COOKIES_ROTATED'],
|
||||
// Fichier vide
|
||||
['ERROR: The downloaded file is empty', 'EMPTY_DOWNLOAD'],
|
||||
// Binaire absent / délai
|
||||
['spawn yt-dlp ENOENT', 'BINARY_MISSING'],
|
||||
['ERROR: yt-dlp introuvable (ENOENT)', 'BINARY_MISSING'],
|
||||
['ERROR: [youtube] abc: Unable to download webpage: <urlopen error timed out> (caused by URLError(TimeoutError(\'timed out\')))', 'TIMEOUT'],
|
||||
['aucune donnée après 20s', 'TIMEOUT'],
|
||||
// Informations seules
|
||||
['WARNING: [youtube] Skipping client "visionos" since it does not support cookies', 'UNKNOWN'],
|
||||
['ERROR: Login with OAuth is no longer supported', 'UNKNOWN'],
|
||||
['', 'UNKNOWN'],
|
||||
];
|
||||
|
||||
console.log('# Tableau des messages');
|
||||
for (const [text, expected] of CASES) {
|
||||
const r = errors.classify(text);
|
||||
check(`${expected.padEnd(18)} <- ${text.slice(0, 90)}`, r.cls === expected, r);
|
||||
}
|
||||
|
||||
console.log('# Combinaisons (ligne par ligne)');
|
||||
{
|
||||
// L'ancien bug : un bloc contenant [youtube] était ignoré. Ici l'erreur est noyée dans du bruit.
|
||||
const noisy = [
|
||||
'[youtube] Extracting URL: https://www.youtube.com/watch?v=jNQXAC9IVRw',
|
||||
'[youtube] jNQXAC9IVRw: Downloading webpage',
|
||||
'WARNING: [youtube] jNQXAC9IVRw: mweb client https formats require a GVS PO Token which was not provided.',
|
||||
'ERROR: [youtube] jNQXAC9IVRw: Sign in to confirm you’re not a bot. Use --cookies-from-browser or --cookies for the authentication.',
|
||||
].join('\r\n');
|
||||
const r = errors.classify(noisy);
|
||||
check('ERROR prime sur WARNING (BOT_CHECK, indice POT)', r.cls === 'BOT_CHECK' && r.hints.includes('POT_MISSING') && r.retry === 'route', r);
|
||||
check('message sans préfixe ERROR:', r.message.startsWith('[youtube] jNQXAC9IVRw: Sign in'), r.message);
|
||||
|
||||
const rootCause = 'WARNING: [youtube] x: n challenge solving failed: Some formats may be missing.\nERROR: [youtube] x: Requested format is not available. Use --list-formats';
|
||||
check('format indisponible + défi JS -> JS_CHALLENGE', errors.classify(rootCause).cls === 'JS_CHALLENGE');
|
||||
const sabrEmpty = 'WARNING: [youtube] x: Some web client https formats have been skipped as they are missing a URL. YouTube may have enabled the SABR-only streaming experiment\nERROR: The downloaded file is empty';
|
||||
check('fichier vide + SABR -> SABR', errors.classify(sabrEmpty).cls === 'SABR');
|
||||
const rotatedBot = 'WARNING: [youtube] The provided YouTube account cookies are no longer valid.\nERROR: [youtube] x: Sign in to confirm you’re not a bot.';
|
||||
const rb = errors.classify(rotatedBot);
|
||||
check('rotation + bot : cls BOT_CHECK, drapeau cookiesRotated', rb.cls === 'BOT_CHECK' && rb.cookiesRotated === true, rb);
|
||||
const rotatedOnly = errors.classify('WARNING: [youtube] The provided YouTube account cookies are no longer valid.');
|
||||
check('rotation seule -> COOKIES_ROTATED', rotatedOnly.cls === 'COOKIES_ROTATED' && rotatedOnly.cookiesRotated, rotatedOnly);
|
||||
const retry403 = '[download] Got error: HTTP Error 403: Forbidden. Retrying (1/3)...\n[download] Got error: HTTP Error 403: Forbidden. Retrying (2/3)...\nERROR: unable to download video data: HTTP Error 403: Forbidden';
|
||||
check('403 avec réessais -> HTTP_403 (retry client)', errors.classify(retry403).cls === 'HTTP_403' && errors.classify(retry403).retry === 'client');
|
||||
check('un WARNING citant "HTTP Error 403" n\'est pas une ligne d\'erreur', errors.isErrorLine('WARNING: [youtube] x: mweb client https formats require a GVS PO Token which was not provided. They will be skipped as they may yield HTTP Error 403.') === false);
|
||||
check('tableau de lignes accepté', errors.classify(['x', 'ERROR: Private video']).cls === 'PRIVATE');
|
||||
check('chaque classe a une action', errors.CLASSES.every(c => ['route', 'cookies', 'client', 'none'].includes(errors.RETRY[c])));
|
||||
}
|
||||
|
||||
console.log('# sanitize');
|
||||
{
|
||||
const s = errors.sanitize('[debug] Command-line config: [\'--cookies\', \'C:\\\\Temp\\\\chopin-ytc-AbC123\\\\cookies.txt\', \'--proxy\', \'http://user:secret@warp:1080\'] --cookies /tmp/chopin-ytc-x1/cookies.txt Cookie: SID=abcdef; HSID=zzz Authorization: SAPISIDHASH 123_abc SAPISID=VALUE123 __Secure-3PSID=SECRETVALUE po_token=mweb.gvs+AAAAAAAAAAAA');
|
||||
check('aucun secret', !/secret|abcdef|VALUE123|SECRETVALUE|AAAAAAAAAAAA|123_abc|AbC123|x1\//.test(s), s);
|
||||
check('proxy masqué', s.includes('http://***@warp:1080'), s);
|
||||
check('texte normal inchangé', errors.sanitize('ERROR: [youtube] abc: Video unavailable') === 'ERROR: [youtube] abc: Video unavailable');
|
||||
check('null -> ""', errors.sanitize(null) === '');
|
||||
}
|
||||
|
||||
done();
|
||||
@@ -0,0 +1,257 @@
|
||||
// Test du flux Google (OAuth2.js + YoutubeList.js + PlaylistManager.js) avec googleapis, la base de données,
|
||||
// la configuration, les utilisateurs et loguix simulés : aucun fichier de data/ n'est lu ni écrit, aucun réseau.
|
||||
const assert = require("assert")
|
||||
const Module = require("module")
|
||||
const EventEmitter = require("events")
|
||||
const path = require("path")
|
||||
|
||||
const SRC = require("path").resolve(__dirname, "../../src")
|
||||
const norm = (p) => p.replace(/\\/g, "/").toLowerCase()
|
||||
|
||||
// --- Simulations ---
|
||||
const cfg = { id: "client-id", secret: "client-secret", website: "https://chopin.example/" }
|
||||
const databases = {}
|
||||
class FakeDatabase {
|
||||
constructor(name, file, empty) { this.name = name; this.path = file; this.data = JSON.parse(JSON.stringify(empty || {})); this.saves = 0; databases[name] = this }
|
||||
save() { this.saves++ }
|
||||
}
|
||||
const fakeLog = { LogType: class { log() {} warn() {} error() {} }, getInstance: () => ({ log() {}, warn() {}, error() {} }) }
|
||||
|
||||
const api = { calls: { playlists: 0, items: 0, videos: 0, channels: 0 }, failWith: null, revoked: [] }
|
||||
const PLAYLISTS = [
|
||||
{ id: "PLa", snippet: { title: "Rock", channelTitle: "Moi", channelId: "UCme", thumbnails: { default: { url: "t-a" } } }, contentDetails: { itemCount: 120 } },
|
||||
{ id: "PLb", snippet: { title: "Calme", channelTitle: "Moi", channelId: "UCme" }, contentDetails: { itemCount: 3 } },
|
||||
{ id: "PLc", snippet: { title: "Vide", channelTitle: "Moi", channelId: "UCme", thumbnails: null }, contentDetails: { itemCount: 0 } },
|
||||
]
|
||||
function itemsFor(listId) {
|
||||
const count = { PLa: 120, PLb: 3, PLc: 0, LLme: 60 }[listId] ?? 0
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
// LL réutilise les vidéos de PLa (cache des durées partagé)
|
||||
const vid = (listId === "LLme" ? "PLa" : listId) + "-v" + i
|
||||
const priv = listId === "PLb" && i === 1
|
||||
return {
|
||||
snippet: {
|
||||
title: priv ? "Private video" : "Titre " + vid,
|
||||
resourceId: { videoId: vid },
|
||||
videoOwnerChannelTitle: priv ? undefined : "Artiste",
|
||||
videoOwnerChannelId: priv ? undefined : "UCart",
|
||||
thumbnails: { medium: { url: "m-" + vid } }
|
||||
},
|
||||
contentDetails: { videoId: vid }
|
||||
}
|
||||
})
|
||||
}
|
||||
function page(all, token, size) {
|
||||
const start = token ? Number(token) : 0
|
||||
return { items: all.slice(start, start + size), nextPageToken: start + size < all.length ? String(start + size) : undefined }
|
||||
}
|
||||
function maybeFail() { if (api.failWith) throw api.failWith }
|
||||
class FakeOAuth2 extends EventEmitter {
|
||||
constructor(id, secret, uri) { super(); this.args = [id, secret, uri]; this.credentials = null }
|
||||
generateAuthUrl(opts) { this.lastOpts = opts; return "https://accounts.google.com/o/oauth2/v2/auth?" + new URLSearchParams({ ...opts, scope: opts.scope.join(" ") }) }
|
||||
async getToken(code) {
|
||||
if (code === "bad") { const e = new Error("invalid_grant"); e.response = { data: { error: "invalid_grant" } }; throw e }
|
||||
return { tokens: { access_token: "AT-" + code, refresh_token: "RT-" + code, expiry_date: 123, scope: "yt" } }
|
||||
}
|
||||
setCredentials(t) { this.credentials = t }
|
||||
async revokeToken(t) { api.revoked.push(t); return {} }
|
||||
}
|
||||
const fakeGoogle = {
|
||||
google: {
|
||||
auth: { OAuth2: FakeOAuth2 },
|
||||
youtube: ({ auth }) => {
|
||||
assert.ok(auth instanceof FakeOAuth2, "service authentifié")
|
||||
return {
|
||||
channels: { list: async () => { api.calls.channels++; return { data: { items: [{ id: "UCme", snippet: { title: "Ma chaîne" }, contentDetails: { relatedPlaylists: { likes: "LLme" } } }] } } } },
|
||||
playlists: { list: async (p) => {
|
||||
api.calls.playlists++; maybeFail()
|
||||
if (p.id) return { data: { items: PLAYLISTS.filter(x => p.id.includes(x.id)) } }
|
||||
assert.strictEqual(p.mine, true)
|
||||
return { data: page(PLAYLISTS, p.pageToken, 2) }
|
||||
} },
|
||||
playlistItems: { list: async (p) => {
|
||||
api.calls.items++; maybeFail()
|
||||
assert.strictEqual(p.maxResults, 50)
|
||||
return { data: page(itemsFor(p.playlistId), p.pageToken, 50) }
|
||||
} },
|
||||
videos: { list: async (p) => {
|
||||
api.calls.videos++; maybeFail()
|
||||
assert.ok(p.id.length <= 50, "50 ids max par appel")
|
||||
return { data: { items: p.id.map((id, i) => ({ id, contentDetails: { duration: "PT" + (i % 5) + "M" + (i % 60) + "S" } })) } }
|
||||
} },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const stubs = {
|
||||
"googleapis": fakeGoogle,
|
||||
"loguix": fakeLog,
|
||||
[norm(SRC + "/utils/Database/Database")]: { Database: FakeDatabase, getDatabase: (n) => databases[n] },
|
||||
[norm(SRC + "/utils/Database/Configuration")]: { getYoutubeApiClientId: () => cfg.id, getYoutubeApiClientSecret: () => cfg.secret, getWebsiteLink: () => cfg.website },
|
||||
[norm(SRC + "/server/auth/User")]: { getUserById: (id) => (id ? { identity: { id } } : null) },
|
||||
[norm(SRC + "/player/Finder")]: { search: async () => null },
|
||||
[norm(SRC + "/media/SpotifyInformation")]: { getTracks: async (p) => p },
|
||||
[norm(SRC + "/utils/SafeMetric")]: { toSafeDuration: (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 && n <= 86400 ? Math.floor(n) : 0 } },
|
||||
}
|
||||
const origLoad = Module._load
|
||||
Module._load = function (request, parent, isMain) {
|
||||
if (stubs[request]) return stubs[request]
|
||||
if (request.startsWith(".") && parent) {
|
||||
const resolved = norm(path.resolve(path.dirname(parent.filename), request)).replace(/\.js$/, "")
|
||||
if (stubs[resolved]) return stubs[resolved]
|
||||
}
|
||||
return origLoad.apply(this, arguments)
|
||||
}
|
||||
|
||||
const events = []
|
||||
process.on("GOOGLE_UPDATE", (u) => events.push("GOOGLE_UPDATE:" + u))
|
||||
process.on("PLAYLISTS_UPDATE", (u) => events.push("PLAYLISTS_UPDATE:" + u))
|
||||
|
||||
const OAuth2 = require(SRC + "/playlists/Google/OAuth2.js")
|
||||
const PM = require(SRC + "/playlists/PlaylistManager.js")
|
||||
const store = databases.google
|
||||
assert.ok(store && store.path.endsWith("google.json"), "base google.json")
|
||||
|
||||
;(async () => {
|
||||
// --- Configuration absente : bouton désactivé ---
|
||||
cfg.id = ""
|
||||
assert.strictEqual(OAuth2.getStatus("u1").configured, false)
|
||||
assert.strictEqual(OAuth2.createAuthUrl("u1"), null)
|
||||
cfg.id = "client-id"
|
||||
|
||||
// --- URL de consentement ---
|
||||
const url = new URL(OAuth2.createAuthUrl("u1"))
|
||||
const state = url.searchParams.get("state")
|
||||
assert.strictEqual(url.searchParams.get("prompt"), "consent")
|
||||
assert.strictEqual(url.searchParams.get("access_type"), "offline")
|
||||
assert.match(state, /^[0-9a-f]{48}$/)
|
||||
|
||||
// --- Liaison : state d'un autre utilisateur / invalide refusé ---
|
||||
await assert.rejects(OAuth2.linkAccount("u2", "code1", state), { code: "INVALID_STATE" })
|
||||
await assert.rejects(OAuth2.linkAccount("u1", "code1", "nope"), { code: "INVALID_STATE" })
|
||||
await assert.rejects(OAuth2.linkAccount("u1", "", state), { code: "INVALID_PARAMS" })
|
||||
const status = await OAuth2.linkAccount("u1", "code1", state)
|
||||
assert.deepStrictEqual([status.linked, status.valid, status.channel.title], [true, true, "Ma chaîne"])
|
||||
assert.strictEqual(store.data.u1.tokens.refresh_token, "RT-code1")
|
||||
assert.strictEqual(store.data.u1.likesPlaylistId, "LLme")
|
||||
await assert.rejects(OAuth2.linkAccount("u1", "code1", state), { code: "INVALID_STATE" }, "state à usage unique")
|
||||
assert.ok(!JSON.stringify(OAuth2.getStatus("u1")).includes("RT-"), "aucun jeton dans le statut")
|
||||
// Redirect URI = site + /oauth2callback (slash final retiré)
|
||||
const client = OAuth2.getClient("u1")
|
||||
assert.strictEqual(client.args[2], "https://chopin.example/oauth2callback")
|
||||
assert.strictEqual(OAuth2.getClient("u1"), client, "client en cache")
|
||||
|
||||
// --- Jetons rafraîchis par la librairie : persistés, refresh_token conservé ---
|
||||
client.emit("tokens", { access_token: "AT-new", expiry_date: 999 })
|
||||
assert.deepStrictEqual([store.data.u1.tokens.access_token, store.data.u1.tokens.refresh_token], ["AT-new", "RT-code1"])
|
||||
|
||||
// --- CONFIG_UPDATE : client recréé (identifiants relus) ---
|
||||
cfg.secret = "secret-2"
|
||||
process.emit("CONFIG_UPDATE")
|
||||
const client2 = OAuth2.getClient("u1")
|
||||
assert.notStrictEqual(client2, client)
|
||||
assert.strictEqual(client2.args[1], "secret-2")
|
||||
assert.strictEqual(client2.credentials.access_token, "AT-new")
|
||||
|
||||
// --- Synchronisation (+ concurrence) ---
|
||||
const p1 = PM.syncYoutube("u1", { force: true })
|
||||
assert.strictEqual(OAuth2.getStatus("u1").syncing, true)
|
||||
const p2 = await PM.syncYoutube("u1", { force: true })
|
||||
assert.strictEqual(p2.error, "ALREADY_SYNCING")
|
||||
const r1 = await p1
|
||||
assert.strictEqual(r1.ok, true, JSON.stringify(r1))
|
||||
assert.strictEqual(r1.playlists, 4, "3 playlists + J'aime")
|
||||
const list = PM.getPlaylistsOfUser("u1")
|
||||
assert.strictEqual(list.length, 4)
|
||||
const byYt = Object.fromEntries(list.map(p => [p.youtubeId, p]))
|
||||
assert.strictEqual(byYt.PLa.songs.length, 120, "pagination 50+50+20")
|
||||
assert.strictEqual(byYt.PLb.songs.length, 2, "vidéo privée ignorée")
|
||||
assert.strictEqual(byYt.PLc.songs.length, 0)
|
||||
assert.strictEqual(byYt.LLme.title, "Vidéos « J'aime »")
|
||||
for (const p of list) {
|
||||
assert.strictEqual(typeof p.playlistId, "string")
|
||||
assert.strictEqual(p.type, "youtube")
|
||||
assert.strictEqual(p.source, "google")
|
||||
assert.ok(Number.isFinite(p.duration))
|
||||
for (const s of p.songs) {
|
||||
assert.strictEqual(s.type, "youtube")
|
||||
assert.ok(Number.isFinite(s.duration))
|
||||
assert.ok(s.readduration)
|
||||
}
|
||||
}
|
||||
assert.strictEqual(new Set(list.map(p => p.playlistId)).size, 4, "identifiants uniques")
|
||||
assert.strictEqual(byYt.PLa.songs[1].duration, 61, "durée ISO-8601 : PT1M1S")
|
||||
assert.strictEqual(byYt.PLc.thumbnail, "https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png", "miniature absente tolérée")
|
||||
// 2 pages de playlists ; durées : J'aime (60 → 2 appels), PLa (60 restantes grâce au cache → 2 appels), PLb (1 appel) = 5
|
||||
assert.strictEqual(api.calls.playlists, 2)
|
||||
assert.strictEqual(api.calls.videos, 5)
|
||||
assert.ok(events.includes("PLAYLISTS_UPDATE:u1"))
|
||||
assert.strictEqual(OAuth2.getStatus("u1").syncing, false)
|
||||
assert.strictEqual(OAuth2.getStatus("u1").lastSync.ok, true)
|
||||
|
||||
// --- Délai entre deux synchronisations, puis resynchronisation : identifiants et ordre conservés ---
|
||||
assert.strictEqual((await PM.syncYoutube("u1")).error, "COOLDOWN")
|
||||
const ids = list.map(p => p.playlistId).join(",")
|
||||
assert.strictEqual((await PM.syncYoutube("u1", { force: true })).ok, true)
|
||||
assert.strictEqual(PM.getPlaylistsOfUser("u1").map(p => p.playlistId).join(","), ids, "même ordre, mêmes ids")
|
||||
|
||||
// --- Lecture par identifiant (chaîne ou nombre) ---
|
||||
const anyId = PM.getPlaylistsOfUser("u1")[0].playlistId
|
||||
assert.ok(PM.getPlaylistOfUser("u1", anyId))
|
||||
assert.ok(PM.getPlaylistOfUser("u1", Number(anyId)))
|
||||
|
||||
// --- Rafraîchissement d'une playlist importée via l'API (J'aime) ---
|
||||
const likesId = PM.getPlaylistsOfUser("u1").find(p => p.youtubeId === "LLme").playlistId
|
||||
const before = api.calls.items
|
||||
const refreshed = await PM.refreshPlaylist("u1", likesId)
|
||||
assert.ok(refreshed && refreshed.playlistId === likesId && refreshed.songs.length === 60)
|
||||
assert.ok(api.calls.items > before, "rafraîchie via playlistItems.list")
|
||||
|
||||
// --- Création classique : identifiant primitif (X1) + copie indépendante (X5) ---
|
||||
const created = await PM.addPlaylist("u1", "Ma liste", null, "moi", "u1", "av")
|
||||
assert.strictEqual(typeof created.playlistId, "string")
|
||||
assert.ok(PM.getPlaylistOfUser("u1", created.playlistId), "retrouvée immédiatement")
|
||||
const copy = PM.copyPlaylist("u1", "u2", created.playlistId)
|
||||
assert.ok(copy && copy !== created)
|
||||
assert.strictEqual(PM.copyPlaylist("u1", "u2", created.playlistId), null, "doublon de titre refusé")
|
||||
PM.renamePlaylist("u2", copy.playlistId, "Autre")
|
||||
assert.strictEqual(created.title, "Ma liste", "copie indépendante")
|
||||
|
||||
// --- Autorisation révoquée chez Google : compte invalide ---
|
||||
const err = new Error("invalid_grant"); err.response = { status: 400, data: { error: "invalid_grant" } }
|
||||
api.failWith = err
|
||||
const failed = await PM.syncYoutube("u1", { force: true })
|
||||
api.failWith = null
|
||||
assert.strictEqual(failed.error, "AUTH_EXPIRED")
|
||||
const st = OAuth2.getStatus("u1")
|
||||
assert.deepStrictEqual([st.linked, st.valid, st.lastSync.ok], [true, false, false])
|
||||
assert.strictEqual(OAuth2.getClient("u1"), null)
|
||||
assert.strictEqual((await PM.syncYoutube("u1", { force: true })).error, "NOT_LINKED")
|
||||
|
||||
// --- Reconnexion puis quota épuisé ---
|
||||
const url2 = new URL(OAuth2.createAuthUrl("u1"))
|
||||
await OAuth2.linkAccount("u1", "code2", url2.searchParams.get("state"))
|
||||
assert.strictEqual(OAuth2.getStatus("u1").valid, true, "reconnexion")
|
||||
const q = new Error("quota"); q.errors = [{ reason: "quotaExceeded" }]; q.response = { status: 403, data: {} }
|
||||
api.failWith = q
|
||||
const quota = await PM.syncYoutube("u1", { force: true })
|
||||
api.failWith = null
|
||||
assert.strictEqual(quota.error, "QUOTA")
|
||||
assert.strictEqual(OAuth2.getStatus("u1").valid, true, "quota : compte toujours valide")
|
||||
|
||||
// --- Code invalide ---
|
||||
const url3 = new URL(OAuth2.createAuthUrl("u3"))
|
||||
await assert.rejects(OAuth2.linkAccount("u3", "bad", url3.searchParams.get("state")), { code: "TOKEN_ERROR" })
|
||||
|
||||
// --- Déconnexion : suppression + révocation ---
|
||||
const res = await OAuth2.unlink("u1")
|
||||
assert.deepStrictEqual(res, { removed: true, revoked: true })
|
||||
assert.deepStrictEqual(api.revoked, ["RT-code2"])
|
||||
assert.strictEqual(store.data.u1, undefined)
|
||||
assert.strictEqual(OAuth2.getStatus("u1").linked, false)
|
||||
assert.ok(PM.getPlaylistsOfUser("u1").length >= 4, "playlists importées conservées")
|
||||
assert.deepStrictEqual(await OAuth2.unlink("u1"), { removed: false, revoked: false })
|
||||
assert.ok(events.filter(e => e === "GOOGLE_UPDATE:u1").length >= 4)
|
||||
|
||||
console.log("google-flow.test.js : OK", JSON.stringify(api.calls))
|
||||
})().catch((e) => { console.error(e); process.exit(1) })
|
||||
@@ -0,0 +1,84 @@
|
||||
// Tests des fonctions pures de backend/src/playlists/Google/Utils.js (aucune dépendance)
|
||||
const assert = require("assert")
|
||||
const U = require(require("path").resolve(__dirname, "../../src/playlists/Google/Utils.js"))
|
||||
|
||||
;(async () => {
|
||||
// --- parseIsoDuration ---
|
||||
const cases = [["PT1H2M3S", 3723], ["PT4M13S", 253], ["PT45S", 45], ["PT0S", 0], ["P0D", 0], ["P1DT2H", 93600],
|
||||
["PT10M", 600], ["P1W", 604800], ["PT1.5S", 1], ["pt3m", 180], ["P", null], ["PT", null], ["", null],
|
||||
["1:23", null], [null, null], [123, null], ["P1Y", null], ["PT1H2M3", null]]
|
||||
for (const [input, expected] of cases) assert.strictEqual(U.parseIsoDuration(input), expected, "parseIsoDuration(" + input + ")")
|
||||
|
||||
// --- collectPages : pagination simulée (3 pages de 50 + 1 de 20 = 170) ---
|
||||
const makeFetcher = (total, size, log) => async (token) => {
|
||||
log.push(token)
|
||||
const start = token ? Number(token) : 0
|
||||
const items = Array.from({ length: Math.min(size, total - start) }, (_, i) => start + i)
|
||||
const next = start + size < total ? String(start + size) : undefined
|
||||
return { items, nextPageToken: next }
|
||||
}
|
||||
let log = []
|
||||
let r = await U.collectPages(makeFetcher(170, 50, log), { maxItems: 500 })
|
||||
assert.strictEqual(r.items.length, 170); assert.strictEqual(r.truncated, false); assert.strictEqual(r.pages, 4)
|
||||
assert.deepStrictEqual(log, [undefined, "50", "100", "150"])
|
||||
// limite atteinte au milieu d'une page
|
||||
log = []
|
||||
r = await U.collectPages(makeFetcher(1000, 50, log), { maxItems: 120 })
|
||||
assert.strictEqual(r.items.length, 120); assert.strictEqual(r.truncated, true); assert.strictEqual(r.pages, 3)
|
||||
assert.deepStrictEqual(r.items.slice(-1), [119])
|
||||
// limite atteinte pile en fin de page alors que Google annonce encore une page
|
||||
r = await U.collectPages(makeFetcher(1000, 50, []), { maxItems: 100 })
|
||||
assert.strictEqual(r.items.length, 100); assert.strictEqual(r.truncated, true); assert.strictEqual(r.pages, 2)
|
||||
// limite pile sur le total : pas tronqué
|
||||
r = await U.collectPages(makeFetcher(100, 50, []), { maxItems: 100 })
|
||||
assert.strictEqual(r.truncated, false); assert.strictEqual(r.items.length, 100)
|
||||
// jeton répété : pas de boucle infinie
|
||||
let calls = 0
|
||||
r = await U.collectPages(async () => { calls++; return { items: [calls], nextPageToken: "SAME" } }, { maxItems: 500 })
|
||||
assert.strictEqual(calls, 2); assert.strictEqual(r.items.length, 2)
|
||||
// maxPages
|
||||
r = await U.collectPages(makeFetcher(1000, 10, []), { maxItems: 500, maxPages: 3 })
|
||||
assert.strictEqual(r.items.length, 30); assert.strictEqual(r.truncated, true)
|
||||
// page vide / réponse inattendue
|
||||
r = await U.collectPages(async () => null)
|
||||
assert.deepStrictEqual(r.items, [])
|
||||
// erreur propagée
|
||||
await assert.rejects(U.collectPages(async () => { throw new Error("boom") }), /boom/)
|
||||
|
||||
// --- chunk ---
|
||||
assert.deepStrictEqual(U.chunk([1, 2, 3, 4, 5], 2), [[1, 2], [3, 4], [5]])
|
||||
assert.strictEqual(U.chunk(Array.from({ length: 120 }), 50).length, 3)
|
||||
assert.deepStrictEqual(U.chunk([], 50), [])
|
||||
|
||||
// --- createStateStore (horloge simulée) ---
|
||||
let now = 1_000_000
|
||||
const store = U.createStateStore({ ttlMs: 10 * 60 * 1000, maxPerKey: 3, now: () => now })
|
||||
const s1 = store.create("alice")
|
||||
assert.match(s1, /^[0-9a-f]{48}$/)
|
||||
assert.strictEqual(store.consume(s1, "bob"), false, "autre utilisateur refusé")
|
||||
assert.strictEqual(store.consume(s1, "alice"), true, "propriétaire accepté")
|
||||
assert.strictEqual(store.consume(s1, "alice"), false, "usage unique")
|
||||
const s2 = store.create("alice")
|
||||
now += 10 * 60 * 1000 + 1
|
||||
assert.strictEqual(store.consume(s2, "alice"), false, "expiré après 10 min")
|
||||
const s3 = store.create("alice")
|
||||
now += 9 * 60 * 1000
|
||||
assert.strictEqual(store.consume(s3, "alice"), true, "valide avant 10 min")
|
||||
// maxPerKey : les plus anciens sont évincés
|
||||
const olds = [store.create("carol"), store.create("carol"), store.create("carol"), store.create("carol")]
|
||||
assert.strictEqual(store.consume(olds[0], "carol"), false, "plus ancien évincé")
|
||||
assert.strictEqual(store.consume(olds[3], "carol"), true)
|
||||
store.clear("carol")
|
||||
assert.strictEqual(store.consume(olds[2], "carol"), false, "clear")
|
||||
assert.strictEqual(store.consume(undefined, "carol"), false)
|
||||
assert.strictEqual(store.consume(12, "carol"), false)
|
||||
|
||||
// --- pickTokens / bestThumbnail ---
|
||||
assert.deepStrictEqual(U.pickTokens({ access_token: "a", refresh_token: null, expiry_date: 5, foo: 1 }), { access_token: "a", expiry_date: 5 })
|
||||
assert.deepStrictEqual(U.pickTokens(null), {})
|
||||
assert.strictEqual(U.bestThumbnail({ default: { url: "d" }, high: { url: "h" } }), "h")
|
||||
assert.strictEqual(U.bestThumbnail({}), null)
|
||||
assert.strictEqual(U.bestThumbnail(undefined), null)
|
||||
|
||||
console.log("google-utils.test.js : OK")
|
||||
})().catch((e) => { console.error(e); process.exit(1) })
|
||||
@@ -0,0 +1,120 @@
|
||||
// Tests unitaires de backend/src/utils/Limiter.js (node limiter.test.js)
|
||||
const assert = require("assert");
|
||||
const path = require("path").resolve(__dirname, "../../src/utils/Limiter.js");
|
||||
const { createLimiter, mapLimit, createInflight, createCache } = require(path);
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
const tests = [];
|
||||
const test = (name, fn) => tests.push({ name, fn });
|
||||
|
||||
test("limiter: concurrence max respectée et résultats corrects", async () => {
|
||||
const lim = createLimiter(2);
|
||||
let active = 0, peak = 0;
|
||||
const res = await Promise.all([1, 2, 3, 4, 5, 6].map(i => lim.run(async () => {
|
||||
active++; peak = Math.max(peak, active);
|
||||
await sleep(20);
|
||||
active--;
|
||||
return i * 10;
|
||||
})));
|
||||
assert.deepStrictEqual(res, [10, 20, 30, 40, 50, 60]);
|
||||
assert.strictEqual(peak, 2);
|
||||
assert.strictEqual(lim.active, 0);
|
||||
assert.strictEqual(lim.pending, 0);
|
||||
});
|
||||
|
||||
test("limiter: erreurs (async et sync) propagées sans bloquer la file", async () => {
|
||||
const lim = createLimiter(1);
|
||||
await assert.rejects(lim.run(async () => { throw new Error("a"); }), /a/);
|
||||
await assert.rejects(lim.run(() => { throw new Error("b"); }), /b/);
|
||||
assert.strictEqual(await lim.run(() => 42), 42);
|
||||
});
|
||||
|
||||
test("limiter: minIntervalMs espace les démarrages", async () => {
|
||||
const lim = createLimiter(5, { minIntervalMs: 30 });
|
||||
const starts = [];
|
||||
await Promise.all([0, 1, 2, 3].map(() => lim.run(async () => { starts.push(Date.now()); })));
|
||||
for (let i = 1; i < starts.length; i++) assert.ok(starts[i] - starts[i - 1] >= 25, "écart " + (starts[i] - starts[i - 1]));
|
||||
});
|
||||
|
||||
test("mapLimit: ordre conservé, concurrence bornée, erreurs ignorées", async () => {
|
||||
let active = 0, peak = 0;
|
||||
const out = await mapLimit([30, 5, 20, 1, 10], 3, async (ms, i) => {
|
||||
active++; peak = Math.max(peak, active);
|
||||
await sleep(ms);
|
||||
active--;
|
||||
if (i === 2) throw new Error("x");
|
||||
return ms;
|
||||
}, () => null);
|
||||
assert.deepStrictEqual(out, [30, 5, null, 1, 10]);
|
||||
assert.strictEqual(peak, 3);
|
||||
assert.deepStrictEqual(await mapLimit(null, 2, async x => x), []);
|
||||
});
|
||||
|
||||
test("inflight: appels simultanés dédupliqués, libérés ensuite", async () => {
|
||||
const inf = createInflight();
|
||||
let calls = 0;
|
||||
const fn = async () => { calls++; await sleep(10); return "v"; };
|
||||
const [a, b] = await Promise.all([inf.run("k", fn), inf.run("k", fn)]);
|
||||
assert.strictEqual(a, "v"); assert.strictEqual(b, "v"); assert.strictEqual(calls, 1);
|
||||
assert.strictEqual(inf.size, 0);
|
||||
await inf.run("k", fn);
|
||||
assert.strictEqual(calls, 2);
|
||||
await assert.rejects(inf.run("e", async () => { throw new Error("boom"); }));
|
||||
assert.strictEqual(inf.size, 0);
|
||||
});
|
||||
|
||||
test("cache: TTL, null explicite, expiration", async () => {
|
||||
const c = createCache({ ttlMs: 40, max: 10 });
|
||||
c.set("a", 1);
|
||||
c.set("n", null);
|
||||
assert.strictEqual(c.get("a"), 1);
|
||||
assert.strictEqual(c.get("n"), null);
|
||||
assert.strictEqual(c.get("absent"), undefined);
|
||||
c.set("z", 1, 0); // TTL 0 → pas mis en cache
|
||||
assert.strictEqual(c.get("z"), undefined);
|
||||
await sleep(60);
|
||||
assert.strictEqual(c.get("a"), undefined);
|
||||
assert.strictEqual(c.size, 1); // "n" expiré mais pas encore lu
|
||||
});
|
||||
|
||||
test("cache: taille max et éviction LRU", () => {
|
||||
const c = createCache({ ttlMs: 10000, max: 3 });
|
||||
c.set("a", 1); c.set("b", 2); c.set("c", 3);
|
||||
c.get("a"); // a devient le plus récent
|
||||
c.set("d", 4);
|
||||
assert.strictEqual(c.size, 3);
|
||||
assert.strictEqual(c.get("b"), undefined);
|
||||
assert.strictEqual(c.get("a"), 1);
|
||||
assert.strictEqual(c.get("d"), 4);
|
||||
});
|
||||
|
||||
test("cache.wrap: hit, dédup en cours, null non mis en cache, ttlFor", async () => {
|
||||
const c = createCache({ ttlMs: 10000, max: 10 });
|
||||
let calls = 0;
|
||||
const fn = async () => { calls++; await sleep(10); return { x: 1 }; };
|
||||
const [a, b] = await Promise.all([c.wrap("k", fn), c.wrap("k", fn)]);
|
||||
assert.strictEqual(a, b); assert.strictEqual(calls, 1);
|
||||
await c.wrap("k", fn);
|
||||
assert.strictEqual(calls, 1);
|
||||
let nulls = 0;
|
||||
await c.wrap("n", async () => { nulls++; return null; });
|
||||
await c.wrap("n", async () => { nulls++; return null; });
|
||||
assert.strictEqual(nulls, 2);
|
||||
await c.wrap("m", async () => { nulls++; return null; }, { ttlFor: v => v === null ? 5000 : 10000 });
|
||||
await c.wrap("m", async () => { nulls++; return null; }, { ttlFor: v => v === null ? 5000 : 10000 });
|
||||
assert.strictEqual(nulls, 3);
|
||||
await assert.rejects(c.wrap("e", async () => { throw new Error("err"); }));
|
||||
assert.strictEqual(c.get("e"), undefined);
|
||||
const s = c.stats();
|
||||
assert.ok(s.hits >= 2 && s.misses >= 4, JSON.stringify(s));
|
||||
});
|
||||
|
||||
(async () => {
|
||||
let failed = 0;
|
||||
for (const t of tests) {
|
||||
try { await t.fn(); console.log("OK " + t.name); }
|
||||
catch (e) { failed++; console.log("FAIL " + t.name + "\n " + (e && e.stack || e)); }
|
||||
}
|
||||
console.log(`\n${tests.length - failed}/${tests.length} réussis`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
})();
|
||||
@@ -0,0 +1,107 @@
|
||||
// Test réseau de YoutubeInformation / SpotifyInformation / Finder (cache + limiteur), sans démarrer le bot.
|
||||
const assert = require("assert");
|
||||
const path = require("path");
|
||||
const SRC = require("path").resolve(__dirname, "../../src");
|
||||
// Logs loguix dans le scratchpad (sinon LogType.error plante : flux non initialisé)
|
||||
require(path.join(SRC, "../node_modules/loguix")).setup(path.join(__dirname, "logs"), path.join(SRC, "../package.json"));
|
||||
|
||||
// Configuration factice : évite de lire data/config.json (et son process.exit sans token)
|
||||
const confPath = require.resolve(path.join(SRC, "utils/Database/Configuration.js"));
|
||||
const fakeConf = { id: "", secret: "" };
|
||||
require.cache[confPath] = { id: confPath, filename: confPath, loaded: true, exports: {
|
||||
getSpotifyClientId: () => fakeConf.id,
|
||||
getSpotifyClientSecret: () => fakeConf.secret,
|
||||
} };
|
||||
|
||||
const yt = require(path.join(SRC, "media/YoutubeInformation.js"));
|
||||
const spotify = require(path.join(SRC, "media/SpotifyInformation.js"));
|
||||
const Finder = require(path.join(SRC, "player/Finder.js"));
|
||||
const { Playlist } = require(path.join(SRC, "playlists/Playlist.js"));
|
||||
|
||||
async function timed(label, fn) {
|
||||
const t = Date.now();
|
||||
const r = await fn();
|
||||
const ms = Date.now() - t;
|
||||
console.log(`${label}: ${ms} ms`);
|
||||
return [r, ms];
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// 1) Recherche : 2e appel servi par le cache
|
||||
const [s1, m1] = await timed("getQuery #1 (réseau)", () => yt.getQuery("Daft Punk Around the World", true));
|
||||
assert.ok(Array.isArray(s1) && s1.length > 0, "résultats attendus");
|
||||
const [s2, m2] = await timed("getQuery #2 (cache)", () => yt.getQuery(" daft punk around the world ", false));
|
||||
assert.ok(s2 && s2.url === s1[0].url, "même premier résultat");
|
||||
assert.ok(m2 < 20, "le 2e appel doit venir du cache");
|
||||
assert.notStrictEqual(s2, s1[0], "instances Song distinctes");
|
||||
|
||||
// 2) Vidéo par id : 2e appel + getSecondsFromUrl servis par le cache
|
||||
const url = "https://www.youtube.com/watch?v=jNQXAC9IVRw";
|
||||
const [v1] = await timed("getVideo #1 (réseau)", () => yt.getVideo(url));
|
||||
assert.ok(v1 && v1.id === "jNQXAC9IVRw" && v1.duration > 0, JSON.stringify(v1));
|
||||
const [v2, mv2] = await timed("getVideo #2 (cache)", () => yt.getVideo(url));
|
||||
assert.ok(mv2 < 20 && v2.title === v1.title);
|
||||
const [secs, ms3] = await timed("getSecondsFromUrl (cache)", () => yt.getSecondsFromUrl("https://youtu.be/jNQXAC9IVRw"));
|
||||
assert.strictEqual(secs, v1.duration); assert.ok(ms3 < 20);
|
||||
|
||||
// 3) Appels simultanés dédupliqués (1 seul scrape)
|
||||
const [[a, b], md] = await timed("2 recherches simultanées identiques", () => Promise.all([yt.getQuery("lofi hip hop radio"), yt.getQuery("LOFI hip hop radio")]));
|
||||
assert.ok(a && b && a.url === b.url);
|
||||
|
||||
// 4) Durée d'une vidéo issue d'une recherche : déjà en cache
|
||||
const [secs2, ms4] = await timed("getSecondsFromUrl d'un résultat de recherche", () => yt.getSecondsFromUrl(s1[1].url));
|
||||
assert.ok(ms4 < 20 && secs2 === s1[1].duration);
|
||||
|
||||
// 5) Playlists YouTube (nouvelle mise en page) + avatar ; 2e appel servi par le cache
|
||||
for (const listUrl of ["https://www.youtube.com/playlist?list=PLdXJrX9OsbOVq2TKvUc3xGUp2Rm8nTUVh", "https://www.youtube.com/watch?v=fcnDmrtj6Sk&list=PL4fGSI1pDJn6puJdseH2Rt9sMvt9E2M4i"]) {
|
||||
const [p1] = await timed("getPlaylist #1 (réseau)", () => yt.getPlaylist(listUrl));
|
||||
assert.ok(p1 && p1.songs.length > 0, "playlist attendue");
|
||||
const s0 = p1.songs[0];
|
||||
console.log(" playlist:", p1.title, "| auteur:", p1.author, p1.authorId, "| titres:", p1.songs.length, "| durée:", p1.readduration, "| avatar:", String(p1.authorAvatar).slice(0, 50));
|
||||
console.log(" 1er titre:", s0.title, "|", s0.author, s0.authorId, "|", s0.readduration, s0.url);
|
||||
assert.ok(s0.url.startsWith("https://www.youtube.com/watch?v=") && s0.type === "youtube" && s0.duration > 0);
|
||||
const [p2, mp2] = await timed("getPlaylist #2 (cache)", () => yt.getPlaylist(listUrl));
|
||||
assert.ok(mp2 < 20 && p2.songs.length === p1.songs.length && p2.authorAvatar === p1.authorAvatar);
|
||||
const [sec, ms5] = await timed("getSecondsFromUrl d'un titre de playlist", () => yt.getSecondsFromUrl(s0.url));
|
||||
assert.ok(ms5 < 20 && sec === s0.duration);
|
||||
}
|
||||
// Avatar d'une chaîne (en-têtes navigateur + consentement), 2e appel en cache
|
||||
|
||||
// 6) getTracks : concurrence 3, ordre conservé, titres invalides ignorés, idempotent
|
||||
const pl = new Playlist("Test", "https://open.spotify.com/playlist/x");
|
||||
pl.type = "spotify";
|
||||
pl.songs = [
|
||||
{ id: "t1", name: "Get Lucky", artists: [{ name: "Daft Punk" }], duration_ms: 248000 },
|
||||
null,
|
||||
{ id: "t2", name: "Bohemian Rhapsody", artists: [{ name: "Queen" }], duration_ms: 354000 },
|
||||
{ id: "pod", name: "Episode", artists: [] },
|
||||
{ id: "t3", name: "Billie Jean", artists: [{ name: "Michael Jackson" }], duration_ms: 294000 },
|
||||
];
|
||||
const [out] = await timed("getTracks (3 titres)", () => spotify.getTracks(pl));
|
||||
assert.strictEqual(out, pl);
|
||||
assert.deepStrictEqual(out.songs.map(s => s.id), ["t1", "t2", "t3"]);
|
||||
assert.ok(out.songs.every(s => s.type === "youtube" && s.url.startsWith("https://www.youtube.com/watch?v=")));
|
||||
assert.strictEqual(out.duration, 248 + 354 + 294);
|
||||
const [, mi] = await timed("getTracks idempotent", () => spotify.getTracks(pl));
|
||||
assert.ok(mi < 20 && pl.songs.length === 3);
|
||||
|
||||
// 7) Finder : playlist Spotify convertie (spotify.getPlaylist simulé), échec total → null
|
||||
const realGetPlaylist = spotify.getPlaylist;
|
||||
spotify.getPlaylist = async () => { const p = new Playlist("Sim", "u"); p.type = "spotify"; p.songs = [{ id: "z", name: "Get Lucky", artists: [{ name: "Daft Punk" }], duration_ms: 1000 }]; return p; };
|
||||
const fp = await Finder.search("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", true);
|
||||
assert.ok(fp instanceof Playlist && fp.songs[0].type === "youtube" && fp.songs[0].url, "Finder doit renvoyer des Song convertis");
|
||||
const fp2 = await Finder.search("https://open.spotify.com/intl-fr/playlist/37i9dQZF1DXcBWIGoYBM5M?si=abc", false, "PLAYLIST");
|
||||
assert.ok(fp2 && fp2.songs[0].url);
|
||||
spotify.getPlaylist = async () => { const p = new Playlist("Vide", "u"); p.type = "spotify"; p.songs = [null]; return p; };
|
||||
assert.strictEqual(await Finder.search("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", true), null);
|
||||
spotify.getPlaylist = realGetPlaylist;
|
||||
console.log("Finder Spotify: OK");
|
||||
|
||||
// 8) Spotify sans identifiants : erreur propre (pas d'appel réseau), lecture paresseuse
|
||||
assert.strictEqual(await spotify.getSong("https://open.spotify.com/track/4uLU6hMCjMI75M1A2tKUQC"), null);
|
||||
process.emit("CONFIG_UPDATE");
|
||||
console.log("Spotify sans identifiants: OK (null)");
|
||||
|
||||
console.log("\nTOUS LES TESTS RÉUSSIS");
|
||||
process.exit(0);
|
||||
})().catch(e => { console.error("ÉCHEC", e); process.exit(1); });
|
||||
@@ -0,0 +1,113 @@
|
||||
const { check, done, req } = require('./_setup');
|
||||
const plan = req('src/youtube/plan');
|
||||
|
||||
const W = 'http://warp:1080';
|
||||
const ROUTES = { 'warp+direct': [W, null], 'direct': [null], 'warp seul': [W] };
|
||||
const FB = 'web_safari,tv_downgraded';
|
||||
|
||||
const label = (a) => `${a.proxy ? 'W' : 'D'}/${a.identity === 'cookies' ? 'C' : 'A'}${a.clients ? '/fb' : ''}${a.seek ? '/' + a.seek : ''}`;
|
||||
|
||||
/**
|
||||
* Simule getStream : chaque tentative échoue avec la classe donnée (tableau, ou fonction (attempt, i, history))
|
||||
* okAt : index de la tentative qui réussit ; rotateAt : la tentative i invalide les cookies
|
||||
*/
|
||||
function simulate({ routes, mode = 'fallback', cookiesValid = true, cookiesOnProxy = false, fail, okAt = -1, rotateAt = -1, seekMode = null, fallbackClients = FB }) {
|
||||
const history = [];
|
||||
const seq = [];
|
||||
let valid = cookiesValid;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const a = plan.planNext({ routes, mode, cookiesValid: valid, cookiesOnProxy, clients: '', fallbackClients, seekMode, history, maxAttempts: 4 });
|
||||
if (!a) break;
|
||||
seq.push(label(a));
|
||||
if (i === okAt) { seq.push('OK'); break; }
|
||||
const cls = typeof fail === 'function' ? fail(a, i, history) : (Array.isArray(fail) ? fail[Math.min(i, fail.length - 1)] : fail);
|
||||
if (i === rotateAt) valid = false;
|
||||
history.push({ ...a, cls });
|
||||
}
|
||||
return seq.join(' > ');
|
||||
}
|
||||
|
||||
function expect(name, input, expected) {
|
||||
const got = simulate(input);
|
||||
check(`${name.padEnd(62)} ${got}`, got === expected, { expected });
|
||||
}
|
||||
|
||||
console.log('# Mode never');
|
||||
expect('never, warp+direct, UNKNOWN', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'UNKNOWN' }, 'W/A > D/A');
|
||||
expect('never, warp+direct, BOT_CHECK (jamais de cookies)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'BOT_CHECK' }, 'W/A > D/A');
|
||||
expect('never, warp+direct, HTTP_403 (clients de repli une fois)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'HTTP_403' }, 'W/A > W/A/fb > D/A');
|
||||
expect('never, direct, LOGIN_REQUIRED (arrêt)', { routes: ROUTES.direct, mode: 'never', fail: 'LOGIN_REQUIRED' }, 'D/A');
|
||||
expect('never, warp seul, TIMEOUT', { routes: ROUTES['warp seul'], mode: 'never', fail: 'TIMEOUT' }, 'W/A');
|
||||
expect('never, warp+direct, PRIVATE (fatal)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'PRIVATE' }, 'W/A');
|
||||
expect('never, warp+direct, UNAVAILABLE (fatal)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'UNAVAILABLE' }, 'W/A');
|
||||
expect('never, warp+direct, RATE_LIMITED (autre route uniquement)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'RATE_LIMITED' }, 'W/A > D/A');
|
||||
expect('never, warp+direct, BINARY_MISSING (arrêt)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'BINARY_MISSING' }, 'W/A');
|
||||
|
||||
console.log('# Mode fallback (cookies valides)');
|
||||
expect('fallback, warp+direct, BOT_CHECK', { routes: ROUTES['warp+direct'], fail: 'BOT_CHECK' }, 'W/A > D/A > D/C');
|
||||
expect('fallback, warp+direct, IP_BLOCKED, cookiesOnProxy', { routes: ROUTES['warp+direct'], cookiesOnProxy: true, fail: 'IP_BLOCKED' }, 'W/A > D/A > W/C > D/C');
|
||||
expect('fallback, direct, CAPTCHA', { routes: ROUTES.direct, fail: 'CAPTCHA' }, 'D/A > D/C');
|
||||
expect('fallback, warp seul, BOT_CHECK (proxy = seule route)', { routes: ROUTES['warp seul'], fail: 'BOT_CHECK' }, 'W/A > W/C');
|
||||
expect('fallback, warp+direct, AGE_RESTRICTED (compte directement)', { routes: ROUTES['warp+direct'], fail: 'AGE_RESTRICTED' }, 'W/A > D/C');
|
||||
expect('fallback, warp+direct, MEMBERS_ONLY', { routes: ROUTES['warp+direct'], fail: 'MEMBERS_ONLY' }, 'W/A > D/C');
|
||||
expect('fallback, warp+direct, LOGIN_REQUIRED, cookiesOnProxy (refus avec compte = arrêt)', { routes: ROUTES['warp+direct'], cookiesOnProxy: true, fail: 'LOGIN_REQUIRED' }, 'W/A > W/C');
|
||||
expect('fallback, warp+direct, UNKNOWN (pas de cookies)', { routes: ROUTES['warp+direct'], fail: 'UNKNOWN' }, 'W/A > D/A');
|
||||
expect('fallback, warp+direct, GEO_BLOCKED (autre route seulement)', { routes: ROUTES['warp+direct'], fail: 'GEO_BLOCKED' }, 'W/A > D/A');
|
||||
expect('fallback, direct, GEO_BLOCKED', { routes: ROUTES.direct, fail: 'GEO_BLOCKED' }, 'D/A');
|
||||
expect('fallback, direct, HTTP_403', { routes: ROUTES.direct, fail: 'HTTP_403' }, 'D/A > D/A/fb');
|
||||
expect('fallback, direct, SABR', { routes: ROUTES.direct, fail: 'SABR' }, 'D/A > D/A/fb');
|
||||
expect('fallback, direct, POT_MISSING', { routes: ROUTES.direct, fail: 'POT_MISSING' }, 'D/A > D/A/fb');
|
||||
expect('fallback, direct, EMPTY_DOWNLOAD', { routes: ROUTES.direct, fail: 'EMPTY_DOWNLOAD' }, 'D/A > D/A/fb');
|
||||
expect('fallback, direct, FORMAT_UNAVAILABLE, repli désactivé', { routes: ROUTES.direct, fail: 'FORMAT_UNAVAILABLE', fallbackClients: '' }, 'D/A');
|
||||
expect('fallback, warp+direct, 403 puis BOT_CHECK', { routes: ROUTES['warp+direct'], fail: ['HTTP_403', 'BOT_CHECK'] }, 'W/A > W/A/fb > D/A > D/C');
|
||||
expect('fallback, warp+direct, BOT puis 403 (max 4)', { routes: ROUTES['warp+direct'], fail: ['BOT_CHECK', 'BOT_CHECK', 'HTTP_403'] }, 'W/A > D/A > D/C > D/C/fb');
|
||||
expect('fallback, warp+direct, réussite à la 2e', { routes: ROUTES['warp+direct'], fail: 'BOT_CHECK', okAt: 1 }, 'W/A > D/A > OK');
|
||||
expect('fallback, cookies tournés pendant la tentative avec compte', { routes: ROUTES['warp+direct'], fail: 'BOT_CHECK', rotateAt: 2 }, 'W/A > D/A > D/C');
|
||||
expect('fallback, cookies invalides, BOT_CHECK', { routes: ROUTES['warp+direct'], cookiesValid: false, fail: 'BOT_CHECK' }, 'W/A > D/A');
|
||||
expect('fallback, cookies invalides, AGE_RESTRICTED (arrêt)', { routes: ROUTES['warp+direct'], cookiesValid: false, fail: 'AGE_RESTRICTED' }, 'W/A');
|
||||
expect('fallback, compte refusé (AGE avec cookies) -> arrêt', { routes: ROUTES.direct, fail: 'AGE_RESTRICTED' }, 'D/A > D/C');
|
||||
|
||||
console.log('# Mode always');
|
||||
expect('always, warp+direct (pas de compte via WARP)', { routes: ROUTES['warp+direct'], mode: 'always', fail: 'UNKNOWN' }, 'W/A > D/C');
|
||||
expect('always, warp+direct, cookiesOnProxy', { routes: ROUTES['warp+direct'], mode: 'always', cookiesOnProxy: true, fail: 'UNKNOWN' }, 'W/C > D/C');
|
||||
expect('always, direct, BOT_CHECK', { routes: ROUTES.direct, mode: 'always', fail: 'BOT_CHECK' }, 'D/C');
|
||||
expect('always, warp seul (compte autorisé)', { routes: ROUTES['warp seul'], mode: 'always', fail: 'UNKNOWN' }, 'W/C');
|
||||
expect('always, direct, rotation -> anonyme', { routes: ROUTES.direct, mode: 'always', fail: 'COOKIES_ROTATED', rotateAt: 0 }, 'D/C > D/A');
|
||||
expect('always, warp+direct, AGE sur WARP anonyme -> compte', { routes: ROUTES['warp+direct'], mode: 'always', fail: 'AGE_RESTRICTED' }, 'W/A > D/C');
|
||||
expect('always, direct, HTTP_403 -> clients de repli avec compte', { routes: ROUTES.direct, mode: 'always', fail: 'HTTP_403' }, 'D/C > D/C/fb');
|
||||
expect('always sans cookies valides = never', { routes: ROUTES['warp+direct'], mode: 'always', cookiesValid: false, fail: 'BOT_CHECK' }, 'W/A > D/A');
|
||||
expect('always, PRIVATE', { routes: ROUTES['warp+direct'], mode: 'always', fail: 'PRIVATE' }, 'W/A');
|
||||
|
||||
console.log('# Seek');
|
||||
check('seekModeFor auto 30 -> native', plan.seekModeFor('auto', 30) === 'native');
|
||||
check('seekModeFor auto 1200 -> native', plan.seekModeFor('auto', 1200) === 'native');
|
||||
check('seekModeFor auto 1201 -> sections', plan.seekModeFor('auto', 1201) === 'sections');
|
||||
check('seekModeFor ffmpeg 10 -> sections', plan.seekModeFor('ffmpeg', 10) === 'sections');
|
||||
check('seekModeFor native 5000 -> native', plan.seekModeFor('native', 5000) === 'native');
|
||||
check('seekModeFor sans seek -> null', plan.seekModeFor('auto', 0) === null);
|
||||
expect('sections + HTTP_403 -> natif puis clients de repli', { routes: ROUTES.direct, seekMode: 'sections', fail: 'HTTP_403' }, 'D/A/sections > D/A/native > D/A/fb/native');
|
||||
expect('sections + 403 sur warp+direct -> la suite en natif', { routes: ROUTES['warp+direct'], seekMode: 'sections', fail: ['HTTP_403', 'BOT_CHECK'] }, 'W/A/sections > W/A/native > D/A/native > D/C/native');
|
||||
expect('natif + UNKNOWN', { routes: ROUTES['warp+direct'], seekMode: 'native', fail: 'UNKNOWN' }, 'W/A/native > D/A/native');
|
||||
|
||||
console.log('# Matrice (modes x routes x classes)');
|
||||
const classes = ['BOT_CHECK', 'IP_BLOCKED', 'CAPTCHA', 'RATE_LIMITED', 'HTTP_403', 'HTTP_429', 'AGE_RESTRICTED', 'LOGIN_REQUIRED', 'MEMBERS_ONLY', 'PRIVATE', 'UNAVAILABLE', 'GEO_BLOCKED', 'POT_MISSING', 'SABR', 'JS_CHALLENGE', 'FORMAT_UNAVAILABLE', 'PAGE_RELOAD', 'COOKIES_ROTATED', 'EMPTY_DOWNLOAD', 'BINARY_MISSING', 'TIMEOUT', 'UNKNOWN'];
|
||||
let maxLen = 0, dup = 0;
|
||||
for (const mode of ['never', 'always', 'fallback']) {
|
||||
for (const [rname, routes] of Object.entries(ROUTES)) {
|
||||
const row = [];
|
||||
for (const cls of classes) {
|
||||
const seq = simulate({ routes, mode, fail: cls });
|
||||
const parts = seq.split(' > ');
|
||||
maxLen = Math.max(maxLen, parts.length);
|
||||
if (new Set(parts).size !== parts.length) dup++;
|
||||
row.push(`${cls}: ${seq}`);
|
||||
}
|
||||
console.log(` [${mode} | ${rname}]`);
|
||||
for (const r of row) console.log(` ${r}`);
|
||||
}
|
||||
}
|
||||
check('jamais plus de 4 tentatives', maxLen <= 4, maxLen);
|
||||
check('jamais deux fois la même tentative', dup === 0, dup);
|
||||
check('pas de compte en mode never', !['warp+direct', 'direct', 'warp seul'].some(r => classes.some(c => simulate({ routes: ROUTES[r], mode: 'never', fail: c }).includes('/C'))));
|
||||
|
||||
done();
|
||||
@@ -0,0 +1,227 @@
|
||||
// Test de backend/src/server/Server.js avec un vrai serveur socket.io (port local) et des clients socket.io-client.
|
||||
// Toutes les dépendances (bot Discord, utilisateurs, players, Admin, playlists, Google...) sont simulées :
|
||||
// aucun bot Discord n'est connecté, aucun fichier de data/ n'est lu ni écrit.
|
||||
const assert = require("assert")
|
||||
const Module = require("module")
|
||||
const path = require("path")
|
||||
|
||||
const ROOT = require("path").resolve(__dirname, "../../..")
|
||||
const SRC = ROOT + "/backend/src"
|
||||
const { io: ioClient } = require(ROOT + "/frontend/node_modules/socket.io-client")
|
||||
const norm = (p) => p.replace(/\\/g, "/").toLowerCase()
|
||||
const PORT = 45000 + Math.floor(Math.random() * 1000)
|
||||
|
||||
const uncaught = []
|
||||
process.on("uncaughtException", (e) => { uncaught.push(e); console.error("uncaughtException", e) })
|
||||
process.on("unhandledRejection", (e) => { uncaught.push(e); console.error("unhandledRejection", e) })
|
||||
|
||||
// --- Utilisateurs : chaque lecture renvoie un NOUVEL objet (comme loadUsers() après chaque saveUsers()) ---
|
||||
const raw = {
|
||||
A: { id: "A", tokens: ["tA1", "tA2"], labels: ["ADMIN"] },
|
||||
B: { id: "B", tokens: ["tB"], labels: [] },
|
||||
C: { id: "C", tokens: ["tC"], labels: [] },
|
||||
}
|
||||
function makeUser(r) {
|
||||
if (!r) return null
|
||||
const labels = [...r.labels]
|
||||
return {
|
||||
identity: { id: r.id, username: "user" + r.id, global_name: "User " + r.id, avatar: null },
|
||||
labels, auth: { ok: true }, tokens: [...r.tokens],
|
||||
isAdmin: () => labels.includes("ADMIN"),
|
||||
isFullBanned: () => labels.includes("BAN"),
|
||||
isBanned: (g) => labels.includes("BAN_" + g),
|
||||
isMod: (g) => labels.includes("ADMIN") || labels.includes("MOD_" + g),
|
||||
isDeleted: () => labels.includes("DELETED"),
|
||||
needUpdate: () => false, justUpdated() {}, clearToken() {},
|
||||
removeToken(t) { r.tokens = r.tokens.filter(x => x !== t) },
|
||||
}
|
||||
}
|
||||
const usersStub = {
|
||||
getUserById: (id) => makeUser(raw[id]),
|
||||
getUserByToken: (t) => makeUser(Object.values(raw).find(r => r.tokens.includes(t))),
|
||||
setGuildBan: async (id, g) => { const r = raw[id]; const l = "BAN_" + g; r.labels = r.labels.includes(l) ? r.labels.filter(x => x !== l) : [...r.labels, l]; return true },
|
||||
setGuildMod: async () => true,
|
||||
deleteAccount: async (id) => { raw[id].labels = ["DELETED"]; raw[id].tokens = [] },
|
||||
updateIdentity: async () => true,
|
||||
addUser: async () => null,
|
||||
}
|
||||
|
||||
// --- Bot / players ---
|
||||
const guilds = new Map([
|
||||
["g1", { id: "g1", name: "Guilde 1", owner: "C", allMembers: ["A", "B", "C"] }],
|
||||
["g2", { id: "g2", name: "Guilde 2", owner: "A", allMembers: ["A", "B"] }],
|
||||
])
|
||||
const AllPlayers = new Map()
|
||||
const seekCalls = []
|
||||
class FakePlayer {
|
||||
constructor(guildId) { if (AllPlayers.has(guildId)) return AllPlayers.get(guildId); this.guildId = guildId; this.connected = false; this.queue = { current: { title: "x" } }; AllPlayers.set(guildId, this) }
|
||||
isConnected() { return this.connected }
|
||||
getState() { return { v: 2, guildId: this.guildId, status: this.connected ? "playing" : "idle" } }
|
||||
async setDuration(t) { seekCalls.push(t); return { ok: true } }
|
||||
async skip() { return "no_music" }
|
||||
}
|
||||
let ioRef = null
|
||||
const adminStub = {
|
||||
ADMIN_ROOM: "ADMIN", LOGS_ROOM: "ADMIN_LOGS",
|
||||
init(io) { ioRef = io }, attach() {},
|
||||
syncUserSockets(userId) {
|
||||
const user = usersStub.getUserById(userId)
|
||||
for (const s of ioRef.sockets.sockets.values()) {
|
||||
if (s.data.userId !== userId) continue
|
||||
for (const room of [...s.rooms]) if (guilds.has(room) && user.isBanned(room)) s.leave(room)
|
||||
}
|
||||
process.emit("USERS_UPDATE")
|
||||
},
|
||||
}
|
||||
const fakeLog = { LogType: class { constructor() { this.step = { init() {}, end() {} } } log() {} warn() {} error(m) { if (process.env.VERBOSE) console.log("[error]", m) } } }
|
||||
let reportResult = false
|
||||
let finderThrows = true
|
||||
const stubs = {
|
||||
"loguix": fakeLog,
|
||||
[norm(SRC + "/utils/SafeMetric")]: { getGuildMetrics: () => [], toSafeNumber: (v) => v },
|
||||
[norm(SRC + "/server/auth/Session")]: { checkSession: () => false, addSession: () => "sess", removeSession() {} },
|
||||
[norm(SRC + "/server/auth/User")]: usersStub,
|
||||
[norm(SRC + "/player/Player")]: { Player: FakePlayer, AllPlayers, offlineState: (g) => ({ v: 2, guildId: g, status: "offline" }), getPlayer: () => { throw new Error("getPlayer ne doit plus être appelé") }, getAllPlayers: () => [...AllPlayers.values()] },
|
||||
[norm(SRC + "/discord/Bot")]: { isReady: async () => true, getGuilds: () => guilds, getGuildMember: () => null, getMembersVoices: () => new Map(), getChannel: () => null, getGuildRoles: () => [] },
|
||||
[norm(SRC + "/server/auth/DiscordAuth")]: {},
|
||||
[norm(SRC + "/discord/ReportSender")]: { Report: class { async send() { return reportResult } } },
|
||||
[norm(SRC + "/player/Finder")]: { search: async () => { if (finderThrows) throw new Error("panne simulée"); return [] } },
|
||||
[norm(SRC + "/playlists/PlaylistManager")]: { syncYoutube: async () => ({ ok: true }) },
|
||||
[norm(SRC + "/playlists/History")]: { getPersonalHistory: () => [], addToPersonalHistory() {}, clearPersonalHistory: async () => {} },
|
||||
[norm(SRC + "/lyrics/Lyrics")]: {},
|
||||
[norm(SRC + "/discord/ServerSettings")]: { getSecureRole: () => null },
|
||||
[norm(SRC + "/discord/MediaBase")]: {},
|
||||
[norm(SRC + "/playlists/Google/OAuth2")]: { getStatus: (id) => ({ configured: false, linked: false, user: id }), isSyncing: () => false },
|
||||
[norm(SRC + "/utils/Database/Configuration")]: { getPort: () => PORT },
|
||||
[norm(SRC + "/player/List")]: { List: class { getPrevious() { return [] } } },
|
||||
[norm(SRC + "/server/Admin")]: adminStub,
|
||||
[norm(SRC + "/utils/AudioBufferCheck")]: { isAudioFile: () => false },
|
||||
[norm(SRC + "/player/Song")]: { Song: class {} },
|
||||
[norm(SRC + "/media/MediaInformation")]: {},
|
||||
}
|
||||
const origLoad = Module._load
|
||||
Module._load = function (request, parent) {
|
||||
if (stubs[request]) return stubs[request]
|
||||
if (request.startsWith(".") && parent) {
|
||||
const resolved = norm(path.resolve(path.dirname(parent.filename), request)).replace(/\.js$/, "")
|
||||
if (stubs[resolved]) return stubs[resolved]
|
||||
}
|
||||
return origLoad.apply(this, arguments)
|
||||
}
|
||||
|
||||
const Server = require(SRC + "/server/Server.js")
|
||||
assert.strictEqual(typeof Server.init, "function", "Server.init exporté")
|
||||
Server.init()
|
||||
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
||||
const clients = []
|
||||
function connect(token) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = ioClient("http://127.0.0.1:" + PORT, { auth: { token }, reconnection: false, transports: ["websocket"] })
|
||||
clients.push(s)
|
||||
s.once("/USER/READY", () => resolve(s))
|
||||
s.once("AUTH_ERROR", (e) => reject(new Error("AUTH_ERROR " + e)))
|
||||
setTimeout(() => reject(new Error("timeout connexion " + token)), 3000)
|
||||
})
|
||||
}
|
||||
function request(s, event, data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
s.once(event, resolve)
|
||||
s.emit(event, data)
|
||||
setTimeout(() => reject(new Error("pas de réponse " + event)), 3000)
|
||||
})
|
||||
}
|
||||
function serverSocket(client) { return ioRef.sockets.sockets.get(client.id) }
|
||||
|
||||
;(async () => {
|
||||
await sleep(100)
|
||||
const a1 = await connect("tA1"), a2 = await connect("tA2"), b = await connect("tB")
|
||||
|
||||
// --- Rooms conservées lors d'un changement de guilde ---
|
||||
serverSocket(a1).join("ADMIN_LOGS")
|
||||
assert.strictEqual(await request(a1, "/GUILD/JOIN", "g1"), true)
|
||||
assert.strictEqual(await request(a1, "/GUILD/JOIN", "g2"), true)
|
||||
const rooms = [...serverSocket(a1).rooms]
|
||||
assert.ok(rooms.includes("g2") && !rooms.includes("g1"), "guilde précédente quittée : " + rooms)
|
||||
for (const r of ["user:A", "ADMIN", "ADMIN_LOGS", a1.id]) assert.ok(rooms.includes(r), "room conservée : " + r)
|
||||
assert.strictEqual(await request(a1, "/GUILD/JOIN", "inconnue"), false)
|
||||
|
||||
// --- Présence multi-appareils ---
|
||||
await request(a1, "/GUILD/JOIN", "g1")
|
||||
await request(a2, "/GUILD/JOIN", "g1")
|
||||
await request(b, "/GUILD/JOIN", "g1")
|
||||
let members = await request(b, "/USERS/LIST")
|
||||
assert.deepStrictEqual(members.map(m => m.id).sort(), ["A", "B"], "A une seule fois")
|
||||
a1.disconnect()
|
||||
await sleep(150)
|
||||
members = await request(b, "/USERS/LIST")
|
||||
assert.ok(members.some(m => m.id === "A"), "A toujours présent (2e appareil)")
|
||||
const info = await request(b, "/GUILD/INFO")
|
||||
assert.deepStrictEqual(info.members.map(m => m.id), ["A"])
|
||||
a2.disconnect()
|
||||
await sleep(150)
|
||||
members = await request(b, "/USERS/LIST")
|
||||
assert.ok(!members.some(m => m.id === "A"), "A absent après la dernière déconnexion")
|
||||
|
||||
// --- PLAYER_STATE : jamais de création de player, /GUILD/UPDATE seulement au changement de connexion ---
|
||||
let guildUpdates = 0, playerUpdates = 0
|
||||
b.on("/GUILD/UPDATE", () => guildUpdates++)
|
||||
b.on("/PLAYER/UPDATE", () => playerUpdates++)
|
||||
process.emit("PLAYER_STATE", "g1")
|
||||
process.emit("PLAYERS_UPDATE")
|
||||
await sleep(100)
|
||||
assert.strictEqual(AllPlayers.size, 0, "aucun player créé")
|
||||
assert.strictEqual(playerUpdates, 1, "état hors-ligne envoyé à la room g1")
|
||||
const player = new FakePlayer("g1"); player.connected = true
|
||||
process.emit("PLAYER_STATE", "g1"); await sleep(50)
|
||||
process.emit("PLAYER_STATE", "g1"); await sleep(50)
|
||||
assert.strictEqual(guildUpdates, 2, "1re émission + passage à connecté uniquement (" + guildUpdates + ")")
|
||||
assert.strictEqual((await request(b, "/PLAYER/STATE")).status, "playing")
|
||||
|
||||
// --- SEEK : 0 accepté, négatif refusé, refus du player transmis ---
|
||||
assert.strictEqual(await request(b, "/PLAYER/SEEK", { time: 0 }), true)
|
||||
assert.strictEqual(await request(b, "/PLAYER/SEEK", 0), true)
|
||||
assert.deepStrictEqual(seekCalls, [0, 0])
|
||||
assert.strictEqual(await request(b, "/PLAYER/SEEK", { time: -1 }), false)
|
||||
assert.strictEqual(await request(b, "/PLAYER/SEEK", {}), false)
|
||||
player.queue.current = null
|
||||
assert.strictEqual(await request(b, "/PLAYER/SEEK", { time: 5 }), false, "pas de musique : false (pas de ReferenceError)")
|
||||
assert.strictEqual(await request(b, "/PLAYER/FORWARD"), "no_music")
|
||||
|
||||
// --- Exception dans un handler : réponse false, process intact ---
|
||||
assert.strictEqual(await request(b, "/SEARCH", "test"), false)
|
||||
// --- /REPORT : résultat réel de l'envoi ---
|
||||
assert.strictEqual(await request(b, "/REPORT", { level: "bug", desc: "x" }), false)
|
||||
reportResult = true
|
||||
assert.strictEqual(await request(b, "/REPORT", { level: "bug", desc: "x" }), true)
|
||||
|
||||
// --- /MOD/USERS/BAN (plus de UsersBySocket) + droits relus à chaque requête ---
|
||||
const c = await connect("tC")
|
||||
await request(c, "/GUILD/JOIN", "g1")
|
||||
assert.strictEqual(await request(c, "/MOD/USERS/BAN", "B"), true, "le propriétaire bannit B")
|
||||
await sleep(50)
|
||||
assert.ok(!serverSocket(b).rooms.has("g1"), "B a quitté la room g1")
|
||||
assert.strictEqual(await request(b, "/PLAYER/STATE"), false, "B banni : refus immédiat (utilisateur relu)")
|
||||
assert.strictEqual(await request(b, "/GUILD/JOIN", "g1"), false)
|
||||
assert.strictEqual(await request(c, "/MOD/USERS/BAN", "B"), true, "levée du ban")
|
||||
assert.strictEqual(await request(b, "/GUILD/JOIN", "g1"), true)
|
||||
|
||||
// --- Bannissement global en cours de session ---
|
||||
raw.B.labels.push("BAN")
|
||||
const authError = new Promise(r => b.once("AUTH_ERROR", r))
|
||||
b.emit("/USER/INFO")
|
||||
assert.strictEqual(await authError, "Vous êtes banni du serveur")
|
||||
await sleep(100)
|
||||
assert.strictEqual(b.connected, false)
|
||||
|
||||
// --- /GOOGLE/STATUS par ack ---
|
||||
const status = await new Promise(r => c.emit("/GOOGLE/STATUS", null, r))
|
||||
assert.strictEqual(status.user, "C")
|
||||
|
||||
await sleep(100)
|
||||
assert.deepStrictEqual(uncaught, [], "aucune exception non gérée")
|
||||
console.log("server-socket.test.js : OK")
|
||||
clients.forEach(s => s.disconnect())
|
||||
ioRef.close()
|
||||
setTimeout(() => process.exit(0), 100)
|
||||
})().catch((e) => { console.error(e); process.exit(1) })
|
||||
@@ -0,0 +1,155 @@
|
||||
// Test de bout en bout : vrai yt-dlp, vrai YouTube, sans proxy (WARP non démarré => route directe)
|
||||
// Usage : node stream.e2e.js [--no-update] [--no-cookies]
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { BACKEND, __glob, isolateData, check, done, req, chopinTmpDirs } = require('./_setup');
|
||||
const dataDir = isolateData('e2e');
|
||||
|
||||
const youtube = req('src/youtube');
|
||||
const { getStream, activeProcesses } = req('src/player/Method/Youtube.js');
|
||||
const warp = req('src/utils/WarpProxy');
|
||||
const { cookies, settings, ytdlp, diagnostics } = youtube;
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const TARGET = 200 * 1024;
|
||||
const LONG_URL = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||||
|
||||
function procCount(image) {
|
||||
if (process.platform !== 'win32') return null;
|
||||
try {
|
||||
const out = execFileSync('tasklist', ['/FI', `IMAGENAME eq ${image}`, '/NH'], { encoding: 'utf8', windowsHide: true });
|
||||
return out.split(/\r?\n/).filter(l => l.toLowerCase().startsWith(image.toLowerCase())).length;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
/**
|
||||
* Lit ~200 Ko puis annule via AbortController
|
||||
*/
|
||||
async function readThenAbort(label, seek, { expectRaw = false, url } = {}) {
|
||||
console.log(`\n# ${label}`);
|
||||
const controller = new AbortController();
|
||||
const progress = [];
|
||||
const t0 = Date.now();
|
||||
const song = { id: 'e2e', title: 'e2e', url: url || settings.get().testUrl, type: 'youtube' };
|
||||
const stream = await getStream(song, seek, { signal: controller.signal, guildId: 'e2e-guild', onProgress: (p) => progress.push(p) });
|
||||
const tStream = Date.now() - t0;
|
||||
console.log(` getStream -> ${stream ? 'flux' : 'null'} en ${tStream} ms, progression : ${JSON.stringify(progress)}`);
|
||||
console.log(` activeProcesses pendant la lecture : ${JSON.stringify([...activeProcesses.entries()].map(([g, p]) => ({ g, route: p.route, identity: p.identity, pid: !!p.pid })))}`);
|
||||
if (!stream) {
|
||||
console.log(' diagnostics :', JSON.stringify(diagnostics.getRecent(4), null, 0));
|
||||
return { ok: false };
|
||||
}
|
||||
check(`${label} : streamType ${expectRaw ? '"raw"' : 'conteneur'}`, expectRaw ? stream.streamType === 'raw' : stream.streamType === undefined);
|
||||
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
await new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, 60000);
|
||||
stream.on('data', (c) => {
|
||||
if (total >= TARGET) return;
|
||||
chunks.push(c);
|
||||
total += c.length;
|
||||
if (total >= TARGET) { clearTimeout(timer); resolve(); }
|
||||
});
|
||||
stream.on('close', () => { clearTimeout(timer); resolve(); });
|
||||
stream.on('error', () => {});
|
||||
});
|
||||
const buf = Buffer.concat(chunks);
|
||||
console.log(` ${total} octets lus en ${Date.now() - t0} ms`);
|
||||
check(`${label} : ~200 Ko reçus`, total >= TARGET, total);
|
||||
|
||||
if (expectRaw) {
|
||||
// PCM s16le stéréo : vérifie qu'il y a du signal (pas que des zéros)
|
||||
let sumSq = 0, n = 0, nonZero = 0;
|
||||
for (let i = 0; i + 1 < buf.length; i += 2) {
|
||||
const v = buf.readInt16LE(i);
|
||||
sumSq += v * v; n++;
|
||||
if (v !== 0) nonZero++;
|
||||
}
|
||||
const rms = Math.sqrt(sumSq / Math.max(1, n));
|
||||
console.log(` PCM : ${n} échantillons, RMS ${rms.toFixed(1)}, ${(100 * nonZero / Math.max(1, n)).toFixed(1)} % non nuls, ~${(n / 2 / 48000).toFixed(2)} s d'audio`);
|
||||
check(`${label} : PCM non silencieux`, rms > 10 && nonZero > n / 10);
|
||||
} else {
|
||||
// WebM (EBML 1A45DFA3) ou MP4 (ftyp)
|
||||
const magic = buf.subarray(0, 4).toString('hex');
|
||||
const isWebm = magic === '1a45dfa3';
|
||||
const isMp4 = buf.subarray(4, 8).toString() === 'ftyp';
|
||||
console.log(` conteneur : ${isWebm ? 'WebM' : isMp4 ? 'MP4' : 'inconnu (' + magic + ')'}`);
|
||||
check(`${label} : conteneur audio reconnu`, isWebm || isMp4);
|
||||
}
|
||||
|
||||
controller.abort();
|
||||
// Processus tués et copies de cookies supprimées
|
||||
let waited = 0;
|
||||
while ((ytdlp.activeCount() > 0 || activeProcesses.size > 0 || cookies.liveCopyCount() > 0) && waited < 5000) {
|
||||
await sleep(100); waited += 100;
|
||||
}
|
||||
console.log(` après abort (${waited} ms) : processus suivis ${ytdlp.activeCount()}, activeProcesses ${activeProcesses.size}, copies de cookies ${cookies.liveCopyCount()}, stream détruit ${stream.destroyed}`);
|
||||
check(`${label} : tous les processus terminés`, ytdlp.activeCount() === 0 && activeProcesses.size === 0);
|
||||
check(`${label} : flux détruit`, stream.destroyed === true);
|
||||
check(`${label} : aucune copie chopin-ytc-* restante`, chopinTmpDirs().length === 0 && cookies.liveCopyCount() === 0, chopinTmpDirs());
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log('routes WARP :', JSON.stringify(warp.getRoutes().map(r => r ? 'warp' : 'direct')));
|
||||
const bin = ytdlp.resolveBinary();
|
||||
const ff = ytdlp.resolveFfmpeg();
|
||||
console.log(`yt-dlp : ${bin.source} ${path.relative(BACKEND, bin.path) || bin.path}`);
|
||||
console.log(`ffmpeg : ${ff?.source} ${ff ? path.relative(BACKEND, ff.path) : '-'}`);
|
||||
console.log(`version : ${await ytdlp.getVersion({ force: true })}`);
|
||||
const before = { ytdlp: procCount('yt-dlp.exe'), ffmpeg: procCount('ffmpeg.exe') };
|
||||
console.log('processus système avant :', JSON.stringify(before));
|
||||
|
||||
if (!args.includes('--no-update')) {
|
||||
console.log('\n# Mise à jour (yt-dlp -U)');
|
||||
const up = await ytdlp.update({ reason: 'test e2e' });
|
||||
console.log(' ', JSON.stringify({ ok: up.ok, updatable: up.updatable, before: up.before, after: up.after, message: up.message }));
|
||||
console.log(' sortie :', up.output.split('\n').slice(-4).join(' | '));
|
||||
check('update : version connue après', Boolean(up.after));
|
||||
}
|
||||
|
||||
await readThenAbort('Lecture sans seek (anonyme, directe)', 0);
|
||||
// testUrl (Me at the zoo) ne dure que 19 s : seek 30 s sur une vidéo plus longue
|
||||
await readThenAbort('Seek 30 s (natif : yt-dlp -> ffmpeg -> PCM brut)', 30, { expectRaw: true, url: LONG_URL });
|
||||
|
||||
console.log('\n# Seek au-delà de la fin (testUrl, 19 s) : un seul essai, sans clients de repli');
|
||||
const n0 = diagnostics.getRecent(100).length;
|
||||
const past = await getStream({ id: 'e2e', title: 'e2e', url: settings.get().testUrl, type: 'youtube' }, 30, { guildId: 'e2e-guild' });
|
||||
const tries = diagnostics.getRecent(100).length - n0;
|
||||
check('seek après la fin : null en 1 tentative (final)', past === null && tries === 1 && diagnostics.getRecent(1)[0].cls === 'EMPTY_DOWNLOAD', { tries, last: diagnostics.getRecent(1)[0] });
|
||||
await sleep(2500);
|
||||
check('seek après la fin : processus terminés', ytdlp.activeCount() === 0, ytdlp.activeCount());
|
||||
|
||||
if (!args.includes('--no-cookies')) {
|
||||
// Anciens cookies (fichiers réels en lecture seule) importés dans le dossier isolé, mode "always"
|
||||
__glob.COOKIES = path.join(BACKEND, 'data', 'cookies.txt');
|
||||
__glob.COOKIES_JSON = path.join(BACKEND, 'data', 'cookies.json');
|
||||
const mig = cookies.migrateLegacy();
|
||||
console.log('\n# Cookies : migration', JSON.stringify(mig), '-> valides :', cookies.isValid());
|
||||
if (cookies.isValid()) {
|
||||
settings.update({ cookiesMode: 'always' }, { username: 'e2e' });
|
||||
await readThenAbort('Lecture avec compte (mode always, copie temporaire des cookies)', 0);
|
||||
const st = cookies.getStatus();
|
||||
console.log(' statut cookies après lecture :', JSON.stringify({ valid: st.valid, invalidatedAt: !!st.invalidatedAt, invalidReason: st.invalidReason }));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n# Diagnostics');
|
||||
for (const e of diagnostics.getRecent(20).reverse()) {
|
||||
console.log(` ${e.outcome.padEnd(7)} ${e.route}/${e.identity}${e.clients ? '/' + e.clients : ''}${e.seek ? '/' + e.seek : ''} ${e.ms} ms ${e.cls || ''} ${e.message || ''}`);
|
||||
}
|
||||
const s = diagnostics.getSummary();
|
||||
console.log(' résumé :', JSON.stringify({ ok: s.ok, failed: s.failed, aborted: s.aborted, byClass: s.byClass, consecutiveBlocks: s.consecutiveBlocks }));
|
||||
await sleep(1000);
|
||||
console.log(' getStatus().activeProcesses :', youtube.getStatus().activeProcesses);
|
||||
const after = { ytdlp: procCount('yt-dlp.exe'), ffmpeg: procCount('ffmpeg.exe') };
|
||||
console.log('processus système après :', JSON.stringify(after));
|
||||
check('aucun yt-dlp.exe / ffmpeg.exe orphelin', after.ytdlp === null || (after.ytdlp <= before.ytdlp && after.ffmpeg <= before.ffmpeg), { before, after });
|
||||
check('aucune copie chopin-ytc-* (fin)', chopinTmpDirs().length === 0, chopinTmpDirs());
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
done();
|
||||
// Timers unref : le process se termine seul
|
||||
})().catch((e) => { console.error(e); process.exitCode = 1; });
|
||||
@@ -0,0 +1,55 @@
|
||||
// Cas limites de getStream : binaire absent, annulation avant les données (avec copie de cookies), URL refusée
|
||||
const fs = require('fs');
|
||||
const { SCRATCH, __glob, isolateData, check, done, req, chopinTmpDirs } = require('./_setup');
|
||||
const dataDir = isolateData('edge');
|
||||
const youtube = req('src/youtube');
|
||||
const { getStream, activeProcesses } = req('src/player/Method/Youtube.js');
|
||||
const { cookies, settings, ytdlp, diagnostics } = youtube;
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
const song = { id: 'edge', title: 'edge', url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', type: 'youtube' };
|
||||
|
||||
(async () => {
|
||||
console.log('# URL refusée');
|
||||
check('URL "--exec=..." refusée', (await getStream({ url: '--exec=calc' })) === null);
|
||||
|
||||
console.log('# Binaire absent');
|
||||
const savedPath = process.env.PATH, savedRoot = __glob.ROOT;
|
||||
process.env.PATH = '';
|
||||
__glob.ROOT = SCRATCH + '/';
|
||||
const bin = ytdlp.resolveBinary({ force: true });
|
||||
check('resolveBinary -> missing', bin.source === 'missing', bin);
|
||||
const n0 = diagnostics.getRecent(100).length;
|
||||
const r1 = await getStream(song, 0, { guildId: 'g' });
|
||||
const last = diagnostics.getRecent(1)[0];
|
||||
check('binaire absent : null, 1 tentative, BINARY_MISSING', r1 === null && diagnostics.getRecent(100).length - n0 === 1 && last.cls === 'BINARY_MISSING', last);
|
||||
process.env.PATH = savedPath;
|
||||
__glob.ROOT = savedRoot;
|
||||
check('binaire retrouvé ensuite', ytdlp.resolveBinary({ force: true }).source !== 'missing');
|
||||
|
||||
console.log('# Annulation avant les données (avec compte : copie de cookies)');
|
||||
const T = (...f) => f.join('\t');
|
||||
cookies.importCookies([T('.youtube.com', 'TRUE', '/', 'TRUE', 1830000000, 'LOGIN_INFO', 'fake'), T('.youtube.com', 'TRUE', '/', 'TRUE', 1830000000, 'SAPISID', 'fake')].join('\n'));
|
||||
settings.update({ cookiesMode: 'always' });
|
||||
const controller = new AbortController();
|
||||
const p = getStream(song, 0, { signal: controller.signal, guildId: 'g2' });
|
||||
await sleep(400);
|
||||
const copiesDuring = cookies.liveCopyCount();
|
||||
const procDuring = ytdlp.activeCount();
|
||||
controller.abort();
|
||||
const r2 = await p;
|
||||
console.log(` pendant : copies ${copiesDuring}, processus ${procDuring}`);
|
||||
check('annulation : null', r2 === null);
|
||||
check('annulation : diagnostics "aborted"', diagnostics.getRecent(1)[0].outcome === 'aborted');
|
||||
let waited = 0;
|
||||
while ((ytdlp.activeCount() || cookies.liveCopyCount() || chopinTmpDirs().length) && waited < 5000) { await sleep(100); waited += 100; }
|
||||
console.log(` après ${waited} ms : processus ${ytdlp.activeCount()}, copies ${cookies.liveCopyCount()}, dossiers ${chopinTmpDirs().length}, activeProcesses ${activeProcesses.size}`);
|
||||
check('annulation : la copie existait pendant la tentative', copiesDuring === 1 && procDuring >= 1);
|
||||
check('annulation : processus tués, copie supprimée', ytdlp.activeCount() === 0 && cookies.liveCopyCount() === 0 && chopinTmpDirs().length === 0 && activeProcesses.size === 0);
|
||||
|
||||
console.log('# Signal déjà annulé');
|
||||
const c2 = new AbortController(); c2.abort();
|
||||
check('signal déjà annulé : null sans processus', (await getStream(song, 0, { signal: c2.signal })) === null && ytdlp.activeCount() === 0);
|
||||
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
done();
|
||||
})().catch((e) => { console.error(e); process.exitCode = 1; });
|
||||
@@ -0,0 +1,20 @@
|
||||
// Détecte les identifiants référencés sans liaison (ReferenceError potentielle) via @babel/traverse
|
||||
const FE = require("path").resolve(__dirname, "../../../frontend/node_modules") + "/"
|
||||
const parser = require(FE + "@babel/parser")
|
||||
const traverse = require(FE + "@babel/traverse").default
|
||||
const fs = require("fs")
|
||||
const globals = new Set(Object.getOwnPropertyNames(globalThis).concat(["require","module","exports","__dirname","__filename","process","Buffer","console","setTimeout","clearTimeout","setInterval","clearInterval","setImmediate","URL","fetch","AbortController","structuredClone","undefined","arguments"]))
|
||||
let bad = 0
|
||||
for (const file of process.argv.slice(2)) {
|
||||
const ast = parser.parse(fs.readFileSync(file, "utf8"), { sourceType: "script", allowReturnOutsideFunction: true })
|
||||
traverse(ast, {
|
||||
ReferencedIdentifier(p) {
|
||||
const name = p.node.name
|
||||
if (p.scope.hasBinding(name, true) || globals.has(name)) return
|
||||
if (p.parentPath.isMemberExpression({ property: p.node }) && !p.parentPath.node.computed) return
|
||||
bad++
|
||||
console.log(file.split("/").pop() + ":" + p.node.loc.start.line + " " + name)
|
||||
}
|
||||
})
|
||||
}
|
||||
console.log(bad ? bad + " identifiant(s) non lié(s)" : "aucun identifiant non lié")
|
||||
@@ -0,0 +1,5 @@
|
||||
const { isolateData, req } = require('./_setup');
|
||||
isolateData('upd');
|
||||
const ytdlp = req('src/youtube/ytdlp');
|
||||
const a = ytdlp.update({ reason: 'test' }), b = ytdlp.update();
|
||||
a.then(r => console.log('single-flight:', a === b, JSON.stringify({ ok: r.ok, updatable: r.updatable, before: r.before, after: r.after, message: r.message })));
|
||||
Reference in new issue
Block a user