package web import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "gitgud.io/mike/mpv-manager/pkg/hotkeys" ) // seedInputConf writes an input.conf with the given content into the sandboxed // config home and returns its path. func seedInputConf(t *testing.T, content string) string { t.Helper() configPath := hotkeys.GetInputConfPath() if configPath == "" { t.Fatal("Failed to determine input.conf path") } if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatalf("Failed to create config dir: %v", err) } if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { t.Fatalf("Failed to seed input.conf: %v", err) } return configPath } // readBinding re-reads input.conf from disk and returns the binding for key. func readBinding(t *testing.T, configPath, key string) *hotkeys.Binding { t.Helper() conf, err := hotkeys.ReadInputConf(configPath) if err != nil { t.Fatalf("Failed to re-read input.conf: %v", err) } return hotkeys.FindBinding(conf, key) } func TestHandleHotkeyBindingSetAPI(t *testing.T) { tests := []struct { name string seed string body string wantStatus int verify func(t *testing.T, w *httptest.ResponseRecorder, configPath string) }{ { name: "new binding", seed: "SPACE cycle pause\n", body: `{"key":"F1","command":"show-text hello"}`, wantStatus: http.StatusOK, verify: func(t *testing.T, w *httptest.ResponseRecorder, configPath string) { b := readBinding(t, configPath, "F1") if b == nil { t.Fatal("Expected F1 binding to be persisted") } if b.Command != "show-text hello" { t.Errorf("Expected command 'show-text hello', got %q", b.Command) } }, }, { name: "existing binding without overwrite", seed: "SPACE cycle pause\n", body: `{"key":"SPACE","command":"cycle mute"}`, wantStatus: http.StatusConflict, verify: func(t *testing.T, w *httptest.ResponseRecorder, configPath string) { var resp struct { Conflict bool `json:"conflict"` Existing struct { Key string `json:"key"` Command string `json:"command"` } `json:"existing"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("Failed to parse JSON: %v", err) } if !resp.Conflict { t.Error("Expected conflict=true") } if resp.Existing.Key != "SPACE" || resp.Existing.Command != "cycle pause" { t.Errorf("Unexpected existing binding: %+v", resp.Existing) } // Original binding must be untouched if b := readBinding(t, configPath, "SPACE"); b == nil || b.Command != "cycle pause" { t.Errorf("Expected original binding preserved, got %+v", b) } }, }, { name: "existing binding with overwrite", seed: "SPACE cycle pause\n", body: `{"key":"SPACE","command":"cycle mute","overwrite":true}`, wantStatus: http.StatusOK, verify: func(t *testing.T, w *httptest.ResponseRecorder, configPath string) { var resp map[string]interface{} if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("Failed to parse JSON: %v", err) } if resp["success"] != true { t.Error("Expected success=true") } // A backup of the pre-existing file should be reported if backup, _ := resp["backup"].(string); backup == "" { t.Error("Expected non-empty backup path for overwrite of existing file") } b := readBinding(t, configPath, "SPACE") if b == nil || b.Command != "cycle mute" { t.Errorf("Expected command 'cycle mute', got %+v", b) } }, }, { name: "newline injection in key", seed: "", body: `{"key":"SPACE\nquit","command":"cycle pause"}`, wantStatus: http.StatusBadRequest, }, { name: "newline injection in command", seed: "", body: `{"key":"F2","command":"cycle pause\nquit"}`, wantStatus: http.StatusBadRequest, }, { name: "empty command", seed: "", body: `{"key":"F2","command":""}`, wantStatus: http.StatusBadRequest, }, { name: "malformed body", seed: "", body: `{"key":`, wantStatus: http.StatusBadRequest, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { setupSandboxedConfigHome(t) server := &Server{} configPath := hotkeys.GetInputConfPath() if tt.seed != "" { configPath = seedInputConf(t, tt.seed) } w := postJSON(t, server.handleHotkeyBindingSetAPI, "/api/hotkeys/bindings/set", tt.body) if w.Code != tt.wantStatus { t.Fatalf("Expected status %d, got %d (body: %s)", tt.wantStatus, w.Code, w.Body.String()) } if tt.verify != nil { tt.verify(t, w, configPath) } }) } } func TestHandleHotkeyBindingSetAPI_MethodNotAllowed(t *testing.T) { setupSandboxedConfigHome(t) server := &Server{} req := httptest.NewRequest(http.MethodGet, "/api/hotkeys/bindings/set", nil) w := httptest.NewRecorder() server.handleHotkeyBindingSetAPI(w, req) if w.Code != http.StatusMethodNotAllowed { t.Errorf("Expected status 405, got %d", w.Code) } } func TestHandleHotkeyBindingRemoveAPI(t *testing.T) { tests := []struct { name string seed string body string wantStatus int verify func(t *testing.T, w *httptest.ResponseRecorder, configPath string) }{ { name: "remove existing binding", seed: "SPACE cycle pause\nRIGHT seek 5\n", body: `{"key":"SPACE"}`, wantStatus: http.StatusOK, verify: func(t *testing.T, w *httptest.ResponseRecorder, configPath string) { var resp struct { Success bool `json:"success"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("Failed to parse JSON: %v", err) } if !resp.Success { t.Error("Expected success=true") } if b := readBinding(t, configPath, "SPACE"); b != nil { t.Errorf("Expected SPACE binding removed, got %+v", b) } // Other bindings must survive if b := readBinding(t, configPath, "RIGHT"); b == nil { t.Error("Expected RIGHT binding to be preserved") } }, }, { name: "remove missing binding", seed: "SPACE cycle pause\n", body: `{"key":"F9"}`, wantStatus: http.StatusNotFound, }, { name: "newline injection in key", seed: "", body: `{"key":"SPACE\nquit"}`, wantStatus: http.StatusBadRequest, }, { name: "malformed body", seed: "", body: `{"key":`, wantStatus: http.StatusBadRequest, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { setupSandboxedConfigHome(t) server := &Server{} configPath := hotkeys.GetInputConfPath() if tt.seed != "" { configPath = seedInputConf(t, tt.seed) } w := postJSON(t, server.handleHotkeyBindingRemoveAPI, "/api/hotkeys/bindings/remove", tt.body) if w.Code != tt.wantStatus { t.Fatalf("Expected status %d, got %d (body: %s)", tt.wantStatus, w.Code, w.Body.String()) } if tt.verify != nil { tt.verify(t, w, configPath) } }) } } func TestHandleHotkeyBindingRemoveAPI_MethodNotAllowed(t *testing.T) { setupSandboxedConfigHome(t) server := &Server{} req := httptest.NewRequest(http.MethodGet, "/api/hotkeys/bindings/remove", nil) w := httptest.NewRecorder() server.handleHotkeyBindingRemoveAPI(w, req) if w.Code != http.StatusMethodNotAllowed { t.Errorf("Expected status 405, got %d", w.Code) } } // TestHandleHotkeyBindingSetAPI_ConflictPayloadContentType ensures the 409 // response is JSON (the client polls content-type to decide how to render). func TestHandleHotkeyBindingSetAPI_ConflictPayloadContentType(t *testing.T) { setupSandboxedConfigHome(t) server := &Server{} seedInputConf(t, "SPACE cycle pause\n") w := postJSON(t, server.handleHotkeyBindingSetAPI, "/api/hotkeys/bindings/set", `{"key":"SPACE","command":"cycle mute"}`) if w.Code != http.StatusConflict { t.Fatalf("Expected status 409, got %d", w.Code) } if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") { t.Errorf("Expected JSON content type, got %q", ct) } }