package main import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "github.com/bwmarrin/discordgo" "golang.design/x/hotkey" //nolint:typecheck — key constants are platform-specific but names are identical ) // --- parseKeybind --- func TestParseKeybind(t *testing.T) { tests := []struct { input string numMods int key hotkey.Key wantErr bool }{ {"m", 0, hotkey.KeyM, false}, {"f9", 0, hotkey.KeyF9, false}, {"ctrl+m", 1, hotkey.KeyM, false}, {"ctrl+shift+m", 2, hotkey.KeyM, false}, {"ctrl+alt+delete", 2, hotkey.KeyDelete, false}, {"alt+f4", 1, hotkey.KeyF4, false}, {"CTRL+SHIFT+M", 2, hotkey.KeyM, false}, {"ctrl+enter", 1, hotkey.KeyReturn, false}, {"ctrl+return", 1, hotkey.KeyReturn, false}, {"ctrl+esc", 1, hotkey.KeyEscape, false}, {"ctrl+escape", 1, hotkey.KeyEscape, false}, {"win+l", 1, hotkey.KeyL, false}, {"super+l", 1, hotkey.KeyL, false}, {"ctrl+shift+badkey", 0, 0, true}, {"badmod+m", 0, 0, true}, {"", 0, 0, true}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { mods, key, err := parseKeybind(tt.input) if (err != nil) != tt.wantErr { t.Fatalf("parseKeybind(%q) error = %v, wantErr = %v", tt.input, err, tt.wantErr) } if tt.wantErr { return } if len(mods) != tt.numMods { t.Errorf("got %d modifier(s), want %d", len(mods), tt.numMods) } if key != tt.key { t.Errorf("key = %v, want %v", key, tt.key) } }) } } // --- loadConfig --- func TestLoadConfig(t *testing.T) { t.Run("valid full config", func(t *testing.T) { dir := t.TempDir() want := Config{ Token: "tok", GuildID: "g1", ChannelID: "c1", Keybind: "ctrl+shift+m", ExemptUsers: []string{"u1", "u2"}, } write(t, dir, want) got, err := loadConfig(dir) if err != nil { t.Fatal(err) } if got.Token != want.Token || got.GuildID != want.GuildID || got.ChannelID != want.ChannelID || got.Keybind != want.Keybind { t.Errorf("got %+v, want %+v", got, want) } if len(got.ExemptUsers) != 2 || got.ExemptUsers[0] != "u1" || got.ExemptUsers[1] != "u2" { t.Errorf("exempt_users = %v, want [u1 u2]", got.ExemptUsers) } }) t.Run("no exempt_users field", func(t *testing.T) { dir := t.TempDir() os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"token":"t","guild_id":"g","channel_id":"c","keybind":"a"}`), 0644) got, err := loadConfig(dir) if err != nil { t.Fatal(err) } if len(got.ExemptUsers) != 0 { t.Errorf("expected empty exempt_users, got %v", got.ExemptUsers) } }) t.Run("invalid json", func(t *testing.T) { dir := t.TempDir() os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{bad`), 0644) if _, err := loadConfig(dir); err == nil { t.Fatal("expected error for invalid JSON") } }) t.Run("file not found", func(t *testing.T) { if _, err := loadConfig(t.TempDir()); err == nil { t.Fatal("expected error when config.json is missing") } }) t.Run("falls back to second dir", func(t *testing.T) { empty := t.TempDir() dir := t.TempDir() write(t, dir, Config{Token: "fallback"}) got, err := loadConfig(empty, dir) if err != nil { t.Fatal(err) } if got.Token != "fallback" { t.Errorf("token = %q, want \"fallback\"", got.Token) } }) } func write(t *testing.T, dir string, cfg Config) { t.Helper() data, err := json.Marshal(cfg) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "config.json"), data, 0644); err != nil { t.Fatal(err) } } // --- toggleMute --- type muteCall struct { userID string muted bool } func TestToggleMute(t *testing.T) { var mu sync.Mutex var calls []muteCall srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPatch { w.WriteHeader(http.StatusMethodNotAllowed) return } // path: /guilds/{guildID}/members/{userID} parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") if len(parts) < 4 { w.WriteHeader(http.StatusBadRequest) return } userID := parts[3] var body struct { Mute bool `json:"mute"` } json.NewDecoder(r.Body).Decode(&body) mu.Lock() calls = append(calls, muteCall{userID, body.Mute}) mu.Unlock() w.WriteHeader(http.StatusNoContent) })) defer srv.Close() orig := discordgo.EndpointGuilds discordgo.EndpointGuilds = srv.URL + "/guilds/" defer func() { discordgo.EndpointGuilds = orig }() tests := []struct { name string voices []*discordgo.VoiceState exempt []string muted bool wantHit []string wantSkipped []string }{ { name: "mutes all users in channel", voices: []*discordgo.VoiceState{ {UserID: "u1", ChannelID: "chan1"}, {UserID: "u2", ChannelID: "chan1"}, }, muted: true, wantHit: []string{"u1", "u2"}, }, { name: "ignores users in other channels", voices: []*discordgo.VoiceState{ {UserID: "u1", ChannelID: "chan1"}, {UserID: "u2", ChannelID: "other"}, }, muted: true, wantHit: []string{"u1"}, wantSkipped: []string{"u2"}, }, { name: "skips exempt users", voices: []*discordgo.VoiceState{ {UserID: "u1", ChannelID: "chan1"}, {UserID: "exempt", ChannelID: "chan1"}, }, exempt: []string{"exempt"}, muted: true, wantHit: []string{"u1"}, wantSkipped: []string{"exempt"}, }, { name: "sends mute=false when unmuting", voices: []*discordgo.VoiceState{ {UserID: "u1", ChannelID: "chan1"}, }, muted: false, wantHit: []string{"u1"}, }, { name: "empty channel", voices: []*discordgo.VoiceState{}, muted: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mu.Lock() calls = nil mu.Unlock() dg, _ := discordgo.New("Bot test") dg.State.GuildAdd(&discordgo.Guild{ ID: "guild1", VoiceStates: tt.voices, }) toggleMute(dg, &Config{ GuildID: "guild1", ChannelID: "chan1", ExemptUsers: tt.exempt, }, tt.muted) mu.Lock() defer mu.Unlock() hit := make(map[string]bool, len(calls)) for _, c := range calls { hit[c.userID] = true if c.muted != tt.muted { t.Errorf("user %s: mute flag = %v, want %v", c.userID, c.muted, tt.muted) } } for _, uid := range tt.wantHit { if !hit[uid] { t.Errorf("expected request for user %s, got none", uid) } } for _, uid := range tt.wantSkipped { if hit[uid] { t.Errorf("user %s should have been skipped but was called", uid) } } }) } }