Files
chopin/tests/dev/server-socket.test.js
T
2026-10-08 10:06:46 +02:00

395 lines
22 KiB
JavaScript

// Test de backend/src/server/Server.js avec un vrai serveur socket.io (port local) et des clients socket.io-client.
// Toutes les dépendances (bot Discord, utilisateurs, players, Admin, playlists, Google...) sont simulées :
// aucun bot Discord n'est connecté, aucun fichier de data/ n'est lu ni écrit.
const assert = require("assert")
const Module = require("module")
const path = require("path")
const ROOT = require("path").resolve(__dirname, "../../..")
const SRC = ROOT + "/backend/src"
const { io: ioClient } = require(ROOT + "/frontend/node_modules/socket.io-client")
const norm = (p) => p.replace(/\\/g, "/").toLowerCase()
const PORT = 45000 + Math.floor(Math.random() * 1000)
const uncaught = []
process.on("uncaughtException", (e) => { uncaught.push(e); console.error("uncaughtException", e) })
process.on("unhandledRejection", (e) => { uncaught.push(e); console.error("unhandledRejection", e) })
// --- Utilisateurs : chaque lecture renvoie un NOUVEL objet (comme loadUsers() après chaque saveUsers()) ---
const raw = {
A: { id: "A", tokens: ["tA1", "tA2"], labels: ["ADMIN"] },
B: { id: "B", tokens: ["tB"], labels: [] },
C: { id: "C", tokens: ["tC"], labels: [] },
D: { id: "D", tokens: ["tD"], labels: [] },
}
function makeUser(r) {
if (!r) return null
const labels = [...r.labels]
return {
identity: { id: r.id, username: "user" + r.id, global_name: "User " + r.id, avatar: null },
labels, auth: { ok: true }, tokens: [...r.tokens],
isAdmin: () => labels.includes("ADMIN"),
isFullBanned: () => labels.includes("BAN"),
isBanned: (g) => labels.includes("BAN_" + g),
isMod: (g) => labels.includes("ADMIN") || labels.includes("MOD_" + g),
isDeleted: () => labels.includes("DELETED"),
needUpdate: () => false, justUpdated() {}, clearToken() {},
removeToken(t) { r.tokens = r.tokens.filter(x => x !== t) },
}
}
const usersStub = {
getUserById: (id) => makeUser(raw[id]),
getUserByToken: (t) => makeUser(Object.values(raw).find(r => r.tokens.includes(t))),
setGuildBan: async (id, g) => { const r = raw[id]; const l = "BAN_" + g; r.labels = r.labels.includes(l) ? r.labels.filter(x => x !== l) : [...r.labels, l]; return true },
setGuildMod: async () => true,
deleteAccount: async (id) => { raw[id].labels = ["DELETED"]; raw[id].tokens = [] },
updateIdentity: async () => true,
addUser: async () => null,
}
// --- Bot / players ---
const guilds = new Map([
["g1", { id: "g1", name: "Guilde 1", owner: "C", allMembers: ["A", "B", "C", "D"] }],
["g2", { id: "g2", name: "Guilde 2", owner: "A", allMembers: ["A", "B"] }],
])
const AllPlayers = new Map()
const seekCalls = []
class FakePlayer {
constructor(guildId) { if (AllPlayers.has(guildId)) return AllPlayers.get(guildId); this.guildId = guildId; this.connected = false; this.queue = { current: { title: "x" } }; AllPlayers.set(guildId, this) }
isConnected() { return this.connected }
getState() { return { v: 2, guildId: this.guildId, status: this.connected ? "playing" : "idle" } }
async setDuration(t) { seekCalls.push(t); return { ok: true } }
async skip() { return "no_music" }
}
let ioRef = null
const adminStub = {
ADMIN_ROOM: "ADMIN", LOGS_ROOM: "ADMIN_LOGS",
init(io) { ioRef = io }, attach() {},
syncUserSockets(userId) {
const user = usersStub.getUserById(userId)
for (const s of ioRef.sockets.sockets.values()) {
if (s.data.userId !== userId) continue
for (const room of [...s.rooms]) if (guilds.has(room) && user.isBanned(room)) s.leave(room)
}
process.emit("USERS_UPDATE")
},
}
const fakeLog = { LogType: class { constructor() { this.step = { init() {}, end() {} } } log() {} warn() {} error(m) { if (process.env.VERBOSE) console.log("[error]", m) } } }
let reportResult = false
let finderThrows = true
// --- 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: (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()] },
[norm(SRC + "/discord/Bot")]: { isReady: async () => true, getGuilds: () => guilds, getGuildMember: () => null, getMembersVoices: () => new Map(), getChannel: () => null, getGuildRoles: () => [] },
[norm(SRC + "/server/auth/DiscordAuth")]: {},
[norm(SRC + "/discord/ReportSender")]: { Report: class { async send() { return reportResult } } },
[norm(SRC + "/player/Finder")]: { search: async () => { if (finderThrows) throw new Error("panne simulée"); return [] } },
[norm(SRC + "/playlists/PlaylistManager")]: playlistsStub,
[norm(SRC + "/playlists/GuildPlaylists")]: guildPlaylistsStub,
[norm(SRC + "/playlists/History")]: { getPersonalHistory: () => [], addToPersonalHistory() {}, clearPersonalHistory: async () => {} },
[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 },
[norm(SRC + "/player/List")]: { List: class { getPrevious() { return [] } } },
[norm(SRC + "/server/Admin")]: adminStub,
[norm(SRC + "/utils/AudioBufferCheck")]: { isAudioFile: () => false },
[norm(SRC + "/player/Song")]: { Song: class {} },
[norm(SRC + "/media/MediaInformation")]: {},
}
const origLoad = Module._load
Module._load = function (request, parent) {
if (stubs[request]) return stubs[request]
if (request.startsWith(".") && parent) {
const resolved = norm(path.resolve(path.dirname(parent.filename), request)).replace(/\.js$/, "")
if (stubs[resolved]) return stubs[resolved]
}
return origLoad.apply(this, arguments)
}
const Server = require(SRC + "/server/Server.js")
assert.strictEqual(typeof Server.init, "function", "Server.init exporté")
Server.init()
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
const clients = []
function connect(token) {
return new Promise((resolve, reject) => {
const s = ioClient("http://127.0.0.1:" + PORT, { auth: { token }, reconnection: false, transports: ["websocket"] })
clients.push(s)
s.once("/USER/READY", () => resolve(s))
s.once("AUTH_ERROR", (e) => reject(new Error("AUTH_ERROR " + e)))
setTimeout(() => reject(new Error("timeout connexion " + token)), 3000)
})
}
function request(s, event, data) {
return new Promise((resolve, reject) => {
s.once(event, resolve)
s.emit(event, data)
setTimeout(() => reject(new Error("pas de réponse " + event)), 3000)
})
}
function serverSocket(client) { return ioRef.sockets.sockets.get(client.id) }
;(async () => {
await sleep(100)
const a1 = await connect("tA1"), a2 = await connect("tA2"), b = await connect("tB")
// --- Rooms conservées lors d'un changement de guilde ---
serverSocket(a1).join("ADMIN_LOGS")
assert.strictEqual(await request(a1, "/GUILD/JOIN", "g1"), true)
assert.strictEqual(await request(a1, "/GUILD/JOIN", "g2"), true)
const rooms = [...serverSocket(a1).rooms]
assert.ok(rooms.includes("g2") && !rooms.includes("g1"), "guilde précédente quittée : " + rooms)
for (const r of ["user:A", "ADMIN", "ADMIN_LOGS", a1.id]) assert.ok(rooms.includes(r), "room conservée : " + r)
assert.strictEqual(await request(a1, "/GUILD/JOIN", "inconnue"), false)
// --- Présence multi-appareils ---
await request(a1, "/GUILD/JOIN", "g1")
await request(a2, "/GUILD/JOIN", "g1")
await request(b, "/GUILD/JOIN", "g1")
let members = await request(b, "/USERS/LIST")
assert.deepStrictEqual(members.map(m => m.id).sort(), ["A", "B"], "A une seule fois")
a1.disconnect()
await sleep(150)
members = await request(b, "/USERS/LIST")
assert.ok(members.some(m => m.id === "A"), "A toujours présent (2e appareil)")
const info = await request(b, "/GUILD/INFO")
assert.deepStrictEqual(info.members.map(m => m.id), ["A"])
a2.disconnect()
await sleep(150)
members = await request(b, "/USERS/LIST")
assert.ok(!members.some(m => m.id === "A"), "A absent après la dernière déconnexion")
// --- PLAYER_STATE : jamais de création de player, /GUILD/UPDATE seulement au changement de connexion ---
let guildUpdates = 0, playerUpdates = 0
b.on("/GUILD/UPDATE", () => guildUpdates++)
b.on("/PLAYER/UPDATE", () => playerUpdates++)
process.emit("PLAYER_STATE", "g1")
process.emit("PLAYERS_UPDATE")
await sleep(100)
assert.strictEqual(AllPlayers.size, 0, "aucun player créé")
assert.strictEqual(playerUpdates, 1, "état hors-ligne envoyé à la room g1")
const player = new FakePlayer("g1"); player.connected = true
process.emit("PLAYER_STATE", "g1"); await sleep(50)
process.emit("PLAYER_STATE", "g1"); await sleep(50)
assert.strictEqual(guildUpdates, 2, "1re émission + passage à connecté uniquement (" + guildUpdates + ")")
assert.strictEqual((await request(b, "/PLAYER/STATE")).status, "playing")
// --- SEEK : 0 accepté, négatif refusé, refus du player transmis ---
assert.strictEqual(await request(b, "/PLAYER/SEEK", { time: 0 }), true)
assert.strictEqual(await request(b, "/PLAYER/SEEK", 0), true)
assert.deepStrictEqual(seekCalls, [0, 0])
assert.strictEqual(await request(b, "/PLAYER/SEEK", { time: -1 }), false)
assert.strictEqual(await request(b, "/PLAYER/SEEK", {}), false)
player.queue.current = null
assert.strictEqual(await request(b, "/PLAYER/SEEK", { time: 5 }), false, "pas de musique : false (pas de ReferenceError)")
assert.strictEqual(await request(b, "/PLAYER/FORWARD"), "no_music")
// --- Exception dans un handler : réponse false, process intact ---
assert.strictEqual(await request(b, "/SEARCH", "test"), false)
// --- /REPORT : résultat réel de l'envoi ---
assert.strictEqual(await request(b, "/REPORT", { level: "bug", desc: "x" }), false)
reportResult = true
assert.strictEqual(await request(b, "/REPORT", { level: "bug", desc: "x" }), true)
// --- /MOD/USERS/BAN (plus de UsersBySocket) + droits relus à chaque requête ---
const c = await connect("tC")
await request(c, "/GUILD/JOIN", "g1")
assert.strictEqual(await request(c, "/MOD/USERS/BAN", "B"), true, "le propriétaire bannit B")
await sleep(50)
assert.ok(!serverSocket(b).rooms.has("g1"), "B a quitté la room g1")
assert.strictEqual(await request(b, "/PLAYER/STATE"), false, "B banni : refus immédiat (utilisateur relu)")
assert.strictEqual(await request(b, "/GUILD/JOIN", "g1"), false)
assert.strictEqual(await request(c, "/MOD/USERS/BAN", "B"), true, "levée du ban")
assert.strictEqual(await request(b, "/GUILD/JOIN", "g1"), true)
// --- Bannissement global en cours de session ---
raw.B.labels.push("BAN")
const authError = new Promise(r => b.once("AUTH_ERROR", r))
b.emit("/USER/INFO")
assert.strictEqual(await authError, "Vous êtes banni du serveur")
await sleep(100)
assert.strictEqual(b.connected, false)
// --- /GOOGLE/STATUS par ack ---
const status = await new Promise(r => c.emit("/GOOGLE/STATUS", null, r))
assert.strictEqual(status.user, "C")
// --- 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")
clients.forEach(s => s.disconnect())
ioRef.close()
setTimeout(() => process.exit(0), 100)
})().catch((e) => { console.error(e); process.exit(1) })