package switchbot import ( "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "testing" "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/config" ) func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } func TestClientSendCommandSignsRequestAndSucceeds(t *testing.T) { const token = "test-token" const secret = "test-secret" var gotBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/v1.1/devices/dyson-id/commands" { t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } if got := r.Header.Get("Authorization"); got != token { t.Fatalf("Authorization header = %q, want %q", got, token) } if got := r.Header.Get("Content-Type"); got != "application/json; charset=utf8" { t.Fatalf("Content-Type header = %q", got) } timestamp := r.Header.Get("t") nonce := r.Header.Get("nonce") if timestamp == "" || nonce == "" { t.Fatalf("missing t/nonce headers: t=%q nonce=%q", timestamp, nonce) } mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte(token + timestamp + nonce)) wantSignature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) if got := r.Header.Get("sign"); got != wantSignature { t.Fatalf("sign header = %q, want %q", got, wantSignature) } if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { t.Fatalf("decode request body: %v", err) } _ = json.NewEncoder(w).Encode(map[string]any{ "statusCode": 100, "message": "success", "body": map[string]any{}, }) })) defer server.Close() c := &Client{ token: token, secret: secret, baseURL: server.URL, httpClient: server.Client(), log: testLogger(), } if err := c.SendCommand(context.Background(), "dyson-id", "turnOn", "command"); err != nil { t.Fatalf("SendCommand() error = %v", err) } wantBody := map[string]any{"command": "turnOn", "parameter": "default", "commandType": "command"} if len(gotBody) != len(wantBody) { t.Fatalf("body = %#v, want %#v", gotBody, wantBody) } for k, v := range wantBody { if gotBody[k] != v { t.Fatalf("body[%q] = %#v, want %#v", k, gotBody[k], v) } } } func TestClientSendCommandCustomizeType(t *testing.T) { var gotCommandType string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) gotCommandType, _ = body["commandType"].(string) _ = json.NewEncoder(w).Encode(map[string]any{"statusCode": 100, "message": "success", "body": map[string]any{}}) })) defer server.Close() c := &Client{ token: "token", secret: "secret", baseURL: server.URL, httpClient: server.Client(), log: testLogger(), } if err := c.SendCommand(context.Background(), "dyson-id", "Cool", "customize"); err != nil { t.Fatalf("SendCommand() error = %v", err) } if gotCommandType != "customize" { t.Fatalf("commandType = %q, want %q", gotCommandType, "customize") } } func TestClientSendCommandAPIError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{ "statusCode": 190, "message": "invalid format", "body": map[string]any{}, }) })) defer server.Close() c := &Client{ token: "token", secret: "secret", baseURL: server.URL, httpClient: server.Client(), log: testLogger(), } err := c.SendCommand(context.Background(), "dyson-id", "turnOn", "command") wantErr := "SwitchBot internal error or invalid command format (190)" if err == nil || err.Error() != wantErr { t.Fatalf("SendCommand() error = %v, want %q", err, wantErr) } } func TestClientSendCommandNon2xxHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte("unauthorized")) })) defer server.Close() c := &Client{ token: "token", secret: "secret", baseURL: server.URL, httpClient: server.Client(), log: testLogger(), } err := c.SendCommand(context.Background(), "dyson-id", "turnOn", "command") if err == nil { t.Fatal("SendCommand() error = nil, want error") } } func TestClientSendCommandNotConfigured(t *testing.T) { calls := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ })) defer server.Close() tests := []struct { name string token string secret string }{ {name: "both empty"}, {name: "token only", token: "token"}, {name: "secret only", secret: "secret"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Client{ token: tt.token, secret: tt.secret, baseURL: server.URL, httpClient: server.Client(), log: testLogger(), } err := c.SendCommand(context.Background(), "dyson-id", "turnOn", "command") if err == nil || err.Error() != "switchbot: token/secret not configured" { t.Fatalf("SendCommand() error = %v, want not-configured error", err) } }) } if calls != 0 { t.Fatalf("requests reached test server = %d, want 0", calls) } } func TestNewClient(t *testing.T) { cfg := &config.Config{SwitchBotToken: "tok", SwitchBotSecret: "sec"} c := NewClient(cfg, testLogger()) if c.token != "tok" || c.secret != "sec" { t.Fatalf("NewClient() token/secret = %q/%q, want %q/%q", c.token, c.secret, "tok", "sec") } if c.baseURL != defaultBaseURL { t.Fatalf("NewClient() baseURL = %q, want %q", c.baseURL, defaultBaseURL) } } func TestNewNonceIsUniqueAndFormatted(t *testing.T) { a, err := newNonce() if err != nil { t.Fatalf("newNonce() error = %v", err) } b, err := newNonce() if err != nil { t.Fatalf("newNonce() error = %v", err) } if a == b { t.Fatalf("newNonce() returned duplicate values: %q", a) } if len(a) != len("00000000-0000-0000-0000-000000000000") { t.Fatalf("newNonce() length = %d, want 36", len(a)) } }