Version 2.0.0 - update of everything
This commit is contained in:
85 files changed
+13065
-2107
No files matched your search
@@ -0,0 +1,257 @@
|
||||
// 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) })
|
||||
Reference in new issue
Block a user