diff --git a/src/data/database.test.ts b/src/data/database.test.ts new file mode 100644 index 0000000..cdf026e --- /dev/null +++ b/src/data/database.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createDatabase, type BotDatabase, type BotInstance } from "./database.js"; + +describe("database", () => { + let botDb: BotDatabase; + + beforeEach(() => { + botDb = createDatabase(":memory:"); + }); + + afterEach(() => { + botDb.close(); + }); + + it("creates tables on init", () => { + const tables = botDb.db + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ) + .all() as Array<{ name: string }>; + const names = tables.map((t) => t.name); + expect(names).toContain("play_history"); + expect(names).toContain("bot_instances"); + }); + + it("records and retrieves play history", () => { + botDb.addPlayHistory({ + botId: "bot1", + songId: "song1", + songName: "Test Song", + artist: "Test Artist", + album: "Test Album", + platform: "netease", + coverUrl: "https://example.com/cover.jpg", + playedAt: new Date().toISOString(), + }); + + botDb.addPlayHistory({ + botId: "bot1", + songId: "song2", + songName: "Another Song", + artist: "Another Artist", + album: "Another Album", + platform: "qq", + coverUrl: "https://example.com/cover2.jpg", + playedAt: new Date().toISOString(), + }); + + const history = botDb.getPlayHistory("bot1", 10); + expect(history).toHaveLength(2); + expect(history[0].songName).toBe("Another Song"); + expect(history[1].songName).toBe("Test Song"); + }); + + it("saves and loads bot instances", () => { + const instance: BotInstance = { + id: "bot1", + name: "Music Bot", + serverAddress: "localhost", + serverPort: 9987, + nickname: "MusicBot", + defaultChannel: "Music", + channelPassword: "", + autoStart: true, + }; + + botDb.saveBotInstance(instance); + const instances = botDb.getBotInstances(); + expect(instances).toHaveLength(1); + expect(instances[0]).toEqual(instance); + expect(instances[0].autoStart).toBe(true); + + // Test upsert + botDb.saveBotInstance({ ...instance, nickname: "UpdatedBot", autoStart: false }); + const updated = botDb.getBotInstances(); + expect(updated).toHaveLength(1); + expect(updated[0].nickname).toBe("UpdatedBot"); + expect(updated[0].autoStart).toBe(false); + }); + + it("deletes bot instance", () => { + botDb.saveBotInstance({ + id: "bot1", + name: "Music Bot", + serverAddress: "localhost", + serverPort: 9987, + nickname: "MusicBot", + defaultChannel: "Music", + channelPassword: "", + autoStart: false, + }); + + expect(botDb.deleteBotInstance("bot1")).toBe(true); + expect(botDb.getBotInstances()).toHaveLength(0); + expect(botDb.deleteBotInstance("nonexistent")).toBe(false); + }); +}); diff --git a/src/data/database.ts b/src/data/database.ts new file mode 100644 index 0000000..1de1356 --- /dev/null +++ b/src/data/database.ts @@ -0,0 +1,128 @@ +import Database from "better-sqlite3"; + +export interface PlayHistoryRecord { + id?: number; + botId: string; + songId: string; + songName: string; + artist: string; + album: string; + platform: string; + coverUrl: string; + playedAt: string; +} + +export interface BotInstance { + id: string; + name: string; + serverAddress: string; + serverPort: number; + nickname: string; + defaultChannel: string; + channelPassword: string; + autoStart: boolean; +} + +export interface BotDatabase { + db: Database.Database; + addPlayHistory(record: Omit): void; + getPlayHistory(botId: string, limit: number): PlayHistoryRecord[]; + saveBotInstance(instance: BotInstance): void; + getBotInstances(): BotInstance[]; + deleteBotInstance(id: string): boolean; + close(): void; +} + +function initTables(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS play_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + botId TEXT NOT NULL, + songId TEXT NOT NULL, + songName TEXT NOT NULL, + artist TEXT NOT NULL, + album TEXT NOT NULL, + platform TEXT NOT NULL, + coverUrl TEXT NOT NULL, + playedAt TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS bot_instances ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + serverAddress TEXT NOT NULL, + serverPort INTEGER NOT NULL, + nickname TEXT NOT NULL, + defaultChannel TEXT NOT NULL, + channelPassword TEXT NOT NULL, + autoStart INTEGER NOT NULL DEFAULT 0 + ); + `); +} + +export function createDatabase(dbPath: string): BotDatabase { + const db = new Database(dbPath); + db.pragma("journal_mode = WAL"); + initTables(db); + + const insertHistory = db.prepare(` + INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl, playedAt) + VALUES (@botId, @songId, @songName, @artist, @album, @platform, @coverUrl, @playedAt) + `); + + const selectHistory = db.prepare(` + SELECT * FROM play_history WHERE botId = ? ORDER BY id DESC LIMIT ? + `); + + const upsertInstance = db.prepare(` + INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart) + VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + serverAddress = excluded.serverAddress, + serverPort = excluded.serverPort, + nickname = excluded.nickname, + defaultChannel = excluded.defaultChannel, + channelPassword = excluded.channelPassword, + autoStart = excluded.autoStart + `); + + const selectInstances = db.prepare(`SELECT * FROM bot_instances`); + + const deleteInstance = db.prepare(`DELETE FROM bot_instances WHERE id = ?`); + + return { + db, + + addPlayHistory(record) { + insertHistory.run(record); + }, + + getPlayHistory(botId, limit) { + return selectHistory.all(botId, limit) as PlayHistoryRecord[]; + }, + + saveBotInstance(instance) { + upsertInstance.run({ + ...instance, + autoStart: instance.autoStart ? 1 : 0, + }); + }, + + getBotInstances() { + const rows = selectInstances.all() as Array< + Omit & { autoStart: number } + >; + return rows.map((r) => ({ ...r, autoStart: r.autoStart === 1 })); + }, + + deleteBotInstance(id) { + const result = deleteInstance.run(id); + return result.changes > 0; + }, + + close() { + db.close(); + }, + }; +}