// Test du flux Google (OAuth2.js + YoutubeList.js + PlaylistManager.js) avec googleapis, la base de données, // la configuration, les utilisateurs et loguix simulés : aucun fichier de data/ n'est lu ni écrit, aucun réseau. const assert = require("assert") const Module = require("module") const EventEmitter = require("events") const path = require("path") const SRC = require("path").resolve(__dirname, "../../src") const norm = (p) => p.replace(/\\/g, "/").toLowerCase() // --- Simulations --- const cfg = { id: "client-id", secret: "client-secret", website: "https://chopin.example/" } const databases = {} class FakeDatabase { constructor(name, file, empty) { this.name = name; this.path = file; this.data = JSON.parse(JSON.stringify(empty || {})); this.saves = 0; databases[name] = this } save() { this.saves++ } } const fakeLog = { LogType: class { log() {} warn() {} error() {} }, getInstance: () => ({ log() {}, warn() {}, error() {} }) } const api = { calls: { playlists: 0, items: 0, videos: 0, channels: 0 }, failWith: null, revoked: [] } const PLAYLISTS = [ { id: "PLa", snippet: { title: "Rock", channelTitle: "Moi", channelId: "UCme", thumbnails: { default: { url: "t-a" } } }, contentDetails: { itemCount: 120 } }, { id: "PLb", snippet: { title: "Calme", channelTitle: "Moi", channelId: "UCme" }, contentDetails: { itemCount: 3 } }, { id: "PLc", snippet: { title: "Vide", channelTitle: "Moi", channelId: "UCme", thumbnails: null }, contentDetails: { itemCount: 0 } }, ] function itemsFor(listId) { const count = { PLa: 120, PLb: 3, PLc: 0, LLme: 60 }[listId] ?? 0 return Array.from({ length: count }, (_, i) => { // LL réutilise les vidéos de PLa (cache des durées partagé) const vid = (listId === "LLme" ? "PLa" : listId) + "-v" + i const priv = listId === "PLb" && i === 1 return { snippet: { title: priv ? "Private video" : "Titre " + vid, resourceId: { videoId: vid }, videoOwnerChannelTitle: priv ? undefined : "Artiste", videoOwnerChannelId: priv ? undefined : "UCart", thumbnails: { medium: { url: "m-" + vid } } }, contentDetails: { videoId: vid } } }) } function page(all, token, size) { const start = token ? Number(token) : 0 return { items: all.slice(start, start + size), nextPageToken: start + size < all.length ? String(start + size) : undefined } } function maybeFail() { if (api.failWith) throw api.failWith } class FakeOAuth2 extends EventEmitter { constructor(id, secret, uri) { super(); this.args = [id, secret, uri]; this.credentials = null } generateAuthUrl(opts) { this.lastOpts = opts; return "https://accounts.google.com/o/oauth2/v2/auth?" + new URLSearchParams({ ...opts, scope: opts.scope.join(" ") }) } async getToken(code) { if (code === "bad") { const e = new Error("invalid_grant"); e.response = { data: { error: "invalid_grant" } }; throw e } return { tokens: { access_token: "AT-" + code, refresh_token: "RT-" + code, expiry_date: 123, scope: "yt" } } } setCredentials(t) { this.credentials = t } async revokeToken(t) { api.revoked.push(t); return {} } } const fakeGoogle = { google: { auth: { OAuth2: FakeOAuth2 }, youtube: ({ auth }) => { assert.ok(auth instanceof FakeOAuth2, "service authentifié") return { channels: { list: async () => { api.calls.channels++; return { data: { items: [{ id: "UCme", snippet: { title: "Ma chaîne" }, contentDetails: { relatedPlaylists: { likes: "LLme" } } }] } } } }, playlists: { list: async (p) => { api.calls.playlists++; maybeFail() if (p.id) return { data: { items: PLAYLISTS.filter(x => p.id.includes(x.id)) } } assert.strictEqual(p.mine, true) return { data: page(PLAYLISTS, p.pageToken, 2) } } }, playlistItems: { list: async (p) => { api.calls.items++; maybeFail() assert.strictEqual(p.maxResults, 50) return { data: page(itemsFor(p.playlistId), p.pageToken, 50) } } }, videos: { list: async (p) => { api.calls.videos++; maybeFail() assert.ok(p.id.length <= 50, "50 ids max par appel") return { data: { items: p.id.map((id, i) => ({ id, contentDetails: { duration: "PT" + (i % 5) + "M" + (i % 60) + "S" } })) } } } }, } } } } const stubs = { "googleapis": fakeGoogle, "loguix": fakeLog, [norm(SRC + "/utils/Database/Database")]: { Database: FakeDatabase, getDatabase: (n) => databases[n] }, [norm(SRC + "/utils/Database/Configuration")]: { getYoutubeApiClientId: () => cfg.id, getYoutubeApiClientSecret: () => cfg.secret, getWebsiteLink: () => cfg.website }, [norm(SRC + "/server/auth/User")]: { getUserById: (id) => (id ? { identity: { id } } : null) }, [norm(SRC + "/player/Finder")]: { search: async () => null }, [norm(SRC + "/media/SpotifyInformation")]: { getTracks: async (p) => p }, [norm(SRC + "/utils/SafeMetric")]: { toSafeDuration: (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 && n <= 86400 ? Math.floor(n) : 0 } }, } const origLoad = Module._load Module._load = function (request, parent, isMain) { if (stubs[request]) return stubs[request] if (request.startsWith(".") && parent) { const resolved = norm(path.resolve(path.dirname(parent.filename), request)).replace(/\.js$/, "") if (stubs[resolved]) return stubs[resolved] } return origLoad.apply(this, arguments) } const events = [] process.on("GOOGLE_UPDATE", (u) => events.push("GOOGLE_UPDATE:" + u)) process.on("PLAYLISTS_UPDATE", (u) => events.push("PLAYLISTS_UPDATE:" + u)) const OAuth2 = require(SRC + "/playlists/Google/OAuth2.js") const PM = require(SRC + "/playlists/PlaylistManager.js") const store = databases.google assert.ok(store && store.path.endsWith("google.json"), "base google.json") ;(async () => { // --- Configuration absente : bouton désactivé --- cfg.id = "" assert.strictEqual(OAuth2.getStatus("u1").configured, false) assert.strictEqual(OAuth2.createAuthUrl("u1"), null) cfg.id = "client-id" // --- URL de consentement --- const url = new URL(OAuth2.createAuthUrl("u1")) const state = url.searchParams.get("state") assert.strictEqual(url.searchParams.get("prompt"), "consent") assert.strictEqual(url.searchParams.get("access_type"), "offline") assert.match(state, /^[0-9a-f]{48}$/) // --- Liaison : state d'un autre utilisateur / invalide refusé --- await assert.rejects(OAuth2.linkAccount("u2", "code1", state), { code: "INVALID_STATE" }) await assert.rejects(OAuth2.linkAccount("u1", "code1", "nope"), { code: "INVALID_STATE" }) await assert.rejects(OAuth2.linkAccount("u1", "", state), { code: "INVALID_PARAMS" }) const status = await OAuth2.linkAccount("u1", "code1", state) assert.deepStrictEqual([status.linked, status.valid, status.channel.title], [true, true, "Ma chaîne"]) assert.strictEqual(store.data.u1.tokens.refresh_token, "RT-code1") assert.strictEqual(store.data.u1.likesPlaylistId, "LLme") await assert.rejects(OAuth2.linkAccount("u1", "code1", state), { code: "INVALID_STATE" }, "state à usage unique") assert.ok(!JSON.stringify(OAuth2.getStatus("u1")).includes("RT-"), "aucun jeton dans le statut") // Redirect URI = site + /oauth2callback (slash final retiré) const client = OAuth2.getClient("u1") assert.strictEqual(client.args[2], "https://chopin.example/oauth2callback") assert.strictEqual(OAuth2.getClient("u1"), client, "client en cache") // --- Jetons rafraîchis par la librairie : persistés, refresh_token conservé --- client.emit("tokens", { access_token: "AT-new", expiry_date: 999 }) assert.deepStrictEqual([store.data.u1.tokens.access_token, store.data.u1.tokens.refresh_token], ["AT-new", "RT-code1"]) // --- CONFIG_UPDATE : client recréé (identifiants relus) --- cfg.secret = "secret-2" process.emit("CONFIG_UPDATE") const client2 = OAuth2.getClient("u1") assert.notStrictEqual(client2, client) assert.strictEqual(client2.args[1], "secret-2") assert.strictEqual(client2.credentials.access_token, "AT-new") // --- Synchronisation (+ concurrence) --- const p1 = PM.syncYoutube("u1", { force: true }) assert.strictEqual(OAuth2.getStatus("u1").syncing, true) const p2 = await PM.syncYoutube("u1", { force: true }) assert.strictEqual(p2.error, "ALREADY_SYNCING") const r1 = await p1 assert.strictEqual(r1.ok, true, JSON.stringify(r1)) assert.strictEqual(r1.playlists, 4, "3 playlists + J'aime") const list = PM.getPlaylistsOfUser("u1") assert.strictEqual(list.length, 4) const byYt = Object.fromEntries(list.map(p => [p.youtubeId, p])) assert.strictEqual(byYt.PLa.songs.length, 120, "pagination 50+50+20") assert.strictEqual(byYt.PLb.songs.length, 2, "vidéo privée ignorée") assert.strictEqual(byYt.PLc.songs.length, 0) assert.strictEqual(byYt.LLme.title, "Vidéos « J'aime »") for (const p of list) { assert.strictEqual(typeof p.playlistId, "string") assert.strictEqual(p.type, "youtube") assert.strictEqual(p.source, "google") assert.ok(Number.isFinite(p.duration)) for (const s of p.songs) { assert.strictEqual(s.type, "youtube") assert.ok(Number.isFinite(s.duration)) assert.ok(s.readduration) } } assert.strictEqual(new Set(list.map(p => p.playlistId)).size, 4, "identifiants uniques") assert.strictEqual(byYt.PLa.songs[1].duration, 61, "durée ISO-8601 : PT1M1S") assert.strictEqual(byYt.PLc.thumbnail, "https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png", "miniature absente tolérée") // 2 pages de playlists ; durées : J'aime (60 → 2 appels), PLa (60 restantes grâce au cache → 2 appels), PLb (1 appel) = 5 assert.strictEqual(api.calls.playlists, 2) assert.strictEqual(api.calls.videos, 5) assert.ok(events.includes("PLAYLISTS_UPDATE:u1")) assert.strictEqual(OAuth2.getStatus("u1").syncing, false) assert.strictEqual(OAuth2.getStatus("u1").lastSync.ok, true) // --- Délai entre deux synchronisations, puis resynchronisation : identifiants et ordre conservés --- assert.strictEqual((await PM.syncYoutube("u1")).error, "COOLDOWN") const ids = list.map(p => p.playlistId).join(",") assert.strictEqual((await PM.syncYoutube("u1", { force: true })).ok, true) assert.strictEqual(PM.getPlaylistsOfUser("u1").map(p => p.playlistId).join(","), ids, "même ordre, mêmes ids") // --- Lecture par identifiant (chaîne ou nombre) --- const anyId = PM.getPlaylistsOfUser("u1")[0].playlistId assert.ok(PM.getPlaylistOfUser("u1", anyId)) assert.ok(PM.getPlaylistOfUser("u1", Number(anyId))) // --- Rafraîchissement d'une playlist importée via l'API (J'aime) --- const likesId = PM.getPlaylistsOfUser("u1").find(p => p.youtubeId === "LLme").playlistId const before = api.calls.items const refreshed = await PM.refreshPlaylist("u1", likesId) assert.ok(refreshed && refreshed.playlistId === likesId && refreshed.songs.length === 60) assert.ok(api.calls.items > before, "rafraîchie via playlistItems.list") // --- Création classique : identifiant primitif (X1) + copie indépendante (X5) --- const created = await PM.addPlaylist("u1", "Ma liste", null, "moi", "u1", "av") assert.strictEqual(typeof created.playlistId, "string") assert.ok(PM.getPlaylistOfUser("u1", created.playlistId), "retrouvée immédiatement") const copy = PM.copyPlaylist("u1", "u2", created.playlistId) assert.ok(copy && copy !== created) assert.strictEqual(PM.copyPlaylist("u1", "u2", created.playlistId), null, "doublon de titre refusé") PM.renamePlaylist("u2", copy.playlistId, "Autre") assert.strictEqual(created.title, "Ma liste", "copie indépendante") // --- Autorisation révoquée chez Google : compte invalide --- const err = new Error("invalid_grant"); err.response = { status: 400, data: { error: "invalid_grant" } } api.failWith = err const failed = await PM.syncYoutube("u1", { force: true }) api.failWith = null assert.strictEqual(failed.error, "AUTH_EXPIRED") const st = OAuth2.getStatus("u1") assert.deepStrictEqual([st.linked, st.valid, st.lastSync.ok], [true, false, false]) assert.strictEqual(OAuth2.getClient("u1"), null) assert.strictEqual((await PM.syncYoutube("u1", { force: true })).error, "NOT_LINKED") // --- Reconnexion puis quota épuisé --- const url2 = new URL(OAuth2.createAuthUrl("u1")) await OAuth2.linkAccount("u1", "code2", url2.searchParams.get("state")) assert.strictEqual(OAuth2.getStatus("u1").valid, true, "reconnexion") const q = new Error("quota"); q.errors = [{ reason: "quotaExceeded" }]; q.response = { status: 403, data: {} } api.failWith = q const quota = await PM.syncYoutube("u1", { force: true }) api.failWith = null assert.strictEqual(quota.error, "QUOTA") assert.strictEqual(OAuth2.getStatus("u1").valid, true, "quota : compte toujours valide") // --- Code invalide --- const url3 = new URL(OAuth2.createAuthUrl("u3")) await assert.rejects(OAuth2.linkAccount("u3", "bad", url3.searchParams.get("state")), { code: "TOKEN_ERROR" }) // --- Déconnexion : suppression + révocation --- const res = await OAuth2.unlink("u1") assert.deepStrictEqual(res, { removed: true, revoked: true }) assert.deepStrictEqual(api.revoked, ["RT-code2"]) assert.strictEqual(store.data.u1, undefined) assert.strictEqual(OAuth2.getStatus("u1").linked, false) assert.ok(PM.getPlaylistsOfUser("u1").length >= 4, "playlists importées conservées") assert.deepStrictEqual(await OAuth2.unlink("u1"), { removed: false, revoked: false }) assert.ok(events.filter(e => e === "GOOGLE_UPDATE:u1").length >= 4) console.log("google-flow.test.js : OK", JSON.stringify(api.calls)) })().catch((e) => { console.error(e); process.exit(1) })