Nik Afiq b327150d45
All checks were successful
CI / test (push) Successful in 5s
CI / build-ai-gateway (push) Successful in 39s
CI / build-ha-gateway (push) Successful in 39s
CI / build-discord-bot (push) Successful in 40s
feat: implement ClimateApp for managing HVAC entities
- Added ClimateApp to handle climate entity operations including turning on/off, adjusting temperature, and setting HVAC modes.
- Implemented caching mechanism for climate entities to optimize state retrieval.
- Created domain model for Climate with attributes such as current temperature, target temperature, and HVAC modes.
- Developed unit tests for ClimateApp to ensure functionality and correctness.

feat: add RemoteApp for SwitchBot commands

- Introduced RemoteApp to facilitate sending commands to SwitchBot devices.
- Implemented SendCommand method for executing device commands without state management.

chore: update configuration for SwitchBot integration

- Added SwitchBotToken and SwitchBotSecret to configuration for enabling SwitchBot Cloud commands.

feat: define gRPC services for Climate and Remote operations

- Created climate.proto and remote.proto files to define gRPC services for climate management and remote command execution.
- Implemented corresponding request and response message structures for gRPC interactions.
2026-07-23 13:49:54 +09:00

223 lines
6.1 KiB
Go

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))
}
}