diff --git a/internal/achievements/database.go b/internal/achievements/database.go index 032ff90..a5228c4 100644 --- a/internal/achievements/database.go +++ b/internal/achievements/database.go @@ -1,301 +1,422 @@ package achievements import ( - "eq2emu/internal/database" + "context" "fmt" "time" + + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" ) // LoadAllAchievements loads all achievements from database into master list -func LoadAllAchievements(db *database.DB, masterList *MasterList) error { +func LoadAllAchievements(pool *sqlitex.Pool, masterList *MasterList) error { + conn, err := pool.Take(context.Background()) + if err != nil { + return fmt.Errorf("failed to get connection: %w", err) + } + defer pool.Put(conn) + query := `SELECT achievement_id, title, uncompleted_text, completed_text, category, expansion, icon, point_value, qty_req, hide_achievement, unknown3a, unknown3b FROM achievements` - err := db.Query(query, func(row *database.Row) error { - achievement := NewAchievement() - achievement.ID = uint32(row.Int(0)) - achievement.Title = row.Text(1) - achievement.UncompletedText = row.Text(2) - achievement.CompletedText = row.Text(3) - achievement.Category = row.Text(4) - achievement.Expansion = row.Text(5) - achievement.Icon = uint16(row.Int(6)) - achievement.PointValue = uint32(row.Int(7)) - achievement.QtyRequired = uint32(row.Int(8)) - achievement.Hide = row.Bool(9) - achievement.Unknown3A = uint32(row.Int(10)) - achievement.Unknown3B = uint32(row.Int(11)) + return sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + achievement := NewAchievement() + achievement.ID = uint32(stmt.ColumnInt64(0)) + achievement.Title = stmt.ColumnText(1) + achievement.UncompletedText = stmt.ColumnText(2) + achievement.CompletedText = stmt.ColumnText(3) + achievement.Category = stmt.ColumnText(4) + achievement.Expansion = stmt.ColumnText(5) + achievement.Icon = uint16(stmt.ColumnInt64(6)) + achievement.PointValue = uint32(stmt.ColumnInt32(7)) + achievement.QtyRequired = uint32(stmt.ColumnInt64(8)) + achievement.Hide = stmt.ColumnInt64(9) != 0 + achievement.Unknown3A = uint32(stmt.ColumnInt64(10)) + achievement.Unknown3B = uint32(stmt.ColumnInt64(11)) - // Load requirements and rewards - if err := loadAchievementRequirements(db, achievement); err != nil { - return fmt.Errorf("failed to load requirements for achievement %d: %w", achievement.ID, err) - } + // Load requirements and rewards + if err := loadAchievementRequirements(conn, achievement); err != nil { + return fmt.Errorf("failed to load requirements for achievement %d: %w", achievement.ID, err) + } - if err := loadAchievementRewards(db, achievement); err != nil { - return fmt.Errorf("failed to load rewards for achievement %d: %w", achievement.ID, err) - } + if err := loadAchievementRewards(conn, achievement); err != nil { + return fmt.Errorf("failed to load rewards for achievement %d: %w", achievement.ID, err) + } - if !masterList.AddAchievement(achievement) { - return fmt.Errorf("duplicate achievement ID: %d", achievement.ID) - } + if !masterList.AddAchievement(achievement) { + return fmt.Errorf("duplicate achievement ID: %d", achievement.ID) + } - return nil + return nil + }, }) - - return err } // loadAchievementRequirements loads requirements for a specific achievement -func loadAchievementRequirements(db *database.DB, achievement *Achievement) error { +func loadAchievementRequirements(conn *sqlite.Conn, achievement *Achievement) error { query := `SELECT achievement_id, name, qty_req FROM achievements_requirements WHERE achievement_id = ?` - return db.Query(query, func(row *database.Row) error { - req := Requirement{ - AchievementID: uint32(row.Int(0)), - Name: row.Text(1), - QtyRequired: uint32(row.Int(2)), - } - achievement.AddRequirement(req) - return nil - }, achievement.ID) + return sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: []any{achievement.ID}, + ResultFunc: func(stmt *sqlite.Stmt) error { + req := Requirement{ + AchievementID: uint32(stmt.ColumnInt64(0)), + Name: stmt.ColumnText(1), + QtyRequired: uint32(stmt.ColumnInt64(2)), + } + achievement.AddRequirement(req) + return nil + }, + }) } // loadAchievementRewards loads rewards for a specific achievement -func loadAchievementRewards(db *database.DB, achievement *Achievement) error { +func loadAchievementRewards(conn *sqlite.Conn, achievement *Achievement) error { query := `SELECT achievement_id, reward FROM achievements_rewards WHERE achievement_id = ?` - return db.Query(query, func(row *database.Row) error { - reward := Reward{ - AchievementID: uint32(row.Int(0)), - Reward: row.Text(1), - } - achievement.AddReward(reward) - return nil - }, achievement.ID) + return sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: []any{achievement.ID}, + ResultFunc: func(stmt *sqlite.Stmt) error { + reward := Reward{ + AchievementID: uint32(stmt.ColumnInt64(0)), + Reward: stmt.ColumnText(1), + } + achievement.AddReward(reward) + return nil + }, + }) } // LoadPlayerAchievements loads player achievements from database -func LoadPlayerAchievements(db *database.DB, playerID uint32, playerList *PlayerList) error { +func LoadPlayerAchievements(pool *sqlitex.Pool, playerID uint32, playerList *PlayerList) error { + conn, err := pool.Take(context.Background()) + if err != nil { + return fmt.Errorf("failed to get connection: %w", err) + } + defer pool.Put(conn) + query := `SELECT achievement_id, title, uncompleted_text, completed_text, category, expansion, icon, point_value, qty_req, hide_achievement, unknown3a, unknown3b FROM achievements` - err := db.Query(query, func(row *database.Row) error { - achievement := NewAchievement() - achievement.ID = uint32(row.Int(0)) - achievement.Title = row.Text(1) - achievement.UncompletedText = row.Text(2) - achievement.CompletedText = row.Text(3) - achievement.Category = row.Text(4) - achievement.Expansion = row.Text(5) - achievement.Icon = uint16(row.Int(6)) - achievement.PointValue = uint32(row.Int(7)) - achievement.QtyRequired = uint32(row.Int(8)) - achievement.Hide = row.Bool(9) - achievement.Unknown3A = uint32(row.Int(10)) - achievement.Unknown3B = uint32(row.Int(11)) + return sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + achievement := NewAchievement() + achievement.ID = uint32(stmt.ColumnInt64(0)) + achievement.Title = stmt.ColumnText(1) + achievement.UncompletedText = stmt.ColumnText(2) + achievement.CompletedText = stmt.ColumnText(3) + achievement.Category = stmt.ColumnText(4) + achievement.Expansion = stmt.ColumnText(5) + achievement.Icon = uint16(stmt.ColumnInt64(6)) + achievement.PointValue = uint32(stmt.ColumnInt64(7)) + achievement.QtyRequired = uint32(stmt.ColumnInt64(8)) + achievement.Hide = stmt.ColumnInt64(9) != 0 + achievement.Unknown3A = uint32(stmt.ColumnInt64(10)) + achievement.Unknown3B = uint32(stmt.ColumnInt64(11)) - // Load requirements and rewards - if err := loadAchievementRequirements(db, achievement); err != nil { - return fmt.Errorf("failed to load requirements: %w", err) - } + // Load requirements and rewards + if err := loadAchievementRequirements(conn, achievement); err != nil { + return fmt.Errorf("failed to load requirements: %w", err) + } - if err := loadAchievementRewards(db, achievement); err != nil { - return fmt.Errorf("failed to load rewards: %w", err) - } + if err := loadAchievementRewards(conn, achievement); err != nil { + return fmt.Errorf("failed to load rewards: %w", err) + } - if !playerList.AddAchievement(achievement) { - return fmt.Errorf("duplicate achievement ID: %d", achievement.ID) - } + if !playerList.AddAchievement(achievement) { + return fmt.Errorf("duplicate achievement ID: %d", achievement.ID) + } - return nil + return nil + }, }) - - return err } // LoadPlayerAchievementUpdates loads player achievement progress from database -func LoadPlayerAchievementUpdates(db *database.DB, playerID uint32, updateList *PlayerUpdateList) error { +func LoadPlayerAchievementUpdates(pool *sqlitex.Pool, playerID uint32, updateList *PlayerUpdateList) error { + conn, err := pool.Take(context.Background()) + if err != nil { + return fmt.Errorf("failed to get connection: %w", err) + } + defer pool.Put(conn) + query := `SELECT char_id, achievement_id, completed_date FROM character_achievements WHERE char_id = ?` - return db.Query(query, func(row *database.Row) error { - update := NewUpdate() - update.ID = uint32(row.Int(1)) + return sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: []any{playerID}, + ResultFunc: func(stmt *sqlite.Stmt) error { + update := NewUpdate() + update.ID = uint32(stmt.ColumnInt64(1)) - // Convert completed_date from Unix timestamp - if !row.IsNull(2) { - timestamp := row.Int64(2) - update.CompletedDate = time.Unix(timestamp, 0) - } + // Convert completed_date from Unix timestamp + if stmt.ColumnType(2) != sqlite.TypeNull { + timestamp := stmt.ColumnInt64(2) + update.CompletedDate = time.Unix(timestamp, 0) + } - // Load update items - if err := loadPlayerAchievementUpdateItems(db, playerID, update); err != nil { - return fmt.Errorf("failed to load update items: %w", err) - } + // Load update items + if err := loadPlayerAchievementUpdateItems(conn, playerID, update); err != nil { + return fmt.Errorf("failed to load update items: %w", err) + } - if !updateList.AddUpdate(update) { - return fmt.Errorf("duplicate achievement update ID: %d", update.ID) - } + if !updateList.AddUpdate(update) { + return fmt.Errorf("duplicate achievement update ID: %d", update.ID) + } - return nil - }, playerID) + return nil + }, + }) } // loadPlayerAchievementUpdateItems loads progress items for an achievement update -func loadPlayerAchievementUpdateItems(db *database.DB, playerID uint32, update *Update) error { +func loadPlayerAchievementUpdateItems(conn *sqlite.Conn, playerID uint32, update *Update) error { query := `SELECT achievement_id, items FROM character_achievements_items WHERE char_id = ? AND achievement_id = ?` - return db.Query(query, func(row *database.Row) error { - item := UpdateItem{ - AchievementID: uint32(row.Int(0)), - ItemUpdate: uint32(row.Int(1)), - } - update.AddUpdateItem(item) - return nil - }, playerID, update.ID) + return sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: []any{playerID, update.ID}, + ResultFunc: func(stmt *sqlite.Stmt) error { + item := UpdateItem{ + AchievementID: uint32(stmt.ColumnInt64(0)), + ItemUpdate: uint32(stmt.ColumnInt64(1)), + } + update.AddUpdateItem(item) + return nil + }, + }) } // SavePlayerAchievementUpdate saves or updates player achievement progress -func SavePlayerAchievementUpdate(db *database.DB, playerID uint32, update *Update) error { - return db.Transaction(func(tx *database.DB) error { - // Save or update main achievement record - query := `INSERT OR REPLACE INTO character_achievements - (char_id, achievement_id, completed_date) VALUES (?, ?, ?)` +func SavePlayerAchievementUpdate(pool *sqlitex.Pool, playerID uint32, update *Update) error { + conn, err := pool.Take(context.Background()) + if err != nil { + return fmt.Errorf("failed to get connection: %w", err) + } + defer pool.Put(conn) - var completedDate *int64 - if !update.CompletedDate.IsZero() { - timestamp := update.CompletedDate.Unix() - completedDate = ×tamp - } + err = sqlitex.Execute(conn, "BEGIN", nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer sqlitex.Execute(conn, "ROLLBACK", nil) - if err := tx.Exec(query, playerID, update.ID, completedDate); err != nil { - return fmt.Errorf("failed to save achievement update: %w", err) - } + // Save or update main achievement record + query := `INSERT OR REPLACE INTO character_achievements + (char_id, achievement_id, completed_date) VALUES (?, ?, ?)` - // Delete existing update items - deleteQuery := `DELETE FROM character_achievements_items - WHERE char_id = ? AND achievement_id = ?` - if err := tx.Exec(deleteQuery, playerID, update.ID); err != nil { - return fmt.Errorf("failed to delete old update items: %w", err) - } + var completedDate any + if !update.CompletedDate.IsZero() { + completedDate = update.CompletedDate.Unix() + } - // Insert new update items - itemQuery := `INSERT INTO character_achievements_items - (char_id, achievement_id, items) VALUES (?, ?, ?)` - for _, item := range update.UpdateItems { - if err := tx.Exec(itemQuery, playerID, item.AchievementID, item.ItemUpdate); err != nil { - return fmt.Errorf("failed to save update item: %w", err) - } - } - - return nil + err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: []any{playerID, update.ID, completedDate}, }) + if err != nil { + return fmt.Errorf("failed to save achievement update: %w", err) + } + + // Delete existing update items + deleteQuery := `DELETE FROM character_achievements_items + WHERE char_id = ? AND achievement_id = ?` + err = sqlitex.Execute(conn, deleteQuery, &sqlitex.ExecOptions{ + Args: []any{playerID, update.ID}, + }) + if err != nil { + return fmt.Errorf("failed to delete old update items: %w", err) + } + + // Insert new update items + itemQuery := `INSERT INTO character_achievements_items + (char_id, achievement_id, items) VALUES (?, ?, ?)` + for _, item := range update.UpdateItems { + err = sqlitex.Execute(conn, itemQuery, &sqlitex.ExecOptions{ + Args: []any{playerID, item.AchievementID, item.ItemUpdate}, + }) + if err != nil { + return fmt.Errorf("failed to save update item: %w", err) + } + } + + return sqlitex.Execute(conn, "COMMIT", nil) } // DeletePlayerAchievementUpdate removes player achievement progress from database -func DeletePlayerAchievementUpdate(db *database.DB, playerID uint32, achievementID uint32) error { - return db.Transaction(func(tx *database.DB) error { - // Delete main achievement record - query := `DELETE FROM character_achievements - WHERE char_id = ? AND achievement_id = ?` - if err := tx.Exec(query, playerID, achievementID); err != nil { - return fmt.Errorf("failed to delete achievement update: %w", err) - } +func DeletePlayerAchievementUpdate(pool *sqlitex.Pool, playerID uint32, achievementID uint32) error { + conn, err := pool.Take(context.Background()) + if err != nil { + return fmt.Errorf("failed to get connection: %w", err) + } + defer pool.Put(conn) - // Delete update items - itemQuery := `DELETE FROM character_achievements_items - WHERE char_id = ? AND achievement_id = ?` - if err := tx.Exec(itemQuery, playerID, achievementID); err != nil { - return fmt.Errorf("failed to delete update items: %w", err) - } + err = sqlitex.Execute(conn, "BEGIN", nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer sqlitex.Execute(conn, "ROLLBACK", nil) - return nil + // Delete main achievement record + query := `DELETE FROM character_achievements + WHERE char_id = ? AND achievement_id = ?` + err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: []any{playerID, achievementID}, }) + if err != nil { + return fmt.Errorf("failed to delete achievement update: %w", err) + } + + // Delete update items + itemQuery := `DELETE FROM character_achievements_items + WHERE char_id = ? AND achievement_id = ?` + err = sqlitex.Execute(conn, itemQuery, &sqlitex.ExecOptions{ + Args: []any{playerID, achievementID}, + }) + if err != nil { + return fmt.Errorf("failed to delete update items: %w", err) + } + + return sqlitex.Execute(conn, "COMMIT", nil) } // SaveAchievement saves or updates an achievement in the database -func SaveAchievement(db *database.DB, achievement *Achievement) error { - return db.Transaction(func(tx *database.DB) error { - // Save main achievement record - query := `INSERT OR REPLACE INTO achievements - (achievement_id, title, uncompleted_text, completed_text, - category, expansion, icon, point_value, qty_req, - hide_achievement, unknown3a, unknown3b) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` +func SaveAchievement(pool *sqlitex.Pool, achievement *Achievement) error { + conn, err := pool.Take(context.Background()) + if err != nil { + return fmt.Errorf("failed to get connection: %w", err) + } + defer pool.Put(conn) - if err := tx.Exec(query, achievement.ID, achievement.Title, + err = sqlitex.Execute(conn, "BEGIN", nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer sqlitex.Execute(conn, "ROLLBACK", nil) + + // Save main achievement record + query := `INSERT OR REPLACE INTO achievements + (achievement_id, title, uncompleted_text, completed_text, + category, expansion, icon, point_value, qty_req, + hide_achievement, unknown3a, unknown3b) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + + err = sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: []any{ + achievement.ID, achievement.Title, achievement.UncompletedText, achievement.CompletedText, achievement.Category, achievement.Expansion, achievement.Icon, achievement.PointValue, achievement.QtyRequired, achievement.Hide, - achievement.Unknown3A, achievement.Unknown3B); err != nil { - return fmt.Errorf("failed to save achievement: %w", err) - } - - // Delete existing requirements and rewards - if err := tx.Exec("DELETE FROM achievements_requirements WHERE achievement_id = ?", achievement.ID); err != nil { - return fmt.Errorf("failed to delete old requirements: %w", err) - } - if err := tx.Exec("DELETE FROM achievements_rewards WHERE achievement_id = ?", achievement.ID); err != nil { - return fmt.Errorf("failed to delete old rewards: %w", err) - } - - // Insert requirements - reqQuery := `INSERT INTO achievements_requirements - (achievement_id, name, qty_req) VALUES (?, ?, ?)` - for _, req := range achievement.Requirements { - if err := tx.Exec(reqQuery, req.AchievementID, req.Name, req.QtyRequired); err != nil { - return fmt.Errorf("failed to save requirement: %w", err) - } - } - - // Insert rewards - rewardQuery := `INSERT INTO achievements_rewards - (achievement_id, reward) VALUES (?, ?)` - for _, reward := range achievement.Rewards { - if err := tx.Exec(rewardQuery, reward.AchievementID, reward.Reward); err != nil { - return fmt.Errorf("failed to save reward: %w", err) - } - } - - return nil + achievement.Unknown3A, achievement.Unknown3B, + }, }) + if err != nil { + return fmt.Errorf("failed to save achievement: %w", err) + } + + // Delete existing requirements and rewards + err = sqlitex.Execute(conn, "DELETE FROM achievements_requirements WHERE achievement_id = ?", &sqlitex.ExecOptions{ + Args: []any{achievement.ID}, + }) + if err != nil { + return fmt.Errorf("failed to delete old requirements: %w", err) + } + + err = sqlitex.Execute(conn, "DELETE FROM achievements_rewards WHERE achievement_id = ?", &sqlitex.ExecOptions{ + Args: []any{achievement.ID}, + }) + if err != nil { + return fmt.Errorf("failed to delete old rewards: %w", err) + } + + // Insert requirements + reqQuery := `INSERT INTO achievements_requirements + (achievement_id, name, qty_req) VALUES (?, ?, ?)` + for _, req := range achievement.Requirements { + err = sqlitex.Execute(conn, reqQuery, &sqlitex.ExecOptions{ + Args: []any{req.AchievementID, req.Name, req.QtyRequired}, + }) + if err != nil { + return fmt.Errorf("failed to save requirement: %w", err) + } + } + + // Insert rewards + rewardQuery := `INSERT INTO achievements_rewards + (achievement_id, reward) VALUES (?, ?)` + for _, reward := range achievement.Rewards { + err = sqlitex.Execute(conn, rewardQuery, &sqlitex.ExecOptions{ + Args: []any{reward.AchievementID, reward.Reward}, + }) + if err != nil { + return fmt.Errorf("failed to save reward: %w", err) + } + } + + return sqlitex.Execute(conn, "COMMIT", nil) } // DeleteAchievement removes an achievement and all related records from database -func DeleteAchievement(db *database.DB, achievementID uint32) error { - return db.Transaction(func(tx *database.DB) error { - // Delete main achievement - if err := tx.Exec("DELETE FROM achievements WHERE achievement_id = ?", achievementID); err != nil { - return fmt.Errorf("failed to delete achievement: %w", err) - } +func DeleteAchievement(pool *sqlitex.Pool, achievementID uint32) error { + conn, err := pool.Take(context.Background()) + if err != nil { + return fmt.Errorf("failed to get connection: %w", err) + } + defer pool.Put(conn) - // Delete requirements - if err := tx.Exec("DELETE FROM achievements_requirements WHERE achievement_id = ?", achievementID); err != nil { - return fmt.Errorf("failed to delete requirements: %w", err) - } + err = sqlitex.Execute(conn, "BEGIN", nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer sqlitex.Execute(conn, "ROLLBACK", nil) - // Delete rewards - if err := tx.Exec("DELETE FROM achievements_rewards WHERE achievement_id = ?", achievementID); err != nil { - return fmt.Errorf("failed to delete rewards: %w", err) - } - - // Delete player progress (optional - might want to preserve history) - if err := tx.Exec("DELETE FROM character_achievements WHERE achievement_id = ?", achievementID); err != nil { - return fmt.Errorf("failed to delete player achievements: %w", err) - } - if err := tx.Exec("DELETE FROM character_achievements_items WHERE achievement_id = ?", achievementID); err != nil { - return fmt.Errorf("failed to delete player achievement items: %w", err) - } - - return nil + // Delete main achievement + err = sqlitex.Execute(conn, "DELETE FROM achievements WHERE achievement_id = ?", &sqlitex.ExecOptions{ + Args: []any{achievementID}, }) + if err != nil { + return fmt.Errorf("failed to delete achievement: %w", err) + } + + // Delete requirements + err = sqlitex.Execute(conn, "DELETE FROM achievements_requirements WHERE achievement_id = ?", &sqlitex.ExecOptions{ + Args: []any{achievementID}, + }) + if err != nil { + return fmt.Errorf("failed to delete requirements: %w", err) + } + + // Delete rewards + err = sqlitex.Execute(conn, "DELETE FROM achievements_rewards WHERE achievement_id = ?", &sqlitex.ExecOptions{ + Args: []any{achievementID}, + }) + if err != nil { + return fmt.Errorf("failed to delete rewards: %w", err) + } + + // Delete player progress (optional - might want to preserve history) + err = sqlitex.Execute(conn, "DELETE FROM character_achievements WHERE achievement_id = ?", &sqlitex.ExecOptions{ + Args: []any{achievementID}, + }) + if err != nil { + return fmt.Errorf("failed to delete player achievements: %w", err) + } + + err = sqlitex.Execute(conn, "DELETE FROM character_achievements_items WHERE achievement_id = ?", &sqlitex.ExecOptions{ + Args: []any{achievementID}, + }) + if err != nil { + return fmt.Errorf("failed to delete player achievement items: %w", err) + } + + return sqlitex.Execute(conn, "COMMIT", nil) } diff --git a/internal/database/database_test.go b/internal/database/database_test.go deleted file mode 100644 index e2f80ef..0000000 --- a/internal/database/database_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package database - -import ( - "os" - "testing" -) - -func TestOpen(t *testing.T) { - // Create a temporary database file - tempFile := "test.db" - defer os.Remove(tempFile) - - db, err := Open(tempFile) - if err != nil { - t.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - if db == nil { - t.Fatal("Database instance is nil") - } -} - -func TestExec(t *testing.T) { - tempFile := "test_exec.db" - defer os.Remove(tempFile) - - db, err := Open(tempFile) - if err != nil { - t.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - // Test table creation - err = db.Exec(`CREATE TABLE test_table (id INTEGER PRIMARY KEY, name TEXT)`) - if err != nil { - t.Fatalf("Failed to create table: %v", err) - } - - // Test data insertion - err = db.Exec(`INSERT INTO test_table (name) VALUES (?)`, "test_name") - if err != nil { - t.Fatalf("Failed to insert data: %v", err) - } -} - -func TestQueryRow(t *testing.T) { - tempFile := "test_query.db" - defer os.Remove(tempFile) - - db, err := Open(tempFile) - if err != nil { - t.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - // Setup test data - err = db.Exec(`CREATE TABLE test_table (id INTEGER PRIMARY KEY, name TEXT, value INTEGER)`) - if err != nil { - t.Fatalf("Failed to create table: %v", err) - } - - err = db.Exec(`INSERT INTO test_table (name, value) VALUES (?, ?)`, "test", 42) - if err != nil { - t.Fatalf("Failed to insert data: %v", err) - } - - // Test query - row, err := db.QueryRow("SELECT name, value FROM test_table WHERE id = ?", 1) - if err != nil { - t.Fatalf("Failed to query row: %v", err) - } - - if row == nil { - t.Fatal("Row is nil") - } - defer row.Close() - - name := row.Text(0) - value := row.Int(1) - - if name != "test" { - t.Errorf("Expected name 'test', got '%s'", name) - } - - if value != 42 { - t.Errorf("Expected value 42, got %d", value) - } -} - -func TestQuery(t *testing.T) { - tempFile := "test_query_all.db" - defer os.Remove(tempFile) - - db, err := Open(tempFile) - if err != nil { - t.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - // Setup test data - err = db.Exec(`CREATE TABLE test_table (id INTEGER PRIMARY KEY, name TEXT)`) - if err != nil { - t.Fatalf("Failed to create table: %v", err) - } - - names := []string{"test1", "test2", "test3"} - for _, name := range names { - err = db.Exec(`INSERT INTO test_table (name) VALUES (?)`, name) - if err != nil { - t.Fatalf("Failed to insert data: %v", err) - } - } - - // Test query with callback - var results []string - err = db.Query("SELECT name FROM test_table ORDER BY id", func(row *Row) error { - results = append(results, row.Text(0)) - return nil - }) - - if err != nil { - t.Fatalf("Failed to query: %v", err) - } - - if len(results) != 3 { - t.Errorf("Expected 3 results, got %d", len(results)) - } - - for i, expected := range names { - if i < len(results) && results[i] != expected { - t.Errorf("Expected result[%d] = '%s', got '%s'", i, expected, results[i]) - } - } -} - -func TestTransaction(t *testing.T) { - tempFile := "test_transaction.db" - defer os.Remove(tempFile) - - db, err := Open(tempFile) - if err != nil { - t.Fatalf("Failed to open database: %v", err) - } - defer db.Close() - - // Setup - err = db.Exec(`CREATE TABLE test_table (id INTEGER PRIMARY KEY, name TEXT)`) - if err != nil { - t.Fatalf("Failed to create table: %v", err) - } - - // Test successful transaction - err = db.Transaction(func(txDB *DB) error { - err := txDB.Exec(`INSERT INTO test_table (name) VALUES (?)`, "tx_test1") - if err != nil { - return err - } - return txDB.Exec(`INSERT INTO test_table (name) VALUES (?)`, "tx_test2") - }) - - if err != nil { - t.Fatalf("Transaction failed: %v", err) - } - - // Verify data was committed - var count int - row, err := db.QueryRow("SELECT COUNT(*) FROM test_table") - if err != nil { - t.Fatalf("Failed to count rows: %v", err) - } - if row != nil { - count = row.Int(0) - row.Close() - } - - if count != 2 { - t.Errorf("Expected 2 rows, got %d", count) - } -} \ No newline at end of file diff --git a/internal/database/wrapper.go b/internal/database/wrapper.go deleted file mode 100644 index caa5e98..0000000 --- a/internal/database/wrapper.go +++ /dev/null @@ -1,262 +0,0 @@ -package database - -import ( - "fmt" - - "zombiezen.com/go/sqlite" - "zombiezen.com/go/sqlite/sqlitex" -) - -// DB wraps sqlite.Conn with simplified query methods -type DB struct { - conn *sqlite.Conn -} - -// Row represents a single database row with easy column access -type Row struct { - stmt *sqlite.Stmt -} - -// QueryFunc processes each row in a result set -type QueryFunc func(*Row) error - -// Open creates a new database connection with common settings -func Open(path string) (*DB, error) { - conn, err := sqlite.OpenConn(path, sqlite.OpenReadWrite|sqlite.OpenCreate) - if err != nil { - return nil, fmt.Errorf("failed to open database: %w", err) - } - - // Enable foreign keys and WAL mode for better performance - if err := sqlitex.ExecuteTransient(conn, "PRAGMA foreign_keys = ON", nil); err != nil { - conn.Close() - return nil, fmt.Errorf("failed to enable foreign keys: %w", err) - } - - if err := sqlitex.ExecuteTransient(conn, "PRAGMA journal_mode = WAL", nil); err != nil { - conn.Close() - return nil, fmt.Errorf("failed to enable WAL mode: %w", err) - } - - return &DB{conn: conn}, nil -} - -// Close closes the database connection -func (db *DB) Close() error { - return db.conn.Close() -} - -// Exec executes a statement with parameters -func (db *DB) Exec(query string, args ...any) error { - return sqlitex.Execute(db.conn, query, &sqlitex.ExecOptions{ - Args: args, - }) -} - -// QueryRow executes a query expecting a single row result -func (db *DB) QueryRow(query string, args ...any) (*Row, error) { - stmt, err := db.conn.Prepare(query) - if err != nil { - return nil, fmt.Errorf("prepare failed: %w", err) - } - - // Bind parameters - for i, arg := range args { - if err := bindParam(stmt, i+1, arg); err != nil { - stmt.Finalize() - return nil, err - } - } - - hasRow, err := stmt.Step() - if err != nil { - stmt.Finalize() - return nil, fmt.Errorf("query failed: %w", err) - } - if !hasRow { - stmt.Finalize() - return nil, nil // No row found - } - - return &Row{stmt: stmt}, nil -} - -// Query executes a query and calls fn for each row -func (db *DB) Query(query string, fn QueryFunc, args ...any) error { - stmt, err := db.conn.Prepare(query) - if err != nil { - return fmt.Errorf("prepare failed: %w", err) - } - defer stmt.Finalize() - - // Bind parameters - for i, arg := range args { - if err := bindParam(stmt, i+1, arg); err != nil { - return err - } - } - - row := &Row{stmt: stmt} - for { - hasRow, err := stmt.Step() - if err != nil { - return fmt.Errorf("query failed: %w", err) - } - if !hasRow { - break - } - - if err := fn(row); err != nil { - return err - } - } - - return nil -} - -// QuerySlice executes a query and returns all rows in a slice -func (db *DB) QuerySlice(query string, args ...any) ([]*Row, error) { - var rows []*Row - - stmt, err := db.conn.Prepare(query) - if err != nil { - return nil, fmt.Errorf("prepare failed: %w", err) - } - defer stmt.Finalize() - - // Bind parameters - for i, arg := range args { - if err := bindParam(stmt, i+1, arg); err != nil { - return nil, err - } - } - - for { - hasRow, err := stmt.Step() - if err != nil { - return nil, fmt.Errorf("query failed: %w", err) - } - if !hasRow { - break - } - - // Create a snapshot of the current row - rowData := &Row{stmt: stmt} - rows = append(rows, rowData) - } - - return rows, nil -} - -// LastInsertID returns the last inserted row ID -func (db *DB) LastInsertID() int64 { - return db.conn.LastInsertRowID() -} - -// Changes returns the number of rows affected by the last statement -func (db *DB) Changes() int { - return db.conn.Changes() -} - -// Transaction executes fn within a database transaction -func (db *DB) Transaction(fn func(*DB) error) error { - if err := sqlitex.ExecuteTransient(db.conn, "BEGIN", nil); err != nil { - return fmt.Errorf("begin transaction failed: %w", err) - } - - if err := fn(db); err != nil { - sqlitex.ExecuteTransient(db.conn, "ROLLBACK", nil) - return err - } - - if err := sqlitex.ExecuteTransient(db.conn, "COMMIT", nil); err != nil { - return fmt.Errorf("commit transaction failed: %w", err) - } - - return nil -} - -// Row column access methods - -// Close releases the row's statement -func (r *Row) Close() { - if r.stmt != nil { - r.stmt.Finalize() - r.stmt = nil - } -} - -// Int returns column as int -func (r *Row) Int(col int) int { - return r.stmt.ColumnInt(col) -} - -// Int64 returns column as int64 -func (r *Row) Int64(col int) int64 { - return r.stmt.ColumnInt64(col) -} - -// Text returns column as string -func (r *Row) Text(col int) string { - return r.stmt.ColumnText(col) -} - -// Bool returns column as bool (0 = false, non-zero = true) -func (r *Row) Bool(col int) bool { - return r.stmt.ColumnInt(col) != 0 -} - -// Float returns column as float64 -func (r *Row) Float(col int) float64 { - return r.stmt.ColumnFloat(col) -} - -// IsNull checks if column is NULL -func (r *Row) IsNull(col int) bool { - return r.stmt.ColumnType(col) == sqlite.TypeNull -} - -// bindParam binds a parameter to a statement at the given index -func bindParam(stmt *sqlite.Stmt, index int, value any) error { - switch v := value.(type) { - case nil: - stmt.BindNull(index) - case int: - stmt.BindInt64(index, int64(v)) - case int8: - stmt.BindInt64(index, int64(v)) - case int16: - stmt.BindInt64(index, int64(v)) - case int32: - stmt.BindInt64(index, int64(v)) - case int64: - stmt.BindInt64(index, v) - case uint: - stmt.BindInt64(index, int64(v)) - case uint8: - stmt.BindInt64(index, int64(v)) - case uint16: - stmt.BindInt64(index, int64(v)) - case uint32: - stmt.BindInt64(index, int64(v)) - case uint64: - stmt.BindInt64(index, int64(v)) - case float32: - stmt.BindFloat(index, float64(v)) - case float64: - stmt.BindFloat(index, v) - case bool: - if v { - stmt.BindInt64(index, 1) - } else { - stmt.BindInt64(index, 0) - } - case string: - stmt.BindText(index, v) - case []byte: - stmt.BindBytes(index, v) - default: - return fmt.Errorf("unsupported parameter type: %T", value) - } - return nil -} diff --git a/internal/guilds/database.go b/internal/guilds/database.go index 65d7e24..e992a86 100644 --- a/internal/guilds/database.go +++ b/internal/guilds/database.go @@ -10,11 +10,11 @@ import ( // DatabaseGuildManager implements GuildDatabase interface using the existing database wrapper type DatabaseGuildManager struct { - db *database.DB + db database.DBInterface } // NewDatabaseGuildManager creates a new database guild manager -func NewDatabaseGuildManager(db *database.DB) *DatabaseGuildManager { +func NewDatabaseGuildManager(db database.DBInterface) *DatabaseGuildManager { return &DatabaseGuildManager{ db: db, } @@ -24,44 +24,25 @@ func NewDatabaseGuildManager(db *database.DB) *DatabaseGuildManager { func (dgm *DatabaseGuildManager) LoadGuilds(ctx context.Context) ([]GuildData, error) { query := "SELECT `id`, `name`, `motd`, `level`, `xp`, `xp_needed`, `formed_on` FROM `guilds`" - rows, err := dgm.db.QueryContext(ctx, query) - if err != nil { - return nil, fmt.Errorf("failed to query guilds: %w", err) - } - defer rows.Close() - var guilds []GuildData - for rows.Next() { + err := dgm.db.Query(query, func(row *database.Row) error { var guild GuildData - var motd *string - var formedOnTimestamp int64 - - err := rows.Scan( - &guild.ID, - &guild.Name, - &motd, - &guild.Level, - &guild.EXPCurrent, - &guild.EXPToNextLevel, - &formedOnTimestamp, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan guild row: %w", err) + guild.ID = int32(row.Int(0)) + guild.Name = row.Text(1) + if !row.IsNull(2) { + guild.MOTD = row.Text(2) } - - // Handle nullable MOTD field - if motd != nil { - guild.MOTD = *motd - } - - // Convert timestamp to time - guild.FormedDate = time.Unix(formedOnTimestamp, 0) + guild.Level = int8(row.Int(3)) + guild.EXPCurrent = row.Int64(4) + guild.EXPToNextLevel = row.Int64(5) + guild.FormedDate = time.Unix(row.Int64(6), 0) guilds = append(guilds, guild) - } + return nil + }) - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("error iterating guild rows: %w", err) + if err != nil { + return nil, fmt.Errorf("failed to query guilds: %w", err) } return guilds, nil @@ -71,30 +52,25 @@ func (dgm *DatabaseGuildManager) LoadGuilds(ctx context.Context) ([]GuildData, e func (dgm *DatabaseGuildManager) LoadGuild(ctx context.Context, guildID int32) (*GuildData, error) { query := "SELECT `id`, `name`, `motd`, `level`, `xp`, `xp_needed`, `formed_on` FROM `guilds` WHERE `id` = ?" - var guild GuildData - var motd *string - var formedOnTimestamp int64 - - err := dgm.db.QueryRowContext(ctx, query, guildID).Scan( - &guild.ID, - &guild.Name, - &motd, - &guild.Level, - &guild.EXPCurrent, - &guild.EXPToNextLevel, - &formedOnTimestamp, - ) + row, err := dgm.db.QueryRow(query, guildID) if err != nil { return nil, fmt.Errorf("failed to load guild %d: %w", guildID, err) } - - // Handle nullable MOTD field - if motd != nil { - guild.MOTD = *motd + if row == nil { + return nil, fmt.Errorf("guild %d not found", guildID) } + defer row.Close() - // Convert timestamp to time - guild.FormedDate = time.Unix(formedOnTimestamp, 0) + var guild GuildData + guild.ID = int32(row.Int(0)) + guild.Name = row.Text(1) + if !row.IsNull(2) { + guild.MOTD = row.Text(2) + } + guild.Level = int8(row.Int(3)) + guild.EXPCurrent = row.Int64(4) + guild.EXPToNextLevel = row.Int64(5) + guild.FormedDate = time.Unix(row.Int64(6), 0) return &guild, nil } @@ -107,110 +83,72 @@ func (dgm *DatabaseGuildManager) LoadGuildMembers(ctx context.Context, guildID i recruiter_description, recruiter_picture_data, recruiting_show_adventure_class FROM guild_members WHERE guild_id = ?` - rows, err := dgm.db.QueryContext(ctx, query, guildID) - if err != nil { - return nil, fmt.Errorf("failed to query guild members for guild %d: %w", guildID, err) - } - defer rows.Close() - + // Use the main implementation with zero-copy optimization var members []GuildMemberData - for rows.Next() { + err := dgm.db.Query(query, func(row *database.Row) error { var member GuildMemberData - var joinDateTimestamp int64 - var lastLoginTimestamp int64 - var note, officerNote, recruiterDesc *string - var pictureData []byte - - err := rows.Scan( - &member.CharacterID, - &member.GuildID, - &member.AccountID, - &member.RecruiterID, - &member.Name, - &member.GuildStatus, - &member.Points, - &member.AdventureClass, - &member.AdventureLevel, - &member.TradeskillClass, - &member.TradeskillLevel, - &member.Rank, - &member.MemberFlags, - &member.Zone, - &joinDateTimestamp, - &lastLoginTimestamp, - ¬e, - &officerNote, - &recruiterDesc, - &pictureData, - &member.RecruitingShowAdventureClass, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan guild member row: %w", err) + member.CharacterID = int32(row.Int(0)) + member.GuildID = int32(row.Int(1)) + member.AccountID = int32(row.Int(2)) + member.RecruiterID = int32(row.Int(3)) + member.Name = row.Text(4) + member.GuildStatus = int32(row.Int(5)) + member.Points = row.Float(6) + member.AdventureClass = int8(row.Int(7)) + member.AdventureLevel = int8(row.Int(8)) + member.TradeskillClass = int8(row.Int(9)) + member.TradeskillLevel = int8(row.Int(10)) + member.Rank = int8(row.Int(11)) + member.MemberFlags = int8(row.Int(12)) + member.Zone = row.Text(13) + member.JoinDate = time.Unix(row.Int64(14), 0) + member.LastLoginDate = time.Unix(row.Int64(15), 0) + if !row.IsNull(16) { + member.Note = row.Text(16) } - - // Handle nullable fields - if note != nil { - member.Note = *note + if !row.IsNull(17) { + member.OfficerNote = row.Text(17) } - if officerNote != nil { - member.OfficerNote = *officerNote + if !row.IsNull(18) { + member.RecruiterDescription = row.Text(18) } - if recruiterDesc != nil { - member.RecruiterDescription = *recruiterDesc - } - member.RecruiterPictureData = pictureData - - // Convert timestamps to time - member.JoinDate = time.Unix(joinDateTimestamp, 0) - member.LastLoginDate = time.Unix(lastLoginTimestamp, 0) + // TODO: Handle blob data for recruiter_picture_data (column 19) + member.RecruitingShowAdventureClass = int8(row.Int(20)) members = append(members, member) - } + return nil + }, guildID) - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("error iterating guild member rows: %w", err) + if err != nil { + return nil, fmt.Errorf("failed to query guild members for guild %d: %w", guildID, err) } return members, nil } + // LoadGuildEvents retrieves events for a guild func (dgm *DatabaseGuildManager) LoadGuildEvents(ctx context.Context, guildID int32) ([]GuildEventData, error) { query := `SELECT event_id, guild_id, date, type, description, locked FROM guild_events WHERE guild_id = ? ORDER BY event_id DESC LIMIT ?` - rows, err := dgm.db.QueryContext(ctx, query, guildID, MaxEvents) - if err != nil { - return nil, fmt.Errorf("failed to query guild events for guild %d: %w", guildID, err) - } - defer rows.Close() - var events []GuildEventData - for rows.Next() { + err := dgm.db.Query(query, func(row *database.Row) error { var event GuildEventData - var dateTimestamp int64 - - err := rows.Scan( - &event.EventID, - &event.GuildID, - &dateTimestamp, - &event.Type, - &event.Description, - &event.Locked, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan guild event row: %w", err) - } - - // Convert timestamp to time - event.Date = time.Unix(dateTimestamp, 0) + event.EventID = row.Int64(0) + event.GuildID = int32(row.Int(1)) + event.Date = time.Unix(row.Int64(2), 0) + event.Type = int32(row.Int(3)) + event.Description = row.Text(4) + event.Locked = int8(row.Int(5)) events = append(events, event) - } + return nil + }, guildID, MaxEvents) - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("error iterating guild event rows: %w", err) + if err != nil { + return nil, fmt.Errorf("failed to query guild events for guild %d: %w", guildID, err) } return events, nil @@ -220,30 +158,19 @@ func (dgm *DatabaseGuildManager) LoadGuildEvents(ctx context.Context, guildID in func (dgm *DatabaseGuildManager) LoadGuildRanks(ctx context.Context, guildID int32) ([]GuildRankData, error) { query := "SELECT guild_id, rank, name FROM guild_ranks WHERE guild_id = ?" - rows, err := dgm.db.QueryContext(ctx, query, guildID) - if err != nil { - return nil, fmt.Errorf("failed to query guild ranks for guild %d: %w", guildID, err) - } - defer rows.Close() - var ranks []GuildRankData - for rows.Next() { + err := dgm.db.Query(query, func(row *database.Row) error { var rank GuildRankData - - err := rows.Scan( - &rank.GuildID, - &rank.Rank, - &rank.Name, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan guild rank row: %w", err) - } + rank.GuildID = int32(row.Int(0)) + rank.Rank = int8(row.Int(1)) + rank.Name = row.Text(2) ranks = append(ranks, rank) - } + return nil + }, guildID) - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("error iterating guild rank rows: %w", err) + if err != nil { + return nil, fmt.Errorf("failed to query guild ranks for guild %d: %w", guildID, err) } return ranks, nil @@ -253,31 +180,20 @@ func (dgm *DatabaseGuildManager) LoadGuildRanks(ctx context.Context, guildID int func (dgm *DatabaseGuildManager) LoadGuildPermissions(ctx context.Context, guildID int32) ([]GuildPermissionData, error) { query := "SELECT guild_id, rank, permission, value FROM guild_permissions WHERE guild_id = ?" - rows, err := dgm.db.QueryContext(ctx, query, guildID) - if err != nil { - return nil, fmt.Errorf("failed to query guild permissions for guild %d: %w", guildID, err) - } - defer rows.Close() - var permissions []GuildPermissionData - for rows.Next() { + err := dgm.db.Query(query, func(row *database.Row) error { var permission GuildPermissionData - - err := rows.Scan( - &permission.GuildID, - &permission.Rank, - &permission.Permission, - &permission.Value, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan guild permission row: %w", err) - } + permission.GuildID = int32(row.Int(0)) + permission.Rank = int8(row.Int(1)) + permission.Permission = int8(row.Int(2)) + permission.Value = int8(row.Int(3)) permissions = append(permissions, permission) - } + return nil + }, guildID) - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("error iterating guild permission rows: %w", err) + if err != nil { + return nil, fmt.Errorf("failed to query guild permissions for guild %d: %w", guildID, err) } return permissions, nil @@ -287,31 +203,20 @@ func (dgm *DatabaseGuildManager) LoadGuildPermissions(ctx context.Context, guild func (dgm *DatabaseGuildManager) LoadGuildEventFilters(ctx context.Context, guildID int32) ([]GuildEventFilterData, error) { query := "SELECT guild_id, event_id, category, value FROM guild_event_filters WHERE guild_id = ?" - rows, err := dgm.db.QueryContext(ctx, query, guildID) - if err != nil { - return nil, fmt.Errorf("failed to query guild event filters for guild %d: %w", guildID, err) - } - defer rows.Close() - var filters []GuildEventFilterData - for rows.Next() { + err := dgm.db.Query(query, func(row *database.Row) error { var filter GuildEventFilterData - - err := rows.Scan( - &filter.GuildID, - &filter.EventID, - &filter.Category, - &filter.Value, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan guild event filter row: %w", err) - } + filter.GuildID = int32(row.Int(0)) + filter.EventID = int8(row.Int(1)) + filter.Category = int8(row.Int(2)) + filter.Value = int8(row.Int(3)) filters = append(filters, filter) - } + return nil + }, guildID) - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("error iterating guild event filter rows: %w", err) + if err != nil { + return nil, fmt.Errorf("failed to query guild event filters for guild %d: %w", guildID, err) } return filters, nil @@ -321,30 +226,19 @@ func (dgm *DatabaseGuildManager) LoadGuildEventFilters(ctx context.Context, guil func (dgm *DatabaseGuildManager) LoadGuildRecruiting(ctx context.Context, guildID int32) ([]GuildRecruitingData, error) { query := "SELECT guild_id, flag, value FROM guild_recruiting WHERE guild_id = ?" - rows, err := dgm.db.QueryContext(ctx, query, guildID) - if err != nil { - return nil, fmt.Errorf("failed to query guild recruiting for guild %d: %w", guildID, err) - } - defer rows.Close() - var recruiting []GuildRecruitingData - for rows.Next() { + err := dgm.db.Query(query, func(row *database.Row) error { var recruit GuildRecruitingData - - err := rows.Scan( - &recruit.GuildID, - &recruit.Flag, - &recruit.Value, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan guild recruiting row: %w", err) - } + recruit.GuildID = int32(row.Int(0)) + recruit.Flag = int8(row.Int(1)) + recruit.Value = int8(row.Int(2)) recruiting = append(recruiting, recruit) - } + return nil + }, guildID) - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("error iterating guild recruiting rows: %w", err) + if err != nil { + return nil, fmt.Errorf("failed to query guild recruiting for guild %d: %w", guildID, err) } return recruiting, nil @@ -356,36 +250,21 @@ func (dgm *DatabaseGuildManager) LoadPointHistory(ctx context.Context, character FROM guild_point_history WHERE char_id = ? ORDER BY date DESC LIMIT ?` - rows, err := dgm.db.QueryContext(ctx, query, characterID, MaxPointHistory) - if err != nil { - return nil, fmt.Errorf("failed to query point history for character %d: %w", characterID, err) - } - defer rows.Close() - var history []PointHistoryData - for rows.Next() { + err := dgm.db.Query(query, func(row *database.Row) error { var entry PointHistoryData - var dateTimestamp int64 - - err := rows.Scan( - &entry.CharacterID, - &dateTimestamp, - &entry.ModifiedBy, - &entry.Comment, - &entry.Points, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan point history row: %w", err) - } - - // Convert timestamp to time - entry.Date = time.Unix(dateTimestamp, 0) + entry.CharacterID = int32(row.Int(0)) + entry.Date = time.Unix(row.Int64(1), 0) + entry.ModifiedBy = row.Text(2) + entry.Comment = row.Text(3) + entry.Points = row.Float(4) history = append(history, entry) - } + return nil + }, characterID, MaxPointHistory) - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("error iterating point history rows: %w", err) + if err != nil { + return nil, fmt.Errorf("failed to query point history for character %d: %w", characterID, err) } return history, nil @@ -397,10 +276,9 @@ func (dgm *DatabaseGuildManager) SaveGuild(ctx context.Context, guild *Guild) er (id, name, motd, level, xp, xp_needed, formed_on) VALUES (?, ?, ?, ?, ?, ?, ?)` - guildInfo := guild.GetGuildInfo() formedTimestamp := guild.GetFormedDate().Unix() - _, err := dgm.db.ExecContext(ctx, query, + err := dgm.db.Exec(query, guild.GetID(), guild.GetName(), guild.GetMOTD(), @@ -423,63 +301,52 @@ func (dgm *DatabaseGuildManager) SaveGuildMembers(ctx context.Context, guildID i } // Use a transaction for atomic updates - tx, err := dgm.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - defer tx.Rollback() - - // Delete existing members for this guild - _, err = tx.ExecContext(ctx, "DELETE FROM guild_members WHERE guild_id = ?", guildID) - if err != nil { - return fmt.Errorf("failed to delete existing guild members: %w", err) - } - - // Insert all members - insertQuery := `INSERT INTO guild_members - (char_id, guild_id, account_id, recruiter_id, name, guild_status, points, - adventure_class, adventure_level, tradeskill_class, tradeskill_level, rank, - member_flags, zone, join_date, last_login_date, note, officer_note, - recruiter_description, recruiter_picture_data, recruiting_show_adventure_class) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - - for _, member := range members { - joinTimestamp := member.GetJoinDate().Unix() - lastLoginTimestamp := member.GetLastLoginDate().Unix() - - _, err = tx.ExecContext(ctx, insertQuery, - member.GetCharacterID(), - guildID, - member.AccountID, - member.GetRecruiterID(), - member.GetName(), - member.GuildStatus, - member.GetPoints(), - member.GetAdventureClass(), - member.GetAdventureLevel(), - member.GetTradeskillClass(), - member.GetTradeskillLevel(), - member.GetRank(), - member.GetMemberFlags(), - member.GetZone(), - joinTimestamp, - lastLoginTimestamp, - member.GetNote(), - member.GetOfficerNote(), - member.GetRecruiterDescription(), - member.GetRecruiterPictureData(), - member.RecruitingShowAdventureClass, - ) - if err != nil { - return fmt.Errorf("failed to insert guild member %d: %w", member.GetCharacterID(), err) + return dgm.db.Transaction(func(db database.DBInterface) error { + // Delete existing members for this guild + if err := db.Exec("DELETE FROM guild_members WHERE guild_id = ?", guildID); err != nil { + return fmt.Errorf("failed to delete existing guild members: %w", err) } - } - if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit transaction: %w", err) - } + // Insert all members + insertQuery := `INSERT INTO guild_members + (char_id, guild_id, account_id, recruiter_id, name, guild_status, points, + adventure_class, adventure_level, tradeskill_class, tradeskill_level, rank, + member_flags, zone, join_date, last_login_date, note, officer_note, + recruiter_description, recruiter_picture_data, recruiting_show_adventure_class) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - return nil + for _, member := range members { + joinTimestamp := member.GetJoinDate().Unix() + lastLoginTimestamp := member.GetLastLoginDate().Unix() + + if err := db.Exec(insertQuery, + member.GetCharacterID(), + guildID, + member.AccountID, + member.GetRecruiterID(), + member.GetName(), + member.GuildStatus, + member.GetPoints(), + member.GetAdventureClass(), + member.GetAdventureLevel(), + member.GetTradeskillClass(), + member.GetTradeskillLevel(), + member.GetRank(), + member.GetMemberFlags(), + member.GetZone(), + joinTimestamp, + lastLoginTimestamp, + member.GetNote(), + member.GetOfficerNote(), + member.GetRecruiterDescription(), + member.GetRecruiterPictureData(), + member.RecruitingShowAdventureClass, + ); err != nil { + return fmt.Errorf("failed to insert guild member %d: %w", member.GetCharacterID(), err) + } + } + return nil + }) } // SaveGuildEvents saves guild events @@ -499,15 +366,14 @@ func (dgm *DatabaseGuildManager) SaveGuildEvents(ctx context.Context, guildID in dateTimestamp := event.Date.Unix() - _, err := dgm.db.ExecContext(ctx, query, + if err := dgm.db.Exec(query, event.EventID, guildID, dateTimestamp, event.Type, event.Description, event.Locked, - ) - if err != nil { + ); err != nil { return fmt.Errorf("failed to save guild event %d: %w", event.EventID, err) } } @@ -518,144 +384,99 @@ func (dgm *DatabaseGuildManager) SaveGuildEvents(ctx context.Context, guildID in // SaveGuildRanks saves guild rank names func (dgm *DatabaseGuildManager) SaveGuildRanks(ctx context.Context, guildID int32, ranks map[int8]string) error { // Use a transaction for atomic updates - tx, err := dgm.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - defer tx.Rollback() + return dgm.db.Transaction(func(db database.DBInterface) error { + // Delete existing ranks for this guild + if err := db.Exec("DELETE FROM guild_ranks WHERE guild_id = ?", guildID); err != nil { + return fmt.Errorf("failed to delete existing guild ranks: %w", err) + } - // Delete existing ranks for this guild - _, err = tx.ExecContext(ctx, "DELETE FROM guild_ranks WHERE guild_id = ?", guildID) - if err != nil { - return fmt.Errorf("failed to delete existing guild ranks: %w", err) - } + // Insert all ranks + insertQuery := "INSERT INTO guild_ranks (guild_id, rank, name) VALUES (?, ?, ?)" - // Insert all ranks - insertQuery := "INSERT INTO guild_ranks (guild_id, rank, name) VALUES (?, ?, ?)" - - for rank, name := range ranks { - // Only save non-default rank names - if defaultName, exists := DefaultRankNames[rank]; !exists || name != defaultName { - _, err = tx.ExecContext(ctx, insertQuery, guildID, rank, name) - if err != nil { - return fmt.Errorf("failed to insert guild rank %d: %w", rank, err) + for rank, name := range ranks { + // Only save non-default rank names + if defaultName, exists := DefaultRankNames[rank]; !exists || name != defaultName { + if err := db.Exec(insertQuery, guildID, rank, name); err != nil { + return fmt.Errorf("failed to insert guild rank %d: %w", rank, err) + } } } - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit transaction: %w", err) - } - - return nil + return nil + }) } // SaveGuildPermissions saves guild permissions func (dgm *DatabaseGuildManager) SaveGuildPermissions(ctx context.Context, guildID int32, permissions map[int8]map[int8]int8) error { // Use a transaction for atomic updates - tx, err := dgm.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - defer tx.Rollback() + return dgm.db.Transaction(func(db database.DBInterface) error { + // Delete existing permissions for this guild + if err := db.Exec("DELETE FROM guild_permissions WHERE guild_id = ?", guildID); err != nil { + return fmt.Errorf("failed to delete existing guild permissions: %w", err) + } - // Delete existing permissions for this guild - _, err = tx.ExecContext(ctx, "DELETE FROM guild_permissions WHERE guild_id = ?", guildID) - if err != nil { - return fmt.Errorf("failed to delete existing guild permissions: %w", err) - } + // Insert all permissions + insertQuery := "INSERT INTO guild_permissions (guild_id, rank, permission, value) VALUES (?, ?, ?, ?)" - // Insert all permissions - insertQuery := "INSERT INTO guild_permissions (guild_id, rank, permission, value) VALUES (?, ?, ?, ?)" - - for rank, rankPermissions := range permissions { - for permission, value := range rankPermissions { - _, err = tx.ExecContext(ctx, insertQuery, guildID, rank, permission, value) - if err != nil { - return fmt.Errorf("failed to insert guild permission %d/%d: %w", rank, permission, err) + for rank, rankPermissions := range permissions { + for permission, value := range rankPermissions { + if err := db.Exec(insertQuery, guildID, rank, permission, value); err != nil { + return fmt.Errorf("failed to insert guild permission %d/%d: %w", rank, permission, err) + } } } - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit transaction: %w", err) - } - - return nil + return nil + }) } // SaveGuildEventFilters saves guild event filters func (dgm *DatabaseGuildManager) SaveGuildEventFilters(ctx context.Context, guildID int32, filters map[int8]map[int8]int8) error { // Use a transaction for atomic updates - tx, err := dgm.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - defer tx.Rollback() + return dgm.db.Transaction(func(db database.DBInterface) error { + // Delete existing filters for this guild + if err := db.Exec("DELETE FROM guild_event_filters WHERE guild_id = ?", guildID); err != nil { + return fmt.Errorf("failed to delete existing guild event filters: %w", err) + } - // Delete existing filters for this guild - _, err = tx.ExecContext(ctx, "DELETE FROM guild_event_filters WHERE guild_id = ?", guildID) - if err != nil { - return fmt.Errorf("failed to delete existing guild event filters: %w", err) - } + // Insert all filters + insertQuery := "INSERT INTO guild_event_filters (guild_id, event_id, category, value) VALUES (?, ?, ?, ?)" - // Insert all filters - insertQuery := "INSERT INTO guild_event_filters (guild_id, event_id, category, value) VALUES (?, ?, ?, ?)" - - for eventID, eventFilters := range filters { - for category, value := range eventFilters { - _, err = tx.ExecContext(ctx, insertQuery, guildID, eventID, category, value) - if err != nil { - return fmt.Errorf("failed to insert guild event filter %d/%d: %w", eventID, category, err) + for eventID, eventFilters := range filters { + for category, value := range eventFilters { + if err := db.Exec(insertQuery, guildID, eventID, category, value); err != nil { + return fmt.Errorf("failed to insert guild event filter %d/%d: %w", eventID, category, err) + } } } - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit transaction: %w", err) - } - - return nil + return nil + }) } // SaveGuildRecruiting saves guild recruiting settings func (dgm *DatabaseGuildManager) SaveGuildRecruiting(ctx context.Context, guildID int32, flags, descTags map[int8]int8) error { // Use a transaction for atomic updates - tx, err := dgm.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - defer tx.Rollback() - - // Delete existing recruiting settings for this guild - _, err = tx.ExecContext(ctx, "DELETE FROM guild_recruiting WHERE guild_id = ?", guildID) - if err != nil { - return fmt.Errorf("failed to delete existing guild recruiting: %w", err) - } - - // Insert recruiting flags - insertQuery := "INSERT INTO guild_recruiting (guild_id, flag, value) VALUES (?, ?, ?)" - - for flag, value := range flags { - _, err = tx.ExecContext(ctx, insertQuery, guildID, flag, value) - if err != nil { - return fmt.Errorf("failed to insert guild recruiting flag %d: %w", flag, err) + return dgm.db.Transaction(func(db database.DBInterface) error { + // Delete existing recruiting settings for this guild + if err := db.Exec("DELETE FROM guild_recruiting WHERE guild_id = ?", guildID); err != nil { + return fmt.Errorf("failed to delete existing guild recruiting: %w", err) } - } - // Insert description tags (with negative flag values to distinguish) - for tag, value := range descTags { - _, err = tx.ExecContext(ctx, insertQuery, guildID, -tag-1, value) // Negative to distinguish from flags - if err != nil { - return fmt.Errorf("failed to insert guild recruiting desc tag %d: %w", tag, err) + // Insert recruiting flags + insertQuery := "INSERT INTO guild_recruiting (guild_id, flag, value) VALUES (?, ?, ?)" + + for flag, value := range flags { + if err := db.Exec(insertQuery, guildID, flag, value); err != nil { + return fmt.Errorf("failed to insert guild recruiting flag %d: %w", flag, err) + } } - } - if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit transaction: %w", err) - } - - return nil + // Insert description tags (with negative flag values to distinguish) + for tag, value := range descTags { + if err := db.Exec(insertQuery, guildID, -tag-1, value); err != nil { // Negative to distinguish from flags + return fmt.Errorf("failed to insert guild recruiting desc tag %d: %w", tag, err) + } + } + return nil + }) } // SavePointHistory saves point history for a member @@ -665,53 +486,46 @@ func (dgm *DatabaseGuildManager) SavePointHistory(ctx context.Context, character } // Use a transaction for atomic updates - tx, err := dgm.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - defer tx.Rollback() - - // Delete existing history for this character - _, err = tx.ExecContext(ctx, "DELETE FROM guild_point_history WHERE char_id = ?", characterID) - if err != nil { - return fmt.Errorf("failed to delete existing point history: %w", err) - } - - // Insert all history entries - insertQuery := "INSERT INTO guild_point_history (char_id, date, modified_by, comment, points) VALUES (?, ?, ?, ?, ?)" - - for _, entry := range history { - dateTimestamp := entry.Date.Unix() - - _, err = tx.ExecContext(ctx, insertQuery, - characterID, - dateTimestamp, - entry.ModifiedBy, - entry.Comment, - entry.Points, - ) - if err != nil { - return fmt.Errorf("failed to insert point history entry: %w", err) + return dgm.db.Transaction(func(db database.DBInterface) error { + // Delete existing history for this character + if err := db.Exec("DELETE FROM guild_point_history WHERE char_id = ?", characterID); err != nil { + return fmt.Errorf("failed to delete existing point history: %w", err) } - } - if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit transaction: %w", err) - } + // Insert all history entries + insertQuery := "INSERT INTO guild_point_history (char_id, date, modified_by, comment, points) VALUES (?, ?, ?, ?, ?)" - return nil + for _, entry := range history { + dateTimestamp := entry.Date.Unix() + + if err := db.Exec(insertQuery, + characterID, + dateTimestamp, + entry.ModifiedBy, + entry.Comment, + entry.Points, + ); err != nil { + return fmt.Errorf("failed to insert point history entry: %w", err) + } + } + return nil + }) } // GetGuildIDByCharacterID returns guild ID for a character func (dgm *DatabaseGuildManager) GetGuildIDByCharacterID(ctx context.Context, characterID int32) (int32, error) { query := "SELECT guild_id FROM guild_members WHERE char_id = ?" - var guildID int32 - err := dgm.db.QueryRowContext(ctx, query, characterID).Scan(&guildID) + row, err := dgm.db.QueryRow(query, characterID) if err != nil { return 0, fmt.Errorf("failed to get guild ID for character %d: %w", characterID, err) } + if row == nil { + return 0, fmt.Errorf("character %d not found in any guild", characterID) + } + defer row.Close() + guildID := int32(row.Int(0)) return guildID, nil } @@ -722,7 +536,7 @@ func (dgm *DatabaseGuildManager) CreateGuild(ctx context.Context, guildData Guil formedTimestamp := guildData.FormedDate.Unix() - result, err := dgm.db.ExecContext(ctx, query, + id, err := dgm.db.ExecAndGetLastInsertID(query, guildData.Name, guildData.MOTD, guildData.Level, @@ -734,72 +548,60 @@ func (dgm *DatabaseGuildManager) CreateGuild(ctx context.Context, guildData Guil return 0, fmt.Errorf("failed to create guild: %w", err) } - id, err := result.LastInsertId() - if err != nil { - return 0, fmt.Errorf("failed to get new guild ID: %w", err) - } - return int32(id), nil } // DeleteGuild removes a guild and all related data func (dgm *DatabaseGuildManager) DeleteGuild(ctx context.Context, guildID int32) error { // Use a transaction for atomic deletion - tx, err := dgm.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - defer tx.Rollback() - - // Delete related data first (foreign key constraints) - tables := []string{ - "guild_point_history", - "guild_members", - "guild_events", - "guild_ranks", - "guild_permissions", - "guild_event_filters", - "guild_recruiting", - } - - for _, table := range tables { - var query string - if table == "guild_point_history" { - // Special case: need to join with guild_members to get char_ids - query = fmt.Sprintf("DELETE ph FROM %s ph JOIN guild_members gm ON ph.char_id = gm.char_id WHERE gm.guild_id = ?", table) - } else { - query = fmt.Sprintf("DELETE FROM %s WHERE guild_id = ?", table) + return dgm.db.Transaction(func(db database.DBInterface) error { + // Delete related data first (foreign key constraints) + tables := []string{ + "guild_point_history", + "guild_members", + "guild_events", + "guild_ranks", + "guild_permissions", + "guild_event_filters", + "guild_recruiting", } - _, err = tx.ExecContext(ctx, query, guildID) - if err != nil { - return fmt.Errorf("failed to delete from %s: %w", table, err) + for _, table := range tables { + var query string + if table == "guild_point_history" { + // Special case: delete point history for all members of this guild + query = "DELETE FROM guild_point_history WHERE character_id IN (SELECT char_id FROM guild_members WHERE guild_id = ?)" + } else { + query = fmt.Sprintf("DELETE FROM %s WHERE guild_id = ?", table) + } + + if err := db.Exec(query, guildID); err != nil { + return fmt.Errorf("failed to delete from %s: %w", table, err) + } } - } - // Finally delete the guild itself - _, err = tx.ExecContext(ctx, "DELETE FROM guilds WHERE id = ?", guildID) - if err != nil { - return fmt.Errorf("failed to delete guild: %w", err) - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit transaction: %w", err) - } - - return nil + // Finally delete the guild itself + if err := db.Exec("DELETE FROM guilds WHERE id = ?", guildID); err != nil { + return fmt.Errorf("failed to delete guild: %w", err) + } + return nil + }) } // GetNextGuildID returns the next available guild ID func (dgm *DatabaseGuildManager) GetNextGuildID(ctx context.Context) (int32, error) { query := "SELECT COALESCE(MAX(id), 0) + 1 FROM guilds" - var nextID int32 - err := dgm.db.QueryRowContext(ctx, query).Scan(&nextID) + row, err := dgm.db.QueryRow(query) if err != nil { return 0, fmt.Errorf("failed to get next guild ID: %w", err) } + if row == nil { + return 1, nil // If no guilds exist, start with ID 1 + } + defer row.Close() + nextID := int32(row.Int(0)) return nextID, nil } @@ -807,12 +609,16 @@ func (dgm *DatabaseGuildManager) GetNextGuildID(ctx context.Context) (int32, err func (dgm *DatabaseGuildManager) GetNextEventID(ctx context.Context, guildID int32) (int64, error) { query := "SELECT COALESCE(MAX(event_id), 0) + 1 FROM guild_events WHERE guild_id = ?" - var nextID int64 - err := dgm.db.QueryRowContext(ctx, query, guildID).Scan(&nextID) + row, err := dgm.db.QueryRow(query, guildID) if err != nil { return 0, fmt.Errorf("failed to get next event ID for guild %d: %w", guildID, err) } + if row == nil { + return 1, nil // If no events exist for this guild, start with ID 1 + } + defer row.Close() + nextID := row.Int64(0) return nextID, nil } @@ -907,8 +713,7 @@ func (dgm *DatabaseGuildManager) EnsureGuildTables(ctx context.Context) error { } for i, query := range queries { - _, err := dgm.db.ExecContext(ctx, query) - if err != nil { + if err := dgm.db.Exec(query); err != nil { return fmt.Errorf("failed to create guild table %d: %w", i+1, err) } } @@ -926,8 +731,7 @@ func (dgm *DatabaseGuildManager) EnsureGuildTables(ctx context.Context) error { } for i, query := range indexes { - _, err := dgm.db.ExecContext(ctx, query) - if err != nil { + if err := dgm.db.Exec(query); err != nil { return fmt.Errorf("failed to create guild index %d: %w", i+1, err) } } diff --git a/internal/guilds/database_test.go b/internal/guilds/database_test.go new file mode 100644 index 0000000..90d0837 --- /dev/null +++ b/internal/guilds/database_test.go @@ -0,0 +1,608 @@ +package guilds + +import ( + "context" + "fmt" + "path/filepath" + "testing" + "time" + + "eq2emu/internal/database" +) + +// createTestDB creates a temporary test database +func createTestDB(t *testing.T) *database.DB { + // Create temporary directory for test database + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "test_guilds.db") + + // Create and initialize database + db, err := database.Open(dbPath) + if err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + + // Create guild tables for testing + err = createGuildTables(db) + if err != nil { + t.Fatalf("Failed to create guild tables: %v", err) + } + + return db +} + +// createGuildTables creates the necessary tables for guild testing +func createGuildTables(db *database.DB) error { + tables := []string{ + `CREATE TABLE IF NOT EXISTS guilds ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + motd TEXT, + level INTEGER DEFAULT 1, + xp INTEGER DEFAULT 111, + xp_needed INTEGER DEFAULT 2521, + formed_on INTEGER DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS guild_members ( + char_id INTEGER PRIMARY KEY, + guild_id INTEGER NOT NULL, + account_id INTEGER NOT NULL, + recruiter_id INTEGER DEFAULT 0, + name TEXT NOT NULL, + guild_status INTEGER DEFAULT 0, + points REAL DEFAULT 0.0, + adventure_class INTEGER DEFAULT 0, + adventure_level INTEGER DEFAULT 1, + tradeskill_class INTEGER DEFAULT 0, + tradeskill_level INTEGER DEFAULT 1, + rank INTEGER DEFAULT 7, + member_flags INTEGER DEFAULT 0, + zone TEXT DEFAULT '', + join_date INTEGER DEFAULT 0, + last_login_date INTEGER DEFAULT 0, + note TEXT DEFAULT '', + officer_note TEXT DEFAULT '', + recruiter_description TEXT DEFAULT '', + recruiter_picture_data BLOB, + recruiting_show_adventure_class INTEGER DEFAULT 0, + FOREIGN KEY (guild_id) REFERENCES guilds(id) + )`, + `CREATE TABLE IF NOT EXISTS guild_events ( + event_id INTEGER PRIMARY KEY, + guild_id INTEGER NOT NULL, + date INTEGER NOT NULL, + type INTEGER NOT NULL, + description TEXT NOT NULL, + locked INTEGER DEFAULT 0, + FOREIGN KEY (guild_id) REFERENCES guilds(id) + )`, + `CREATE TABLE IF NOT EXISTS guild_ranks ( + guild_id INTEGER NOT NULL, + rank INTEGER NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (guild_id, rank), + FOREIGN KEY (guild_id) REFERENCES guilds(id) + )`, + `CREATE TABLE IF NOT EXISTS guild_permissions ( + guild_id INTEGER NOT NULL, + rank INTEGER NOT NULL, + permission INTEGER NOT NULL, + value INTEGER NOT NULL, + PRIMARY KEY (guild_id, rank, permission), + FOREIGN KEY (guild_id) REFERENCES guilds(id) + )`, + `CREATE TABLE IF NOT EXISTS guild_event_filters ( + guild_id INTEGER NOT NULL, + event_id INTEGER NOT NULL, + category INTEGER NOT NULL, + value INTEGER NOT NULL, + PRIMARY KEY (guild_id, event_id, category), + FOREIGN KEY (guild_id) REFERENCES guilds(id) + )`, + `CREATE TABLE IF NOT EXISTS guild_recruiting ( + guild_id INTEGER NOT NULL, + flag INTEGER NOT NULL, + value INTEGER NOT NULL, + PRIMARY KEY (guild_id, flag), + FOREIGN KEY (guild_id) REFERENCES guilds(id) + )`, + `CREATE TABLE IF NOT EXISTS guild_point_history ( + character_id INTEGER NOT NULL, + date INTEGER NOT NULL, + modified_by TEXT NOT NULL, + comment TEXT NOT NULL, + points REAL NOT NULL, + FOREIGN KEY (character_id) REFERENCES guild_members(char_id) + )`, + } + + for _, sql := range tables { + if err := db.Exec(sql); err != nil { + return err + } + } + + return nil +} + +// TestDatabaseGuildManager_LoadGuilds tests loading guilds from database +func TestDatabaseGuildManager_LoadGuilds(t *testing.T) { + db := createTestDB(t) + defer db.Close() + + dgm := NewDatabaseGuildManager(db) + ctx := context.Background() + + // Insert test data + insertGuildSQL := `INSERT INTO guilds (id, name, motd, level, xp, xp_needed, formed_on) + VALUES (1, 'Test Guild', 'Welcome!', 5, 1000, 5000, ?)` + formedTime := time.Now().Unix() + err := db.Exec(insertGuildSQL, formedTime) + if err != nil { + t.Fatalf("Failed to insert test guild: %v", err) + } + + // Test loading guilds + guilds, err := dgm.LoadGuilds(ctx) + if err != nil { + t.Fatalf("Failed to load guilds: %v", err) + } + + if len(guilds) != 1 { + t.Errorf("Expected 1 guild, got %d", len(guilds)) + } + + guild := guilds[0] + if guild.ID != 1 { + t.Errorf("Expected guild ID 1, got %d", guild.ID) + } + if guild.Name != "Test Guild" { + t.Errorf("Expected guild name 'Test Guild', got '%s'", guild.Name) + } + if guild.MOTD != "Welcome!" { + t.Errorf("Expected MOTD 'Welcome!', got '%s'", guild.MOTD) + } + if guild.Level != 5 { + t.Errorf("Expected level 5, got %d", guild.Level) + } + if guild.EXPCurrent != 1000 { + t.Errorf("Expected current exp 1000, got %d", guild.EXPCurrent) + } + if guild.EXPToNextLevel != 5000 { + t.Errorf("Expected next level exp 5000, got %d", guild.EXPToNextLevel) + } +} + +// TestDatabaseGuildManager_LoadGuild tests loading a specific guild +func TestDatabaseGuildManager_LoadGuild(t *testing.T) { + db := createTestDB(t) + defer db.Close() + + dgm := NewDatabaseGuildManager(db) + ctx := context.Background() + + // Insert test data + insertGuildSQL := `INSERT INTO guilds (id, name, motd, level, xp, xp_needed, formed_on) + VALUES (123, 'Specific Guild', 'Test MOTD', 10, 2000, 8000, ?)` + formedTime := time.Now().Unix() + err := db.Exec(insertGuildSQL, formedTime) + if err != nil { + t.Fatalf("Failed to insert test guild: %v", err) + } + + // Test loading specific guild + guild, err := dgm.LoadGuild(ctx, 123) + if err != nil { + t.Fatalf("Failed to load guild: %v", err) + } + + if guild.ID != 123 { + t.Errorf("Expected guild ID 123, got %d", guild.ID) + } + if guild.Name != "Specific Guild" { + t.Errorf("Expected guild name 'Specific Guild', got '%s'", guild.Name) + } + if guild.MOTD != "Test MOTD" { + t.Errorf("Expected MOTD 'Test MOTD', got '%s'", guild.MOTD) + } + + // Test loading non-existent guild + _, err = dgm.LoadGuild(ctx, 999) + if err == nil { + t.Error("Expected error when loading non-existent guild") + } +} + +// TestDatabaseGuildManager_LoadGuildMembers tests loading guild members +func TestDatabaseGuildManager_LoadGuildMembers(t *testing.T) { + db := createTestDB(t) + defer db.Close() + + dgm := NewDatabaseGuildManager(db) + ctx := context.Background() + + // Insert test guild + err := db.Exec(`INSERT INTO guilds (id, name) VALUES (1, 'Test Guild')`) + if err != nil { + t.Fatalf("Failed to insert test guild: %v", err) + } + + // Insert test members + joinTime := time.Now().Unix() + memberSQL := `INSERT INTO guild_members + (char_id, guild_id, account_id, name, rank, adventure_level, join_date, last_login_date) + VALUES (?, 1, 100, ?, ?, 50, ?, ?)` + + err = db.Exec(memberSQL, 1, "Player1", RankLeader, joinTime, joinTime) + if err != nil { + t.Fatalf("Failed to insert member 1: %v", err) + } + + err = db.Exec(memberSQL, 2, "Player2", RankMember, joinTime, joinTime) + if err != nil { + t.Fatalf("Failed to insert member 2: %v", err) + } + + // Test loading members + members, err := dgm.LoadGuildMembers(ctx, 1) + if err != nil { + t.Fatalf("Failed to load guild members: %v", err) + } + + if len(members) != 2 { + t.Errorf("Expected 2 members, got %d", len(members)) + } + + // Verify first member + member1 := members[0] + if member1.CharacterID != 1 { + t.Errorf("Expected character ID 1, got %d", member1.CharacterID) + } + if member1.Name != "Player1" { + t.Errorf("Expected name 'Player1', got '%s'", member1.Name) + } + if member1.Rank != RankLeader { + t.Errorf("Expected rank %d, got %d", RankLeader, member1.Rank) + } +} + +// TestDatabaseGuildManager_SaveGuild tests saving guild data +func TestDatabaseGuildManager_SaveGuild(t *testing.T) { + db := createTestDB(t) + defer db.Close() + + dgm := NewDatabaseGuildManager(db) + ctx := context.Background() + + // Create test guild + guild := NewGuild() + guild.SetID(1) + guild.SetName("Saved Guild", false) + guild.SetLevel(15, false) + guild.SetMOTD("Saved MOTD", false) + guild.SetEXPCurrent(3000, false) + guild.SetEXPToNextLevel(9000, false) + guild.SetFormedDate(time.Now()) + + // Test saving guild + err := dgm.SaveGuild(ctx, guild) + if err != nil { + t.Fatalf("Failed to save guild: %v", err) + } + + // Verify the guild was saved + savedGuild, err := dgm.LoadGuild(ctx, 1) + if err != nil { + t.Fatalf("Failed to load saved guild: %v", err) + } + + if savedGuild.Name != "Saved Guild" { + t.Errorf("Expected saved name 'Saved Guild', got '%s'", savedGuild.Name) + } + if savedGuild.Level != 15 { + t.Errorf("Expected saved level 15, got %d", savedGuild.Level) + } + if savedGuild.MOTD != "Saved MOTD" { + t.Errorf("Expected saved MOTD 'Saved MOTD', got '%s'", savedGuild.MOTD) + } +} + +// TestDatabaseGuildManager_CreateGuild tests creating a new guild +func TestDatabaseGuildManager_CreateGuild(t *testing.T) { + db := createTestDB(t) + defer db.Close() + + dgm := NewDatabaseGuildManager(db) + ctx := context.Background() + + // Create guild data + guildData := GuildData{ + Name: "New Guild", + Level: 1, + FormedDate: time.Now(), + MOTD: "New guild MOTD", + EXPCurrent: 111, + EXPToNextLevel: 2521, + RecruitingShortDesc: "Short description", + RecruitingFullDesc: "Full description", + RecruitingMinLevel: 1, + RecruitingPlayStyle: 0, + } + + // Test creating guild + guildID, err := dgm.CreateGuild(ctx, guildData) + if err != nil { + t.Fatalf("Failed to create guild: %v", err) + } + + if guildID <= 0 { + t.Errorf("Expected positive guild ID, got %d", guildID) + } + + // Verify the guild was created + createdGuild, err := dgm.LoadGuild(ctx, guildID) + if err != nil { + t.Fatalf("Failed to load created guild: %v", err) + } + + if createdGuild.Name != "New Guild" { + t.Errorf("Expected created name 'New Guild', got '%s'", createdGuild.Name) + } +} + +// TestDatabaseGuildManager_DeleteGuild tests deleting a guild +func TestDatabaseGuildManager_DeleteGuild(t *testing.T) { + db := createTestDB(t) + defer db.Close() + + dgm := NewDatabaseGuildManager(db) + ctx := context.Background() + + // Insert test guild + err := db.Exec(`INSERT INTO guilds (id, name) VALUES (1, 'To Delete')`) + if err != nil { + t.Fatalf("Failed to insert test guild: %v", err) + } + + // Insert test member + err = db.Exec(`INSERT INTO guild_members (char_id, guild_id, account_id, name) VALUES (1, 1, 100, 'Member')`) + if err != nil { + t.Fatalf("Failed to insert test member: %v", err) + } + + // Verify guild exists + _, err = dgm.LoadGuild(ctx, 1) + if err != nil { + t.Fatalf("Guild should exist before deletion: %v", err) + } + + // Test deleting guild + err = dgm.DeleteGuild(ctx, 1) + if err != nil { + t.Fatalf("Failed to delete guild: %v", err) + } + + // Verify guild was deleted + _, err = dgm.LoadGuild(ctx, 1) + if err == nil { + t.Error("Guild should not exist after deletion") + } + + // Verify members were deleted + members, err := dgm.LoadGuildMembers(ctx, 1) + if err != nil { + t.Fatalf("Failed to check members after guild deletion: %v", err) + } + if len(members) != 0 { + t.Errorf("Expected 0 members after guild deletion, got %d", len(members)) + } +} + +// TestDatabaseGuildManager_GetGuildIDByCharacterID tests getting guild ID by character ID +func TestDatabaseGuildManager_GetGuildIDByCharacterID(t *testing.T) { + db := createTestDB(t) + defer db.Close() + + dgm := NewDatabaseGuildManager(db) + ctx := context.Background() + + // Insert test guild + err := db.Exec(`INSERT INTO guilds (id, name) VALUES (1, 'Test Guild')`) + if err != nil { + t.Fatalf("Failed to insert test guild: %v", err) + } + + // Insert test member + err = db.Exec(`INSERT INTO guild_members (char_id, guild_id, account_id, name) VALUES (123, 1, 100, 'TestPlayer')`) + if err != nil { + t.Fatalf("Failed to insert test member: %v", err) + } + + // Test getting guild ID for character + guildID, err := dgm.GetGuildIDByCharacterID(ctx, 123) + if err != nil { + t.Fatalf("Failed to get guild ID by character ID: %v", err) + } + + if guildID != 1 { + t.Errorf("Expected guild ID 1, got %d", guildID) + } + + // Test getting guild ID for non-existent character + _, err = dgm.GetGuildIDByCharacterID(ctx, 999) + if err == nil { + t.Error("Expected error for non-existent character") + } +} + +// TestDatabaseGuildManager_ConcurrentOperations tests concurrent database operations +// Now enabled with sqlitex.Pool for proper connection management +func TestDatabaseGuildManager_ConcurrentOperations(t *testing.T) { + db := createTestDB(t) + defer db.Close() + + dgm := NewDatabaseGuildManager(db) + ctx := context.Background() + + // Insert initial guild + err := db.Exec(`INSERT INTO guilds (id, name) VALUES (1, 'Concurrent Test')`) + if err != nil { + t.Fatalf("Failed to insert test guild: %v", err) + } + + const numGoroutines = 5 // Reduce concurrency to avoid SQLite issues + done := make(chan error, numGoroutines) + + // Test concurrent reads + for i := 0; i < numGoroutines; i++ { + go func(id int) { + // Add small delay to reduce contention + time.Sleep(time.Duration(id) * time.Millisecond) + _, err := dgm.LoadGuild(ctx, 1) + done <- err + }(i) + } + + // Wait for all reads to complete + for i := 0; i < numGoroutines; i++ { + if err := <-done; err != nil { + t.Errorf("Concurrent read failed: %v", err) + } + } + + // Test concurrent member additions + for i := 0; i < numGoroutines; i++ { + go func(id int) { + memberSQL := `INSERT INTO guild_members (char_id, guild_id, account_id, name) VALUES (?, 1, 100, ?)` + err := db.Exec(memberSQL, 100+id, fmt.Sprintf("Player%d", id)) + done <- err + }(i) + } + + // Wait for all insertions + successCount := 0 + for i := 0; i < numGoroutines; i++ { + if err := <-done; err == nil { + successCount++ + } + } + + if successCount != numGoroutines { + t.Logf("Only %d out of %d concurrent insertions succeeded (some conflicts expected)", successCount, numGoroutines) + } +} + +// TestDatabaseGuildManager_TransactionRollback tests transaction rollback on errors +func TestDatabaseGuildManager_TransactionRollback(t *testing.T) { + db := createTestDB(t) + defer db.Close() + + dgm := NewDatabaseGuildManager(db) + ctx := context.Background() + + // Try to create a guild with invalid data that should trigger a rollback + invalidGuildData := GuildData{ + Name: "", // Empty name should cause validation error + Level: 1, + FormedDate: time.Now(), + } + + // This should fail but we test that it handles errors gracefully + _, err := dgm.CreateGuild(ctx, invalidGuildData) + + // We don't expect specific error behavior here since the implementation + // may or may not have validation, but we test that it doesn't crash + t.Logf("Create guild with invalid data returned: %v", err) + + // Verify no partial data was left behind + guilds, err := dgm.LoadGuilds(ctx) + if err != nil { + t.Fatalf("Failed to load guilds after rollback test: %v", err) + } + + // Should have no guilds (or any existing test data, but no new invalid guild) + t.Logf("Found %d guilds after invalid creation attempt", len(guilds)) +} + +// BenchmarkDatabaseGuildManager_LoadGuilds benchmarks loading guilds +func BenchmarkDatabaseGuildManager_LoadGuilds(b *testing.B) { + // Create test database in temp directory + tempDir := b.TempDir() + dbPath := filepath.Join(tempDir, "bench_guilds.db") + + db, err := database.Open(dbPath) + if err != nil { + b.Fatalf("Failed to create benchmark database: %v", err) + } + defer db.Close() + + if err := createGuildTables(db); err != nil { + b.Fatalf("Failed to create guild tables: %v", err) + } + + dgm := NewDatabaseGuildManager(db) + ctx := context.Background() + + // Insert test guilds + for i := 0; i < 100; i++ { + insertSQL := `INSERT INTO guilds (id, name, level, xp, xp_needed, formed_on) VALUES (?, ?, 1, 111, 2521, ?)` + formedTime := time.Now().Unix() + if err := db.Exec(insertSQL, i+1, fmt.Sprintf("Guild%d", i+1), formedTime); err != nil { + b.Fatalf("Failed to insert test guild %d: %v", i+1, err) + } + } + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + _, err := dgm.LoadGuilds(ctx) + if err != nil { + b.Fatalf("Failed to load guilds in benchmark: %v", err) + } + } +} + +// BenchmarkDatabaseGuildManager_LoadGuildMembers benchmarks loading guild members +func BenchmarkDatabaseGuildManager_LoadGuildMembers(b *testing.B) { + // Create test database in temp directory + tempDir := b.TempDir() + dbPath := filepath.Join(tempDir, "bench_members.db") + + db, err := database.Open(dbPath) + if err != nil { + b.Fatalf("Failed to create benchmark database: %v", err) + } + defer db.Close() + + if err := createGuildTables(db); err != nil { + b.Fatalf("Failed to create guild tables: %v", err) + } + + dgm := NewDatabaseGuildManager(db) + ctx := context.Background() + + // Insert test guild + if err := db.Exec(`INSERT INTO guilds (id, name) VALUES (1, 'Benchmark Guild')`); err != nil { + b.Fatalf("Failed to insert benchmark guild: %v", err) + } + + // Insert test members + joinTime := time.Now().Unix() + for i := 0; i < 100; i++ { + memberSQL := `INSERT INTO guild_members (char_id, guild_id, account_id, name, rank, join_date, last_login_date) + VALUES (?, 1, 100, ?, ?, ?, ?)` + if err := db.Exec(memberSQL, i+1, fmt.Sprintf("Player%d", i+1), RankMember, joinTime, joinTime); err != nil { + b.Fatalf("Failed to insert test member %d: %v", i+1, err) + } + } + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + _, err := dgm.LoadGuildMembers(ctx, 1) + if err != nil { + b.Fatalf("Failed to load guild members in benchmark: %v", err) + } + } +} + diff --git a/internal/guilds/guild_test.go b/internal/guilds/guild_test.go new file mode 100644 index 0000000..f1156ed --- /dev/null +++ b/internal/guilds/guild_test.go @@ -0,0 +1,529 @@ +package guilds + +import ( + "fmt" + "testing" + "time" +) + +// TestNewGuild tests guild creation with default values +func TestNewGuild(t *testing.T) { + guild := NewGuild() + + // Test initial state + if guild.GetLevel() != 1 { + t.Errorf("Expected initial level 1, got %d", guild.GetLevel()) + } + + if guild.GetEXPCurrent() != 111 { + t.Errorf("Expected initial expCurrent 111, got %d", guild.GetEXPCurrent()) + } + + if guild.GetEXPToNextLevel() != 2521 { + t.Errorf("Expected initial expToNextLevel 2521, got %d", guild.GetEXPToNextLevel()) + } + + if guild.GetRecruitingMinLevel() != 1 { + t.Errorf("Expected initial recruitingMinLevel 1, got %d", guild.GetRecruitingMinLevel()) + } + + if guild.GetRecruitingPlayStyle() != RecruitingPlayStyleNone { + t.Errorf("Expected initial recruitingPlayStyle %d, got %d", RecruitingPlayStyleNone, guild.GetRecruitingPlayStyle()) + } + + if guild.GetNextEventID() != 1 { + t.Errorf("Expected initial nextEventID 1, got %d", guild.GetNextEventID()) + } + + // Test recruiting flags are initialized + if guild.GetRecruitingFlag(RecruitingFlagTraining) != 0 { + t.Error("Training recruiting flag should be initialized to 0") + } + + if guild.GetRecruitingFlag(RecruitingFlagFighters) != 0 { + t.Error("Fighters recruiting flag should be initialized to 0") + } + + // Test description tags are initialized + if guild.GetRecruitingDescTag(0) != RecruitingDescTagNone { + t.Error("Description tag 0 should be initialized to None") + } + + // Test default rank names are set + if guild.GetRankName(RankLeader) != "Leader" { + t.Errorf("Expected leader rank name 'Leader', got '%s'", guild.GetRankName(RankLeader)) + } + + if guild.GetRankName(RankRecruit) != "Recruit" { + t.Errorf("Expected recruit rank name 'Recruit', got '%s'", guild.GetRankName(RankRecruit)) + } +} + +// TestGuildBasicOperations tests basic guild getter/setter operations +func TestGuildBasicOperations(t *testing.T) { + guild := NewGuild() + + // Test ID operations + testID := int32(12345) + guild.SetID(testID) + if guild.GetID() != testID { + t.Errorf("Expected ID %d, got %d", testID, guild.GetID()) + } + + // Test name operations + testName := "Test Guild" + guild.SetName(testName, false) + if guild.GetName() != testName { + t.Errorf("Expected name '%s', got '%s'", testName, guild.GetName()) + } + + // Test level operations + testLevel := int8(10) + guild.SetLevel(testLevel, false) + if guild.GetLevel() != testLevel { + t.Errorf("Expected level %d, got %d", testLevel, guild.GetLevel()) + } + + // Test MOTD operations + testMOTD := "Welcome to our guild!" + guild.SetMOTD(testMOTD, false) + if guild.GetMOTD() != testMOTD { + t.Errorf("Expected MOTD '%s', got '%s'", testMOTD, guild.GetMOTD()) + } + + // Test formed date operations + testDate := time.Now().Add(-24 * time.Hour) + guild.SetFormedDate(testDate) + if !guild.GetFormedDate().Equal(testDate) { + t.Errorf("Expected formed date %v, got %v", testDate, guild.GetFormedDate()) + } + + // Test experience operations + testExpCurrent := int64(5000) + testExpNext := int64(10000) + guild.SetEXPCurrent(testExpCurrent, false) + guild.SetEXPToNextLevel(testExpNext, false) + + current := guild.GetEXPCurrent() + next := guild.GetEXPToNextLevel() + if current != testExpCurrent { + t.Errorf("Expected current exp %d, got %d", testExpCurrent, current) + } + if next != testExpNext { + t.Errorf("Expected next level exp %d, got %d", testExpNext, next) + } +} + +// TestGuildMemberOperations tests guild member management +func TestGuildMemberOperations(t *testing.T) { + guild := NewGuild() + guild.SetID(1) + + // Test adding members using the actual method + characterID1 := int32(1) + characterID2 := int32(2) + inviterName := "TestInviter" + joinDate := time.Now() + + // Add first member + success := guild.AddNewGuildMember(characterID1, inviterName, joinDate, RankRecruit) + if !success { + t.Error("Should be able to add first member") + } + + // Add second member + success = guild.AddNewGuildMember(characterID2, inviterName, joinDate, RankMember) + if !success { + t.Error("Should be able to add second member") + } + + // Test getting member by ID + member1 := guild.GetGuildMember(characterID1) + if member1 == nil { + t.Error("Should be able to retrieve member by ID") + } + if member1.CharacterID != characterID1 { + t.Errorf("Expected member ID %d, got %d", characterID1, member1.CharacterID) + } + + // Test getting all members + allMembers := guild.GetAllMembers() + if len(allMembers) != 2 { + t.Errorf("Expected 2 members, got %d", len(allMembers)) + } + + // Test removing member + guild.RemoveGuildMember(characterID1, false) + member1After := guild.GetGuildMember(characterID1) + if member1After != nil { + t.Error("Member should be nil after removal") + } + + allMembersAfter := guild.GetAllMembers() + if len(allMembersAfter) != 1 { + t.Errorf("Expected 1 member after removal, got %d", len(allMembersAfter)) + } +} + +// TestGuildEventOperations tests guild event management +func TestGuildEventOperations(t *testing.T) { + guild := NewGuild() + + // Test adding events + eventType := int32(EventMemberJoins) + description := "Member joined guild" + eventDate := time.Now() + + // Add first event (should get ID 1) + guild.AddNewGuildEvent(eventType, description, eventDate, false) + + // Add another event (should get ID 2) + guild.AddNewGuildEvent(EventMemberLeaves, "Member left guild", eventDate, false) + + // Test getting next event ID (should be 3 now) + nextID := guild.GetNextEventID() + if nextID != 3 { + t.Errorf("Expected next event ID 3, got %d", nextID) + } + + // Test getting specific event (first event should have ID 1) + event := guild.GetGuildEvent(1) + if event == nil { + t.Error("Should be able to retrieve event by ID") + } + if event != nil && event.Description != description { + t.Errorf("Expected event description '%s', got '%s'", description, event.Description) + } +} + +// TestGuildRankOperations tests guild rank management +func TestGuildRankOperations(t *testing.T) { + guild := NewGuild() + + // Test setting custom rank name + customRankName := "Elite Member" + success := guild.SetRankName(RankMember, customRankName, false) + if !success { + t.Error("Should be able to set rank name") + } + + rankName := guild.GetRankName(RankMember) + if rankName != customRankName { + t.Errorf("Expected rank name '%s', got '%s'", customRankName, rankName) + } + + // Test getting default rank names + leaderName := guild.GetRankName(RankLeader) + if leaderName != "Leader" { + t.Errorf("Expected leader rank name 'Leader', got '%s'", leaderName) + } +} + +// TestGuildRecruitingOperations tests guild recruiting settings +func TestGuildRecruitingOperations(t *testing.T) { + guild := NewGuild() + + // Test recruiting descriptions + shortDesc := "Looking for members" + fullDesc := "We are a friendly guild looking for active members" + guild.SetRecruitingShortDesc(shortDesc, false) + guild.SetRecruitingFullDesc(fullDesc, false) + + short := guild.GetRecruitingShortDesc() + full := guild.GetRecruitingFullDesc() + if short != shortDesc { + t.Errorf("Expected short description '%s', got '%s'", shortDesc, short) + } + if full != fullDesc { + t.Errorf("Expected full description '%s', got '%s'", fullDesc, full) + } + + // Test recruiting settings + minLevel := int8(20) + playStyle := int8(2) + guild.SetRecruitingMinLevel(minLevel, false) + guild.SetRecruitingPlayStyle(playStyle, false) + + getMinLevel := guild.GetRecruitingMinLevel() + getPlayStyle := guild.GetRecruitingPlayStyle() + if getMinLevel != minLevel { + t.Errorf("Expected min level %d, got %d", minLevel, getMinLevel) + } + if getPlayStyle != playStyle { + t.Errorf("Expected play style %d, got %d", playStyle, getPlayStyle) + } + + // Test recruiting flags + success := guild.SetRecruitingFlag(RecruitingFlagFighters, 1, false) + if !success { + t.Error("Should be able to set recruiting flag") + } + flag := guild.GetRecruitingFlag(RecruitingFlagFighters) + if flag != 1 { + t.Errorf("Expected recruiting flag 1, got %d", flag) + } + + // Test recruiting description tags + success = guild.SetRecruitingDescTag(0, RecruitingDescTagRoleplay, false) + if !success { + t.Error("Should be able to set recruiting desc tag") + } + tag := guild.GetRecruitingDescTag(0) + if tag != RecruitingDescTagRoleplay { + t.Errorf("Expected description tag %d, got %d", RecruitingDescTagRoleplay, tag) + } +} + +// TestGuildPermissions tests guild permission system +func TestGuildPermissions(t *testing.T) { + guild := NewGuild() + + // Test setting permissions + rank := int8(RankMember) + permission := int8(PermissionInvite) + value := int8(1) + + success := guild.SetPermission(rank, permission, value, false, false) + if !success { + t.Error("Should be able to set permission") + } + + getValue := guild.GetPermission(rank, permission) + if getValue != value { + t.Errorf("Expected permission value %d, got %d", value, getValue) + } + + // Test removing permission + success = guild.SetPermission(rank, permission, 0, false, false) + if !success { + t.Error("Should be able to remove permission") + } + + getValue = guild.GetPermission(rank, permission) + if getValue != 0 { + t.Errorf("Expected permission value 0 after removal, got %d", getValue) + } +} + +// TestGuildSaveFlags tests the save flag system +func TestGuildSaveFlags(t *testing.T) { + guild := NewGuild() + + // Test initial state + if guild.GetSaveNeeded() { + t.Error("Guild should not need save initially") + } + + // Test marking save needed + guild.SetSaveNeeded(true) + if !guild.GetSaveNeeded() { + t.Error("Guild should need save after marking") + } + + // Test clearing save needed + guild.SetSaveNeeded(false) + if guild.GetSaveNeeded() { + t.Error("Guild should not need save after clearing") + } +} + +// TestGuildMemberPromotionDemotion tests member rank changes +func TestGuildMemberPromotionDemotion(t *testing.T) { + guild := NewGuild() + guild.SetID(1) + + // Add a member + characterID := int32(1) + inviterName := "TestInviter" + joinDate := time.Now() + + success := guild.AddNewGuildMember(characterID, inviterName, joinDate, RankRecruit) + if !success { + t.Error("Should be able to add member") + } + + // Get member and check initial rank + member := guild.GetGuildMember(characterID) + if member == nil { + t.Fatal("Member should exist") + } + if member.Rank != RankRecruit { + t.Errorf("Expected initial rank %d, got %d", RankRecruit, member.Rank) + } + + // Test promotion + promoterName := "TestPromoter" + success = guild.PromoteGuildMember(characterID, promoterName, false) + if !success { + t.Error("Should be able to promote member") + } + + member = guild.GetGuildMember(characterID) + if member.Rank != RankInitiate { + t.Errorf("Expected rank after promotion %d, got %d", RankInitiate, member.Rank) + } + + // Test demotion + demoterName := "TestDemoter" + success = guild.DemoteGuildMember(characterID, demoterName, false) + if !success { + t.Error("Should be able to demote member") + } + + member = guild.GetGuildMember(characterID) + if member.Rank != RankRecruit { + t.Errorf("Expected rank after demotion %d, got %d", RankRecruit, member.Rank) + } +} + +// TestGuildPointsSystem tests the guild points system +func TestGuildPointsSystem(t *testing.T) { + guild := NewGuild() + guild.SetID(1) + + // Add a member + characterID := int32(1) + inviterName := "TestInviter" + joinDate := time.Now() + + success := guild.AddNewGuildMember(characterID, inviterName, joinDate, RankRecruit) + if !success { + t.Error("Should be able to add member") + } + + // Get initial points + member := guild.GetGuildMember(characterID) + if member == nil { + t.Fatal("Member should exist") + } + initialPoints := member.Points + + // Add points + pointsToAdd := 100.0 + modifiedBy := "TestAdmin" + comment := "Test point award" + + success = guild.AddPointsToGuildMember(characterID, pointsToAdd, modifiedBy, comment, false) + if !success { + t.Error("Should be able to add points to member") + } + + // Check points were added + member = guild.GetGuildMember(characterID) + expectedPoints := initialPoints + pointsToAdd + if member.Points != expectedPoints { + t.Errorf("Expected points %f, got %f", expectedPoints, member.Points) + } +} + +// TestGuildConcurrency tests thread safety of guild operations +func TestGuildConcurrency(t *testing.T) { + guild := NewGuild() + guild.SetID(1) + guild.SetName("Concurrent Test Guild", false) + + const numGoroutines = 20 + done := make(chan bool, numGoroutines) + + // Test concurrent reads + for i := 0; i < numGoroutines; i++ { + go func(id int) { + _ = guild.GetID() + _ = guild.GetName() + _ = guild.GetLevel() + _ = guild.GetMOTD() + _ = guild.GetEXPCurrent() + _ = guild.GetRecruitingShortDesc() + _ = guild.GetRankName(RankMember) + _ = guild.GetPermission(RankMember, PermissionInvite) + done <- true + }(i) + } + + // Wait for all read operations + for i := 0; i < numGoroutines; i++ { + <-done + } + + // Test concurrent member additions (smaller number to avoid conflicts) + const memberGoroutines = 10 + for i := 0; i < memberGoroutines; i++ { + go func(id int) { + inviterName := fmt.Sprintf("Inviter%d", id) + joinDate := time.Now() + characterID := int32(100 + id) + + guild.AddNewGuildMember(characterID, inviterName, joinDate, RankRecruit) + done <- true + }(i) + } + + // Wait for all member additions + for i := 0; i < memberGoroutines; i++ { + <-done + } + + // Verify members were added + members := guild.GetAllMembers() + if len(members) != memberGoroutines { + t.Logf("Expected %d members, got %d (some concurrent additions may have failed, which is acceptable)", memberGoroutines, len(members)) + } +} + +// TestGuildEventFilters tests guild event filter system +func TestGuildEventFilters(t *testing.T) { + guild := NewGuild() + + // Test setting event filters + eventID := int8(EventMemberJoins) + category := int8(EventFilterCategoryBroadcast) + value := int8(1) + + success := guild.SetEventFilter(eventID, category, value, false, false) + if !success { + t.Error("Should be able to set event filter") + } + + getValue := guild.GetEventFilter(eventID, category) + if getValue != value { + t.Errorf("Expected event filter value %d, got %d", value, getValue) + } + + // Test removing event filter + success = guild.SetEventFilter(eventID, category, 0, false, false) + if !success { + t.Error("Should be able to remove event filter") + } + + getValue = guild.GetEventFilter(eventID, category) + if getValue != 0 { + t.Errorf("Expected event filter value 0 after removal, got %d", getValue) + } +} + +// TestGuildInfo tests the guild info structure +func TestGuildInfo(t *testing.T) { + guild := NewGuild() + guild.SetID(123) + guild.SetName("Test Guild Info", false) + guild.SetLevel(25, false) + guild.SetMOTD("Test MOTD", false) + + info := guild.GetGuildInfo() + + if info.ID != 123 { + t.Errorf("Expected guild info ID 123, got %d", info.ID) + } + + if info.Name != "Test Guild Info" { + t.Errorf("Expected guild info name 'Test Guild Info', got '%s'", info.Name) + } + + if info.Level != 25 { + t.Errorf("Expected guild info level 25, got %d", info.Level) + } + + if info.MOTD != "Test MOTD" { + t.Errorf("Expected guild info MOTD 'Test MOTD', got '%s'", info.MOTD) + } +} \ No newline at end of file