// Stockage des fichiers envoyés : conversion Opus (ffmpeg-static réel), liens Discord expirants, // MediaBase avec un faux client Discord. Dossier data temporaire : jamais backend/data. const { BACKEND, SCRATCH, __glob, check, done } = require('./_setup'); const path = require('path'); const fs = require('fs'); const { EventEmitter } = require('events'); const { spawnSync } = require('child_process'); const DATA = fs.mkdtempSync(path.join(SCRATCH, 'media-')); __glob.MEDIA_DB = path.join(DATA, 'media.json'); __glob.METRIC_FILE = path.join(DATA, 'metrics.json'); const src = (rel) => path.join(BACKEND, 'src', rel); const ffmpeg = require(path.join(BACKEND, 'node_modules', 'ffmpeg-static')); const discordUrl = require(src('media/DiscordUrl')); const audioPrepare = require(src('media/AudioPrepare')); const HOUR = 3600 * 1000; const hex = (ms) => Math.floor(ms / 1000).toString(16); const cdn = (name, expiresAt, n = 0) => `https://cdn.discordapp.com/attachments/111/${222 + n}/${name}?ex=${hex(expiresAt)}&is=${hex(expiresAt - 24 * HOUR)}&hm=${'ab'.repeat(8)}${n}&`; function makeFixture(file, args) { const res = spawnSync(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-y', ...args, file], { windowsHide: true }); if (res.status !== 0) throw new Error('ffmpeg fixture: ' + res.stderr.toString()); return fs.readFileSync(file); } (async () => { console.log('DiscordUrl'); const now = Date.now(); const fresh = cdn('a.ogg', now + 20 * HOUR); check('lien CDN reconnu', discordUrl.isDiscordCdn(fresh)); check('lien non Discord ignoré', !discordUrl.isDiscordCdn('https://example.com/a.ogg') && !discordUrl.needsRefresh('https://example.com/a.ogg')); check('http refusé', !discordUrl.isDiscordCdn('http://cdn.discordapp.com/a.ogg')); check('expiration lue (hex)', Math.abs(discordUrl.getExpiry(fresh) - (now + 20 * HOUR)) < 1000); check('lien valide : pas de renouvellement', !discordUrl.needsRefresh(fresh, HOUR, now)); check('lien proche de l\'échéance : renouvellement', discordUrl.needsRefresh(cdn('a.ogg', now + 30 * 60 * 1000), HOUR, now)); check('lien expiré : renouvellement', discordUrl.needsRefresh(cdn('a.ogg', now - HOUR), HOUR, now)); check('lien non signé : renouvellement', discordUrl.needsRefresh('https://cdn.discordapp.com/attachments/1/2/a.ogg')); check('base identique malgré la signature', discordUrl.baseOf(fresh) === discordUrl.baseOf(cdn('a.ogg', now + 5 * HOUR))); console.log('Planification du débit'); const MiB = 1024 * 1024; check('court : débit préféré (96k)', audioPrepare.planBitrate(180, 10 * MiB) === 96000); const long = audioPrepare.planBitrate(20 * 60, 10 * MiB); check('20 min dans 10 Mo : débit réduit ≥ 32k', long >= 32000 && long < 96000 && long % 1000 === 0, long); check('trop long : refusé', audioPrepare.planBitrate(60 * 60, 10 * MiB) === null); check('durée inconnue : 64k', audioPrepare.planBitrate(0, 10 * MiB) === 64000); check('durée max ~38 min pour 10 Mo', Math.abs(audioPrepare.maxDurationFor(10 * MiB) - 2411) < 5, audioPrepare.maxDurationFor(10 * MiB)); check('nom de fichier sûr', audioPrepare.safeFileName('../../Été — mon titre!!.mp3', 'ogg') === 'Ete_mon_titre.ogg', audioPrepare.safeFileName('../../Été — mon titre!!.mp3', 'ogg')); check('titre depuis le nom', audioPrepare.titleFromName('Mon_titre_final.wav') === 'Mon titre final'); console.log('Conversion (ffmpeg réel)'); const cover = path.join(DATA, 'cover.png'); makeFixture(cover, ['-f', 'lavfi', '-i', 'color=c=red:s=64x64', '-frames:v', '1']); const mp3 = makeFixture(path.join(DATA, 'tagged.mp3'), [ '-f', 'lavfi', '-i', 'sine=frequency=440:duration=20', '-i', cover, '-map', '0:a', '-map', '1:v', '-c:a', 'libmp3lame', '-b:a', '192k', '-c:v', 'mjpeg', '-disposition:v', 'attached_pic', '-id3v2_version', '3', '-metadata', 'title=Titre test', '-metadata', 'artist=Artiste test' ]); const wav = makeFixture(path.join(DATA, 'plain.wav'), ['-f', 'lavfi', '-i', 'sine=frequency=220:duration=8']); const ok = await audioPrepare.prepare(mp3, 'tagged.mp3', { limitBytes: 10 * MiB }); check('MP3 converti', ok.ok === true, ok); check('sortie Ogg/Opus', ok.audio?.subarray(0, 4).toString() === 'OggS' && ok.audio.includes(Buffer.from('OpusHead'))); check('étiquettes lues', ok.meta?.title === 'Titre test' && ok.meta?.artist === 'Artiste test', ok.meta); check('durée ~20 s', Math.abs((ok.meta?.duration || 0) - 20) < 0.5, ok.meta?.duration); check('pochette extraite (JPEG)', ok.cover?.subarray(0, 2).toString('hex') === 'ffd8' && ok.coverName === 'cover.jpg'); check('débit 96k', ok.bitrate === 96000, ok.bitrate); check('nom .ogg', ok.audioName === 'tagged.ogg'); check('plus léger que l\'original', ok.audio.length < mp3.length, [ok.audio.length, mp3.length]); const plain = await audioPrepare.prepare(wav, 'Ma prise.wav', { limitBytes: 10 * MiB }); check('WAV sans étiquettes : titre du nom', plain.ok && plain.meta.title === 'Ma prise' && plain.meta.artist === null && plain.cover === null, plain.meta); const tight = await audioPrepare.prepare(mp3, 'tagged.mp3', { limitBytes: 150 * 1024 }); check('budget serré : débit abaissé et respecté', tight.ok && tight.bitrate < 96000 && tight.audio.length + (tight.cover?.length || 0) <= 150 * 1024, tight.ok ? [tight.bitrate, tight.audio.length] : tight); const tooLong = await audioPrepare.prepare(mp3, 'tagged.mp3', { limitBytes: 40 * 1024 }); check('trop long pour la limite : TOO_LONG', tooLong.ok === false && tooLong.error === 'TOO_LONG', tooLong); const garbage = await audioPrepare.prepare(Buffer.from('ceci n\'est pas un fichier audio'.repeat(50)), 'faux.mp3', { limitBytes: 10 * MiB }); check('fichier non audio : INVALID_FILE', garbage.ok === false && garbage.error === 'INVALID_FILE', garbage); const empty = await audioPrepare.prepare(Buffer.alloc(0), 'vide.mp3', { limitBytes: 10 * MiB }); check('fichier vide : INVALID_FILE', empty.error === 'INVALID_FILE'); check('aucun dossier temporaire laissé', fs.readdirSync(require('os').tmpdir()).filter(e => e.startsWith('chopin-media-')).length === 0); console.log('MediaBase (faux Discord)'); const { Collection } = require(path.join(BACKEND, 'node_modules', 'discord.js')); let messageSeq = 900; const messages = new Map(); const calls = { refresh: 0, fetch: 0, delete: 0 }; let refreshMode = 'ok'; let nextExpiry = now + 20 * HOUR; function buildMessage(id, files) { const attachments = new Collection(); files.forEach((f, i) => attachments.set(String(i), { name: f.name, url: cdn(f.name, nextExpiry, Number(id) + i), size: f.size })); return { id, channelId: 'chan', attachments }; } const channel = { id: 'chan', guild: { premiumTier: 0 }, send: async (opts) => { const id = String(++messageSeq); const files = opts.files.map(a => ({ name: a.name, size: a.attachment.length })); messages.set(id, files); return buildMessage(id, files); }, messages: { fetch: async ({ message }) => { calls.fetch++; if (!messages.has(message)) throw Object.assign(new Error('Unknown Message'), { code: 10008 }); return buildMessage(message, messages.get(message)); }, delete: async (id) => { calls.delete++; messages.delete(id); } } }; const client = new EventEmitter(); client.isReady = () => true; client.channels = { fetch: async () => channel }; client.rest = { post: async (route, { body }) => { calls.refresh++; if (route !== '/attachments/refresh-urls') throw new Error('route inattendue'); if (refreshMode === 'fail') throw new Error('refusé'); return { refreshed_urls: body.attachment_urls.map(u => ({ original: u, refreshed: u.replace(/ex=[0-9a-f]+/, 'ex=' + hex(now + 23 * HOUR)) })) }; } }; const stub = (rel, exports) => { const file = require.resolve(src(rel)); require.cache[file] = { id: file, filename: file, loaded: true, exports }; }; stub('discord/Bot', { getClient: () => client, getChannel: () => channel }); stub('utils/Database/Configuration', { getMediaGuildId: () => '1', getMediaChannelId: () => '2' }); const mediaBase = require(src('discord/MediaBase')); check('non connecté avant ready', mediaBase.isConnected() === false); const refused = await mediaBase.postMedia({ name: 'a.mp3', file: mp3 }, { id: 'u1' }); check('envoi refusé tant que le stockage est indisponible', refused.ok === false && refused.error === 'UNAVAILABLE'); client.emit('ready'); await new Promise(r => setImmediate(r)); check('connecté après ready', mediaBase.isConnected() === true); const info = mediaBase.getInfo(); check('limite 10 Mo (serveur non boosté)', info.storageLimitBytes === 10 * MiB && info.available === true && info.maxDurationSec > 30 * 60, info); channel.guild.premiumTier = 3; check('limite 100 Mo au niveau 3', mediaBase.getStorageLimitBytes() === 100 * MiB); channel.guild.premiumTier = 0; const posted = await mediaBase.postMedia({ name: 'tagged.mp3', file: mp3.buffer.slice(mp3.byteOffset, mp3.byteOffset + mp3.length) }, { id: 'u1', username: 'alice' }); check('envoi OK (ArrayBuffer accepté)', posted.ok === true, posted); check('morceau renvoyé', posted.song?.type === 'attachment' && posted.song.title === 'Titre test' && posted.song.author === 'Artiste test' && Math.round(posted.song.duration) === 20, posted.song); check('pochette en miniature', /cover\.jpg/.test(posted.song?.thumbnail || '')); const stored = JSON.parse(fs.readFileSync(__glob.MEDIA_DB, 'utf8')); check('entrée persistée avec identifiant du message', stored.length === 1 && stored[0].id === posted.song.id && stored[0].format === 'opus' && stored[0].userId === 'u1' && stored[0].channelId === 'chan', stored[0]); const badFile = await mediaBase.postMedia({ name: 'faux.mp3', file: Buffer.from('nope'.repeat(100)) }, { id: 'u1' }); check('fichier invalide refusé sans message Discord', badFile.ok === false && badFile.error === 'INVALID_FILE' && messages.size === 1); const noName = await mediaBase.postMedia({ name: '', file: mp3 }, { id: 'u1' }); check('nom manquant refusé', noName.ok === false); let list = await mediaBase.getAllMedia('u1'); check('liste : 1 fichier, aucun renouvellement inutile', list.length === 1 && calls.refresh === 0 && calls.fetch === 0, calls); check('autre utilisateur : liste vide', (await mediaBase.getAllMedia('u2')).length === 0); // Lien expiré : renouvellement groupé, l'entrée n'est plus supprimée comme avant const db = () => JSON.parse(fs.readFileSync(__glob.MEDIA_DB, 'utf8')); const entry = require(path.join(BACKEND, 'src', 'utils', 'Database', 'Database')).getDatabase('media').data[0]; entry.url = cdn('tagged.ogg', now - HOUR, 900); entry.thumbnail = cdn('cover.jpg', now - HOUR, 901); list = await mediaBase.getAllMedia('u1'); check('lien expiré renouvelé via refresh-urls', list.length === 1 && calls.refresh === 1 && !discordUrl.needsRefresh(list[0].url) && !discordUrl.needsRefresh(list[0].thumbnail), calls); check('renouvellement persisté', !discordUrl.needsRefresh(db()[0].url)); // refresh-urls indisponible : relecture du message refreshMode = 'fail'; entry.url = cdn('tagged.ogg', now - HOUR, 900); list = await mediaBase.getAllMedia('u1'); check('repli : relecture du message', list.length === 1 && calls.fetch === 1 && !discordUrl.needsRefresh(list[0].url), calls); // Lecture : lien frais + format Opus (transmis sans ffmpeg) entry.url = cdn('tagged.ogg', now - HOUR, 900); const playable = await mediaBase.resolvePlayableUrl({ id: posted.song.id, url: 'https://cdn.discordapp.com/vieux' }); check('lecture : lien frais et format opus', playable && playable.format === 'opus' && !discordUrl.needsRefresh(playable.url), playable); const forced = await mediaBase.resolvePlayableUrl({ id: posted.song.id }, { force: true }); check('lecture forcée : message relu', forced && calls.fetch === 3, calls); refreshMode = 'ok'; const foreign = await mediaBase.resolvePlayableUrl({ id: 'inconnu', url: cdn('x.mp3', now - HOUR, 5) }); check('pièce jointe hors stockage renouvelée', foreign && foreign.format === null && !discordUrl.needsRefresh(foreign.url), foreign); const external = await mediaBase.resolvePlayableUrl({ id: 'inconnu', url: 'https://example.com/a.mp3' }); check('lien non Discord inchangé', external?.url === 'https://example.com/a.mp3'); // Message supprimé sur Discord : masqué, mais l'entrée reste en base const second = await mediaBase.postMedia({ name: 'plain.wav', file: wav }, { id: 'u1', username: 'alice' }); check('second envoi OK', second.ok === true && second.song.title === 'plain'); messages.delete(second.song.id); const entry2 = require(path.join(BACKEND, 'src', 'utils', 'Database', 'Database')).getDatabase('media').data[1]; entry2.url = cdn('plain.ogg', now - HOUR, 902); refreshMode = 'fail'; list = await mediaBase.getAllMedia('u1'); check('message supprimé : fichier masqué', list.length === 1 && list[0].id === posted.song.id, list.map(s => s.id)); check('entrée conservée et marquée manquante', db().length === 2 && db()[1].missing === true); check('lecture d\'un fichier manquant : null', (await mediaBase.resolvePlayableUrl({ id: second.song.id })) === null); // Suppression check('suppression par un autre utilisateur refusée', (await mediaBase.deleteMedia({ id: posted.song.id }, 'u2')) === false); check('suppression OK', (await mediaBase.deleteMedia({ id: posted.song.id }, 'u1')) === true && calls.delete === 1 && !messages.has(posted.song.id)); check('base mise à jour', db().length === 1 && db()[0].id === second.song.id); console.log('Administration'); let pushes = 0; process.on('MEDIA_UPDATE', () => { pushes++; }); const status = mediaBase.getStorageStatus(); check('état du stockage', status.connected === true && status.configured === true && status.premiumTier === 0 && status.storageLimitBytes === 10 * MiB, status); const copy = mediaBase.listEntries(); copy[0].title = 'modifié'; check('listEntries renvoie une copie', mediaBase.listEntries()[0].title !== 'modifié'); check('fichier inconnu : undefined', (await mediaBase.refreshMedia('123')) === undefined); check('fichier toujours absent de Discord : null', (await mediaBase.refreshMedia(second.song.id)) === null && db()[0].missing === true); // Message rétabli (salon restauré) : la vérification manuelle lève le marquage messages.set(second.song.id, [{ name: 'plain.ogg', size: 1000 }]); const restored = await mediaBase.refreshMedia(second.song.id); check('fichier rétabli : marquage levé', restored && !restored.missing && !db()[0].missing && !discordUrl.needsRefresh(restored.url), restored); check('poussée MEDIA_UPDATE à chaque enregistrement', pushes >= 2, pushes); entry2.url = cdn('plain.ogg', now - HOUR, 902); refreshMode = 'ok'; const all = await mediaBase.refreshAllMedia(); check('renouvellement global', all.checked === 1 && all.refreshed === 1 && all.missing === 0, all); const none = await mediaBase.refreshAllMedia(); check('rien à renouveler', none.checked === 0); const removedByAdmin = await mediaBase.adminDeleteMedia(second.song.id); check('suppression admin quel que soit le propriétaire', removedByAdmin?.userId === 'u1' && db().length === 0 && !messages.has(second.song.id)); check('suppression admin d\'un inconnu : null', (await mediaBase.adminDeleteMedia(second.song.id)) === null); done(); })().catch((e) => { console.error(e); process.exitCode = 1; });