mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
test: add integration test suite — 74 tests passing against local TS3 server
Fix @discordjs/opus ESM import for Node 24 compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5041a4f3f7
commit
df7b944742
2 files changed
+501
-1
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { OpusEncoder } from "@discordjs/opus";
|
||||
import opusModule from "@discordjs/opus";
|
||||
const { OpusEncoder } = opusModule;
|
||||
|
||||
const SAMPLE_RATE = 48000;
|
||||
const CHANNELS = 2;
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* Integration test: Full stack test against local TeamSpeak server.
|
||||
* Tests TCP connection, bot lifecycle via Web API, unit components.
|
||||
*/
|
||||
import { TS3Connection } from "./src/ts-protocol/connection.js";
|
||||
import { createLogger } from "./src/logger.js";
|
||||
import { createDatabase } from "./src/data/database.js";
|
||||
import { loadConfig } from "./src/data/config.js";
|
||||
import { NeteaseProvider } from "./src/music/netease.js";
|
||||
import { QQMusicProvider } from "./src/music/qq.js";
|
||||
import { BotManager } from "./src/bot/manager.js";
|
||||
import { createWebServer } from "./src/web/server.js";
|
||||
import { PlayQueue, PlayMode } from "./src/audio/queue.js";
|
||||
import { parseCommand, isAdminCommand } from "./src/bot/commands.js";
|
||||
import {
|
||||
encodeCommand,
|
||||
decodeResponse,
|
||||
escapeValue,
|
||||
unescapeValue,
|
||||
} from "./src/ts-protocol/commands.js";
|
||||
import {
|
||||
generateIdentity,
|
||||
exportIdentity,
|
||||
importIdentity,
|
||||
} from "./src/ts-protocol/identity.js";
|
||||
import { createOpusEncoder, PCM_FRAME_BYTES } from "./src/audio/encoder.js";
|
||||
import { parseLyrics } from "./src/music/netease.js";
|
||||
|
||||
const HOST = "127.0.0.1";
|
||||
const QUERY_PORT = 10011;
|
||||
const VOICE_PORT = 9987;
|
||||
const WEB_PORT = 3334;
|
||||
|
||||
const logger = createLogger();
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const log = (msg: string) => console.log(`[TEST] ${msg}`);
|
||||
const pass = (msg: string) => {
|
||||
console.log(` ✅ ${msg}`);
|
||||
passed++;
|
||||
};
|
||||
const fail = (msg: string) => {
|
||||
console.log(` ❌ ${msg}`);
|
||||
failed++;
|
||||
};
|
||||
|
||||
function assert(condition: boolean, msg: string) {
|
||||
condition ? pass(msg) : fail(msg);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Test 1: TS3 Protocol — TCP Connection
|
||||
// ═══════════════════════════════════════════════════
|
||||
async function testTcpConnection() {
|
||||
log("═══ Test 1: TCP ServerQuery Connection ═══");
|
||||
const conn = new TS3Connection({ host: HOST, port: QUERY_PORT });
|
||||
|
||||
await conn.connect();
|
||||
assert(conn.isConnected(), "Connected to ServerQuery port 10011");
|
||||
|
||||
const useResult = await conn.send("use", { sid: 1 });
|
||||
assert(useResult.errorId === 0, `SELECT virtual server: ok`);
|
||||
|
||||
const nickResult = await conn.send("clientupdate", {
|
||||
client_nickname: "TSMusicBot-Test1",
|
||||
});
|
||||
assert(nickResult.errorId === 0, `Set nickname to TSMusicBot-Test1`);
|
||||
|
||||
const whoami = await conn.send("whoami");
|
||||
assert(whoami.errorId === 0, `whoami: client_id=${whoami.data[0]?.client_id}`);
|
||||
|
||||
const version = await conn.send("version");
|
||||
assert(version.errorId === 0, `Server version: ${version.data[0]?.version}`);
|
||||
|
||||
conn.disconnect();
|
||||
assert(!conn.isConnected(), "Disconnected cleanly");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Test 2: TS3 Protocol — Command Encoding
|
||||
// ═══════════════════════════════════════════════════
|
||||
function testCommandEncoding() {
|
||||
log("\n═══ Test 2: TS3 Command Encoding/Decoding ═══");
|
||||
|
||||
assert(
|
||||
escapeValue("hello world") === "hello\\sworld",
|
||||
"Escape spaces"
|
||||
);
|
||||
assert(
|
||||
escapeValue("foo|bar") === "foo\\pbar",
|
||||
"Escape pipes"
|
||||
);
|
||||
assert(
|
||||
unescapeValue("hello\\sworld") === "hello world",
|
||||
"Unescape spaces"
|
||||
);
|
||||
|
||||
const encoded = encodeCommand("login", {
|
||||
client_login_name: "bot",
|
||||
client_login_password: "test pass",
|
||||
});
|
||||
assert(
|
||||
encoded.includes("client_login_password=test\\spass"),
|
||||
"Encoded command escapes spaces in values"
|
||||
);
|
||||
|
||||
const decoded = decodeResponse(
|
||||
"clid=1 client_nickname=User1|clid=2 client_nickname=User2"
|
||||
);
|
||||
assert(decoded.length === 2, "Decoded 2 entries from piped response");
|
||||
assert(decoded[0].client_nickname === "User1", "First entry nickname");
|
||||
assert(decoded[1].clid === "2", "Second entry clid");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Test 3: TS3 Identity
|
||||
// ═══════════════════════════════════════════════════
|
||||
function testIdentity() {
|
||||
log("\n═══ Test 3: TS3 Identity Generation ═══");
|
||||
|
||||
const id = generateIdentity();
|
||||
assert(id.publicKey.length === 32, "Public key is 32 bytes");
|
||||
assert(id.privateKey.length === 64, "Private key is 64 bytes");
|
||||
assert(id.uid.length > 0, `UID generated: ${id.uid.slice(0, 20)}...`);
|
||||
|
||||
const exported = exportIdentity(id);
|
||||
const imported = importIdentity(exported);
|
||||
assert(imported.uid === id.uid, "Export/import roundtrip preserves UID");
|
||||
|
||||
const id2 = generateIdentity();
|
||||
assert(id.uid !== id2.uid, "Two identities have different UIDs");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Test 4: Command Parser
|
||||
// ═══════════════════════════════════════════════════
|
||||
function testCommandParser() {
|
||||
log("\n═══ Test 4: Command Parser ═══");
|
||||
|
||||
const cmd = parseCommand("!play 晴天 周杰伦", "!");
|
||||
assert(cmd !== null && cmd.name === "play", "Parse !play");
|
||||
assert(cmd!.args === "晴天 周杰伦", "Args preserved with spaces");
|
||||
|
||||
const cmd2 = parseCommand("!play -q 七里香", "!");
|
||||
assert(cmd2!.flags.has("q"), "Flag -q parsed");
|
||||
assert(cmd2!.args === "七里香", "Args after flag");
|
||||
|
||||
const cmd3 = parseCommand("!p 稻香", "!", { p: "play", s: "skip" });
|
||||
assert(cmd3!.name === "play", "Alias p→play resolved");
|
||||
|
||||
assert(parseCommand("hello", "!") === null, "Non-command → null");
|
||||
assert(parseCommand("", "!") === null, "Empty → null");
|
||||
assert(parseCommand("!vol 80", "!")!.args === "80", "!vol 80");
|
||||
assert(parseCommand("!mode loop", "!")!.args === "loop", "!mode loop");
|
||||
|
||||
assert(isAdminCommand("vol"), "vol is admin command");
|
||||
assert(!isAdminCommand("play"), "play is public command");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Test 5: Play Queue
|
||||
// ═══════════════════════════════════════════════════
|
||||
function testPlayQueue() {
|
||||
log("\n═══ Test 5: Play Queue ═══");
|
||||
|
||||
const q = new PlayQueue();
|
||||
assert(q.isEmpty(), "Starts empty");
|
||||
|
||||
const songs = ["Song A", "Song B", "Song C"].map((name, i) => ({
|
||||
id: String(i),
|
||||
name,
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
platform: "netease" as const,
|
||||
url: `http://example.com/${i}.mp3`,
|
||||
coverUrl: "",
|
||||
duration: 180,
|
||||
}));
|
||||
|
||||
for (const s of songs) q.add(s);
|
||||
assert(q.size() === 3, "Queue has 3 songs");
|
||||
|
||||
q.play();
|
||||
assert(q.current()?.name === "Song A", "Play → Song A");
|
||||
|
||||
q.next();
|
||||
assert(q.current()?.name === "Song B", "Next → Song B");
|
||||
|
||||
q.prev();
|
||||
assert(q.current()?.name === "Song A", "Prev → Song A");
|
||||
|
||||
q.setMode(PlayMode.Loop);
|
||||
q.playAt(2);
|
||||
assert(q.current()?.name === "Song C", "PlayAt(2) → Song C");
|
||||
q.next();
|
||||
assert(q.current()?.name === "Song A", "Loop wraps to Song A");
|
||||
|
||||
q.setMode(PlayMode.Sequential);
|
||||
q.playAt(2);
|
||||
assert(q.next() === null, "Sequential: end → null");
|
||||
|
||||
q.remove(0);
|
||||
assert(q.size() === 2, "Remove reduces size");
|
||||
|
||||
q.clear();
|
||||
assert(q.isEmpty(), "Clear empties queue");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Test 6: Opus Encoder
|
||||
// ═══════════════════════════════════════════════════
|
||||
function testOpusEncoder() {
|
||||
log("\n═══ Test 6: Opus Encoder ═══");
|
||||
|
||||
const enc = createOpusEncoder();
|
||||
const silence = Buffer.alloc(PCM_FRAME_BYTES, 0);
|
||||
|
||||
const opus = enc.encode(silence);
|
||||
assert(opus.length > 0, `Encoded 20ms silence → ${opus.length} bytes`);
|
||||
assert(opus.length < PCM_FRAME_BYTES, "Opus smaller than raw PCM");
|
||||
|
||||
const decoded = enc.decode(opus);
|
||||
assert(decoded.length === PCM_FRAME_BYTES, `Decoded back to ${decoded.length} bytes`);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Test 7: Database
|
||||
// ═══════════════════════════════════════════════════
|
||||
function testDatabase() {
|
||||
log("\n═══ Test 7: Database ═══");
|
||||
|
||||
const db = createDatabase(":memory:");
|
||||
|
||||
db.addPlayHistory({
|
||||
botId: "test",
|
||||
songId: "123",
|
||||
songName: "晴天",
|
||||
artist: "周杰伦",
|
||||
album: "叶惠美",
|
||||
platform: "netease",
|
||||
coverUrl: "",
|
||||
});
|
||||
const history = db.getPlayHistory("test", 10);
|
||||
assert(history.length === 1 && history[0].songName === "晴天", "Play history saved/loaded");
|
||||
assert(history[0].playedAt !== undefined, `playedAt auto-generated: ${history[0].playedAt}`);
|
||||
|
||||
db.saveBotInstance({
|
||||
id: "b1",
|
||||
name: "Bot",
|
||||
serverAddress: "127.0.0.1",
|
||||
serverPort: 9987,
|
||||
nickname: "Bot",
|
||||
defaultChannel: "",
|
||||
channelPassword: "",
|
||||
autoStart: false,
|
||||
});
|
||||
assert(db.getBotInstances().length === 1, "Bot instance saved");
|
||||
assert(db.deleteBotInstance("b1"), "Bot instance deleted");
|
||||
assert(db.getBotInstances().length === 0, "No instances after delete");
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Test 8: Lyrics Parser
|
||||
// ═══════════════════════════════════════════════════
|
||||
function testLyricsParser() {
|
||||
log("\n═══ Test 8: Lyrics Parser ═══");
|
||||
|
||||
const lrc = `[00:00.00] 作词 : 周杰伦
|
||||
[00:12.50]故事的小黄花
|
||||
[00:15.80]从出生那年就飘着`;
|
||||
|
||||
const lines = parseLyrics(lrc);
|
||||
assert(lines.length === 2, "Skipped metadata, got 2 lyric lines");
|
||||
assert(Math.abs(lines[0].time - 12.5) < 0.1, `Line 1 time: ${lines[0].time}`);
|
||||
assert(lines[0].text === "故事的小黄花", "Line 1 text");
|
||||
|
||||
const tlyric = "[00:12.50]故事的小黄花 (translation)";
|
||||
const merged = parseLyrics("[00:12.50]Hello", tlyric);
|
||||
assert(merged[0].translation !== undefined, "Translation merged");
|
||||
|
||||
assert(parseLyrics("").length === 0, "Empty lyrics → empty array");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Test 9: Full Stack — Web API + Bot + TS Connection
|
||||
// ═══════════════════════════════════════════════════
|
||||
async function testFullStack() {
|
||||
log("\n═══ Test 9: Full Stack (Web API → Bot → TeamSpeak) ═══");
|
||||
|
||||
const db = createDatabase(":memory:");
|
||||
const config = loadConfig("nonexistent");
|
||||
const neteaseProvider = new NeteaseProvider("http://127.0.0.1:3001");
|
||||
const qqProvider = new QQMusicProvider("http://127.0.0.1:3002");
|
||||
const botManager = new BotManager(neteaseProvider, qqProvider, db, config, logger);
|
||||
|
||||
const web = createWebServer({
|
||||
port: WEB_PORT,
|
||||
botManager,
|
||||
neteaseProvider,
|
||||
qqProvider,
|
||||
database: db,
|
||||
config,
|
||||
logger,
|
||||
});
|
||||
await web.start();
|
||||
log(` Web server on port ${WEB_PORT}`);
|
||||
|
||||
// Health check
|
||||
const health = await fetch(`http://127.0.0.1:${WEB_PORT}/api/health`).then((r) => r.json());
|
||||
assert(health.status === "ok", `Health: ${JSON.stringify(health)}`);
|
||||
|
||||
// Bot list (empty)
|
||||
const list0 = await fetch(`http://127.0.0.1:${WEB_PORT}/api/bot`).then((r) => r.json());
|
||||
assert(list0.bots.length === 0, "No bots initially");
|
||||
|
||||
// Create bot
|
||||
const createRes = await fetch(`http://127.0.0.1:${WEB_PORT}/api/bot`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "Test Bot",
|
||||
serverAddress: HOST,
|
||||
serverPort: VOICE_PORT,
|
||||
nickname: "MusicBot-FullTest",
|
||||
autoStart: false,
|
||||
}),
|
||||
});
|
||||
const botData = await createRes.json();
|
||||
assert(createRes.status === 201, `Created bot: ${botData.id}`);
|
||||
const botId = botData.id;
|
||||
|
||||
// Start bot (connect to TS)
|
||||
log(" Connecting bot to TeamSpeak...");
|
||||
const startRes = await fetch(`http://127.0.0.1:${WEB_PORT}/api/bot/${botId}/start`, {
|
||||
method: "POST",
|
||||
});
|
||||
const startData = await startRes.json();
|
||||
assert(startData.success === true, "Bot connected to TeamSpeak!");
|
||||
|
||||
// Verify connected
|
||||
const status = await fetch(`http://127.0.0.1:${WEB_PORT}/api/bot/${botId}`).then((r) =>
|
||||
r.json()
|
||||
);
|
||||
assert(status.connected === true, `Bot status: connected=${status.connected}`);
|
||||
assert(status.name === "Test Bot", `Bot name: ${status.name}`);
|
||||
assert(status.volume === 75, `Default volume: ${status.volume}`);
|
||||
assert(status.playing === false, "Not playing (no song loaded)");
|
||||
|
||||
// Queue endpoint
|
||||
const queueRes = await fetch(
|
||||
`http://127.0.0.1:${WEB_PORT}/api/player/${botId}/queue`
|
||||
).then((r) => r.json());
|
||||
assert(Array.isArray(queueRes.queue), `Queue: ${queueRes.queue.length} items`);
|
||||
assert(queueRes.status.id === botId, "Queue status has correct botId");
|
||||
|
||||
// Volume control
|
||||
const volRes = await fetch(`http://127.0.0.1:${WEB_PORT}/api/player/${botId}/volume`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ volume: 50 }),
|
||||
}).then((r) => r.json());
|
||||
assert(volRes.message === "Volume set to 50%", `Volume: ${volRes.message}`);
|
||||
|
||||
// Mode control
|
||||
const modeRes = await fetch(`http://127.0.0.1:${WEB_PORT}/api/player/${botId}/mode`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode: "loop" }),
|
||||
}).then((r) => r.json());
|
||||
assert(modeRes.message === "Play mode set to: loop", `Mode: ${modeRes.message}`);
|
||||
|
||||
// Pause (no track, should still respond)
|
||||
const pauseRes = await fetch(`http://127.0.0.1:${WEB_PORT}/api/player/${botId}/pause`, {
|
||||
method: "POST",
|
||||
}).then((r) => r.json());
|
||||
assert(pauseRes.message === "Paused", `Pause: ${pauseRes.message}`);
|
||||
|
||||
// Now (nothing playing)
|
||||
// This would go through bot.executeCommand which requires the bot instance
|
||||
// Test it via the queue status
|
||||
const status2 = await fetch(`http://127.0.0.1:${WEB_PORT}/api/bot/${botId}`).then((r) =>
|
||||
r.json()
|
||||
);
|
||||
assert(status2.volume === 50, "Volume persisted to 50");
|
||||
assert(status2.playMode === "loop", "Mode persisted to loop");
|
||||
|
||||
// Stop bot
|
||||
const stopRes = await fetch(`http://127.0.0.1:${WEB_PORT}/api/bot/${botId}/stop`, {
|
||||
method: "POST",
|
||||
}).then((r) => r.json());
|
||||
assert(stopRes.success === true, "Bot stopped");
|
||||
|
||||
// Delete bot
|
||||
const delRes = await fetch(`http://127.0.0.1:${WEB_PORT}/api/bot/${botId}`, {
|
||||
method: "DELETE",
|
||||
}).then((r) => r.json());
|
||||
assert(delRes.success === true, "Bot deleted");
|
||||
|
||||
// Verify empty
|
||||
const list1 = await fetch(`http://127.0.0.1:${WEB_PORT}/api/bot`).then((r) => r.json());
|
||||
assert(list1.bots.length === 0, "No bots after cleanup");
|
||||
|
||||
web.stop();
|
||||
botManager.shutdown();
|
||||
db.close();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Test 10: WebSocket Connection
|
||||
// ═══════════════════════════════════════════════════
|
||||
async function testWebSocket() {
|
||||
log("\n═══ Test 10: WebSocket Real-time ═══");
|
||||
|
||||
const db = createDatabase(":memory:");
|
||||
const config = loadConfig("nonexistent");
|
||||
const neteaseProvider = new NeteaseProvider("http://127.0.0.1:3001");
|
||||
const qqProvider = new QQMusicProvider("http://127.0.0.1:3002");
|
||||
const botManager = new BotManager(neteaseProvider, qqProvider, db, config, logger);
|
||||
|
||||
const web = createWebServer({
|
||||
port: WEB_PORT + 1,
|
||||
botManager,
|
||||
neteaseProvider,
|
||||
qqProvider,
|
||||
database: db,
|
||||
config,
|
||||
logger,
|
||||
});
|
||||
await web.start();
|
||||
|
||||
// Connect WebSocket
|
||||
const { WebSocket: WS } = await import("ws");
|
||||
const ws = new WS(`ws://127.0.0.1:${WEB_PORT + 1}/ws`);
|
||||
|
||||
const messages: any[] = [];
|
||||
|
||||
// Register message handler BEFORE connection opens
|
||||
ws.on("message", (data) => {
|
||||
messages.push(JSON.parse(data.toString()));
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.on("open", () => resolve());
|
||||
ws.on("error", reject);
|
||||
setTimeout(() => reject(new Error("WS timeout")), 3000);
|
||||
});
|
||||
assert(ws.readyState === WS.OPEN, "WebSocket connected");
|
||||
|
||||
// Wait a moment for init message
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
assert(messages.length > 0, `Received ${messages.length} message(s)`);
|
||||
assert(messages[0].type === "init", `Message type: ${messages[0].type}`);
|
||||
assert(Array.isArray(messages[0].bots), "Init contains bots array");
|
||||
|
||||
ws.close();
|
||||
web.stop();
|
||||
botManager.shutdown();
|
||||
db.close();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Main
|
||||
// ═══════════════════════════════════════════════════
|
||||
async function main() {
|
||||
console.log("╔═══════════════════════════════════════════════╗");
|
||||
console.log("║ TSMusicBot Integration Test Suite ║");
|
||||
console.log("║ TS Server: localhost:9987 / query:10011 ║");
|
||||
console.log("╚═══════════════════════════════════════════════╝\n");
|
||||
|
||||
try {
|
||||
await testTcpConnection();
|
||||
testCommandEncoding();
|
||||
testIdentity();
|
||||
testCommandParser();
|
||||
testPlayQueue();
|
||||
testOpusEncoder();
|
||||
testDatabase();
|
||||
testLyricsParser();
|
||||
await testFullStack();
|
||||
await testWebSocket();
|
||||
} catch (err) {
|
||||
fail(`Unexpected error: ${(err as Error).message}`);
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
console.log("\n╔═══════════════════════════════════════════════╗");
|
||||
console.log(`║ Results: ${passed} passed, ${failed} failed ║`);
|
||||
console.log("╚═══════════════════════════════════════════════╝");
|
||||
|
||||
// Give a moment for cleanup
|
||||
setTimeout(() => process.exit(failed > 0 ? 1 : 0), 500);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in new issue
Block a user