Version 2.1.0 - Update UI
This commit is contained in:
18 files changed
+3508
-330
No files matched your search
+2
-2
@@ -3,8 +3,8 @@
|
||||
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`
|
||||
- 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`, `guild-playlists.test.js` (playlists personnelles / partagées, dossier data temporaire), `stream.edge.js`, `lyrics.test.js` (fetch simulé)
|
||||
- Réseau (YouTube / GitHub / LRCLIB réels) : `stream.e2e.js`, `check.e2e.js`, `metadata.live.test.js`, `avatar.live.test.js`, `update.check.js`, `lyrics.live.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).
|
||||
@@ -175,7 +175,7 @@ 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'];
|
||||
const expected = ['/ADMIN/CONFIG/GET', '/ADMIN/CONFIG/SET', '/ADMIN/CONFIG/SPOTIFY/TEST', '/ADMIN/WARP/CONFIG', '/ADMIN/PLAYLISTS/LIST', '/ADMIN/PLAYLISTS/DELETE', '/ADMIN/PLAYLISTS/GUILDS', '/ADMIN/GUILDS/SET_SHARED_PLAYLISTS'];
|
||||
check('routes enregistrées', expected.every(r => handlers.has(r)), expected.filter(r => !handlers.has(r)));
|
||||
|
||||
console.log('# Configuration : validateur pur');
|
||||
@@ -316,6 +316,7 @@ process.on('PLAYLISTS_UPDATE', (userId) => playlistEvents.push(userId));
|
||||
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)));
|
||||
check('dates de création / modification exposées', lists.every(p => 'createdAt' in p && 'updatedAt' 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' });
|
||||
|
||||
@@ -25,6 +25,7 @@ const stubs = {
|
||||
[norm(SRC + '/discord/Bot')]: { getGuilds: () => guilds, getClient: () => null, isReady: () => false },
|
||||
[norm(SRC + '/discord/ServerSettings')]: {},
|
||||
[norm(SRC + '/playlists/PlaylistManager')]: {},
|
||||
[norm(SRC + '/playlists/GuildPlaylists')]: { countPlaylists: () => 0, getPlaylists: () => [], getPlaylist: () => null, listGuildIds: () => [] },
|
||||
[norm(SRC + '/utils/SafeMetric')]: { getAll: () => [], get: () => 0 },
|
||||
[norm(SRC + '/utils/LogReader')]: { init() {}, getCurrentName: () => 'x.log', isInterrupted: async () => false },
|
||||
[norm(SRC + '/utils/Maintenance')]: {},
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
// Playlists : horodatages / réparation au démarrage, ordre, déplacements, limites (PlaylistManager),
|
||||
// playlists partagées par serveur (GuildPlaylists), option du serveur (ServerSettings) et routes d'administration
|
||||
// associées (Admin.js). Vrais modules et vraie base JSON, mais TOUS les chemins de __glob qui pointent vers
|
||||
// backend/data sont redirigés vers un dossier temporaire. Aucun appel réseau (Finder / Spotify / Google simulés).
|
||||
const Module = require('module');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { BACKEND, SCRATCH, __glob, isolateData, check, done } = 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 realGuildFile = path.join(DATA_DIR, 'guild_playlists.json');
|
||||
const realGuildFileExisted = fs.existsSync(realGuildFile);
|
||||
const tmp = fs.mkdtempSync(path.join(SCRATCH, 'guild-playlists-'));
|
||||
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('guild-playlists-yt');
|
||||
const safePaths = Object.entries(__glob).every(([k, v]) => typeof v !== 'string' || !path.resolve(v).toLowerCase().startsWith(DATA_DIR.toLowerCase()));
|
||||
if (!safePaths || !__glob.GUILD_PLAYLISTS_DB || path.dirname(__glob.GUILD_PLAYLISTS_DB) !== tmp) {
|
||||
console.error('Chemins non isolés : arrêt');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const USER = '111111111111111111';
|
||||
const OTHER = '444444444444444444';
|
||||
const GUILD = '222222222222222222';
|
||||
const GUILD_UNKNOWN = '999999999999999999';
|
||||
const LEGACY_ID = '1754933300538';
|
||||
|
||||
// --- Fichiers synthétiques (anciennes entrées sans dates, identifiant numérique, durée en texte) ---
|
||||
const ytSong = (id, extra = {}) => ({ type: 'youtube', url: `https://www.youtube.com/watch?v=${id}`, id, title: 'Titre ' + id, author: 'Artiste', duration: 100, ...extra });
|
||||
fs.writeFileSync(__glob.PLAYLISTFILE, JSON.stringify({
|
||||
[USER]: [
|
||||
{ title: 'Ancienne', playlistId: LEGACY_ID, url: null, songs: [ytSong('a', { duration: '120' })], type: 'playlist' },
|
||||
{ title: 'Numérique', playlistId: 1754933300999, url: null, songs: [], type: 'playlist' },
|
||||
{ title: 'Sans id', url: 'https://www.youtube.com/playlist?list=PLx', songs: [], type: 'youtube', syncedAt: '2026-01-02T03:04:05.000Z' }
|
||||
]
|
||||
}));
|
||||
fs.writeFileSync(__glob.GUILD_PLAYLISTS_DB, JSON.stringify({
|
||||
[GUILD]: [{ title: 'Partagée ancienne', playlistId: 1700000000000, url: null, songs: [ytSong('g', { duration: '30' })], type: 'playlist' }]
|
||||
}));
|
||||
fs.writeFileSync(__glob.SERVER_DB, JSON.stringify({ [GUILD]: { secureRole: null } }));
|
||||
|
||||
const SRC = path.join(BACKEND, 'src');
|
||||
const norm = (p) => p.replace(/\\/g, '/').toLowerCase().replace(/\.js$/, '');
|
||||
|
||||
// --- Stubs (réseau, Discord, administration) ---
|
||||
let finderCalls = 0;
|
||||
const finderStub = {
|
||||
search: async (url, multiple, forceType) => {
|
||||
finderCalls++;
|
||||
if (forceType !== 'PLAYLIST' || url.includes('echec')) return null;
|
||||
const { Playlist } = require(path.join(SRC, 'playlists', 'Playlist.js'));
|
||||
const songs = [ytSong('r1'), ytSong('r2', { duration: 200 })];
|
||||
const playlist = new Playlist('Distante', url, 'Chaîne', 'https://www.youtube.com/channel/UCx', songs, 'https://i.ytimg.com/vi/r1/hq.jpg');
|
||||
playlist.type = 'youtube';
|
||||
return playlist;
|
||||
}
|
||||
};
|
||||
const admin = { identity: { id: USER, username: 'testadmin', global_name: 'Admin', avatar: null }, labels: ['ADMIN'], isAdmin: () => true, isFullBanned: () => false };
|
||||
const guilds = new Map([[GUILD, { id: GUILD, name: 'Serveur Test', icon: null, owner: OTHER, allMembers: [USER, OTHER] }]]);
|
||||
const stubs = {
|
||||
[norm(SRC + '/player/Finder')]: finderStub,
|
||||
[norm(SRC + '/media/SpotifyInformation')]: { getTracks: async (p) => p },
|
||||
[norm(SRC + '/playlists/Google/YoutubeList')]: {},
|
||||
[norm(SRC + '/playlists/Google/OAuth2')]: { isLinked: () => false },
|
||||
[norm(SRC + '/media/MediaInformation')]: {},
|
||||
[norm(SRC + '/server/auth/User')]: { getUserById: (id) => id === USER ? 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: () => ({ guilds: { cache: new Map() } }), getChannel: () => null, getGuildRoles: () => [] },
|
||||
[norm(SRC + '/discord/MediaBase')]: {},
|
||||
[norm(SRC + '/utils/WarpProxy')]: {},
|
||||
[norm(SRC + '/utils/LogReader')]: { init() {} },
|
||||
[norm(SRC + '/utils/Maintenance')]: {},
|
||||
[norm(SRC + '/utils/Database/Configuration')]: { getMediaGuildId: () => '', getMediaChannelId: () => '' },
|
||||
[norm(SRC + '/youtube')]: {},
|
||||
[norm(SRC + '/player/Method/Youtube')]: { activeProcesses: new Map() },
|
||||
};
|
||||
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 readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
const PlaylistManager = require(path.join(SRC, 'playlists', 'PlaylistManager.js'));
|
||||
const GuildPlaylists = require(path.join(SRC, 'playlists', 'GuildPlaylists.js'));
|
||||
const ServerSettings = require(path.join(SRC, 'discord', 'ServerSettings.js'));
|
||||
const utils = require(path.join(SRC, 'playlists', 'PlaylistUtils.js'));
|
||||
const Admin = require(path.join(SRC, 'server', 'Admin.js'));
|
||||
|
||||
const guildEvents = [];
|
||||
process.on('GUILD_PLAYLISTS_UPDATE', (guildId) => guildEvents.push(guildId));
|
||||
|
||||
(async () => {
|
||||
console.log('# PlaylistManager : réparation au démarrage');
|
||||
let disk = readJson(__glob.PLAYLISTFILE)[USER];
|
||||
check('date déduite de l\'identifiant', disk[0].createdAt === new Date(Number(LEGACY_ID)).toISOString() && disk[0].updatedAt === disk[0].createdAt, disk[0]);
|
||||
check('identifiant numérique converti en chaîne + daté', disk[1].playlistId === '1754933300999' && disk[1].createdAt === new Date(1754933300999).toISOString());
|
||||
check('identifiant attribué + date de synchro', typeof disk[2].playlistId === 'string' && disk[2].createdAt === '2026-01-02T03:04:05.000Z');
|
||||
check('durée en texte réparée', disk[0].songs[0].duration === 120 && disk[0].duration === 120);
|
||||
|
||||
console.log('# PlaylistManager : lecture sans écriture');
|
||||
const before = fs.readFileSync(__glob.PLAYLISTFILE, 'utf-8');
|
||||
const empty = PlaylistManager.getPlaylistsOfUser(OTHER);
|
||||
check('utilisateur inconnu : tableau vide', Array.isArray(empty) && empty.length === 0);
|
||||
check('aucune clé créée sur lecture', fs.readFileSync(__glob.PLAYLISTFILE, 'utf-8') === before && !(OTHER in readJson(__glob.PLAYLISTFILE)));
|
||||
check('playlist inconnue : null', PlaylistManager.getPlaylistOfUser(OTHER, '1') === null);
|
||||
|
||||
console.log('# PlaylistManager : création');
|
||||
const manual = await PlaylistManager.addPlaylist(USER, 'Ma liste', null, 'testadmin', USER, null);
|
||||
check('playlist vide créée', manual && manual.title === 'Ma liste' && manual.type === 'playlist' && Array.isArray(manual.songs));
|
||||
check('avatar par défaut si aucun avatar', /^https:\/\/cdn\.discordapp\.com\/embed\/avatars\/[0-5]\.png$/.test(manual.authorAvatar), manual.authorAvatar);
|
||||
check('authorId = profil Discord', manual.authorId === `https://discord.com/users/${USER}`);
|
||||
check('createdAt = updatedAt (ISO)', utils.isValidIso(manual.createdAt) && manual.createdAt === manual.updatedAt);
|
||||
const withAvatar = utils.discordAvatarUrl(USER, 'a_0123456789abcdef');
|
||||
check('avatar animé en gif', withAvatar === `https://cdn.discordapp.com/avatars/${USER}/a_0123456789abcdef.gif?size=128`);
|
||||
const imported = await PlaylistManager.addPlaylist(USER, 'Nom perso', 'https://www.youtube.com/playlist?list=PLabc', 'testadmin', USER, null);
|
||||
check('import : nom choisi prioritaire', imported && imported.title === 'Nom perso' && imported.customTitle === true && imported.songs.length === 2 && imported.duration === 300, imported && { title: imported.title, d: imported.duration });
|
||||
const importedPlain = await PlaylistManager.addPlaylist(USER, 'https://www.youtube.com/playlist?list=PLdef', 'https://www.youtube.com/playlist?list=PLdef', 'testadmin', USER, null);
|
||||
check('import : nom identique au lien ignoré', importedPlain && importedPlain.title === 'Distante' && !importedPlain.customTitle);
|
||||
check('import en échec : null', await PlaylistManager.addPlaylist(USER, null, 'https://www.youtube.com/playlist?list=echec', 'testadmin', USER, null) === null);
|
||||
check('création sans nom ni lien : null', await PlaylistManager.addPlaylist(USER, null, null, 'testadmin', USER, null) === null);
|
||||
|
||||
console.log('# PlaylistManager : musiques');
|
||||
const pid = manual.playlistId;
|
||||
manual.updatedAt = '2020-01-01T00:00:00.000Z';
|
||||
let res = PlaylistManager.addSong(USER, pid, ytSong('a'));
|
||||
check('ajout + updatedAt', res && res.songs.length === 1 && res.updatedAt !== '2020-01-01T00:00:00.000Z');
|
||||
check('ajout en JSON accepté', PlaylistManager.addSong(USER, pid, JSON.stringify(ytSong('b'))) !== null);
|
||||
PlaylistManager.addSong(USER, pid, ytSong('a'));
|
||||
check('musique invalide refusée', PlaylistManager.addSong(USER, pid, { type: 'evil', url: 'http://127.0.0.1/x', id: 'x', title: 'x' }) === null);
|
||||
check('hôte non autorisé refusé', PlaylistManager.addSong(USER, pid, { type: 'youtube', url: 'https://evil.example/watch?v=x', id: 'x', title: 'x' }) === null);
|
||||
check('playlist inconnue refusée', PlaylistManager.addSong(USER, 'nope', ytSong('c')) === null);
|
||||
// [a, b, a] : l'index exact retire la bonne occurrence
|
||||
res = PlaylistManager.removeSong(USER, pid, 'a', 2);
|
||||
check('retrait par index exact', res && res.songs.map(s => s.id).join(',') === 'a,b', res?.songs?.map(s => s.id));
|
||||
PlaylistManager.addSong(USER, pid, ytSong('c'));
|
||||
res = PlaylistManager.removeSong(USER, pid, 'c', 0);
|
||||
check('index incohérent : première occurrence', res && res.songs.map(s => s.id).join(',') === 'a,b');
|
||||
check('musique absente : null', PlaylistManager.removeSong(USER, pid, 'zzz') === null);
|
||||
PlaylistManager.addSong(USER, pid, ytSong('c'));
|
||||
res = PlaylistManager.moveSong(USER, pid, 0, 2);
|
||||
check('déplacement', res && res.songs.map(s => s.id).join(',') === 'b,c,a');
|
||||
check('déplacement hors limites : null', PlaylistManager.moveSong(USER, pid, 0, 3) === null && PlaylistManager.moveSong(USER, pid, -1, 0) === null);
|
||||
check('durée recalculée', manual.duration === 300);
|
||||
// Limite de 5000 titres
|
||||
const big = PlaylistManager.getPlaylistOfUser(USER, pid);
|
||||
const saved = big.songs;
|
||||
big.songs = Array.from({ length: utils.MAX_SONGS }, (_, i) => ({ ...saved[0], id: 'x' + i }));
|
||||
check('limite de 5000 titres', PlaylistManager.addSong(USER, pid, ytSong('d')) === null);
|
||||
big.songs = saved;
|
||||
|
||||
console.log('# PlaylistManager : ordre, renommage, actualisation, copie');
|
||||
const ids = PlaylistManager.getPlaylistsOfUser(USER).map(p => p.playlistId);
|
||||
check('ordre : refus si pas un tableau', PlaylistManager.reorderPlaylists(USER, 'x') === false);
|
||||
check('ordre appliqué', PlaylistManager.reorderPlaylists(USER, [ids[3], 'inconnu', ids[0], ids[3], 42]) === true);
|
||||
const order = PlaylistManager.getPlaylistsOfUser(USER).map(p => p.playlistId);
|
||||
check('connus d\'abord, sans doublon, oubliés à la suite', order.join() === [ids[3], ids[0], ids[1], ids[2], ...ids.slice(4)].join(), order);
|
||||
check('ordre enregistré', readJson(__glob.PLAYLISTFILE)[USER].map(p => p.playlistId).join() === order.join());
|
||||
check('ordre pour un utilisateur sans playlist : aucune écriture', PlaylistManager.reorderPlaylists(OTHER, []) === true && !(OTHER in readJson(__glob.PLAYLISTFILE)));
|
||||
const renamed = PlaylistManager.renamePlaylist(USER, importedPlain.playlistId, 'Renommée');
|
||||
check('renommage d\'un import : titre conservé à l\'actualisation', renamed.customTitle === true);
|
||||
const createdAt = importedPlain.createdAt;
|
||||
renamed.updatedAt = '2020-01-01T00:00:00.000Z';
|
||||
const refreshed = await PlaylistManager.refreshPlaylist(USER, importedPlain.playlistId);
|
||||
check('actualisation : titre, id, création conservés', refreshed && refreshed.title === 'Renommée' && refreshed.playlistId === importedPlain.playlistId && refreshed.createdAt === createdAt && refreshed.updatedAt !== '2020-01-01T00:00:00.000Z', refreshed && { t: refreshed.title, c: refreshed.createdAt });
|
||||
check('actualisation : même position', PlaylistManager.getPlaylistsOfUser(USER).findIndex(p => p.playlistId === importedPlain.playlistId) === order.indexOf(importedPlain.playlistId));
|
||||
const syncedLike = { ...PlaylistManager.getPlaylistOfUser(USER, LEGACY_ID), source: 'google', scope: 'guild', guildId: GUILD, createdBy: { id: OTHER } };
|
||||
const copy = PlaylistManager.addPlaylistCopy(OTHER, syncedLike);
|
||||
check('copie détachée', copy && !copy.source && !copy.scope && !copy.guildId && !copy.createdBy && copy.createdAt && copy.playlistId);
|
||||
check('copie : clé créée à l\'écriture', readJson(__glob.PLAYLISTFILE)[OTHER].length === 1);
|
||||
|
||||
console.log('# PlaylistManager : limite de playlists personnelles');
|
||||
const otherList = PlaylistManager.getPlaylistsOfUser(OTHER);
|
||||
const otherLength = otherList.length;
|
||||
for (let i = otherLength; i < PlaylistManager.MAX_PLAYLISTS; i++) otherList.push({ playlistId: 'p' + i, title: 'x' + i, songs: [] });
|
||||
const callsBefore = finderCalls;
|
||||
check('limite : hasRoomForPlaylist faux', PlaylistManager.hasRoomForPlaylist(OTHER) === false && PlaylistManager.hasRoomForPlaylist(USER) === true);
|
||||
check('limite : création refusée', await PlaylistManager.addPlaylist(OTHER, 'Trop', null, 'membre', OTHER, null) === null);
|
||||
check('limite : import refusé sans appel réseau', await PlaylistManager.addPlaylist(OTHER, null, 'https://www.youtube.com/playlist?list=PLzz', 'membre', OTHER, null) === null && finderCalls === callsBefore);
|
||||
check('limite : copie refusée', PlaylistManager.addPlaylistCopy(OTHER, syncedLike) === null && PlaylistManager.copyPlaylist(USER, OTHER, LEGACY_ID) === null);
|
||||
otherList.splice(otherLength);
|
||||
check('limite levée après suppression', PlaylistManager.hasRoomForPlaylist(OTHER) === true);
|
||||
|
||||
console.log('# ServerSettings : option des playlists partagées');
|
||||
const serversBefore = fs.readFileSync(__glob.SERVER_DB, 'utf-8');
|
||||
check('valeurs par défaut (ancienne entrée)', JSON.stringify(ServerSettings.getSharedPlaylists(GUILD)) === JSON.stringify({ enabled: false, editors: 'members' }));
|
||||
check('serveur inconnu : défauts', ServerSettings.getSharedPlaylists(GUILD_UNKNOWN).enabled === false && ServerSettings.getSharedPlaylists(undefined).editors === 'members');
|
||||
check('lecture sans écriture', fs.readFileSync(__glob.SERVER_DB, 'utf-8') === serversBefore);
|
||||
check('identifiant invalide refusé', ServerSettings.setSharedPlaylists('__proto__', { enabled: true }) === null && ServerSettings.setSharedPlaylists('abc', { enabled: true }) === null);
|
||||
check('patch vide refusé', ServerSettings.setSharedPlaylists(GUILD, {}) === null);
|
||||
check('enabled non booléen refusé', ServerSettings.setSharedPlaylists(GUILD, { enabled: 'oui' }) === null);
|
||||
check('editors inconnu refusé', ServerSettings.setSharedPlaylists(GUILD, { editors: 'all' }) === null);
|
||||
check('aucune écriture sur refus', fs.readFileSync(__glob.SERVER_DB, 'utf-8') === serversBefore);
|
||||
let settings = ServerSettings.setSharedPlaylists(GUILD, { enabled: true });
|
||||
check('activation', settings && settings.enabled === true && settings.editors === 'members');
|
||||
settings = ServerSettings.setSharedPlaylists(GUILD, { editors: 'mods' });
|
||||
check('champ absent conservé', settings.enabled === true && settings.editors === 'mods');
|
||||
let serversDisk = readJson(__glob.SERVER_DB)[GUILD];
|
||||
check('enregistré, rôle sécurisé conservé', serversDisk.secureRole === null && serversDisk.sharedPlaylists.enabled === true && serversDisk.sharedPlaylists.editors === 'mods');
|
||||
check('getSecureRole intact', ServerSettings.getSecureRole(GUILD) === null);
|
||||
|
||||
console.log('# GuildPlaylists : réparation et lecture');
|
||||
const legacyGuild = GuildPlaylists.getPlaylists(GUILD)[0];
|
||||
check('réparation : id chaîne, dates, rattachement', legacyGuild.playlistId === '1700000000000' && legacyGuild.createdAt === new Date(1700000000000).toISOString() && legacyGuild.scope === 'guild' && legacyGuild.guildId === GUILD);
|
||||
check('réparation : durée', legacyGuild.duration === 30);
|
||||
const guildBefore = fs.readFileSync(__glob.GUILD_PLAYLISTS_DB, 'utf-8');
|
||||
check('serveur inconnu : vide sans écriture', GuildPlaylists.getPlaylists(GUILD_UNKNOWN).length === 0 && GuildPlaylists.getPlaylist(GUILD_UNKNOWN, '1') === null && GuildPlaylists.countPlaylists(GUILD_UNKNOWN) === 0);
|
||||
check('reorder sur serveur inconnu : aucune écriture', GuildPlaylists.reorder(GUILD_UNKNOWN, []) === true);
|
||||
check('fichier inchangé après lectures', fs.readFileSync(__glob.GUILD_PLAYLISTS_DB, 'utf-8') === guildBefore);
|
||||
|
||||
console.log('# GuildPlaylists : création et modifications');
|
||||
const actor = { id: OTHER, username: 'membre', global_name: 'Membre', avatar: null, tokens: ['secret'] };
|
||||
let g = await GuildPlaylists.createPlaylist(GUILD, { name: 'Soirée' }, actor);
|
||||
check('création', g.ok && g.playlist.scope === 'guild' && g.playlist.guildId === GUILD && g.playlist.title === 'Soirée');
|
||||
check('createdBy sans jeton', JSON.stringify(g.playlist.createdBy) === JSON.stringify({ id: OTHER, username: 'membre', global_name: 'Membre', avatar: null }) && g.playlist.updatedBy.id === OTHER);
|
||||
const gid = g.playlist.playlistId;
|
||||
check('identifiant unique dans le serveur', GuildPlaylists.getPlaylists(GUILD).filter(p => p.playlistId === gid).length === 1);
|
||||
check('création sans nom ni lien refusée', (await GuildPlaylists.createPlaylist(GUILD, {}, actor)).error === 'INVALID_PARAMS');
|
||||
check('serveur invalide refusé', (await GuildPlaylists.createPlaylist('abc', { name: 'x' }, actor)).error === 'INVALID_PARAMS');
|
||||
check('import en échec : FAILED', (await GuildPlaylists.createPlaylist(GUILD, { url: 'https://www.youtube.com/playlist?list=echec' }, actor)).error === 'FAILED');
|
||||
const gImported = await GuildPlaylists.createPlaylist(GUILD, { url: 'https://www.youtube.com/playlist?list=PLg' }, admin);
|
||||
check('import partagé', gImported.ok && gImported.playlist.title === 'Distante' && gImported.playlist.createdBy.id === USER && gImported.playlist.songs.length === 2);
|
||||
|
||||
g = GuildPlaylists.addSong(GUILD, gid, ytSong('s1'), admin);
|
||||
check('ajout + updatedBy', g.ok && g.playlist.songs.length === 1 && g.playlist.updatedBy.id === USER);
|
||||
check('musique invalide : INVALID_PARAMS', GuildPlaylists.addSong(GUILD, gid, { type: 'x' }, admin).error === 'INVALID_PARAMS');
|
||||
check('playlist inconnue : NOT_FOUND', GuildPlaylists.addSong(GUILD, 'nope', ytSong('s1'), admin).error === 'NOT_FOUND');
|
||||
GuildPlaylists.addSong(GUILD, gid, ytSong('s2'), admin);
|
||||
GuildPlaylists.addSong(GUILD, gid, ytSong('s1'), admin);
|
||||
g = GuildPlaylists.removeSong(GUILD, gid, 's1', 2, actor);
|
||||
check('retrait par index exact', g.ok && g.playlist.songs.map(s => s.id).join() === 's1,s2' && g.playlist.updatedBy.id === OTHER);
|
||||
check('retrait absent : NOT_FOUND', GuildPlaylists.removeSong(GUILD, gid, 'zz', undefined, actor).error === 'NOT_FOUND');
|
||||
g = GuildPlaylists.moveSong(GUILD, gid, 1, 0, actor);
|
||||
check('déplacement', g.ok && g.playlist.songs.map(s => s.id).join() === 's2,s1');
|
||||
check('déplacement invalide', GuildPlaylists.moveSong(GUILD, gid, 0, 9, actor).error === 'INVALID_PARAMS');
|
||||
g = GuildPlaylists.renamePlaylist(GUILD, gid, 'Soirée 2', actor);
|
||||
check('renommage', g.ok && g.playlist.title === 'Soirée 2');
|
||||
const list = GuildPlaylists.getPlaylists(GUILD).map(p => p.playlistId);
|
||||
check('ordre commun', GuildPlaylists.reorder(GUILD, [list[2], list[0]]) && GuildPlaylists.getPlaylists(GUILD).map(p => p.playlistId).join() === [list[2], list[0], list[1]].join());
|
||||
check('ordre invalide', GuildPlaylists.reorder(GUILD, 'x') === false);
|
||||
|
||||
check('actualisation d\'une playlist manuelle refusée', (await GuildPlaylists.refreshPlaylist(GUILD, gid, actor)).error === 'INVALID_PARAMS');
|
||||
const importedCreated = gImported.playlist.createdAt;
|
||||
g = await GuildPlaylists.refreshPlaylist(GUILD, gImported.playlist.playlistId, actor);
|
||||
check('actualisation : auteur et création conservés', g.ok && g.playlist.createdBy.id === USER && g.playlist.updatedBy.id === OTHER && g.playlist.createdAt === importedCreated && g.playlist.scope === 'guild' && g.playlist.guildId === GUILD);
|
||||
|
||||
const personalSynced = { ...PlaylistManager.getPlaylistOfUser(USER, LEGACY_ID), source: 'google', syncedAt: '2026-01-01T00:00:00.000Z' };
|
||||
g = GuildPlaylists.importPlaylist(GUILD, personalSynced, admin);
|
||||
check('partage d\'une playlist personnelle', g.ok && !g.playlist.source && !g.playlist.syncedAt && g.playlist.scope === 'guild' && g.playlist.title === 'Ancienne' && g.playlist.playlistId);
|
||||
const sharedId = g.playlist.playlistId;
|
||||
check('copie indépendante', g.playlist.songs !== personalSynced.songs);
|
||||
|
||||
// Limite de 200 playlists par serveur
|
||||
const live = GuildPlaylists.getPlaylists(GUILD);
|
||||
const realLength = live.length;
|
||||
for (let i = realLength; i < GuildPlaylists.MAX_PLAYLISTS; i++) live.push({ playlistId: 'fill' + i, title: 'x', songs: [] });
|
||||
check('limite de 200 : création', (await GuildPlaylists.createPlaylist(GUILD, { name: 'Trop' }, actor)).error === 'LIMIT');
|
||||
check('limite de 200 : partage', GuildPlaylists.importPlaylist(GUILD, personalSynced, actor).error === 'LIMIT');
|
||||
live.splice(realLength);
|
||||
|
||||
const diskGuild = readJson(__glob.GUILD_PLAYLISTS_DB)[GUILD];
|
||||
check('enregistré sur disque', diskGuild.length === realLength && diskGuild.every(p => p.scope === 'guild' && p.createdAt && p.updatedAt));
|
||||
|
||||
console.log('# Désactivation : données conservées');
|
||||
ServerSettings.setSharedPlaylists(GUILD, { enabled: false });
|
||||
check('désactivé : playlists conservées', GuildPlaylists.countPlaylists(GUILD) === realLength && readJson(__glob.GUILD_PLAYLISTS_DB)[GUILD].length === realLength);
|
||||
ServerSettings.setSharedPlaylists(GUILD, { enabled: true });
|
||||
|
||||
console.log('# Administration');
|
||||
const handlers = new Map();
|
||||
const socket = { id: 'sock1', data: { userId: USER }, rooms: new Set(), on: (name, fn) => handlers.set(name, fn), emit() {}, leave() {}, join() {} };
|
||||
const fakeIo = {
|
||||
sockets: { adapter: { rooms: new Map() }, sockets: new Map() },
|
||||
to: () => ({ emit() {} }), in: () => ({ socketsLeave() {} }), emit() {}, of: () => ({ adapter: { on() {} } })
|
||||
};
|
||||
Admin.attach({ io: fakeIo, socket, socketUser: () => admin });
|
||||
const call = (name, payload) => new Promise((resolve) => handlers.get(name)(payload, resolve));
|
||||
|
||||
let r = await call('/ADMIN/DATABASES', {});
|
||||
check('base GuildPlaylists listée', r.ok && r.data.some(db => db.name === 'GuildPlaylists' && db.entries === 1), r.data?.map(db => db.name));
|
||||
r = await call('/ADMIN/PLAYLISTS/LIST', { userId: USER });
|
||||
check('LIST : createdAt / updatedAt', r.ok && r.data[0].playlists.every(p => utils.isValidIso(p.createdAt) && utils.isValidIso(p.updatedAt)));
|
||||
r = await call('/ADMIN/PLAYLISTS/GUILDS', {});
|
||||
const entry = r.data?.find(e => e.guildId === GUILD);
|
||||
check('GUILDS : serveur et réglage', r.ok && entry && entry.guildName === 'Serveur Test' && entry.settings.enabled === true && entry.settings.editors === 'mods', r);
|
||||
check('GUILDS : playlists résumées', entry && entry.playlists.length === realLength && entry.playlists.every(p => typeof p.songCount === 'number' && !('songs' in p) && p.createdAt));
|
||||
check('GUILDS : createdBy léger', entry && entry.playlists.find(p => p.playlistId === gid)?.createdBy?.id === OTHER && !JSON.stringify(entry).includes('secret'));
|
||||
r = await call('/ADMIN/PLAYLISTS/GUILDS', { guildId: 'abc' });
|
||||
check('GUILDS : guildId invalide', !r.ok && r.error === 'INVALID_PARAMS');
|
||||
r = await call('/ADMIN/PLAYLISTS/GUILDS', { guildId: GUILD_UNKNOWN });
|
||||
check('GUILDS : filtre sur serveur sans playlist', r.ok && r.data.length === 1 && r.data[0].playlists.length === 0 && !GuildPlaylists.listGuildIds().includes(GUILD_UNKNOWN));
|
||||
|
||||
guildEvents.length = 0;
|
||||
r = await call('/ADMIN/GUILDS/SET_SHARED_PLAYLISTS', { guildId: GUILD, editors: 'members' });
|
||||
check('SET_SHARED_PLAYLISTS', r.ok && r.data.sharedPlaylists.enabled === true && r.data.sharedPlaylists.editors === 'members' && r.data.sharedPlaylists.count === realLength, r);
|
||||
check('poussée vers le serveur', guildEvents.includes(GUILD));
|
||||
r = await call('/ADMIN/GUILDS/SET_SHARED_PLAYLISTS', { guildId: GUILD, editors: 'tous' });
|
||||
check('editors invalide', !r.ok && r.error === 'INVALID_PARAMS');
|
||||
r = await call('/ADMIN/GUILDS/SET_SHARED_PLAYLISTS', { guildId: GUILD, enabled: 'oui' });
|
||||
check('enabled invalide', !r.ok && r.error === 'INVALID_PARAMS');
|
||||
r = await call('/ADMIN/GUILDS/SET_SHARED_PLAYLISTS', { guildId: GUILD });
|
||||
check('aucune modification', !r.ok && r.error === 'INVALID_PARAMS');
|
||||
r = await call('/ADMIN/GUILDS/SET_SHARED_PLAYLISTS', { guildId: GUILD_UNKNOWN, enabled: true });
|
||||
check('serveur inconnu', !r.ok && r.error === 'NOT_FOUND');
|
||||
r = await call('/ADMIN/GUILDS/LIST', {});
|
||||
check('résumé du serveur : sharedPlaylists', r.ok && JSON.stringify(r.data[0].sharedPlaylists) === JSON.stringify({ enabled: true, editors: 'members', count: realLength }), r.data?.[0]?.sharedPlaylists);
|
||||
|
||||
guildEvents.length = 0;
|
||||
r = await call('/ADMIN/PLAYLISTS/DELETE', { guildId: GUILD, playlistId: 'inconnue' });
|
||||
check('DELETE partagée inconnue', !r.ok && r.error === 'NOT_FOUND');
|
||||
r = await call('/ADMIN/PLAYLISTS/DELETE', { guildId: GUILD, playlistId: sharedId });
|
||||
check('DELETE partagée', r.ok && r.data.deleted === true && !GuildPlaylists.getPlaylist(GUILD, sharedId) && guildEvents.includes(GUILD));
|
||||
r = await call('/ADMIN/PLAYLISTS/DELETE', { userId: USER, playlistId: pid });
|
||||
check('DELETE personnelle toujours possible', r.ok && !PlaylistManager.getPlaylistOfUser(USER, pid));
|
||||
|
||||
check('suppression d\'une playlist partagée (module)', GuildPlaylists.deletePlaylist(GUILD, gid) === true && GuildPlaylists.deletePlaylist(GUILD, gid) === false);
|
||||
|
||||
await sleep(50);
|
||||
check('backend/data jamais utilisé', fs.existsSync(realGuildFile) === realGuildFileExisted && !fs.existsSync(realGuildFile + '.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,90 @@
|
||||
// Test réseau de backend/src/lyrics/Lyrics.js contre LRCLIB réel (node lyrics.live.js) : affiche les résultats
|
||||
const { check, done, req } = require("./_setup");
|
||||
const L = req("src/lyrics/Lyrics.js");
|
||||
|
||||
// Formes rencontrées en vrai : titres YouTube bruts, chaînes VEVO / Topic, morceaux Spotify propres
|
||||
const SONGS = [
|
||||
{ type: "youtube", id: "FGBhQbmPwH8", title: "Daft Punk - One More Time (Official Video)", author: "Daft Punk", duration: 320, expectSynced: true },
|
||||
{ type: "youtube", id: "dQw4w9WgXcQ", title: "Rick Astley - Never Gonna Give You Up (Official Music Video)", author: "Rick Astley", duration: 213, expectSynced: true },
|
||||
{ type: "youtube", id: "spotify-papaoutai", title: "Papaoutai", author: "Stromae", duration: 232, expectSynced: true },
|
||||
{ type: "youtube", id: "fJ9rUzIMcZQ", title: "Queen – Bohemian Rhapsody (Official Video Remastered)", author: "Queen Official", duration: 360 },
|
||||
{ type: "youtube", id: "topic-test", title: "Get Lucky (feat. Pharrell Williams and Nile Rodgers)", author: "Daft Punk - Topic", duration: 369 },
|
||||
{ type: "youtube", id: "nope", title: "zzqx introuvable 8472 kq", author: "Personne", duration: 100, expectMiss: true },
|
||||
];
|
||||
|
||||
function preview(result) {
|
||||
if (!result.ok) return `échec ${result.error}`;
|
||||
const first = result.synced ? result.lines.find(l => l.text) : null;
|
||||
return `${result.source} · ${result.instrumental ? "instrumental" : result.synced ? `synchronisées (${result.lines.length} lignes, 1re à ${(first.t / 1000).toFixed(2)} s : « ${first.text} »)` : "texte seul"}`
|
||||
+ ` · match ${result.match.artist} - ${result.match.track} (${result.match.album || "?"}, ${result.match.duration} s, id ${result.match.id ?? "-"})`;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
for (const song of SONGS) {
|
||||
const t = Date.now();
|
||||
const result = await L.getLyricsForSong(song);
|
||||
console.log(`\n${song.title} / ${song.author}\n requête : ${JSON.stringify(L.guessQuery(song))}\n ${preview(result)} [${Date.now() - t} ms]`);
|
||||
if (song.expectMiss) check("introuvable → NOT_FOUND", !result.ok && result.error === "NOT_FOUND", result);
|
||||
else check(`trouvé : ${song.title}`, result.ok && !!(result.plain || result.instrumental), result.error);
|
||||
if (song.expectSynced) {
|
||||
check(`synchronisées : ${song.title}`, result.ok && result.synced);
|
||||
// Horodatages exploitables : croissants, en ms, dans la durée du morceau
|
||||
const lines = result.ok && Array.isArray(result.lines) ? result.lines : [];
|
||||
const texts = lines.filter(l => l.text);
|
||||
const sorted = lines.every((l, i) => Number.isInteger(l.t) && l.t >= 0 && (i === 0 || l.t >= lines[i - 1].t));
|
||||
const last = lines.length ? lines[lines.length - 1].t : 0;
|
||||
check(`horodatages croissants (${texts.length} lignes)`, texts.length >= 10 && sorted && last > 30000 && last <= (song.duration + 15) * 1000, { lines: lines.length, last });
|
||||
check("User-Agent identifiant envoyé", /^Subsonics-Chopin\/\S+ \(https?:\/\//.test(L.USER_AGENT));
|
||||
}
|
||||
check("songKey", result.songKey === `${song.type}:${song.id}`);
|
||||
}
|
||||
|
||||
const t = Date.now();
|
||||
await L.getLyricsForSong(SONGS[0]);
|
||||
check("2e appel servi par le cache", Date.now() - t < 50);
|
||||
|
||||
const search = await L.searchCandidates("daft punk one more time");
|
||||
console.log(`\nsearchCandidates : ${search.ok ? search.results.length + " résultats" : search.error}`);
|
||||
if (search.ok) for (const c of search.results.slice(0, 5)) console.log(` #${c.id} ${c.artist} - ${c.track} (${c.album}, ${c.duration} s${c.synced ? ", synchro" : ""})`);
|
||||
check("searchCandidates ok", search.ok && search.results.length > 0);
|
||||
|
||||
if (search.ok && search.results.length) {
|
||||
const byId = await L.getById(search.results[0].id);
|
||||
console.log(`getById(${search.results[0].id}) : ${preview(byId)}`);
|
||||
check("getById ok", byId.ok);
|
||||
}
|
||||
|
||||
const legacy = await L.getLyrics("Daft Punk - One More Time");
|
||||
check("getLyrics (compat) renvoie du texte", typeof legacy === "string" && legacy.length > 20);
|
||||
|
||||
// Repli : LRCLIB rendu injoignable, Deezer + lyrics.ovh réels
|
||||
console.log("\nRepli lyrics.ovh (LRCLIB simulé en panne)");
|
||||
const realFetch = globalThis.fetch;
|
||||
const hosts = [];
|
||||
globalThis.fetch = async (input, options) => {
|
||||
const host = new URL(String(input)).host;
|
||||
hosts.push(host);
|
||||
if (host === "lrclib.net") throw new TypeError("fetch failed");
|
||||
return realFetch(input, options);
|
||||
};
|
||||
try {
|
||||
L.clearCache();
|
||||
const t0 = Date.now();
|
||||
const fallback = await L.getLyricsForSong({ type: "youtube", id: "ovh-test", title: "Coldplay - Yellow (Official Video)", author: "Coldplay", duration: 269 });
|
||||
console.log(` ${fallback.ok ? preview(fallback) : "échec " + fallback.error} [${Date.now() - t0} ms] · hôtes : ${[...new Set(hosts)].join(", ")}`);
|
||||
check("repli tenté (Deezer puis lyrics.ovh)", hosts.includes("api.deezer.com") && hosts.includes("api.lyrics.ovh"), [...new Set(hosts)]);
|
||||
if (fallback.ok) {
|
||||
check("repli : texte seul depuis lyrics.ovh", fallback.source === "lyrics.ovh" && !fallback.synced && fallback.lines === null && fallback.plain.length > 100);
|
||||
} else {
|
||||
// lyrics.ovh est souvent en panne : signalé sans faire échouer le test
|
||||
console.log(` AVERTISSEMENT : lyrics.ovh n'a rien renvoyé (${fallback.error}), le repli n'a pas pu être vérifié de bout en bout`);
|
||||
check("repli en panne → UNAVAILABLE (LRCLIB injoignable)", fallback.error === "UNAVAILABLE");
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
done();
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,390 @@
|
||||
// Tests hors réseau de backend/src/lyrics/Lyrics.js (node lyrics.test.js) : fetch global remplacé par un faux
|
||||
const { check, done, req } = require("./_setup");
|
||||
const L = req("src/lyrics/Lyrics.js");
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
let calls = [];
|
||||
let route = () => json(404, { message: "not found" });
|
||||
|
||||
function json(status, body) {
|
||||
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
function mock(handler) {
|
||||
calls = [];
|
||||
route = handler;
|
||||
L.clearCache();
|
||||
}
|
||||
globalThis.fetch = async (input, options = {}) => {
|
||||
const url = new URL(String(input));
|
||||
calls.push({ url, options });
|
||||
return route(url, options);
|
||||
};
|
||||
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
const LRC = "[ar:Daft Punk]\n[ti:One More Time]\n[00:30.75] One more time\n[00:33.18] \n[00:46.35] Celebrate\n[00:50.00] Oh yeah\n";
|
||||
const PLAIN = "One more time\n\nCelebrate\nOh yeah";
|
||||
function record(over = {}) {
|
||||
return {
|
||||
id: 1, trackName: "One More Time", artistName: "Daft Punk", albumName: "Discovery", duration: 320,
|
||||
instrumental: false, plainLyrics: PLAIN, syncedLyrics: LRC, ...over,
|
||||
};
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log("parseLrc");
|
||||
{
|
||||
check("texte non chaîne → []", eq(L.parseLrc(null), []) && eq(L.parseLrc(42), []) && eq(L.parseLrc(""), []));
|
||||
const lines = L.parseLrc(LRC);
|
||||
check("métadonnées ignorées, lignes vides gardées", eq(lines, [
|
||||
{ t: 30750, text: "One more time" }, { t: 33180, text: "" }, { t: 46350, text: "Celebrate" }, { t: 50000, text: "Oh yeah" },
|
||||
]), lines);
|
||||
check("[mm:ss] sans fraction", eq(L.parseLrc("[01:02]Bye"), [{ t: 62000, text: "Bye" }]));
|
||||
check("fractions .x / .xx / .xxx / :xx", eq(L.parseLrc("[00:01.5]a\n[00:02.25]b\n[00:03.125]c\n[00:04:50]d").map(l => l.t), [1500, 2250, 3125, 4500]));
|
||||
const multi = L.parseLrc("[00:20.00][00:05.00]Refrain\n[00:10.00]Couplet");
|
||||
check("plusieurs horodatages par ligne + tri", eq(multi, [
|
||||
{ t: 5000, text: "Refrain" }, { t: 10000, text: "Couplet" }, { t: 20000, text: "Refrain" },
|
||||
]), multi);
|
||||
check("[offset:+500] avance les paroles", eq(L.parseLrc("[offset:+500]\n[00:10.00]a").map(l => l.t), [9500]));
|
||||
check("[offset:-250] retarde les paroles", eq(L.parseLrc("[offset:-250]\n[00:10.00]a").map(l => l.t), [10250]));
|
||||
check("offset ne rend pas de temps négatif", eq(L.parseLrc("[offset:2000]\n[00:01.00]a").map(l => l.t), [0]));
|
||||
check("CRLF, BOM et horodatages par mot", eq(L.parseLrc("[00:01.00]Hello <00:01.20>world\r\n[00:02.00] x "), [
|
||||
{ t: 1000, text: "Hello world" }, { t: 2000, text: "x" },
|
||||
]));
|
||||
check("lignes sans horodatage ignorées, secondes invalides ignorées", eq(L.parseLrc("texte libre\n[00:75.00]x\n[00:03.00]ok"), [{ t: 3000, text: "ok" }]));
|
||||
check("égalité de temps : ordre d'origine conservé", eq(L.parseLrc("[00:01.00]a\n[00:01.00]b").map(l => l.text), ["a", "b"]));
|
||||
}
|
||||
|
||||
console.log("cleanTitle / cleanArtist");
|
||||
{
|
||||
check("Official Video + [HD]", L.cleanTitle("One More Time (Official Video) [HD]") === "One More Time");
|
||||
check("Clip officiel / Lyrics", L.cleanTitle("Alors on danse (Clip Officiel)") === "Alors on danse" && L.cleanTitle("Hello (Lyrics)") === "Hello");
|
||||
check("feat. retiré", L.cleanTitle("Get Lucky (feat. Pharrell Williams)") === "Get Lucky" && L.cleanTitle("Get Lucky ft. Pharrell") === "Get Lucky");
|
||||
check("suffixe « - Remastered 2011 » retiré", L.cleanTitle("Bohemian Rhapsody - Remastered 2011") === "Bohemian Rhapsody");
|
||||
check("suffixe « | … » retiré", L.cleanTitle("Titre | Clip officiel") === "Titre");
|
||||
check("parenthèse de titre conservée", L.cleanTitle("(Don't Fear) The Reaper") === "(Don't Fear) The Reaper");
|
||||
check("« (Remix) » conservé", L.cleanTitle("Song (Remix)") === "Song (Remix)");
|
||||
check("« Video » dans un vrai titre conservé", L.cleanTitle("The Buggles - Video Killed the Radio Star") === "The Buggles - Video Killed the Radio Star");
|
||||
check("titre entièrement parasite → original", L.cleanTitle("(Official Video)") === "(Official Video)");
|
||||
check("vide / null", L.cleanTitle("") === "" && L.cleanTitle(null) === "");
|
||||
check("Artist - Topic", L.cleanArtist("Stromae - Topic") === "Stromae");
|
||||
check("ArtistVEVO → espaces", L.cleanArtist("DaftPunkVEVO") === "Daft Punk" && L.cleanArtist("Adele VEVO") === "Adele");
|
||||
check("Official / feat.", L.cleanArtist("Daft Punk Official") === "Daft Punk" && L.cleanArtist("Daft Punk feat. Pharrell") === "Daft Punk");
|
||||
check("Auteur inconnu → vide", L.cleanArtist("Auteur inconnu") === "");
|
||||
check("AC/DC intact", L.cleanArtist("AC/DC") === "AC/DC");
|
||||
check("longueur bornée à 200", L.cleanTitle("a".repeat(500)).length === 200);
|
||||
}
|
||||
|
||||
console.log("guessQuery");
|
||||
{
|
||||
const g = (title, author, extra = {}) => L.guessQuery({ title, author, type: "youtube", ...extra });
|
||||
check("Artiste - Titre (Official Video)", eq(g("Daft Punk - One More Time (Official Video)", "Daft Punk", { duration: 320.4 }), { track: "One More Time", artist: "Daft Punk", duration: 320 }));
|
||||
check("chaîne VEVO sans tiret", eq(g("Never Gonna Give You Up", "RickAstleyVEVO"), { track: "Never Gonna Give You Up", artist: "Rick Astley", duration: 0 }));
|
||||
check("chaîne de label → artiste du titre", eq(g("The Buggles - Video Killed the Radio Star", "Island Records"), { track: "Video Killed the Radio Star", artist: "The Buggles", duration: 0 }));
|
||||
check("Titre - Artiste", eq(g("Shape of You - Ed Sheeran", "Ed Sheeran"), { track: "Shape of You", artist: "Ed Sheeran", duration: 0 }));
|
||||
check("suffixe de version Spotify", eq(g("Hello - Live at Wembley", "Adele"), { track: "Hello", artist: "Adele", duration: 0 }));
|
||||
check("feat. dans la partie artiste", eq(g("Daft Punk ft. Pharrell Williams - Get Lucky [HD] | Clip officiel", "Some Label"), { track: "Get Lucky", artist: "Daft Punk", duration: 0 }));
|
||||
check("Artiste « Titre »", eq(g('Stromae "Alors on danse" (Clip officiel)', "StromaeVEVO"), { track: "Alors on danse", artist: "Stromae", duration: 0 }));
|
||||
check("guillemets dans un titre Spotify", eq(g('Theme from "Shaft"', "Isaac Hayes"), { track: "Theme from Shaft", artist: "Isaac Hayes", duration: 0 }));
|
||||
check("fichier envoyé", eq(L.guessQuery({ title: "01 - Muse - Uprising.mp3", author: "Auteur inconnu", type: "attachment" }), { track: "Uprising", artist: "Muse", duration: 0 }));
|
||||
check("valeurs par défaut du Song → vide", eq(L.guessQuery({ title: "Aucun titre", author: "Auteur inconnu" }), { track: "", artist: "", duration: 0 }));
|
||||
check("durée invalide → 0", L.guessQuery({ title: "x", duration: "abc" }).duration === 0 && L.guessQuery({ title: "x", duration: -3 }).duration === 0);
|
||||
check("pas d'objet → vide", eq(L.guessQuery(null), { track: "", artist: "", duration: 0 }));
|
||||
}
|
||||
|
||||
console.log("songKeyOf");
|
||||
{
|
||||
check("type:id", L.songKeyOf({ type: "youtube", id: "abc", url: "u" }) === "youtube:abc");
|
||||
check("repli sur l'URL", L.songKeyOf({ type: "youtube", url: "https://x" }) === "https://x");
|
||||
check("rien → null", L.songKeyOf({}) === null && L.songKeyOf(null) === null);
|
||||
}
|
||||
|
||||
console.log("pickBestCandidate");
|
||||
{
|
||||
const q = { track: "One More Time", artist: "Daft Punk", duration: 320 };
|
||||
const list = [
|
||||
record({ id: 10, duration: 234, albumName: "Charts" }),
|
||||
record({ id: 11, duration: 321, syncedLyrics: null }),
|
||||
record({ id: 12, duration: 319 }),
|
||||
record({ id: 13, trackName: "Around the World", duration: 320 }),
|
||||
record({ id: 14, artistName: "Someone Else", duration: 330 }),
|
||||
];
|
||||
check("synchronisé et durée proche", L.pickBestCandidate(list, q)?.id === 12);
|
||||
check("titre différent rejeté", L.pickBestCandidate([list[3]], q) === null);
|
||||
check("autre artiste rejeté", L.pickBestCandidate([list[4]], q) === null);
|
||||
// Chaîne de label : titre identique et durée quasi exacte suffisent
|
||||
check("autre artiste accepté si titre et durée exacts", L.pickBestCandidate([record({ id: 19, artistName: "Ed Banger Records", duration: 321 })], q)?.id === 19);
|
||||
check("hors fenêtre seulement si rien de mieux", L.pickBestCandidate([list[0]], q)?.id === 10 && L.pickBestCandidate([list[0], list[1]], q)?.id === 11);
|
||||
check("sans contenu ignoré", L.pickBestCandidate([record({ id: 15, syncedLyrics: "", plainLyrics: "" })], q) === null);
|
||||
const upload = record({ id: 16, trackName: "Daft Punk - One More Time (Official Video)" });
|
||||
check("saisie propre préférée à « Artiste - Titre (Official Video) »", L.pickBestCandidate([upload, record({ id: 17 })], q)?.id === 17);
|
||||
const free = { track: "daft punk one more time", artist: "", duration: 0 };
|
||||
check("texte libre : bon candidat", L.pickBestCandidate([list[3], record({ id: 18 })], free)?.id === 18);
|
||||
check("texte libre : titre absent de la requête rejeté", L.pickBestCandidate([list[3]], free) === null);
|
||||
}
|
||||
|
||||
console.log("getLyricsForSong (fetch simulé)");
|
||||
{
|
||||
const song = { type: "youtube", id: "FGBhQbmPwH8", title: "Daft Punk - One More Time (Official Video)", author: "Daft Punk", duration: 320, url: "https://www.youtube.com/watch?v=FGBhQbmPwH8" };
|
||||
|
||||
mock((url) => url.pathname === "/api/get" ? json(200, record()) : json(404, {}));
|
||||
let r = await L.getLyricsForSong(song);
|
||||
check("LRCLIB /get → synchronisées", r.ok && r.source === "lrclib" && r.synced && r.lines.length === 4 && r.lines[0].t === 30750, r);
|
||||
check("songKey renvoyé", r.songKey === "youtube:FGBhQbmPwH8");
|
||||
check("plain et match remplis", r.plain === PLAIN && eq(r.match, { id: 1, track: "One More Time", artist: "Daft Punk", album: "Discovery", duration: 320 }) && r.instrumental === false);
|
||||
const p = calls[0].url.searchParams;
|
||||
check("paramètres /get nettoyés", calls[0].url.host === "lrclib.net" && p.get("artist_name") === "Daft Punk" && p.get("track_name") === "One More Time" && p.get("duration") === "320", calls[0].url.href);
|
||||
check("User-Agent avec la version", /^Subsonics-Chopin\/\S+ \(https:\/\/git\.raphix\.fr\/subsonics\)$/.test(calls[0].options.headers["User-Agent"]) && L.USER_AGENT.includes(require("../../package.json").version));
|
||||
check("délai d'attente fourni", calls[0].options.signal instanceof AbortSignal);
|
||||
const before = calls.length;
|
||||
const again = await L.getLyricsForSong({ ...song, id: "autreVideo" });
|
||||
check("cache par requête (aucun nouvel appel), songKey propre", calls.length === before && again.songKey === "youtube:autreVideo" && again.synced);
|
||||
|
||||
mock((url) => url.pathname === "/api/get" ? json(200, record()) : json(404, {}));
|
||||
r = await L.getLyricsForSong({ ...song, duration: 0 });
|
||||
check("durée 0 → pas de paramètre duration", r.ok && !calls[0].url.searchParams.has("duration"));
|
||||
|
||||
mock((url) => {
|
||||
if (url.pathname === "/api/get") return json(404, { message: "Failed to find specified track" });
|
||||
if (url.pathname === "/api/search" && url.searchParams.get("track_name")) {
|
||||
return json(200, [record({ id: 30, duration: 210 }), record({ id: 31, duration: 321 }), record({ id: 32, trackName: "Digital Love", duration: 320 })]);
|
||||
}
|
||||
return json(200, []);
|
||||
});
|
||||
r = await L.getLyricsForSong(song);
|
||||
check("/get 404 → recherche par champs, bon candidat", r.ok && r.match.id === 31 && r.synced, r.match);
|
||||
check("recherche libre évitée si un candidat convient", !calls.some(c => c.url.searchParams.has("q")));
|
||||
|
||||
mock((url) => {
|
||||
if (url.pathname === "/api/get") return json(404, {});
|
||||
if (url.searchParams.has("q")) return json(200, [record({ id: 40, duration: 320 })]);
|
||||
return json(200, []);
|
||||
});
|
||||
r = await L.getLyricsForSong(song);
|
||||
check("puis recherche libre « artiste titre »", r.ok && r.match.id === 40 && calls.find(c => c.url.searchParams.has("q"))?.url.searchParams.get("q") === "Daft Punk One More Time");
|
||||
|
||||
mock((url) => url.pathname === "/api/get" ? json(200, record({ duration: 260 })) : json(200, []));
|
||||
r = await L.getLyricsForSong(song);
|
||||
check("durée trop différente → texte seul", r.ok && r.synced === false && r.lines === null && r.plain === PLAIN, r);
|
||||
|
||||
mock((url) => url.pathname === "/api/get" ? json(200, record({ duration: 260, plainLyrics: null })) : json(200, []));
|
||||
r = await L.getLyricsForSong(song);
|
||||
check("texte seul reconstruit depuis le LRC", r.ok && !r.synced && r.plain === PLAIN, r.plain);
|
||||
|
||||
mock((url) => url.pathname === "/api/get" ? json(200, record({ instrumental: true, plainLyrics: null, syncedLyrics: null })) : json(404, {}));
|
||||
r = await L.getLyricsForSong(song);
|
||||
check("instrumental", r.ok && r.instrumental && !r.synced && r.lines === null && r.plain === null);
|
||||
|
||||
// Entrée d'essai réellement présente sur LRCLIB (« [00:00.00]probe ») : ignorée au profit de la recherche
|
||||
mock((url) => {
|
||||
if (url.pathname === "/api/get") return json(200, record({ id: 50, plainLyrics: "probe", syncedLyrics: "[00:00.00]probe" }));
|
||||
if (url.pathname === "/api/search") return json(200, [record({ id: 50, plainLyrics: "probe", syncedLyrics: "[00:00.00]probe" }), record({ id: 51 })]);
|
||||
return json(404, {});
|
||||
});
|
||||
r = await L.getLyricsForSong(song);
|
||||
check("entrée vandalisée ignorée", r.ok && r.match.id === 51 && r.synced, r.match);
|
||||
|
||||
// 503 passager : une seule nouvelle tentative
|
||||
let flaky = 0;
|
||||
mock((url) => {
|
||||
if (url.pathname === "/api/get") return ++flaky === 1 ? json(503, {}) : json(200, record());
|
||||
return json(404, {});
|
||||
});
|
||||
r = await L.getLyricsForSong(song);
|
||||
check("503 passager → nouvelle tentative réussie", r.ok && r.synced && flaky === 2);
|
||||
|
||||
mock((url) => {
|
||||
if (url.host === "lrclib.net") return url.pathname === "/api/get" ? json(404, {}) : json(200, []);
|
||||
if (url.host === "api.deezer.com") return json(200, { data: [
|
||||
{ id: 1, title: "Harder, Better, Faster, Stronger", artist: { name: "Daft Punk" }, album: { title: "Discovery" }, duration: 224 },
|
||||
{ id: 2, title: "One More Time (Radio Edit)", title_short: "One More Time", artist: { name: "Daft Punk" }, album: { title: "Discovery" }, duration: 320 },
|
||||
] });
|
||||
if (url.host === "api.lyrics.ovh") return json(200, { lyrics: "Paroles de la chanson One More Time par Daft Punk\r\nOne more time\r\n\r\n\r\nCelebrate\r\nOh yeah" });
|
||||
return json(404, {});
|
||||
});
|
||||
r = await L.getLyricsForSong(song);
|
||||
check("repli Deezer + lyrics.ovh", r.ok && r.source === "lyrics.ovh" && !r.synced && r.lines === null && r.plain === PLAIN, r);
|
||||
const ovh = calls.find(c => c.url.host === "api.lyrics.ovh");
|
||||
check("lyrics.ovh interrogé avec le bon candidat Deezer", ovh && decodeURIComponent(ovh.url.pathname) === "/v1/Daft Punk/One More Time", ovh?.url.pathname);
|
||||
check("Deezer en https", calls.find(c => c.url.host === "api.deezer.com")?.url.protocol === "https:");
|
||||
|
||||
mock(() => json(404, { error: "No lyrics found" }));
|
||||
r = await L.getLyricsForSong(song);
|
||||
check("rien nulle part → NOT_FOUND + songKey", !r.ok && r.error === "NOT_FOUND" && r.songKey === "youtube:FGBhQbmPwH8", r);
|
||||
const n = calls.length;
|
||||
await L.getLyricsForSong(song);
|
||||
check("échec mis en cache", calls.length === n);
|
||||
|
||||
mock((url) => {
|
||||
if (url.host === "lrclib.net") throw new TypeError("fetch failed");
|
||||
return json(404, {});
|
||||
});
|
||||
r = await L.getLyricsForSong(song);
|
||||
check("LRCLIB injoignable → repli puis UNAVAILABLE", !r.ok && r.error === "UNAVAILABLE" && calls.filter(c => c.url.host === "lrclib.net").length === 2 && calls.every(c => c.url.pathname !== "/api/search"), calls.map(c => c.url.href));
|
||||
|
||||
mock((url) => url.host === "lrclib.net" ? json(500, {}) : json(404, {}));
|
||||
r = await L.getLyricsForSong(song);
|
||||
check("HTTP 500 → UNAVAILABLE", !r.ok && r.error === "UNAVAILABLE");
|
||||
|
||||
mock(() => json(200, record()));
|
||||
check("pas de morceau → NO_MUSIC", eq(await L.getLyricsForSong(null), { ok: false, error: "NO_MUSIC" }));
|
||||
r = await L.getLyricsForSong({ type: "youtube", id: "x", title: "Aucun titre", author: "Auteur inconnu" });
|
||||
check("titre vide → INVALID_PARAMS", !r.ok && r.error === "INVALID_PARAMS" && r.songKey === "youtube:x" && calls.length === 0);
|
||||
}
|
||||
|
||||
console.log("searchCandidates / getById / findLyrics / getLyrics");
|
||||
{
|
||||
mock(() => json(200, record()));
|
||||
for (const bad of [undefined, null, 42, "", " ", "x".repeat(201), { q: "x" }]) {
|
||||
const r = await L.searchCandidates(bad);
|
||||
check(`searchCandidates(${JSON.stringify(bad)?.slice(0, 20)}) → INVALID_PARAMS`, eq(r, { ok: false, error: "INVALID_PARAMS" }));
|
||||
}
|
||||
check("aucune requête pour une entrée invalide", calls.length === 0);
|
||||
|
||||
mock(() => json(200, [record({ id: 5 }), record({ id: 6, syncedLyrics: null, instrumental: true, albumName: null }), { bad: true }]));
|
||||
let r = await L.searchCandidates(" daft punk one more time ");
|
||||
check("candidats mis en forme", r.ok && eq(r.results, [
|
||||
{ id: 5, track: "One More Time", artist: "Daft Punk", album: "Discovery", duration: 320, synced: true, instrumental: false },
|
||||
{ id: 6, track: "One More Time", artist: "Daft Punk", album: "", duration: 320, synced: false, instrumental: true },
|
||||
]), r);
|
||||
check("requête q transmise", calls[0].url.pathname === "/api/search" && calls[0].url.searchParams.get("q") === "daft punk one more time");
|
||||
|
||||
mock(() => { throw new TypeError("fetch failed"); });
|
||||
r = await L.searchCandidates("abc");
|
||||
check("réseau en panne → UNAVAILABLE", eq(r, { ok: false, error: "UNAVAILABLE" }));
|
||||
route = () => json(200, []);
|
||||
r = await L.searchCandidates("abc");
|
||||
check("UNAVAILABLE non mis en cache", r.ok && eq(r.results, []));
|
||||
|
||||
mock((url) => url.pathname === "/api/get/77" ? json(200, record({ id: 77, duration: 100 })) : json(404, {}));
|
||||
for (const bad of [0, -1, 1.5, "abc", null, {}, Number.MAX_SAFE_INTEGER + 2]) {
|
||||
check(`getById(${JSON.stringify(bad)}) → INVALID_PARAMS`, (await L.getById(bad)).error === "INVALID_PARAMS");
|
||||
}
|
||||
r = await L.getById(77);
|
||||
check("getById garde la synchro (choix manuel)", r.ok && r.synced && r.match.id === 77 && r.songKey === null, r);
|
||||
check("getById accepte une chaîne numérique", (await L.getById("77")).ok);
|
||||
check("getById inconnu → NOT_FOUND", (await L.getById(78)).error === "NOT_FOUND");
|
||||
|
||||
mock((url) => url.pathname === "/api/get" ? json(200, record()) : json(404, {}));
|
||||
r = await L.findLyrics({ track: "One More Time (Official Video)", artist: "DaftPunkVEVO", duration: 320 });
|
||||
check("findLyrics objet", r.ok && r.synced && r.songKey === null && calls[0].url.searchParams.get("artist_name") === "Daft Punk");
|
||||
check("findLyrics invalide", (await L.findLyrics({ track: "" })).error === "INVALID_PARAMS"
|
||||
&& (await L.findLyrics({ track: "a", artist: 3 })).error === "INVALID_PARAMS"
|
||||
&& (await L.findLyrics({ track: "a", duration: "x" })).error === "INVALID_PARAMS"
|
||||
&& (await L.findLyrics(["a"])).error === "INVALID_PARAMS"
|
||||
&& (await L.findLyrics("x".repeat(201))).error === "INVALID_PARAMS");
|
||||
|
||||
mock((url) => url.searchParams.has("q") ? json(200, [record({ id: 90, trackName: "Time" }), record({ id: 91 })]) : json(404, {}));
|
||||
r = await L.findLyrics("daft punk one more time");
|
||||
check("findLyrics texte libre", r.ok && r.match.track === "One More Time", r);
|
||||
|
||||
mock((url) => url.pathname === "/api/get" ? json(200, record()) : json(404, {}));
|
||||
const text = await L.getLyrics("Daft Punk - One More Time");
|
||||
check("getLyrics (compat) → texte brut", text === PLAIN, text);
|
||||
mock(() => json(404, {}));
|
||||
check("getLyrics (compat) → null si rien", (await L.getLyrics("zzz introuvable")) === null && (await L.getLyrics(null)) === null);
|
||||
}
|
||||
|
||||
console.log("titres YouTube supplémentaires");
|
||||
{
|
||||
const g = (title, author, extra = {}) => L.guessQuery({ title, author, type: "youtube", ...extra });
|
||||
check("Artiste - Titre (Official Video) [4K]", eq(g("Daft Punk - Around the World (Official Video) [4K]", "Daft Punk"), { track: "Around the World", artist: "Daft Punk", duration: 0 }));
|
||||
check("Titre (Official Music Video) 4K sans tiret", L.cleanTitle("Blinding Lights (Official Music Video) 4K") === "Blinding Lights");
|
||||
check("chaîne « - Topic » + feat. dans le titre", eq(g("Instant Crush (feat. Julian Casablancas)", "Daft Punk - Topic", { duration: 337 }), { track: "Instant Crush", artist: "Daft Punk", duration: 337 }));
|
||||
check("ft. en fin de titre", L.cleanTitle("Stay ft. Justin Bieber") === "Stay");
|
||||
check("Titre [Lyrics] / (Audio)", L.cleanTitle("Bad Guy [Lyrics]") === "Bad Guy" && L.cleanTitle("Lose Yourself (Audio)") === "Lose Yourself");
|
||||
check("« (Official Video) » + émoji", L.cleanTitle("🔥 Alors on danse (Official Video) 🔥") === "Alors on danse");
|
||||
check("suffixe « - Live » conservé comme version : titre seul", eq(g("Hey Jude - Live", "The Beatles"), { track: "Hey Jude", artist: "The Beatles", duration: 0 }));
|
||||
check("chaîne Official + titre sans tiret", eq(g("Bohemian Rhapsody (Official Video Remastered)", "Queen Official"), { track: "Bohemian Rhapsody", artist: "Queen", duration: 0 }));
|
||||
}
|
||||
|
||||
console.log("similarity / rankCandidates");
|
||||
{
|
||||
check("identique → 1", L.similarity("One More Time", "one more time") === 1);
|
||||
check("accents et apostrophes ignorés", L.similarity("Je t'aime", "Je taime") === 1 && L.similarity("Éléphant", "elephant") === 1);
|
||||
check("espaces ignorés → 0.95", L.similarity("AC DC", "ACDC") === 0.95);
|
||||
check("inclusion → ≥ 0.6", L.similarity("One More Time", "One More Time Radio Edit") >= 0.6);
|
||||
check("rien en commun → 0", L.similarity("Hello", "Goodbye") === 0 && L.similarity("", "x") === 0);
|
||||
const ranked = L.rankCandidates([
|
||||
record({ id: 1, trackName: "Something Else", artistName: "Nobody" }),
|
||||
record({ id: 2, trackName: "Daft Punk - One More Time (Official Video)" }),
|
||||
record({ id: 3, syncedLyrics: null }),
|
||||
record({ id: 4 }),
|
||||
], "daft punk one more time");
|
||||
check("classement : synchro propre, synchro bruitée, texte seul, hors sujet", eq(ranked.map(r => r.id), [4, 2, 3, 1]), ranked.map(r => r.id));
|
||||
check("rankCandidates ne modifie pas l'entrée", (() => { const list = [record({ id: 9 })]; L.rankCandidates(list, "x"); return list.length === 1; })());
|
||||
}
|
||||
|
||||
console.log("durées de cache (horloge simulée)");
|
||||
{
|
||||
const MIN = 60 * 1000;
|
||||
const HOUR = 60 * MIN;
|
||||
check("ttl trouvé 6 h", L.ttlForResult({ ok: true }) === 6 * HOUR);
|
||||
check("ttl introuvable 20 min", L.ttlForResult({ ok: false, error: "NOT_FOUND" }) === 20 * MIN);
|
||||
check("ttl indisponible 1 min", L.ttlForResult({ ok: false, error: "UNAVAILABLE" }) === MIN);
|
||||
check("ttl autre erreur / rien → 0", L.ttlForResult({ ok: false, error: "INVALID_PARAMS" }) === 0 && L.ttlForResult(null) === 0);
|
||||
|
||||
const realNow = Date.now;
|
||||
// Horloge monotone : le limiteur retient l'heure du dernier démarrage, reculer le bloquerait
|
||||
let offset = 0;
|
||||
let base = 0;
|
||||
const at = (ms) => { offset = base + ms; };
|
||||
const nextScenario = () => { base = offset + HOUR; offset = base; };
|
||||
Date.now = () => realNow() + offset;
|
||||
try {
|
||||
const song = { type: "youtube", id: "ttl", title: "Daft Punk - One More Time", author: "Daft Punk", duration: 320 };
|
||||
const lrclibCalls = () => calls.filter(c => c.url.host === "lrclib.net").length;
|
||||
|
||||
mock((url) => url.pathname === "/api/get" ? json(200, record()) : json(404, {}));
|
||||
await L.getLyricsForSong(song);
|
||||
let n = lrclibCalls();
|
||||
at(6 * HOUR - MIN);
|
||||
await L.getLyricsForSong(song);
|
||||
check("trouvé : encore en cache à 5 h 59", lrclibCalls() === n);
|
||||
at(6 * HOUR + MIN);
|
||||
await L.getLyricsForSong(song);
|
||||
check("trouvé : redemandé après 6 h", lrclibCalls() > n);
|
||||
|
||||
nextScenario();
|
||||
mock(() => json(404, {}));
|
||||
await L.getLyricsForSong(song);
|
||||
n = calls.length;
|
||||
at(19 * MIN);
|
||||
await L.getLyricsForSong(song);
|
||||
check("introuvable : en cache à 19 min", calls.length === n);
|
||||
at(21 * MIN);
|
||||
await L.getLyricsForSong(song);
|
||||
check("introuvable : redemandé après 20 min", calls.length > n);
|
||||
|
||||
nextScenario();
|
||||
mock((url) => { if (url.host === "lrclib.net") throw new TypeError("fetch failed"); return json(404, {}); });
|
||||
await L.getLyricsForSong(song);
|
||||
n = calls.length;
|
||||
at(30 * 1000);
|
||||
await L.getLyricsForSong(song);
|
||||
check("indisponible : en cache 30 s", calls.length === n);
|
||||
at(61 * 1000);
|
||||
await L.getLyricsForSong(song);
|
||||
check("indisponible : redemandé après 1 min", calls.length > n);
|
||||
|
||||
nextScenario();
|
||||
mock(() => json(200, [record({ id: 5 })]));
|
||||
await L.searchCandidates("daft punk");
|
||||
n = calls.length;
|
||||
at(9 * MIN);
|
||||
await L.searchCandidates("Daft Punk");
|
||||
check("recherche manuelle : cache 10 min (requête normalisée)", calls.length === n);
|
||||
at(11 * MIN);
|
||||
await L.searchCandidates("daft punk");
|
||||
check("recherche manuelle : redemandée après 10 min", calls.length > n);
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.fetch = realFetch;
|
||||
done();
|
||||
})().catch((error) => {
|
||||
globalThis.fetch = realFetch;
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -20,6 +20,7 @@ const raw = {
|
||||
A: { id: "A", tokens: ["tA1", "tA2"], labels: ["ADMIN"] },
|
||||
B: { id: "B", tokens: ["tB"], labels: [] },
|
||||
C: { id: "C", tokens: ["tC"], labels: [] },
|
||||
D: { id: "D", tokens: ["tD"], labels: [] },
|
||||
}
|
||||
function makeUser(r) {
|
||||
if (!r) return null
|
||||
@@ -48,7 +49,7 @@ const usersStub = {
|
||||
|
||||
// --- Bot / players ---
|
||||
const guilds = new Map([
|
||||
["g1", { id: "g1", name: "Guilde 1", owner: "C", allMembers: ["A", "B", "C"] }],
|
||||
["g1", { id: "g1", name: "Guilde 1", owner: "C", allMembers: ["A", "B", "C", "D"] }],
|
||||
["g2", { id: "g2", name: "Guilde 2", owner: "A", allMembers: ["A", "B"] }],
|
||||
])
|
||||
const AllPlayers = new Map()
|
||||
@@ -76,9 +77,68 @@ const adminStub = {
|
||||
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
|
||||
|
||||
// --- Playlists personnelles (mémoire) : l'ordre est appliqué par le vrai PlaylistUtils (chargé après les stubs) ---
|
||||
const personal = {
|
||||
C: [{ playlistId: "1", title: "Un", songs: [] }, { playlistId: "2", title: "Deux", songs: [] }, { playlistId: "3", title: "Trois", songs: [] }],
|
||||
}
|
||||
const playlistsStub = {
|
||||
syncYoutube: async () => ({ ok: true }),
|
||||
getPlaylistsOfUser: (id) => personal[id] || [],
|
||||
getPlaylistOfUser: (id, pid) => (personal[id] || []).find(p => p.playlistId === String(pid)) || null,
|
||||
reorderPlaylists: (id, order) => require(SRC + "/playlists/PlaylistUtils").applyOrder(personal[id] || [], order),
|
||||
hasRoomForPlaylist: (id) => (personal[id] || []).length < 500,
|
||||
MAX_PLAYLISTS: 500,
|
||||
addPlaylistCopy: (id, playlist) => { const copy = { ...playlist, playlistId: "copy" + Date.now() }; (personal[id] ||= []).push(copy); return copy },
|
||||
}
|
||||
|
||||
// --- Playlists partagées (mémoire) et réglage par serveur ---
|
||||
const sharedSettings = new Map()
|
||||
const getShared = (g) => ({ enabled: false, editors: "members", ...(sharedSettings.get(g) || {}) })
|
||||
const serverSettingsStub = {
|
||||
getSecureRole: () => null,
|
||||
getSharedPlaylists: getShared,
|
||||
setSharedPlaylists: (g, patch) => {
|
||||
if (!patch || !Object.keys(patch).length) return null
|
||||
if (patch.enabled !== undefined && typeof patch.enabled !== "boolean") return null
|
||||
if (patch.editors !== undefined && !["members", "mods"].includes(patch.editors)) return null
|
||||
const next = { ...getShared(g), ...patch }
|
||||
sharedSettings.set(g, next)
|
||||
return next
|
||||
},
|
||||
}
|
||||
const guildLists = { g1: [{ playlistId: "10", title: "Commune", songs: [], createdBy: { id: "A", username: "userA" } }] }
|
||||
const guildPlaylistsStub = {
|
||||
getPlaylists: (g) => guildLists[g] || [],
|
||||
getPlaylist: (g, id) => (guildLists[g] || []).find(p => p.playlistId === String(id)) || null,
|
||||
countPlaylists: (g) => (guildLists[g] || []).length,
|
||||
deletePlaylist: (g, id) => { const list = guildLists[g] || []; const i = list.findIndex(p => p.playlistId === String(id)); if (i === -1) return false; list.splice(i, 1); return true },
|
||||
createPlaylist: async (g, input, user) => {
|
||||
const playlist = { playlistId: String(Date.now()), title: input.name, songs: [], scope: "guild", guildId: g, createdBy: { id: user.id, username: user.username } }
|
||||
;(guildLists[g] ||= []).push(playlist)
|
||||
return { ok: true, playlist }
|
||||
},
|
||||
}
|
||||
|
||||
// --- Paroles : searchCandidates seulement (getById absent → UNAVAILABLE) ---
|
||||
const lyricsCalls = []
|
||||
const lyricsStub = {
|
||||
searchCandidates: async (q) => { lyricsCalls.push(q); return { ok: true, results: [{ id: 1, track: q, artist: "x", album: null, duration: 100, synced: true, instrumental: false }] } },
|
||||
getLyricsForSong: async (song) => { lyricsCalls.push(song); return { ok: false, error: "NOT_FOUND", songKey: null } },
|
||||
findLyrics: async (query) => { lyricsCalls.push(query); return { ok: false, error: "NOT_FOUND", songKey: null } },
|
||||
}
|
||||
|
||||
const stubs = {
|
||||
"loguix": fakeLog,
|
||||
[norm(SRC + "/utils/SafeMetric")]: { getGuildMetrics: () => [], toSafeNumber: (v) => v },
|
||||
[norm(SRC + "/utils/SafeMetric")]: {
|
||||
getGuildMetrics: (g) => g === "g1" ? [
|
||||
{ name: "numberOfMusicPlayed_g1", value: 12 },
|
||||
{ name: "numberOfSecondsPlayed_g1", value: "3600.4" },
|
||||
{ name: "numberOfCommands_g1", value: NaN },
|
||||
] : [],
|
||||
toSafeNumber: (v, fallback = 0) => typeof v === "number" ? (Number.isFinite(v) ? v : fallback) : (typeof v === "string" && /^\s*-?\d+(\.\d+)?\s*$/.test(v) ? Number(v) : fallback),
|
||||
toSafeDuration: (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 && n <= 86400 ? n : 0 },
|
||||
},
|
||||
[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()] },
|
||||
@@ -86,10 +146,11 @@ const stubs = {
|
||||
[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/PlaylistManager")]: playlistsStub,
|
||||
[norm(SRC + "/playlists/GuildPlaylists")]: guildPlaylistsStub,
|
||||
[norm(SRC + "/playlists/History")]: { getPersonalHistory: () => [], addToPersonalHistory() {}, clearPersonalHistory: async () => {} },
|
||||
[norm(SRC + "/lyrics/Lyrics")]: {},
|
||||
[norm(SRC + "/discord/ServerSettings")]: { getSecureRole: () => null },
|
||||
[norm(SRC + "/lyrics/Lyrics")]: lyricsStub,
|
||||
[norm(SRC + "/discord/ServerSettings")]: serverSettingsStub,
|
||||
[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 },
|
||||
@@ -218,6 +279,112 @@ function serverSocket(client) { return ioRef.sockets.sockets.get(client.id) }
|
||||
const status = await new Promise(r => c.emit("/GOOGLE/STATUS", null, r))
|
||||
assert.strictEqual(status.user, "C")
|
||||
|
||||
// --- Playlists partagées : option, droits, poussées (réponses par acquittement) ---
|
||||
const ackReq = (s, event, data) => new Promise((resolve, reject) => {
|
||||
s.emit(event, data, resolve)
|
||||
setTimeout(() => reject(new Error("pas d'acquittement " + event)), 3000)
|
||||
})
|
||||
const d = await connect("tD")
|
||||
assert.strictEqual(await request(d, "/GUILD/JOIN", "g1"), true)
|
||||
let list = await ackReq(d, "/GUILD/PLAYLISTS/LIST")
|
||||
assert.ok(list.ok && list.guildId === "g1" && list.settings.enabled === false, "LIST désactivé : " + JSON.stringify(list))
|
||||
assert.deepStrictEqual(list.playlists, [], "désactivé : playlists masquées")
|
||||
assert.strictEqual(list.canEdit, false)
|
||||
let r = await ackReq(d, "/GUILD/PLAYLISTS/CREATE", { name: "Test" })
|
||||
assert.strictEqual(r.error, "DISABLED")
|
||||
r = await ackReq(d, "/OWNER/PLAYLISTS/SETTINGS", { enabled: true })
|
||||
assert.strictEqual(r.error, "FORBIDDEN", "un membre ne gère pas l'option")
|
||||
assert.ok(!sharedSettings.has("g1"), "aucun réglage écrit par un refus")
|
||||
|
||||
const pushed = new Promise(res => d.once("/GUILD/PLAYLISTS/UPDATE", res))
|
||||
r = await ackReq(c, "/OWNER/PLAYLISTS/SETTINGS", { enabled: true })
|
||||
assert.ok(r.ok && r.settings.enabled === true && r.settings.editors === "members", JSON.stringify(r))
|
||||
assert.deepStrictEqual(await pushed, { guildId: "g1" }, "poussée /GUILD/PLAYLISTS/UPDATE à la room du serveur")
|
||||
r = await ackReq(c, "/OWNER/PLAYLISTS/SETTINGS", { editors: "tous" })
|
||||
assert.strictEqual(r.error, "INVALID_PARAMS")
|
||||
|
||||
list = await ackReq(d, "/GUILD/PLAYLISTS/LIST")
|
||||
assert.ok(list.ok && list.canEdit === true && list.canManage === false && list.playlists.length === 1, JSON.stringify(list))
|
||||
r = await ackReq(d, "/GUILD/PLAYLISTS/CREATE", { name: " " })
|
||||
assert.strictEqual(r.error, "INVALID_PARAMS", "nom vide refusé")
|
||||
r = await ackReq(d, "/GUILD/PLAYLISTS/CREATE", { name: "x".repeat(101) })
|
||||
assert.strictEqual(r.error, "INVALID_PARAMS", "nom de plus de 100 caractères refusé")
|
||||
r = await ackReq(d, "/GUILD/PLAYLISTS/CREATE", { name: " De D " })
|
||||
assert.ok(r.ok && r.playlist.title === "De D" && r.playlist.createdBy.id === "D", JSON.stringify(r))
|
||||
const createdByD = r.playlist.playlistId
|
||||
|
||||
// Modérateurs seulement : D (simple membre) ne modifie plus, mais peut supprimer SA playlist
|
||||
r = await ackReq(c, "/OWNER/PLAYLISTS/SETTINGS", { editors: "mods" })
|
||||
assert.ok(r.ok && r.settings.editors === "mods" && r.settings.enabled === true)
|
||||
list = await ackReq(d, "/GUILD/PLAYLISTS/LIST")
|
||||
assert.strictEqual(list.canEdit, false)
|
||||
r = await ackReq(d, "/GUILD/PLAYLISTS/CREATE", { name: "Refusée" })
|
||||
assert.strictEqual(r.error, "FORBIDDEN")
|
||||
// Toutes les routes d'écriture sont refusées à un simple membre (vérifié avant le module)
|
||||
for (const [route, payload] of [
|
||||
["/GUILD/PLAYLISTS/RENAME", { id: "10", newName: "Piratée" }],
|
||||
["/GUILD/PLAYLISTS/ADD_SONG", { id: "10", song: { type: "youtube", id: "x", title: "x", url: "https://www.youtube.com/watch?v=x" } }],
|
||||
["/GUILD/PLAYLISTS/REMOVE_SONG", { id: "10", songId: "x" }],
|
||||
["/GUILD/PLAYLISTS/MOVE_SONG", { id: "10", from: 0, to: 1 }],
|
||||
["/GUILD/PLAYLISTS/REORDER", { order: ["10"] }],
|
||||
["/GUILD/PLAYLISTS/REFRESH", { id: "10" }],
|
||||
["/GUILD/PLAYLISTS/IMPORT", { playlistId: "1" }],
|
||||
]) {
|
||||
r = await ackReq(d, route, payload)
|
||||
assert.strictEqual(r.error, "FORBIDDEN", route + " refusé à un membre en mode modérateurs : " + JSON.stringify(r))
|
||||
}
|
||||
assert.strictEqual(guildLists.g1.find(p => p.playlistId === "10").title, "Commune", "aucune modification par un refus")
|
||||
r = await ackReq(d, "/GUILD/PLAYLISTS/DELETE", { id: "10" })
|
||||
assert.strictEqual(r.error, "FORBIDDEN", "playlist d'un autre membre")
|
||||
r = await ackReq(d, "/GUILD/PLAYLISTS/DELETE", { id: createdByD })
|
||||
assert.ok(r.ok, "le créateur supprime sa playlist : " + JSON.stringify(r))
|
||||
r = await ackReq(c, "/GUILD/PLAYLISTS/DELETE", { id: "inconnue" })
|
||||
assert.strictEqual(r.error, "NOT_FOUND")
|
||||
const ginfo = await request(d, "/GUILD/INFO")
|
||||
assert.deepStrictEqual(ginfo.sharedPlaylists, { enabled: true, editors: "mods", canEdit: false, canManage: false })
|
||||
// Copie d'une playlist du serveur dans mes playlists : simple lecture (D n'est pas éditeur)
|
||||
const personalPush = new Promise(res => d.once("/PLAYLISTS/UPDATE", res))
|
||||
r = await ackReq(d, "/GUILD/PLAYLISTS/COPY", { id: "10" })
|
||||
assert.ok(r.ok && r.playlist.title === "Commune", JSON.stringify(r))
|
||||
await personalPush
|
||||
|
||||
// --- Playlists personnelles : réponses par acquittement + poussée à toutes les sessions ---
|
||||
const cPush = new Promise(res => c.once("/PLAYLISTS/UPDATE", res))
|
||||
assert.strictEqual(await ackReq(c, "/PLAYLISTS/REORDER", { order: ["3", "inconnu", "1", "3"] }), true)
|
||||
await cPush
|
||||
assert.deepStrictEqual(personal.C.map(p => p.playlistId), ["3", "1", "2"], "ordre appliqué, inconnus ignorés, oubliés à la suite")
|
||||
assert.strictEqual(await ackReq(c, "/PLAYLISTS/REORDER", { order: "3,1" }), false)
|
||||
const listed = await ackReq(c, "/PLAYLISTS/LIST")
|
||||
assert.deepStrictEqual(listed.map(p => p.playlistId), ["3", "1", "2"])
|
||||
assert.ok(Array.isArray(await request(c, "/PLAYLISTS/LIST")), "ancien client (événement du même nom)")
|
||||
assert.strictEqual(await ackReq(c, "/PLAYLISTS/MOVE_SONG", { id: "1", from: 0, to: "x" }), false)
|
||||
assert.strictEqual(await ackReq(c, "/PLAYLISTS/CREATE", { name: "" }), false)
|
||||
|
||||
// --- Paroles ---
|
||||
r = await ackReq(c, "/PLAYER/LYRICS")
|
||||
assert.deepStrictEqual(r, { ok: false, error: "NO_MUSIC" }, "rien en cours de lecture")
|
||||
r = await ackReq(c, "/LYRICS/SEARCH", { q: " " })
|
||||
assert.strictEqual(r.error, "INVALID_PARAMS")
|
||||
r = await ackReq(c, "/LYRICS/SEARCH", { q: "x".repeat(201) })
|
||||
assert.strictEqual(r.error, "INVALID_PARAMS")
|
||||
r = await ackReq(c, "/LYRICS/SEARCH", { q: " daft punk one more time " })
|
||||
assert.ok(r.ok && r.results[0].track === "daft punk one more time", JSON.stringify(r))
|
||||
r = await ackReq(c, "/LYRICS/GET", { id: -4 })
|
||||
assert.strictEqual(r.error, "INVALID_PARAMS")
|
||||
r = await ackReq(c, "/LYRICS/GET", { id: 42 })
|
||||
assert.strictEqual(r.error, "UNAVAILABLE", "fonction absente du module : UNAVAILABLE")
|
||||
r = await request(c, "/SEARCH/LYRICS", { track: "Titre", artist: "Artiste", duration: 200 })
|
||||
assert.strictEqual(r.error, "NOT_FOUND", "ancienne route : résultat du module")
|
||||
assert.deepStrictEqual(lyricsCalls[lyricsCalls.length - 1], { track: "Titre", artist: "Artiste", duration: 200 })
|
||||
r = await ackReq(c, "/SEARCH/LYRICS", " artiste titre ")
|
||||
assert.strictEqual(lyricsCalls[lyricsCalls.length - 1], "artiste titre", "texte libre transmis tel quel (nettoyé)")
|
||||
r = await ackReq(c, "/SEARCH/LYRICS", { track: "", artist: "x" })
|
||||
assert.strictEqual(r.error, "INVALID_PARAMS")
|
||||
|
||||
// --- Statistiques publiques du serveur ---
|
||||
r = await ackReq(d, "/GUILD/STATS")
|
||||
assert.deepStrictEqual(r, { ok: true, musicPlayed: 12, secondsPlayed: 3600, commands: 0 })
|
||||
|
||||
await sleep(100)
|
||||
assert.deepStrictEqual(uncaught, [], "aucune exception non gérée")
|
||||
console.log("server-socket.test.js : OK")
|
||||
|
||||
Reference in new issue
Block a user