1790 lines
53 KiB
JavaScript
1790 lines
53 KiB
JavaScript
"use strict";
|
|
|
|
const fs = require("node:fs");
|
|
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const Module = require("node:module");
|
|
|
|
function loadPluginFresh() {
|
|
const modulePath = require.resolve("./music_server");
|
|
delete require.cache[modulePath];
|
|
return require("./music_server");
|
|
}
|
|
|
|
function loadPluginFromCode(code) {
|
|
const moduleObj = { exports: {} };
|
|
const wrapped = Function(
|
|
"'use strict'; return function(require,__musicfree_require,module,exports,console,env,URL,process){\n" +
|
|
code +
|
|
"\n}",
|
|
)();
|
|
wrapped(
|
|
require,
|
|
require,
|
|
moduleObj,
|
|
moduleObj.exports,
|
|
console,
|
|
undefined,
|
|
URL,
|
|
process,
|
|
);
|
|
return moduleObj.exports;
|
|
}
|
|
|
|
function clearRuntimeEnv() {
|
|
delete globalThis.env;
|
|
}
|
|
|
|
function createHttpClientStub(handler) {
|
|
const calls = [];
|
|
async function invoke(call) {
|
|
calls.push(call);
|
|
return handler(call, calls.length - 1);
|
|
}
|
|
return {
|
|
calls,
|
|
client: {
|
|
async get(url, config) {
|
|
return invoke({
|
|
url,
|
|
params: (config && config.params) || undefined,
|
|
});
|
|
},
|
|
async post(url, data) {
|
|
return invoke({
|
|
method: "post",
|
|
url,
|
|
data: data || undefined,
|
|
});
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
async function withPatchedModuleLoad(handler, run) {
|
|
const originalLoad = Module._load;
|
|
Module._load = function patchedLoad(request, parent, isMain) {
|
|
return handler(request, parent, isMain, originalLoad);
|
|
};
|
|
|
|
try {
|
|
return await run();
|
|
} finally {
|
|
Module._load = originalLoad;
|
|
}
|
|
}
|
|
|
|
test.afterEach(() => {
|
|
clearRuntimeEnv();
|
|
});
|
|
|
|
test("can require ./music_server", () => {
|
|
const plugin = loadPluginFresh();
|
|
assert.ok(plugin);
|
|
});
|
|
|
|
test("plugin.platform is Music_Server", () => {
|
|
const plugin = loadPluginFresh();
|
|
assert.equal(plugin.platform, "Music_Server");
|
|
});
|
|
|
|
test("plugin.supportedSearchType includes music, artist, and sheet", () => {
|
|
const plugin = loadPluginFresh();
|
|
assert.deepEqual(plugin.supportedSearchType, ["music", "artist", "sheet"]);
|
|
});
|
|
|
|
test("plugin.userVariables has only baseUrl and accessToken", () => {
|
|
const plugin = loadPluginFresh();
|
|
const keys = plugin.userVariables.map((item) => item.key);
|
|
assert.deepEqual(keys, ["baseUrl", "accessToken"]);
|
|
});
|
|
|
|
test("readConfigValue reads from env.getUserVariables", () => {
|
|
globalThis.env = {
|
|
getUserVariables: () => ({
|
|
baseUrl: "https://env-getter.example.com/",
|
|
accessToken: "getter-token",
|
|
}),
|
|
userVariables: {
|
|
baseUrl: "https://env-user.example.com/",
|
|
accessToken: "user-token",
|
|
},
|
|
};
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
assert.equal(
|
|
plugin.readConfigValue("baseUrl"),
|
|
"https://env-getter.example.com/",
|
|
);
|
|
assert.equal(plugin.readConfigValue("accessToken"), "getter-token");
|
|
});
|
|
|
|
test("readConfigValue falls back to env.userVariables", () => {
|
|
globalThis.env = {
|
|
userVariables: {
|
|
baseUrl: "https://env-user.example.com/",
|
|
accessToken: "user-token",
|
|
},
|
|
};
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
assert.equal(
|
|
plugin.readConfigValue("baseUrl"),
|
|
"https://env-user.example.com/",
|
|
);
|
|
assert.equal(plugin.readConfigValue("accessToken"), "user-token");
|
|
});
|
|
|
|
test("readConfigValue tolerates throwing env.userVariables getter", () => {
|
|
globalThis.env = {
|
|
get userVariables() {
|
|
throw new Error("broken getter");
|
|
},
|
|
};
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
assert.equal(plugin.readConfigValue("baseUrl"), undefined);
|
|
assert.equal(plugin.readConfigValue("accessToken"), undefined);
|
|
});
|
|
|
|
test("readConfigValue uses testConfig before runtime env", () => {
|
|
globalThis.env = {
|
|
getUserVariables: () => ({
|
|
baseUrl: "https://env-getter.example.com/",
|
|
accessToken: "getter-token",
|
|
}),
|
|
};
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
plugin.__setConfigForTests({
|
|
baseUrl: "https://test-config.example.com/",
|
|
accessToken: "test-token",
|
|
});
|
|
|
|
assert.equal(
|
|
plugin.readConfigValue("baseUrl"),
|
|
"https://test-config.example.com/",
|
|
);
|
|
assert.equal(plugin.readConfigValue("accessToken"), "test-token");
|
|
});
|
|
|
|
test("readRuntimeValue tolerates throwing runtimeValues getter", () => {
|
|
globalThis.env = {
|
|
get runtimeValues() {
|
|
throw new Error("broken runtime values");
|
|
},
|
|
};
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
assert.equal(plugin.readRuntimeValue("runtimeClientId"), undefined);
|
|
assert.equal(plugin.readRuntimeValue("runtimeClientLabel"), undefined);
|
|
});
|
|
|
|
test("getClient prefers injected test client", async () => {
|
|
let axiosRequireCount = 0;
|
|
|
|
await withPatchedModuleLoad((request, parent, isMain, originalLoad) => {
|
|
if (request === "axios") {
|
|
axiosRequireCount += 1;
|
|
throw new Error("should not require axios for injected client");
|
|
}
|
|
return originalLoad(request, parent, isMain);
|
|
}, async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
const injectedClient = { type: "injected" };
|
|
plugin.__setHttpClientForTests(injectedClient);
|
|
|
|
const client = plugin.getClient();
|
|
assert.equal(client, injectedClient);
|
|
assert.equal(axiosRequireCount, 0);
|
|
});
|
|
});
|
|
|
|
test("default client is cached and rebuilt when config changes", async () => {
|
|
let axiosCreateCalls = 0;
|
|
const createdConfigs = [];
|
|
|
|
await withPatchedModuleLoad((request, parent, isMain, originalLoad) => {
|
|
if (request === "axios") {
|
|
return {
|
|
create: (config) => {
|
|
axiosCreateCalls += 1;
|
|
createdConfigs.push(config);
|
|
return {
|
|
id: axiosCreateCalls,
|
|
config,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
return originalLoad(request, parent, isMain);
|
|
}, async () => {
|
|
globalThis.env = {
|
|
userVariables: {
|
|
baseUrl: "https://music-server.example.com/",
|
|
accessToken: "token-a",
|
|
},
|
|
};
|
|
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
const firstClient = plugin.getClient();
|
|
const secondClient = plugin.getClient();
|
|
assert.equal(firstClient, secondClient);
|
|
assert.equal(axiosCreateCalls, 1);
|
|
assert.equal(createdConfigs[0].baseURL, "https://music-server.example.com");
|
|
assert.equal(createdConfigs[0].headers.Authorization, "Bearer token-a");
|
|
|
|
globalThis.env.userVariables.accessToken = "token-b";
|
|
const thirdClient = plugin.getClient();
|
|
assert.notEqual(thirdClient, firstClient);
|
|
assert.equal(axiosCreateCalls, 2);
|
|
assert.equal(createdConfigs[1].headers.Authorization, "Bearer token-b");
|
|
});
|
|
});
|
|
|
|
test("default client sends Authorization and X-Music-Client-Id headers", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
plugin.__setConfigForTests({
|
|
baseUrl: "https://music-server.example.com",
|
|
accessToken: "token-a",
|
|
runtimeClientId: "client-a",
|
|
runtimeClientLabel: "Alice iPhone",
|
|
});
|
|
|
|
let createdConfig = null;
|
|
await withPatchedModuleLoad((request, parent, isMain, originalLoad) => {
|
|
if (request === "axios") {
|
|
return {
|
|
create: (config) => {
|
|
createdConfig = config;
|
|
return { get() {}, post() {} };
|
|
},
|
|
};
|
|
}
|
|
return originalLoad(request, parent, isMain);
|
|
}, async () => {
|
|
plugin.getClient();
|
|
});
|
|
|
|
assert.equal(createdConfig.headers.Authorization, "Bearer token-a");
|
|
assert.equal(createdConfig.headers["X-Music-Client-Id"], "client-a");
|
|
assert.equal(createdConfig.headers["X-Music-Client-Label"], "Alice iPhone");
|
|
});
|
|
|
|
test("default client strips duplicated Bearer prefix in accessToken", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
plugin.__setConfigForTests({
|
|
baseUrl: "https://music-server.example.com",
|
|
accessToken: "Bearer token-with-prefix",
|
|
});
|
|
|
|
let createdConfig = null;
|
|
await withPatchedModuleLoad((request, parent, isMain, originalLoad) => {
|
|
if (request === "axios") {
|
|
return {
|
|
create: (config) => {
|
|
createdConfig = config;
|
|
return { get() {}, post() {} };
|
|
},
|
|
};
|
|
}
|
|
return originalLoad(request, parent, isMain);
|
|
}, async () => {
|
|
plugin.getClient();
|
|
});
|
|
|
|
assert.equal(
|
|
createdConfig.headers.Authorization,
|
|
"Bearer token-with-prefix",
|
|
);
|
|
});
|
|
|
|
test("default client sanitizes plugin js url to server root baseURL", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
plugin.__setConfigForTests({
|
|
baseUrl: "http://64.83.43.123:18081/plugins/music_server.js",
|
|
accessToken: "token-a",
|
|
});
|
|
|
|
let createdConfig = null;
|
|
await withPatchedModuleLoad((request, parent, isMain, originalLoad) => {
|
|
if (request === "axios") {
|
|
return {
|
|
create: (config) => {
|
|
createdConfig = config;
|
|
return { get() {}, post() {} };
|
|
},
|
|
};
|
|
}
|
|
return originalLoad(request, parent, isMain);
|
|
}, async () => {
|
|
plugin.getClient();
|
|
});
|
|
|
|
assert.equal(createdConfig.baseURL, "http://64.83.43.123:18081");
|
|
});
|
|
|
|
test("default client falls back to srcUrl-derived baseURL when baseUrl is empty", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
plugin.__setConfigForTests({
|
|
baseUrl: "",
|
|
srcUrl: "http://64.83.43.123:18081/plugins/music_server.js",
|
|
accessToken: "token-a",
|
|
});
|
|
|
|
let createdConfig = null;
|
|
await withPatchedModuleLoad((request, parent, isMain, originalLoad) => {
|
|
if (request === "axios") {
|
|
return {
|
|
create: (config) => {
|
|
createdConfig = config;
|
|
return { get() {}, post() {} };
|
|
},
|
|
};
|
|
}
|
|
return originalLoad(request, parent, isMain);
|
|
}, async () => {
|
|
plugin.getClient();
|
|
});
|
|
|
|
assert.equal(createdConfig.baseURL, "http://64.83.43.123:18081");
|
|
});
|
|
|
|
test("default client prefers srcUrl-derived baseURL over stale configured baseUrl", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
plugin.__setConfigForTests({
|
|
baseUrl: "http://111.228.62.29:18081",
|
|
srcUrl: "http://64.83.43.123:18081/plugins/music_server.js",
|
|
accessToken: "token-a",
|
|
});
|
|
|
|
let createdConfig = null;
|
|
await withPatchedModuleLoad((request, parent, isMain, originalLoad) => {
|
|
if (request === "axios") {
|
|
return {
|
|
create: (config) => {
|
|
createdConfig = config;
|
|
return { get() {}, post() {} };
|
|
},
|
|
};
|
|
}
|
|
return originalLoad(request, parent, isMain);
|
|
}, async () => {
|
|
plugin.getClient();
|
|
});
|
|
|
|
assert.equal(createdConfig.baseURL, "http://64.83.43.123:18081");
|
|
});
|
|
|
|
test("distributed plugin uses substituted srcUrl as absolute request base", async () => {
|
|
const pluginSourcePath = require.resolve("./music_server");
|
|
const rawCode = fs.readFileSync(pluginSourcePath, "utf8");
|
|
const distributedCode = rawCode.replace(
|
|
/__MUSIC_SERVER_PLUGIN_SRC_URL__/g,
|
|
"http://64.83.43.123:18081/plugins/music_server.js",
|
|
);
|
|
const plugin = loadPluginFromCode(distributedCode);
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
const fetchCalls = [];
|
|
globalThis.fetch = async (url, options) => {
|
|
fetchCalls.push({ url, options });
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
text: async () =>
|
|
JSON.stringify({
|
|
pinned: [{ id: "all", title: "all" }],
|
|
data: [],
|
|
}),
|
|
};
|
|
};
|
|
|
|
try {
|
|
await withPatchedModuleLoad((request, parent, isMain, originalLoad) => {
|
|
if (request === "axios") {
|
|
throw new Error("axios unavailable");
|
|
}
|
|
return originalLoad(request, parent, isMain);
|
|
}, async () => {
|
|
const result = await plugin.getRecommendSheetTags();
|
|
assert.equal(result.pinned[0].id, "all");
|
|
});
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
|
|
assert.equal(fetchCalls.length, 1);
|
|
assert.equal(
|
|
fetchCalls[0].url,
|
|
"http://64.83.43.123:18081/mf/v1/recommend/tags",
|
|
);
|
|
});
|
|
|
|
test("default client auto-prepends http scheme when baseUrl has no protocol", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
plugin.__setConfigForTests({
|
|
baseUrl: "64.83.43.123:18081",
|
|
accessToken: "token-a",
|
|
});
|
|
|
|
let createdConfig = null;
|
|
await withPatchedModuleLoad((request, parent, isMain, originalLoad) => {
|
|
if (request === "axios") {
|
|
return {
|
|
create: (config) => {
|
|
createdConfig = config;
|
|
return { get() {}, post() {} };
|
|
},
|
|
};
|
|
}
|
|
return originalLoad(request, parent, isMain);
|
|
}, async () => {
|
|
plugin.getClient();
|
|
});
|
|
|
|
assert.equal(createdConfig.baseURL, "http://64.83.43.123:18081");
|
|
});
|
|
|
|
test("getRecommendSheetTags falls back to fetch when axios is unavailable", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
plugin.__setConfigForTests({
|
|
baseUrl: "",
|
|
srcUrl: "http://64.83.43.123:18081/plugins/music_server.js",
|
|
accessToken: "",
|
|
});
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
const fetchCalls = [];
|
|
globalThis.fetch = async (url, options) => {
|
|
fetchCalls.push({ url, options });
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => JSON.stringify({
|
|
pinned: [{ id: "all", title: "all" }],
|
|
data: [],
|
|
}),
|
|
};
|
|
};
|
|
|
|
try {
|
|
await withPatchedModuleLoad((request, parent, isMain, originalLoad) => {
|
|
if (request === "axios") {
|
|
throw new Error("axios unavailable");
|
|
}
|
|
return originalLoad(request, parent, isMain);
|
|
}, async () => {
|
|
const result = await plugin.getRecommendSheetTags();
|
|
assert.equal(result.pinned[0].id, "all");
|
|
});
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
|
|
assert.equal(fetchCalls.length, 1);
|
|
assert.equal(fetchCalls[0].url, "http://64.83.43.123:18081/mf/v1/recommend/tags");
|
|
});
|
|
|
|
test("default client generates stable fallback X-Music-Client-Id from accessToken", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
plugin.__setConfigForTests({
|
|
baseUrl: "http://64.83.43.123:18081",
|
|
accessToken: "msv1_token_for_fallback_id",
|
|
});
|
|
|
|
const createdConfigs = [];
|
|
await withPatchedModuleLoad((request, parent, isMain, originalLoad) => {
|
|
if (request === "axios") {
|
|
return {
|
|
create: (config) => {
|
|
createdConfigs.push(config);
|
|
return { get() {}, post() {} };
|
|
},
|
|
};
|
|
}
|
|
return originalLoad(request, parent, isMain);
|
|
}, async () => {
|
|
const first = plugin.getClient();
|
|
const second = plugin.getClient();
|
|
assert.equal(first, second);
|
|
});
|
|
|
|
assert.equal(createdConfigs.length, 1);
|
|
const fallbackClientId = createdConfigs[0].headers["X-Music-Client-Id"];
|
|
assert.ok(typeof fallbackClientId === "string");
|
|
assert.ok(fallbackClientId.startsWith("mf-fallback-"));
|
|
assert.ok(fallbackClientId.length > "mf-fallback-".length);
|
|
});
|
|
|
|
test("axios remains lazy-loaded", async () => {
|
|
let axiosRequireCount = 0;
|
|
|
|
await withPatchedModuleLoad((request, parent, isMain, originalLoad) => {
|
|
if (request === "axios") {
|
|
axiosRequireCount += 1;
|
|
return {
|
|
create: (config) => ({ config }),
|
|
};
|
|
}
|
|
return originalLoad(request, parent, isMain);
|
|
}, async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
assert.equal(axiosRequireCount, 0);
|
|
|
|
globalThis.env = {
|
|
userVariables: {
|
|
baseUrl: "https://lazy-load.example.com/",
|
|
accessToken: "lazy-token",
|
|
},
|
|
};
|
|
const client = plugin.getClient();
|
|
|
|
assert.ok(client);
|
|
assert.equal(axiosRequireCount, 1);
|
|
});
|
|
});
|
|
|
|
test("search(music) requests /mf/v1/search/songs and maps to musicItem", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.url === "/mf/v1/search/songs") {
|
|
return {
|
|
data: {
|
|
page: 1,
|
|
page_size: 20,
|
|
total: 100,
|
|
data: [
|
|
{
|
|
id: "song-1",
|
|
title: "Moon",
|
|
artist: "Artist A",
|
|
album: "Album A",
|
|
artwork: "cover-a",
|
|
duration: 245,
|
|
},
|
|
{
|
|
id: 2,
|
|
name: "Moon River",
|
|
artists: [{ name: "Artist B" }, { name: "Artist C" }],
|
|
album: { name: "Album B", artwork: "cover-b" },
|
|
duration: 180000,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected request: ${call.url}`);
|
|
});
|
|
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const result = await plugin.search("moon", 1, "music");
|
|
|
|
assert.deepEqual(stub.calls, [
|
|
{
|
|
url: "/mf/v1/search/songs",
|
|
params: {
|
|
q: "moon",
|
|
page: 1,
|
|
page_size: 20,
|
|
},
|
|
},
|
|
]);
|
|
assert.deepEqual(result, {
|
|
isEnd: false,
|
|
data: [
|
|
{
|
|
id: "song-1",
|
|
title: "Moon",
|
|
artist: "Artist A",
|
|
album: "Album A",
|
|
artwork: "cover-a",
|
|
duration: 245,
|
|
},
|
|
{
|
|
id: "2",
|
|
title: "Moon River",
|
|
artist: "Artist B, Artist C",
|
|
album: "Album B",
|
|
artwork: "cover-b",
|
|
duration: 180,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("search(artist) requests /mf/v1/search/artists and maps to artist items", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.url === "/mf/v1/search/artists") {
|
|
return {
|
|
data: {
|
|
isEnd: true,
|
|
data: [
|
|
{
|
|
id: "catalogsync:artist:1",
|
|
name: "Singer A",
|
|
avatar: "artist-cover",
|
|
description: "artist-desc",
|
|
worksNum: 12,
|
|
supportedArtistTabs: ["music"],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected request: ${call.url}`);
|
|
});
|
|
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const result = await plugin.search("singer", 1, "artist");
|
|
|
|
assert.deepEqual(stub.calls, [
|
|
{
|
|
url: "/mf/v1/search/artists",
|
|
params: {
|
|
q: "singer",
|
|
page: 1,
|
|
page_size: 20,
|
|
},
|
|
},
|
|
]);
|
|
assert.deepEqual(result, {
|
|
isEnd: true,
|
|
data: [
|
|
{
|
|
id: "catalogsync:artist:1",
|
|
name: "Singer A",
|
|
avatar: "artist-cover",
|
|
description: "artist-desc",
|
|
worksNum: 12,
|
|
platform: "catalogsync",
|
|
supportedArtistTabs: ["music"],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("search(sheet) requests /mf/v1/search/sheets and maps to sheet items", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.url === "/mf/v1/search/sheets") {
|
|
return {
|
|
data: {
|
|
isEnd: true,
|
|
data: [
|
|
{
|
|
id: "catalogsync:playlist:1",
|
|
title: "Playlist A",
|
|
coverImg: "sheet-cover",
|
|
description: "sheet-desc",
|
|
worksNum: 20,
|
|
playableSongCount: 18,
|
|
play_count: 99,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected request: ${call.url}`);
|
|
});
|
|
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const result = await plugin.search("playlist", 1, "sheet");
|
|
|
|
assert.deepEqual(stub.calls, [
|
|
{
|
|
url: "/mf/v1/search/sheets",
|
|
params: {
|
|
q: "playlist",
|
|
page: 1,
|
|
page_size: 20,
|
|
},
|
|
},
|
|
]);
|
|
assert.deepEqual(result, {
|
|
isEnd: true,
|
|
data: [
|
|
{
|
|
id: "catalogsync:playlist:1",
|
|
title: "Playlist A",
|
|
description: "sheet-desc",
|
|
coverImg: "sheet-cover",
|
|
worksNum: 20,
|
|
playableWorksNum: 18,
|
|
play_count: 99,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("recommend tag APIs hit /mf/v1/recommend/tags and /mf/v1/recommend/sheets", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.url === "/mf/v1/recommend/tags") {
|
|
return {
|
|
data: {
|
|
pinned: [{ id: "hot", title: "热门" }],
|
|
data: [
|
|
{
|
|
title: "语种",
|
|
data: [{ id: "cn", name: "华语" }],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
if (call.url === "/mf/v1/recommend/sheets") {
|
|
return {
|
|
data: {
|
|
page: 1,
|
|
page_size: 60,
|
|
total: 61,
|
|
data: [
|
|
{
|
|
id: "catalogsync:playlist:18165",
|
|
name: "夜晚歌单",
|
|
creator: { nickname: "Editor" },
|
|
description: "desc",
|
|
coverImg: "sheet-cover",
|
|
trackCount: 88,
|
|
play_count: 9999,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected request: ${call.url}`);
|
|
});
|
|
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const tags = await plugin.getRecommendSheetTags();
|
|
const sheets = await plugin.getRecommendSheetsByTag({ id: "cn" }, 1);
|
|
|
|
assert.deepEqual(stub.calls, [
|
|
{ url: "/mf/v1/recommend/tags", params: undefined },
|
|
{
|
|
url: "/mf/v1/recommend/sheets",
|
|
params: {
|
|
tag: "cn",
|
|
page: 1,
|
|
page_size: 60,
|
|
},
|
|
},
|
|
]);
|
|
assert.deepEqual(tags, {
|
|
pinned: [{ id: "hot", title: "热门" }],
|
|
data: [
|
|
{
|
|
title: "语种",
|
|
data: [{ id: "cn", title: "华语" }],
|
|
},
|
|
],
|
|
});
|
|
assert.deepEqual(sheets, {
|
|
isEnd: false,
|
|
data: [
|
|
{
|
|
id: "catalogsync:playlist:18165",
|
|
title: "夜晚歌单",
|
|
artist: "Editor",
|
|
description: "desc",
|
|
coverImg: "sheet-cover",
|
|
worksNum: 88,
|
|
play_count: 9999,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('recommend sheets treats the legacy "default" tag as all', async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.url === "/mf/v1/recommend/sheets") {
|
|
return {
|
|
data: {
|
|
data: [],
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected request: ${call.url}`);
|
|
});
|
|
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const sheets = await plugin.getRecommendSheetsByTag(
|
|
{ id: "", title: "默认" },
|
|
1,
|
|
);
|
|
|
|
assert.deepEqual(stub.calls, [
|
|
{
|
|
url: "/mf/v1/recommend/sheets",
|
|
params: {
|
|
tag: "all",
|
|
page: 1,
|
|
page_size: 60,
|
|
},
|
|
},
|
|
]);
|
|
assert.deepEqual(sheets, {
|
|
isEnd: true,
|
|
data: [],
|
|
});
|
|
});
|
|
|
|
test("recommend sheets maps playableSongCount to playableWorksNum", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.url === "/mf/v1/recommend/sheets") {
|
|
return {
|
|
data: {
|
|
data: [
|
|
{
|
|
id: "catalogsync:playlist:10001",
|
|
name: "Mapped Sheet",
|
|
playableSongCount: 23,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected request: ${call.url}`);
|
|
});
|
|
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const sheets = await plugin.getRecommendSheetsByTag({ id: "cn" }, 1);
|
|
|
|
assert.deepEqual(sheets, {
|
|
isEnd: true,
|
|
data: [
|
|
{
|
|
id: "catalogsync:playlist:10001",
|
|
title: "Mapped Sheet",
|
|
playableWorksNum: 23,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("recommend sheets keeps zero playable counts", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.url === "/mf/v1/recommend/sheets") {
|
|
return {
|
|
data: {
|
|
data: [
|
|
{
|
|
id: "catalogsync:playlist:10002",
|
|
name: "Zero Count Sheet",
|
|
playableSongCount: 0,
|
|
},
|
|
{
|
|
id: "catalogsync:playlist:10003",
|
|
name: "Zero Works Sheet",
|
|
playableWorksNum: 0,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected request: ${call.url}`);
|
|
});
|
|
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const sheets = await plugin.getRecommendSheetsByTag({ id: "cn" }, 1);
|
|
|
|
assert.deepEqual(sheets, {
|
|
isEnd: true,
|
|
data: [
|
|
{
|
|
id: "catalogsync:playlist:10002",
|
|
title: "Zero Count Sheet",
|
|
playableWorksNum: 0,
|
|
},
|
|
{
|
|
id: "catalogsync:playlist:10003",
|
|
title: "Zero Works Sheet",
|
|
playableWorksNum: 0,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("getMusicSheetInfo page 1 requests detail then tracks and returns sheetItem + musicList", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.url === "/mf/v1/playlists/18165") {
|
|
return {
|
|
data: {
|
|
id: "catalogsync:playlist:18165",
|
|
name: "晚安歌单",
|
|
creator: { nickname: "DJ" },
|
|
description: "sleep songs",
|
|
coverImg: "playlist-cover",
|
|
trackCount: 100,
|
|
play_count: 321,
|
|
},
|
|
};
|
|
}
|
|
if (call.url === "/mf/v1/playlists/18165/tracks") {
|
|
return {
|
|
data: {
|
|
page: 1,
|
|
page_size: 60,
|
|
total: 61,
|
|
data: [
|
|
{
|
|
id: "m1",
|
|
name: "Moonlight",
|
|
artists: [{ name: "A" }],
|
|
album: { name: "Night", coverImg: "album-cover" },
|
|
duration: 200000,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected request: ${call.url}`);
|
|
});
|
|
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const result = await plugin.getMusicSheetInfo(
|
|
{ id: "catalogsync:playlist:18165" },
|
|
1,
|
|
);
|
|
|
|
assert.deepEqual(stub.calls, [
|
|
{ url: "/mf/v1/playlists/18165", params: undefined },
|
|
{
|
|
url: "/mf/v1/playlists/18165/tracks",
|
|
params: {
|
|
page: 1,
|
|
page_size: 60,
|
|
},
|
|
},
|
|
]);
|
|
assert.deepEqual(result, {
|
|
isEnd: false,
|
|
sheetItem: {
|
|
id: "catalogsync:playlist:18165",
|
|
title: "晚安歌单",
|
|
artist: "DJ",
|
|
description: "sleep songs",
|
|
coverImg: "playlist-cover",
|
|
worksNum: 100,
|
|
play_count: 321,
|
|
},
|
|
musicList: [
|
|
{
|
|
id: "m1",
|
|
title: "Moonlight",
|
|
artist: "A",
|
|
album: "Night",
|
|
artwork: "album-cover",
|
|
duration: 200,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("getMusicSheetInfo accepts server musicList payload shape", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.url === "/mf/v1/playlists/18165/tracks") {
|
|
return {
|
|
data: {
|
|
isEnd: true,
|
|
musicList: [
|
|
{
|
|
id: "m1",
|
|
title: "Moonlight",
|
|
artist: "A",
|
|
album: "Night",
|
|
artwork: "album-cover",
|
|
duration: 200,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected request: ${call.url}`);
|
|
});
|
|
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const result = await plugin.getMusicSheetInfo(
|
|
{ id: "catalogsync:playlist:18165" },
|
|
2,
|
|
);
|
|
|
|
assert.deepEqual(stub.calls, [
|
|
{
|
|
url: "/mf/v1/playlists/18165/tracks",
|
|
params: {
|
|
page: 2,
|
|
page_size: 60,
|
|
},
|
|
},
|
|
]);
|
|
assert.deepEqual(result, {
|
|
isEnd: true,
|
|
musicList: [
|
|
{
|
|
id: "m1",
|
|
title: "Moonlight",
|
|
artist: "A",
|
|
album: "Night",
|
|
artwork: "album-cover",
|
|
duration: 200,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("getTopLists returns server toplist groups", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.url === "/mf/v1/toplists") {
|
|
return {
|
|
data: {
|
|
data: [
|
|
{
|
|
title: "官方榜",
|
|
data: [
|
|
{
|
|
id: "catalogsync:toplist:kuwo_top_16",
|
|
name: "酷我热歌榜",
|
|
description: "每周更新",
|
|
coverImg: "top-cover",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected request: ${call.url}`);
|
|
});
|
|
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const result = await plugin.getTopLists();
|
|
|
|
assert.deepEqual(stub.calls, [
|
|
{ url: "/mf/v1/toplists", params: undefined },
|
|
]);
|
|
assert.deepEqual(result, [
|
|
{
|
|
title: "官方榜",
|
|
data: [
|
|
{
|
|
id: "catalogsync:toplist:kuwo_top_16",
|
|
title: "酷我热歌榜",
|
|
description: "每周更新",
|
|
coverImg: "top-cover",
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("getTopListDetail page 1 requests detail then tracks and returns topListItem + musicList", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.url === "/mf/v1/toplists/kuwo_top_16") {
|
|
return {
|
|
data: {
|
|
id: "catalogsync:toplist:kuwo_top_16",
|
|
name: "酷我热歌榜",
|
|
description: "top hits",
|
|
coverImg: "top-cover",
|
|
},
|
|
};
|
|
}
|
|
if (call.url === "/mf/v1/toplists/kuwo_top_16/tracks") {
|
|
return {
|
|
data: {
|
|
isEnd: true,
|
|
data: [
|
|
{
|
|
id: "tm1",
|
|
title: "Hot Song",
|
|
artist: "Singer X",
|
|
album: "Hot Album",
|
|
artwork: "art-x",
|
|
duration: 222,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected request: ${call.url}`);
|
|
});
|
|
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const result = await plugin.getTopListDetail(
|
|
{ id: "catalogsync:toplist:kuwo_top_16" },
|
|
1,
|
|
);
|
|
|
|
assert.deepEqual(stub.calls, [
|
|
{ url: "/mf/v1/toplists/kuwo_top_16", params: undefined },
|
|
{
|
|
url: "/mf/v1/toplists/kuwo_top_16/tracks",
|
|
params: {
|
|
page: 1,
|
|
page_size: 60,
|
|
},
|
|
},
|
|
]);
|
|
assert.deepEqual(result, {
|
|
isEnd: true,
|
|
topListItem: {
|
|
id: "catalogsync:toplist:kuwo_top_16",
|
|
title: "酷我热歌榜",
|
|
description: "top hits",
|
|
coverImg: "top-cover",
|
|
},
|
|
musicList: [
|
|
{
|
|
id: "tm1",
|
|
title: "Hot Song",
|
|
artist: "Singer X",
|
|
album: "Hot Album",
|
|
artwork: "art-x",
|
|
duration: 222,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("getTopListDetail accepts server musicList payload shape", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.url === "/mf/v1/toplists/kuwo_top_16/tracks") {
|
|
return {
|
|
data: {
|
|
isEnd: false,
|
|
musicList: [
|
|
{
|
|
id: "tm1",
|
|
title: "Hot Song",
|
|
artist: "Singer X",
|
|
album: "Hot Album",
|
|
artwork: "art-x",
|
|
duration: 222,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected request: ${call.url}`);
|
|
});
|
|
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const result = await plugin.getTopListDetail(
|
|
{ id: "catalogsync:toplist:kuwo_top_16" },
|
|
2,
|
|
);
|
|
|
|
assert.deepEqual(stub.calls, [
|
|
{
|
|
url: "/mf/v1/toplists/kuwo_top_16/tracks",
|
|
params: {
|
|
page: 2,
|
|
page_size: 60,
|
|
},
|
|
},
|
|
]);
|
|
assert.deepEqual(result, {
|
|
isEnd: false,
|
|
topListItem: {
|
|
id: "catalogsync:toplist:kuwo_top_16",
|
|
},
|
|
musicList: [
|
|
{
|
|
id: "tm1",
|
|
title: "Hot Song",
|
|
artist: "Singer X",
|
|
album: "Hot Album",
|
|
artwork: "art-x",
|
|
duration: 222,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("search(non-music) returns empty result without network request", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
const stub = createHttpClientStub((call) => {
|
|
throw new Error(`Unexpected request: ${call.url}`);
|
|
});
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const result = await plugin.search("moon", 1, "album");
|
|
|
|
assert.deepEqual(result, {
|
|
isEnd: true,
|
|
data: [],
|
|
});
|
|
assert.equal(stub.calls.length, 0);
|
|
});
|
|
|
|
test("search fallback isEnd uses raw list length instead of mapped length", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
const fullPageRawList = Array.from({ length: 20 }, (_, index) => ({
|
|
id: `song-${index}`,
|
|
title: `Title-${index}`,
|
|
artist: "Artist",
|
|
album: "Album",
|
|
artwork: "artwork",
|
|
duration: 200,
|
|
}));
|
|
fullPageRawList[7] = {};
|
|
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.url === "/mf/v1/search/songs") {
|
|
return {
|
|
data: {
|
|
page: 1,
|
|
page_size: 20,
|
|
data: fullPageRawList,
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected request: ${call.url}`);
|
|
});
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const result = await plugin.search("moon", 1, "music");
|
|
|
|
assert.equal(result.data.length, 19);
|
|
assert.equal(result.isEnd, false);
|
|
});
|
|
|
|
test("getMusicSheetInfo page 1 still loads tracks when detail request fails", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.url === "/mf/v1/playlists/18165") {
|
|
throw new Error("detail failed");
|
|
}
|
|
if (call.url === "/mf/v1/playlists/18165/tracks") {
|
|
return {
|
|
data: {
|
|
data: [
|
|
{
|
|
id: "m-1",
|
|
title: "Track 1",
|
|
artist: "Singer",
|
|
album: "Album 1",
|
|
artwork: "cover-1",
|
|
duration: 123,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected request: ${call.url}`);
|
|
});
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const result = await plugin.getMusicSheetInfo(
|
|
{
|
|
id: "catalogsync:playlist:18165",
|
|
title: "Fallback Sheet",
|
|
artist: "Fallback Artist",
|
|
},
|
|
1,
|
|
);
|
|
|
|
assert.deepEqual(stub.calls, [
|
|
{ url: "/mf/v1/playlists/18165", params: undefined },
|
|
{
|
|
url: "/mf/v1/playlists/18165/tracks",
|
|
params: {
|
|
page: 1,
|
|
page_size: 60,
|
|
},
|
|
},
|
|
]);
|
|
assert.deepEqual(result.sheetItem, {
|
|
id: "catalogsync:playlist:18165",
|
|
title: "Fallback Sheet",
|
|
artist: "Fallback Artist",
|
|
});
|
|
assert.deepEqual(result.musicList, [
|
|
{
|
|
id: "m-1",
|
|
title: "Track 1",
|
|
artist: "Singer",
|
|
album: "Album 1",
|
|
artwork: "cover-1",
|
|
duration: 123,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("getTopListDetail page 1 still loads tracks when detail request fails", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.url === "/mf/v1/toplists/kuwo_top_16") {
|
|
throw new Error("detail failed");
|
|
}
|
|
if (call.url === "/mf/v1/toplists/kuwo_top_16/tracks") {
|
|
return {
|
|
data: {
|
|
isEnd: true,
|
|
data: [
|
|
{
|
|
id: "t-1",
|
|
title: "Top Track",
|
|
artist: "Top Singer",
|
|
album: "Top Album",
|
|
artwork: "top-art",
|
|
duration: 200,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected request: ${call.url}`);
|
|
});
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const result = await plugin.getTopListDetail(
|
|
{
|
|
id: "catalogsync:toplist:kuwo_top_16",
|
|
title: "Fallback TopList",
|
|
},
|
|
1,
|
|
);
|
|
|
|
assert.deepEqual(stub.calls, [
|
|
{ url: "/mf/v1/toplists/kuwo_top_16", params: undefined },
|
|
{
|
|
url: "/mf/v1/toplists/kuwo_top_16/tracks",
|
|
params: {
|
|
page: 1,
|
|
page_size: 60,
|
|
},
|
|
},
|
|
]);
|
|
assert.deepEqual(result.topListItem, {
|
|
id: "catalogsync:toplist:kuwo_top_16",
|
|
title: "Fallback TopList",
|
|
});
|
|
assert.deepEqual(result.musicList, [
|
|
{
|
|
id: "t-1",
|
|
title: "Top Track",
|
|
artist: "Top Singer",
|
|
album: "Top Album",
|
|
artwork: "top-art",
|
|
duration: 200,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("getMusicSheetInfo keeps detail metadata when tracks request fails", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.url === "/mf/v1/playlists/18165") {
|
|
return {
|
|
data: {
|
|
id: "catalogsync:playlist:18165",
|
|
name: "Detail Sheet",
|
|
creator: { nickname: "Detail Author" },
|
|
description: "detail description",
|
|
coverImg: "detail-cover",
|
|
trackCount: 100,
|
|
play_count: 998,
|
|
},
|
|
};
|
|
}
|
|
if (call.url === "/mf/v1/playlists/18165/tracks") {
|
|
throw new Error("tracks failed");
|
|
}
|
|
throw new Error(`Unexpected request: ${call.url}`);
|
|
});
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const result = await plugin.getMusicSheetInfo(
|
|
{
|
|
id: "catalogsync:playlist:18165",
|
|
title: "Fallback Sheet",
|
|
},
|
|
1,
|
|
);
|
|
|
|
assert.deepEqual(result, {
|
|
isEnd: true,
|
|
sheetItem: {
|
|
id: "catalogsync:playlist:18165",
|
|
title: "Detail Sheet",
|
|
artist: "Detail Author",
|
|
description: "detail description",
|
|
coverImg: "detail-cover",
|
|
worksNum: 100,
|
|
play_count: 998,
|
|
},
|
|
musicList: [],
|
|
});
|
|
});
|
|
|
|
test("getTopListDetail keeps detail metadata when tracks request fails", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.url === "/mf/v1/toplists/kuwo_top_16") {
|
|
return {
|
|
data: {
|
|
id: "catalogsync:toplist:kuwo_top_16",
|
|
name: "Detail TopList",
|
|
description: "detail top description",
|
|
coverImg: "detail-top-cover",
|
|
updateFrequency: "weekly",
|
|
},
|
|
};
|
|
}
|
|
if (call.url === "/mf/v1/toplists/kuwo_top_16/tracks") {
|
|
throw new Error("tracks failed");
|
|
}
|
|
throw new Error(`Unexpected request: ${call.url}`);
|
|
});
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const result = await plugin.getTopListDetail(
|
|
{
|
|
id: "catalogsync:toplist:kuwo_top_16",
|
|
title: "Fallback TopList",
|
|
},
|
|
1,
|
|
);
|
|
|
|
assert.deepEqual(result, {
|
|
isEnd: true,
|
|
topListItem: {
|
|
id: "catalogsync:toplist:kuwo_top_16",
|
|
title: "Detail TopList",
|
|
description: "detail top description",
|
|
coverImg: "detail-top-cover",
|
|
artist: "weekly",
|
|
},
|
|
musicList: [],
|
|
});
|
|
});
|
|
|
|
test("getMediaSource joins baseUrl when stream url is relative", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
plugin.__setConfigForTests({
|
|
baseUrl: "https://media.example.com/",
|
|
accessToken: "",
|
|
});
|
|
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.method === "post" && call.url === "/mf/v1/media/resolve") {
|
|
return {
|
|
data: {
|
|
stream: {
|
|
url: "/mf/v1/media/stream/token-1",
|
|
headers: {
|
|
Referer: "https://music.example.com",
|
|
},
|
|
},
|
|
selected_source: {
|
|
quality: "super",
|
|
},
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected request: ${call.method} ${call.url}`);
|
|
});
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const result = await plugin.getMediaSource({ id: "song-1" }, "high");
|
|
|
|
assert.deepEqual(stub.calls, [
|
|
{
|
|
method: "post",
|
|
url: "/mf/v1/media/resolve",
|
|
data: {
|
|
song_id: "song-1",
|
|
quality: "high",
|
|
},
|
|
},
|
|
]);
|
|
assert.deepEqual(result, {
|
|
url: "https://media.example.com/mf/v1/media/stream/token-1",
|
|
headers: {
|
|
Referer: "https://music.example.com",
|
|
},
|
|
quality: "super",
|
|
});
|
|
});
|
|
|
|
test("getPluginStatus requests /auth/v1/token-status", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.url === "/auth/v1/token-status") {
|
|
return {
|
|
data: {
|
|
valid: true,
|
|
status: "active",
|
|
remainingDays: 89,
|
|
playableSongCount: 12345,
|
|
isCurrentClientBound: true,
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected request: ${call.url}`);
|
|
});
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const result = await plugin.getPluginStatus();
|
|
|
|
assert.deepEqual(stub.calls, [{ url: "/auth/v1/token-status", params: undefined }]);
|
|
assert.deepEqual(result, {
|
|
valid: true,
|
|
status: "active",
|
|
remainingDays: 89,
|
|
playableSongCount: 12345,
|
|
isCurrentClientBound: true,
|
|
});
|
|
});
|
|
|
|
test("getMediaSource keeps absolute stream url", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
plugin.__setConfigForTests({
|
|
baseUrl: "https://media.example.com/",
|
|
accessToken: "",
|
|
});
|
|
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.method === "post" && call.url === "/mf/v1/media/resolve") {
|
|
return {
|
|
data: {
|
|
stream: {
|
|
url: "https://cdn.example.com/audio/song-2.flac",
|
|
},
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected request: ${call.method} ${call.url}`);
|
|
});
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const result = await plugin.getMediaSource({ id: "song-2" }, "standard");
|
|
|
|
assert.deepEqual(result, {
|
|
url: "https://cdn.example.com/audio/song-2.flac",
|
|
headers: {},
|
|
quality: "standard",
|
|
});
|
|
});
|
|
|
|
test("getMediaSource resolves protocol-relative stream url using baseUrl protocol", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
plugin.__setConfigForTests({
|
|
baseUrl: "https://media.example.com",
|
|
accessToken: "",
|
|
});
|
|
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.method === "post" && call.url === "/mf/v1/media/resolve") {
|
|
return {
|
|
data: {
|
|
stream: {
|
|
url: "//cdn.example.com/audio/song-3.flac",
|
|
},
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected request: ${call.method} ${call.url}`);
|
|
});
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const result = await plugin.getMediaSource({ id: "song-3" }, "high");
|
|
|
|
assert.deepEqual(result, {
|
|
url: "https://cdn.example.com/audio/song-3.flac",
|
|
headers: {},
|
|
quality: "high",
|
|
});
|
|
});
|
|
|
|
test("getMediaSource returns null when resolve request fails", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.method === "post" && call.url === "/mf/v1/media/resolve") {
|
|
throw new Error("resolve failed");
|
|
}
|
|
throw new Error(`Unexpected request: ${call.method} ${call.url}`);
|
|
});
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const result = await plugin.getMediaSource({ id: "song-4" }, "standard");
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
test("getMediaSource returns null when stream url is missing", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
const stub = createHttpClientStub((call) => {
|
|
if (call.method === "post" && call.url === "/mf/v1/media/resolve") {
|
|
return {
|
|
data: {
|
|
stream: {
|
|
headers: {
|
|
Cookie: "k=v",
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected request: ${call.method} ${call.url}`);
|
|
});
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const result = await plugin.getMediaSource({ id: "song-5" }, "standard");
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
test("browse methods fail closed on request errors", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
const fallbackSheetItem = {
|
|
id: "catalogsync:playlist:18165",
|
|
title: "Fallback Sheet",
|
|
};
|
|
const fallbackTopListItem = {
|
|
id: "catalogsync:toplist:kuwo_top_16",
|
|
title: "Fallback TopList",
|
|
};
|
|
|
|
const stub = createHttpClientStub(() => {
|
|
throw new Error("network down");
|
|
});
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const searchResult = await plugin.search("moon", 1, "music");
|
|
const sheetsResult = await plugin.getRecommendSheetsByTag({ id: "hot" }, 1);
|
|
const sheetInfoResult = await plugin.getMusicSheetInfo(fallbackSheetItem, 1);
|
|
const topListsResult = await plugin.getTopLists();
|
|
const topListDetailResult = await plugin.getTopListDetail(fallbackTopListItem, 1);
|
|
|
|
assert.deepEqual(searchResult, {
|
|
isEnd: true,
|
|
data: [],
|
|
});
|
|
assert.deepEqual(sheetsResult, {
|
|
isEnd: true,
|
|
data: [],
|
|
});
|
|
assert.deepEqual(sheetInfoResult, {
|
|
isEnd: true,
|
|
sheetItem: fallbackSheetItem,
|
|
musicList: [],
|
|
});
|
|
assert.deepEqual(topListsResult, []);
|
|
assert.deepEqual(topListDetailResult, {
|
|
isEnd: true,
|
|
topListItem: fallbackTopListItem,
|
|
musicList: [],
|
|
});
|
|
});
|
|
|
|
test("getRecommendSheetTags fails closed on request errors", async () => {
|
|
const plugin = loadPluginFresh();
|
|
plugin.__clearTestState();
|
|
const stub = createHttpClientStub(() => {
|
|
throw new Error("network down");
|
|
});
|
|
plugin.__setHttpClientForTests(stub.client);
|
|
|
|
const result = await plugin.getRecommendSheetTags();
|
|
|
|
assert.deepEqual(result, {
|
|
pinned: [],
|
|
data: [],
|
|
});
|
|
});
|
|
|
|
test("netease_17000 compatibility shell re-exports music_server", () => {
|
|
const musicServerPath = require.resolve("./music_server");
|
|
const neteasePath = require.resolve("./netease_17000");
|
|
delete require.cache[musicServerPath];
|
|
delete require.cache[neteasePath];
|
|
|
|
const musicServer = require("./music_server");
|
|
const neteaseCompat = require("./netease_17000");
|
|
|
|
assert.equal(neteaseCompat, musicServer);
|
|
});
|