Files
chopin/tests/dev/google-flow.test.js

258 lines
14 KiB
JavaScript

// 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) })