827 lines
33 KiB
Go
827 lines
33 KiB
Go
package mijia
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type deviceTestServer struct {
|
|
t *testing.T
|
|
fixture []byte
|
|
mu sync.Mutex
|
|
specCalls int
|
|
apiResults []string
|
|
requests []map[string]any
|
|
status int
|
|
server *httptest.Server
|
|
specPaths []string
|
|
}
|
|
|
|
func newDeviceTestServer(t *testing.T, fixture []byte, apiResults []string) *deviceTestServer {
|
|
t.Helper()
|
|
testServer := &deviceTestServer{t: t, fixture: fixture, apiResults: apiResults}
|
|
testServer.server = httptest.NewServer(http.HandlerFunc(testServer.handle))
|
|
oldSpecURL := deviceSpecURL
|
|
deviceSpecURL = testServer.server.URL + "/spec/"
|
|
t.Cleanup(func() {
|
|
deviceSpecURL = oldSpecURL
|
|
testServer.server.Close()
|
|
})
|
|
return testServer
|
|
}
|
|
|
|
func (testServer *deviceTestServer) handle(writer http.ResponseWriter, request *http.Request) {
|
|
testServer.mu.Lock()
|
|
defer testServer.mu.Unlock()
|
|
if strings.HasPrefix(request.URL.Path, "/spec/") {
|
|
testServer.specCalls++
|
|
testServer.specPaths = append(testServer.specPaths, request.URL.Path)
|
|
if request.Method != http.MethodGet {
|
|
testServer.t.Errorf("spec method = %q", request.Method)
|
|
}
|
|
if request.Header.Get("User-Agent") != "mijiaAPI/4.1.2" {
|
|
testServer.t.Errorf("spec User-Agent = %q", request.Header.Get("User-Agent"))
|
|
}
|
|
status := testServer.status
|
|
if status == 0 {
|
|
status = http.StatusOK
|
|
}
|
|
writer.WriteHeader(status)
|
|
_, _ = writer.Write(testServer.fixture)
|
|
return
|
|
}
|
|
if err := request.ParseForm(); err != nil {
|
|
testServer.t.Error(err)
|
|
return
|
|
}
|
|
auth := testAuthData()
|
|
decoded, err := decryptPayload(auth.Ssecurity, request.PostForm.Get("_nonce"), request.PostForm.Get("data"))
|
|
if err != nil {
|
|
testServer.t.Error(err)
|
|
return
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal([]byte(decoded), &payload); err != nil {
|
|
testServer.t.Error(err)
|
|
return
|
|
}
|
|
testServer.requests = append(testServer.requests, payload)
|
|
if len(testServer.apiResults) == 0 {
|
|
testServer.t.Errorf("unexpected API request %s", request.URL.Path)
|
|
http.Error(writer, "unexpected request", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
result := testServer.apiResults[0]
|
|
testServer.apiResults = testServer.apiResults[1:]
|
|
_, _ = fmt.Fprintf(writer, `{"code":0,"result":%s}`, result)
|
|
}
|
|
|
|
func testAuthData() AuthData {
|
|
return AuthData{UA: "test", DeviceID: "device", PassO: "pass", Ssecurity: testSsecurity, UserID: "user", CUserID: "cuser", ServiceToken: "token", ExpireTime: time.Now().Add(time.Hour).UnixMilli()}
|
|
}
|
|
|
|
func loadSpecFixture(t *testing.T) []byte {
|
|
t.Helper()
|
|
fixture, err := os.ReadFile("testdata/miot-spec.html")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return fixture
|
|
}
|
|
|
|
func TestGetDeviceInfoParsesSpecAndCaches(t *testing.T) {
|
|
testServer := newDeviceTestServer(t, loadSpecFixture(t), nil)
|
|
httpClient := testServer.server.Client()
|
|
cacheDir := t.TempDir()
|
|
info, err := GetDeviceInfo(context.Background(), httpClient, "test.light.v1", cacheDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.Name != "Test Lamp" || info.Model != "test.light.v1" || len(info.Properties) != 8 || len(info.Actions) != 2 {
|
|
t.Fatalf("info = %#v", info)
|
|
}
|
|
if got := info.Properties[0]; got.Name != "power" || got.Description != "Power / 电源" || got.Type != "bool" || got.RW != "rw" || got.SIID != 2 || got.PIID != 1 {
|
|
t.Fatalf("power = %#v", got)
|
|
}
|
|
if got := info.Properties[1]; got.Type != "uint" || len(got.Range) != 3 || got.Range[0].String() != "1" {
|
|
t.Fatalf("brightness = %#v", got)
|
|
}
|
|
if got := info.Properties[2].ValueList[1]; got.Value.String() != "1" || got.Description != "On" || got.DescZhCN != "开启" {
|
|
t.Fatalf("value list = %#v", got)
|
|
}
|
|
if info.Properties[7].Name != "outlet-power" || info.Actions[1].Name != "outlet-toggle" {
|
|
t.Fatalf("duplicates = %#v / %#v", info.Properties[7], info.Actions[1])
|
|
}
|
|
if got := info.Actions[0].Inputs; len(got) != 2 || got[0].PIID != 3 || got[1].PIID != 1 || got[0].Description != "Mode / 模式" || got[0].Type != "uint" || len(got[0].ValueList) != 2 {
|
|
t.Fatalf("action inputs = %#v", got)
|
|
}
|
|
if testServer.specCalls != 1 || testServer.specPaths[0] != "/spec/test.light.v1" {
|
|
t.Fatalf("spec requests = %v, want GET /spec/test.light.v1", testServer.specPaths)
|
|
}
|
|
cachePath := filepath.Join(cacheDir, "test.light.v1.json")
|
|
stat, err := os.Stat(cachePath)
|
|
if err != nil || stat.Mode().Perm() != 0o600 {
|
|
t.Fatalf("cache stat = %v, %v", stat, err)
|
|
}
|
|
cacheData, err := os.ReadFile(cachePath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var pythonCache struct {
|
|
Version int `json:"version"`
|
|
Properties []struct {
|
|
Method cacheMethod `json:"method"`
|
|
} `json:"properties"`
|
|
Actions []struct {
|
|
Method cacheMethod `json:"method"`
|
|
} `json:"actions"`
|
|
}
|
|
if err := json.Unmarshal(cacheData, &pythonCache); err != nil || pythonCache.Version != 2 || pythonCache.Properties[0].Method.SIID != 2 || pythonCache.Properties[0].Method.PIID != 1 || pythonCache.Actions[0].Method.SIID != 2 || pythonCache.Actions[0].Method.AIID != 1 {
|
|
t.Fatalf("Python-compatible cache = %#v, %v", pythonCache, err)
|
|
}
|
|
testServer.fixture = nil
|
|
cached, err := GetDeviceInfo(context.Background(), httpClient, "test.light.v1", cacheDir)
|
|
if err != nil || cached.Name != info.Name || testServer.specCalls != 1 {
|
|
t.Fatalf("cached = %#v, %v, calls=%d", cached, err, testServer.specCalls)
|
|
}
|
|
if len(cached.Actions[0].Inputs) != 2 || cached.Actions[0].Inputs[0].PIID != 3 || cached.Actions[0].Inputs[1].PIID != 1 {
|
|
t.Fatalf("cached action inputs = %#v", cached.Actions[0].Inputs)
|
|
}
|
|
}
|
|
|
|
func TestParseDeviceInfoRejectsUnknownActionInput(t *testing.T) {
|
|
fixture := bytes.Replace(loadSpecFixture(t), []byte(`"in":[3,1]`), []byte(`"in":[99]`), 1)
|
|
if _, err := parseDeviceInfoHTML(fixture); err == nil || !strings.Contains(err.Error(), "unknown property IID 99") {
|
|
t.Fatalf("parse error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeviceActionsDeepCopyInputs(t *testing.T) {
|
|
device := Device{actions: map[string]ActionSpec{"toggle": {Inputs: []PropertySpec{{Range: []json.Number{"1", "2"}, ValueList: []ValueListItem{{Value: "1"}}}}}}}
|
|
actions := device.Actions()
|
|
actions["toggle"].Inputs[0].Range[0] = "changed"
|
|
actions["toggle"].Inputs[0].ValueList[0].Value = "changed"
|
|
got := device.actions["toggle"].Inputs[0]
|
|
if got.Range[0] != "1" || got.ValueList[0].Value != "1" {
|
|
t.Fatalf("internal action input mutated: %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestPythonDeviceInfoCacheSupportsOperations(t *testing.T) {
|
|
const pythonCache = `{
|
|
"name": "Python Lamp",
|
|
"model": "test.light.v1",
|
|
"properties": [{"name":"power","description":"Power","type":"bool","rw":"rw","method":{"siid":7,"piid":8}}],
|
|
"actions": [{"name":"toggle","description":"Toggle","method":{"siid":9,"aiid":10}}]
|
|
}`
|
|
testServer := newDeviceTestServer(t, loadSpecFixture(t), []string{
|
|
`{"homelist":[{"id":"10","uid":1}]}`,
|
|
`{"device_info":[{"did":"a","name":"Lamp","model":"test.light.v1"}],"has_more":false}`,
|
|
`[{"did":"a","siid":7,"piid":8,"value":true,"code":0}]`,
|
|
`[{"did":"a","siid":7,"piid":8,"code":0}]`,
|
|
`{"did":"a","siid":9,"aiid":10,"code":0}`,
|
|
})
|
|
cacheDir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(cacheDir, "test.light.v1.json"), []byte(pythonCache), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
client := testClient(t, testServer.server.Client())
|
|
client.baseURL = testServer.server.URL
|
|
device, err := NewDevice(context.Background(), client, DeviceSelector{DID: "a"}, WithDeviceDelay(0), WithDeviceCacheDir(cacheDir))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := device.Get(context.Background(), "power"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := device.Set(context.Background(), "power", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := device.RunAction(context.Background(), "toggle", nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for index, want := range []map[string]any{
|
|
{"siid": float64(2), "piid": float64(1)},
|
|
{"siid": float64(2), "piid": float64(1)},
|
|
{"siid": float64(2), "aiid": float64(1)},
|
|
} {
|
|
params := testServer.requests[index+2]["params"]
|
|
var request map[string]any
|
|
if list, ok := params.([]any); ok {
|
|
request = list[0].(map[string]any)
|
|
} else {
|
|
request = params.(map[string]any)
|
|
}
|
|
for key, value := range want {
|
|
if request[key] != value {
|
|
t.Fatalf("request %d %s = %#v, want %#v", index, key, request[key], value)
|
|
}
|
|
}
|
|
}
|
|
if testServer.specCalls != 1 {
|
|
t.Fatalf("spec calls = %d, want 1", testServer.specCalls)
|
|
}
|
|
}
|
|
|
|
func TestOldDeviceInfoCacheRefreshesActionInputs(t *testing.T) {
|
|
for _, version := range []int{0, 1} {
|
|
t.Run(fmt.Sprintf("version %d", version), func(t *testing.T) {
|
|
testServer := newDeviceTestServer(t, loadSpecFixture(t), nil)
|
|
cacheDir := t.TempDir()
|
|
cache := fmt.Sprintf(`{
|
|
"version":%d,
|
|
"name":"Old Lamp",
|
|
"model":"test.light.v1",
|
|
"properties":[{"name":"power","type":"bool","rw":"rw","siid":2,"piid":1}],
|
|
"actions":[{"name":"toggle","siid":2,"aiid":1}]
|
|
}`, version)
|
|
if err := os.WriteFile(filepath.Join(cacheDir, "test.light.v1.json"), []byte(cache), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
info, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.light.v1", cacheDir)
|
|
if err != nil || testServer.specCalls != 1 || len(info.Actions) == 0 || len(info.Actions[0].Inputs) != 2 {
|
|
t.Fatalf("GetDeviceInfo() = %#v, %v, calls=%d", info, err, testServer.specCalls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVersion2DeviceInfoCacheTrustsActionWithoutInputs(t *testing.T) {
|
|
testServer := newDeviceTestServer(t, nil, nil)
|
|
cacheDir := t.TempDir()
|
|
const cache = `{
|
|
"version":2,
|
|
"name":"Cached Lamp",
|
|
"model":"test.light.v1",
|
|
"properties":[{"name":"power","type":"bool","rw":"rw","siid":2,"piid":1}],
|
|
"actions":[{"name":"toggle","siid":2,"aiid":1}]
|
|
}`
|
|
if err := os.WriteFile(filepath.Join(cacheDir, "test.light.v1.json"), []byte(cache), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
info, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.light.v1", cacheDir)
|
|
if err != nil || testServer.specCalls != 0 || len(info.Actions) != 1 || len(info.Actions[0].Inputs) != 0 {
|
|
t.Fatalf("GetDeviceInfo() = %#v, %v, calls=%d", info, err, testServer.specCalls)
|
|
}
|
|
}
|
|
|
|
func TestStaleDeviceInfoCacheRefreshFailureIncludesBothErrors(t *testing.T) {
|
|
testServer := newDeviceTestServer(t, []byte("unavailable"), nil)
|
|
testServer.status = http.StatusServiceUnavailable
|
|
cacheDir := t.TempDir()
|
|
cachePath := filepath.Join(cacheDir, "test.light.v1.json")
|
|
const cache = `{"version":1,"model":"test.light.v1","properties":[],"actions":[]}`
|
|
if err := os.WriteFile(cachePath, []byte(cache), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.light.v1", cacheDir)
|
|
if err == nil || !strings.Contains(err.Error(), "cache stale") || !strings.Contains(err.Error(), "refresh failed") || !strings.Contains(err.Error(), "503") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestInvalidDeviceInfoCacheRefreshesAndOverwrites(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
cache string
|
|
}{
|
|
{name: "empty object", cache: `{}`},
|
|
{name: "wrong model", cache: `{"model":"other.model","properties":[],"actions":[]}`},
|
|
{name: "zero ID", cache: `{"model":"test.light.v1","properties":[{"name":"power","type":"bool","rw":"rw","siid":0,"piid":1}],"actions":[]}`},
|
|
{name: "trailing JSON", cache: `{"model":"test.light.v1","properties":[],"actions":[]} {}`},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
testServer := newDeviceTestServer(t, loadSpecFixture(t), nil)
|
|
cacheDir := t.TempDir()
|
|
cachePath := filepath.Join(cacheDir, "test.light.v1.json")
|
|
if err := os.WriteFile(cachePath, []byte(test.cache), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
info, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.light.v1", cacheDir)
|
|
if err != nil || info.Model != "test.light.v1" || testServer.specCalls != 1 {
|
|
t.Fatalf("GetDeviceInfo() = %#v, %v, calls=%d", info, err, testServer.specCalls)
|
|
}
|
|
data, err := os.ReadFile(cachePath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := decodeDeviceInfo(data, "test.light.v1"); err != nil {
|
|
t.Fatalf("refreshed cache is invalid: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDecodeDeviceInfoRejectsNonexistentActionInput(t *testing.T) {
|
|
const cache = `{
|
|
"version":2,
|
|
"model":"test.light.v1",
|
|
"properties":[{"name":"power","description":"Power","type":"bool","rw":"rw","siid":2,"piid":1}],
|
|
"actions":[{"name":"toggle","siid":2,"aiid":1,"inputs":[{"name":"missing","type":"bool","rw":"rw","siid":2,"piid":99}]}]
|
|
}`
|
|
_, err := decodeDeviceInfo([]byte(cache), "test.light.v1")
|
|
if err == nil || !strings.Contains(err.Error(), "does not reference a property") {
|
|
t.Fatalf("decodeDeviceInfo() error = %v, want missing property error", err)
|
|
}
|
|
}
|
|
|
|
func TestDecodeDeviceInfoCanonicalizesActionInputMetadata(t *testing.T) {
|
|
const cache = `{
|
|
"version":2,
|
|
"model":"test.light.v1",
|
|
"properties":[{"name":"power","description":"Power","type":"bool","rw":"rw","value-list":[{"value":0,"description":"Off"},{"value":1,"description":"On"}],"siid":2,"piid":1}],
|
|
"actions":[{"name":"toggle","siid":2,"aiid":1,"inputs":[{"name":"forged","description":"Forged","type":"string","rw":"w","range":[1,9,1],"siid":2,"piid":1}]}]
|
|
}`
|
|
info, err := decodeDeviceInfo([]byte(cache), "test.light.v1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(info.Actions) != 1 || len(info.Actions[0].Inputs) != 1 {
|
|
t.Fatalf("actions = %#v", info.Actions)
|
|
}
|
|
if got, want := info.Actions[0].Inputs[0], info.Properties[0]; !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("canonical input = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestInvalidDeviceInfoCacheRefreshFailureIncludesBothErrors(t *testing.T) {
|
|
testServer := newDeviceTestServer(t, []byte("unavailable"), nil)
|
|
testServer.status = http.StatusServiceUnavailable
|
|
cacheDir := t.TempDir()
|
|
cachePath := filepath.Join(cacheDir, "test.light.v1.json")
|
|
if err := os.WriteFile(cachePath, []byte(`{}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.light.v1", cacheDir)
|
|
if err == nil || !strings.Contains(err.Error(), "invalid device info cache") || !strings.Contains(err.Error(), "refresh failed") || !strings.Contains(err.Error(), "503") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
data, readErr := os.ReadFile(cachePath)
|
|
if readErr != nil || string(data) != `{}` {
|
|
t.Fatalf("cache changed after failed refresh: %q, %v", data, readErr)
|
|
}
|
|
}
|
|
|
|
func TestGetDeviceInfoErrors(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
fixture string
|
|
}{
|
|
{name: "http", status: http.StatusNotFound, fixture: "not found"},
|
|
{name: "missing script", fixture: "<html></html>"},
|
|
{name: "invalid json", fixture: `<script data-page="app" type="application/json">{</script>`},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
testServer := newDeviceTestServer(t, []byte(test.fixture), nil)
|
|
testServer.status = test.status
|
|
_, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "bad.model", "")
|
|
var infoErr *GetDeviceInfoError
|
|
if !errors.As(err, &infoErr) {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
if test.status != 0 && !strings.Contains(err.Error(), fmt.Sprint(test.status)) {
|
|
t.Fatalf("error = %v, want status %d", err, test.status)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetDeviceInfoRejectsUnsafeModelsBeforeFileAccess(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
parent := filepath.Dir(cacheDir)
|
|
for _, model := range []string{"", "../escaped", "/tmp/absolute", `vendor\\model`, "vendor/model"} {
|
|
t.Run(model, func(t *testing.T) {
|
|
_, err := GetDeviceInfo(context.Background(), http.DefaultClient, model, cacheDir)
|
|
if err == nil {
|
|
t.Fatal("GetDeviceInfo() error = nil")
|
|
}
|
|
})
|
|
}
|
|
entries, err := os.ReadDir(cacheDir)
|
|
if err != nil || len(entries) != 0 {
|
|
t.Fatalf("cache entries = %v, %v", entries, err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(parent, "escaped.json")); !os.IsNotExist(err) {
|
|
t.Fatalf("escaped cache file exists: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetDeviceInfoRejectsOversizedContent(t *testing.T) {
|
|
t.Run("cache", func(t *testing.T) {
|
|
testServer := newDeviceTestServer(t, loadSpecFixture(t), nil)
|
|
cacheDir := t.TempDir()
|
|
cachePath := filepath.Join(cacheDir, "test.model.json")
|
|
if err := os.WriteFile(cachePath, make([]byte, deviceSpecMaxSize+1), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.model", cacheDir)
|
|
if err == nil || !strings.Contains(err.Error(), "model") || testServer.specCalls != 1 {
|
|
t.Fatalf("error = %v, calls=%d", err, testServer.specCalls)
|
|
}
|
|
})
|
|
|
|
t.Run("HTML", func(t *testing.T) {
|
|
testServer := newDeviceTestServer(t, make([]byte, deviceSpecMaxSize+1), nil)
|
|
_, err := GetDeviceInfo(context.Background(), testServer.server.Client(), "test.model", "")
|
|
if err == nil || !strings.Contains(err.Error(), "exceeds") {
|
|
t.Fatalf("error = %v, want HTML size error", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestParseDeviceInfoHTMLScriptTagVariants(t *testing.T) {
|
|
fixture := string(loadSpecFixture(t))
|
|
const opening = `<script data-page="app" type="application/json">`
|
|
const closing = `</script>`
|
|
variants := []struct {
|
|
name string
|
|
opening string
|
|
closing string
|
|
}{
|
|
{name: "reordered", opening: `<script type="application/json" data-page="app">`, closing: closing},
|
|
{name: "extra attributes", opening: `<script defer data-extra='value' data-page='app' type='application/json'>`, closing: closing},
|
|
{name: "whitespace", opening: "<script\n type = 'application/json'\n async\n data-page = \"app\" >", closing: closing},
|
|
{name: "uppercase tags and attributes", opening: `<SCRIPT DATA-PAGE="app" TYPE="application/json">`, closing: `</SCRIPT>`},
|
|
{name: "unquoted attributes", opening: `<script TYPE=application/json DATA-PAGE=app>`, closing: closing},
|
|
}
|
|
for _, variant := range variants {
|
|
t.Run(variant.name, func(t *testing.T) {
|
|
body := strings.Replace(fixture, opening, variant.opening, 1)
|
|
openingIndex := strings.Index(body, variant.opening)
|
|
body = body[:openingIndex] + strings.Replace(body[openingIndex:], closing, variant.closing, 1)
|
|
info, err := parseDeviceInfoHTML([]byte(body))
|
|
if err != nil || info.Model != "test.light.v1" {
|
|
t.Fatalf("parseDeviceInfoHTML() = %#v, %v", info, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewDeviceSelectorsAndAliases(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
selector DeviceSelector
|
|
devices string
|
|
wantDID string
|
|
wantErrAs any
|
|
}{
|
|
{name: "did wins", selector: DeviceSelector{DID: "a", Name: "duplicate"}, devices: `[{"did":"a","name":"Lamp","model":"test.light.v1"}]`, wantDID: "a"},
|
|
{name: "unique name", selector: DeviceSelector{Name: "Lamp"}, devices: `[{"did":"a","name":"Lamp","model":"test.light.v1"}]`, wantDID: "a"},
|
|
{name: "missing selector", selector: DeviceSelector{}, devices: `[]`, wantErrAs: &DeviceNotFoundError{}},
|
|
{name: "not found", selector: DeviceSelector{DID: "x"}, devices: `[]`, wantErrAs: &DeviceNotFoundError{}},
|
|
{name: "duplicate name", selector: DeviceSelector{Name: "Lamp"}, devices: `[{"did":"a","name":"Lamp"},{"did":"b","name":"Lamp"}]`, wantErrAs: &MultipleDevicesFoundError{}},
|
|
{name: "duplicate did", selector: DeviceSelector{DID: "a"}, devices: `[{"did":"a","name":"Lamp"},{"did":"a","name":"Clone"}]`, wantErrAs: &MultipleDevicesFoundError{}},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
testServer := newDeviceTestServer(t, loadSpecFixture(t), []string{`{"homelist":[{"id":"10","uid":1}]}`, `{"device_info":` + test.devices + `,"has_more":false}`})
|
|
client := testClient(t, testServer.server.Client())
|
|
client.baseURL = testServer.server.URL
|
|
device, err := NewDevice(context.Background(), client, test.selector, WithDeviceDelay(0), WithDeviceCacheDir(t.TempDir()))
|
|
if test.wantErrAs != nil {
|
|
if err == nil || !errors.As(err, &test.wantErrAs) {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
return
|
|
}
|
|
if err != nil || device.DID != test.wantDID || device.Properties()["serial_number"].Name != "serial-number" {
|
|
t.Fatalf("device = %#v, %v", device, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewDeviceDefaults(t *testing.T) {
|
|
testServer := newDeviceTestServer(t, loadSpecFixture(t), []string{`{"homelist":[{"id":"10","uid":1}]}`, `{"device_info":[{"did":"a","model":"test.light.v1"}],"has_more":false}`})
|
|
client := testClient(t, testServer.server.Client())
|
|
client.baseURL = testServer.server.URL
|
|
device, err := NewDevice(context.Background(), client, DeviceSelector{DID: "a"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if device.delay != 500*time.Millisecond {
|
|
t.Fatalf("delay = %v, want 500ms", device.delay)
|
|
}
|
|
cachePath := filepath.Join(filepath.Dir(client.authPath), "test.light.v1.json")
|
|
if _, err := os.Stat(cachePath); err != nil {
|
|
t.Fatalf("default cache path %s: %v", cachePath, err)
|
|
}
|
|
}
|
|
|
|
func TestDeviceGetSetAndAction(t *testing.T) {
|
|
testServer := newDeviceTestServer(t, loadSpecFixture(t), []string{
|
|
`{"homelist":[{"id":"10","uid":1}]}`, `{"device_info":[{"did":"a","name":"Lamp","model":"test.light.v1"}],"has_more":false}`,
|
|
`[{"did":"a","siid":2,"piid":1,"value":true,"code":0}]`,
|
|
`[{"did":"a","siid":2,"piid":1,"code":1}]`,
|
|
`{"did":"a","siid":2,"aiid":1,"code":1,"out":{"ok":true}}`,
|
|
})
|
|
client := testClient(t, testServer.server.Client())
|
|
client.baseURL = testServer.server.URL
|
|
device, err := NewDevice(context.Background(), client, DeviceSelector{DID: "a"}, WithDeviceDelay(0), WithDeviceCacheDir(t.TempDir()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
properties := device.Properties()
|
|
properties["power"] = PropertySpec{}
|
|
properties["brightness"].Range[0] = "999"
|
|
actions := device.Actions()
|
|
actions["toggle"] = ActionSpec{}
|
|
delete(properties, "serial-number")
|
|
delete(actions, "outlet-toggle")
|
|
if device.Properties()["power"].SIID != 2 || device.Properties()["brightness"].Range[0].String() != "1" || device.Actions()["toggle"].SIID != 2 {
|
|
t.Fatal("mutating snapshots changed internal device metadata")
|
|
}
|
|
value, err := device.Get(context.Background(), "power")
|
|
if err != nil || value != true {
|
|
t.Fatalf("Get() = %v, %v", value, err)
|
|
}
|
|
if err := device.Set(context.Background(), "power", "1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := device.RunAction(context.Background(), "toggle", nil)
|
|
if err != nil || string(result.Out) != `{"ok":true}` {
|
|
t.Fatalf("RunAction() = %#v, %v", result, err)
|
|
}
|
|
if testServer.requests[3]["params"].([]any)[0].(map[string]any)["value"] != true {
|
|
t.Fatalf("set request = %#v", testServer.requests[3])
|
|
}
|
|
if _, exists := testServer.requests[4]["params"].(map[string]any)["value"]; exists {
|
|
t.Fatalf("nil action value was sent: %#v", testServer.requests[4])
|
|
}
|
|
}
|
|
|
|
func TestDeviceMetadataSnapshotsSupportConcurrentReads(t *testing.T) {
|
|
device := fixtureDevice(t)
|
|
var waitGroup sync.WaitGroup
|
|
for range 100 {
|
|
waitGroup.Add(1)
|
|
go func() {
|
|
defer waitGroup.Done()
|
|
if device.Properties()["power"].Name != "power" || device.Actions()["toggle"].Name != "toggle" {
|
|
t.Error("metadata snapshot changed during concurrent read")
|
|
}
|
|
}()
|
|
}
|
|
waitGroup.Wait()
|
|
}
|
|
|
|
func TestDeviceValidation(t *testing.T) {
|
|
device := fixtureDevice(t)
|
|
successes := []struct {
|
|
property string
|
|
value any
|
|
want any
|
|
}{
|
|
{property: "power", value: int8(0), want: false},
|
|
{property: "brightness", value: "20", want: uint64(20)},
|
|
{property: "mode", value: json.Number("1.0"), want: uint64(1)},
|
|
{property: "temperature", value: 0.3, want: 0.3},
|
|
{property: "serial-number", value: "abc", want: "abc"},
|
|
}
|
|
for _, test := range successes {
|
|
property := device.Properties()[test.property]
|
|
got, err := convertPropertyValue(property, test.value)
|
|
if err != nil || got != test.want {
|
|
t.Errorf("convertPropertyValue(%s, %v) = %#v, %v, want %#v", test.property, test.value, got, err, test.want)
|
|
}
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
prop string
|
|
value any
|
|
}{
|
|
{name: "unknown", prop: "missing", value: 1},
|
|
{name: "read only", prop: "read-only", value: 1},
|
|
{name: "bad bool integer", prop: "power", value: 2},
|
|
{name: "bad bool string", prop: "power", value: "yes"},
|
|
{name: "fractional integer", prop: "brightness", value: 1.5},
|
|
{name: "integer range", prop: "brightness", value: 101},
|
|
{name: "integer step", prop: "brightness", value: 2.5},
|
|
{name: "float step", prop: "temperature", value: 0.35},
|
|
{name: "value list", prop: "mode", value: json.Number("2")},
|
|
{name: "string", prop: "serial_number", value: 3},
|
|
{name: "non finite", prop: "temperature", value: math.Inf(1)},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if err := device.Set(context.Background(), test.prop, test.value); err == nil {
|
|
t.Fatal("Set() error = nil")
|
|
}
|
|
})
|
|
}
|
|
if _, err := device.Get(context.Background(), "write-only"); err == nil {
|
|
t.Fatal("Get(write-only) error = nil")
|
|
}
|
|
if _, err := device.RunAction(context.Background(), "missing", nil); err == nil {
|
|
t.Fatal("RunAction(missing) error = nil")
|
|
}
|
|
}
|
|
|
|
func TestIntegerValidationIsExact(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
property PropertySpec
|
|
value any
|
|
want any
|
|
wantErr bool
|
|
}{
|
|
{name: "int above 2^53", property: PropertySpec{Type: "int"}, value: "9007199254740993", want: int64(9007199254740993)},
|
|
{name: "max int64", property: PropertySpec{Type: "int", Range: []json.Number{"-9223372036854775808", "9223372036854775807"}}, value: json.Number("9223372036854775807"), want: int64(math.MaxInt64)},
|
|
{name: "max uint64", property: PropertySpec{Type: "uint", Range: []json.Number{"0", "18446744073709551615"}}, value: "18446744073709551615", want: uint64(math.MaxUint64)},
|
|
{name: "int overflow string", property: PropertySpec{Type: "int"}, value: "9223372036854775808", wantErr: true},
|
|
{name: "uint overflow string", property: PropertySpec{Type: "uint"}, value: "18446744073709551616", wantErr: true},
|
|
{name: "fractional number", property: PropertySpec{Type: "int"}, value: json.Number("9007199254740993.5"), wantErr: true},
|
|
{name: "imprecise float", property: PropertySpec{Type: "uint"}, value: float64(9007199254740994), wantErr: true},
|
|
{name: "exact value list", property: PropertySpec{Type: "uint", ValueList: []ValueListItem{{Value: "9007199254740993"}}}, value: "9007199254740993", want: uint64(9007199254740993)},
|
|
{name: "adjacent value list rejected", property: PropertySpec{Type: "uint", ValueList: []ValueListItem{{Value: "9007199254740993"}}}, value: "9007199254740992", wantErr: true},
|
|
{name: "exact range and step", property: PropertySpec{Type: "uint", Range: []json.Number{"9007199254740993", "9007199254740999", "2"}}, value: "9007199254740997", want: uint64(9007199254740997)},
|
|
{name: "exact step rejected", property: PropertySpec{Type: "uint", Range: []json.Number{"9007199254740993", "9007199254740999", "2"}}, value: "9007199254740996", wantErr: true},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
got, err := convertPropertyValue(test.property, test.value)
|
|
if test.wantErr {
|
|
if err == nil {
|
|
t.Fatalf("convertPropertyValue() = %#v, want error", got)
|
|
}
|
|
return
|
|
}
|
|
if err != nil || got != test.want {
|
|
t.Fatalf("convertPropertyValue() = %#v, %v, want %#v", got, err, test.want)
|
|
}
|
|
marshaled, err := json.Marshal(got)
|
|
if err != nil || string(marshaled) != fmt.Sprint(test.want) {
|
|
t.Fatalf("json.Marshal() = %s, %v", marshaled, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFloatIntegerInputsMustBeExactlyRepresentable(t *testing.T) {
|
|
property := PropertySpec{Type: "float"}
|
|
tests := []struct {
|
|
name string
|
|
value any
|
|
wantErr bool
|
|
}{
|
|
{name: "positive boundary", value: int64(1 << 53)},
|
|
{name: "negative boundary", value: int64(-(1 << 53))},
|
|
{name: "int above boundary", value: int64(1<<53 + 1), wantErr: true},
|
|
{name: "uint above boundary", value: uint64(1<<53 + 1), wantErr: true},
|
|
{name: "JSON number boundary", value: json.Number("9007199254740992")},
|
|
{name: "JSON number above boundary", value: json.Number("9007199254740993"), wantErr: true},
|
|
{name: "string positive boundary", value: "9007199254740992"},
|
|
{name: "string negative boundary", value: "-9007199254740992"},
|
|
{name: "string above boundary", value: "9007199254740993", wantErr: true},
|
|
{name: "string below boundary", value: "-9007199254740993", wantErr: true},
|
|
{name: "fractional string", value: "9007199254740993.5"},
|
|
{name: "max uint64", value: uint64(math.MaxUint64), wantErr: true},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
_, err := convertPropertyValue(property, test.value)
|
|
if (err != nil) != test.wantErr {
|
|
t.Fatalf("convertPropertyValue(%v) error = %v, wantErr %v", test.value, err, test.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDeviceOperationErrorsAndCancellation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
result string
|
|
operation func(*Device) error
|
|
want any
|
|
}{
|
|
{name: "get", result: `[{"code":-704030013}]`, operation: func(device *Device) error { _, err := device.Get(context.Background(), "power"); return err }, want: &DeviceGetError{}},
|
|
{name: "set", result: `[{"code":-704030023}]`, operation: func(device *Device) error { return device.Set(context.Background(), "power", true) }, want: &DeviceSetError{}},
|
|
{name: "action", result: `{"code":-704040005}`, operation: func(device *Device) error {
|
|
_, err := device.RunAction(context.Background(), "toggle", nil)
|
|
return err
|
|
}, want: &DeviceActionError{}},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
device := fixtureDeviceWithResults(t, []string{test.result}, 0)
|
|
err := test.operation(device)
|
|
if !errors.As(err, &test.want) {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
device := fixtureDeviceWithResults(t, []string{`[{"value":true,"code":0}]`}, time.Hour)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
time.AfterFunc(10*time.Millisecond, cancel)
|
|
started := time.Now()
|
|
_, err := device.Get(ctx, "power")
|
|
if !errors.Is(err, context.Canceled) || time.Since(started) > time.Second {
|
|
t.Fatalf("Get() cancellation = %v after %v", err, time.Since(started))
|
|
}
|
|
|
|
device = fixtureDeviceWithResults(t, []string{`{"code":0}`}, time.Hour)
|
|
ctx, cancel = context.WithCancel(context.Background())
|
|
cancel()
|
|
started = time.Now()
|
|
_, err = device.RunAction(ctx, "toggle", nil)
|
|
if !errors.Is(err, context.Canceled) || time.Since(started) > time.Second {
|
|
t.Fatalf("RunAction() cancellation = %v after %v", err, time.Since(started))
|
|
}
|
|
}
|
|
|
|
func TestDeviceRunActionWith(t *testing.T) {
|
|
t.Run("sends extra in field like xiaoai speaker", func(t *testing.T) {
|
|
device, testServer := fixtureDeviceWithServer(t, []string{`{"did":"a","siid":2,"aiid":1,"code":0}`})
|
|
result, err := device.RunActionWith(context.Background(), "toggle", nil, map[string]any{"in": []any{"打开空调", 1}})
|
|
if err != nil || result.Code != 0 {
|
|
t.Fatalf("RunActionWith() = %#v, %v", result, err)
|
|
}
|
|
params, ok := testServer.requests[2]["params"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("params = %#v", testServer.requests[2]["params"])
|
|
}
|
|
if len(params) != 4 || params["did"] != "a" || params["siid"] != float64(2) || params["aiid"] != float64(1) {
|
|
t.Fatalf("params = %#v", params)
|
|
}
|
|
in, ok := params["in"].([]any)
|
|
if !ok || len(in) != 2 || in[0] != "打开空调" || in[1] != float64(1) {
|
|
t.Fatalf("in = %#v", params["in"])
|
|
}
|
|
})
|
|
|
|
t.Run("conflicting extra key fails before sending", func(t *testing.T) {
|
|
device, testServer := fixtureDeviceWithServer(t, nil)
|
|
for _, key := range []string{"did", "siid", "aiid", "value"} {
|
|
if _, err := device.RunActionWith(context.Background(), "toggle", nil, map[string]any{key: "x"}); err == nil {
|
|
t.Fatalf("RunActionWith() with extra key %q error = nil", key)
|
|
}
|
|
}
|
|
if len(testServer.requests) != 2 {
|
|
t.Fatalf("requests = %d, conflicting extra keys must not reach the API", len(testServer.requests))
|
|
}
|
|
})
|
|
|
|
t.Run("nil extra matches RunAction", func(t *testing.T) {
|
|
device, testServer := fixtureDeviceWithServer(t, []string{`{"did":"a","siid":2,"aiid":1,"code":0}`, `{"did":"a","siid":2,"aiid":1,"code":0}`})
|
|
if _, err := device.RunAction(context.Background(), "toggle", []any{5}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := device.RunActionWith(context.Background(), "toggle", []any{5}, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
legacy, _ := json.Marshal(testServer.requests[2]["params"])
|
|
with, _ := json.Marshal(testServer.requests[3]["params"])
|
|
if string(legacy) != string(with) {
|
|
t.Fatalf("RunActionWith(nil) body %s != RunAction body %s", with, legacy)
|
|
}
|
|
})
|
|
}
|
|
|
|
func fixtureDevice(t *testing.T) *Device {
|
|
return fixtureDeviceWithResults(t, nil, 0)
|
|
}
|
|
|
|
func fixtureDeviceWithResults(t *testing.T, operationResults []string, delay time.Duration) *Device {
|
|
t.Helper()
|
|
device, _ := fixtureDeviceWithOptions(t, operationResults, delay)
|
|
return device
|
|
}
|
|
|
|
func fixtureDeviceWithServer(t *testing.T, operationResults []string) (*Device, *deviceTestServer) {
|
|
t.Helper()
|
|
return fixtureDeviceWithOptions(t, operationResults, 0)
|
|
}
|
|
|
|
func fixtureDeviceWithOptions(t *testing.T, operationResults []string, delay time.Duration) (*Device, *deviceTestServer) {
|
|
t.Helper()
|
|
results := append([]string{`{"homelist":[{"id":"10","uid":1}]}`, `{"device_info":[{"did":"a","name":"Lamp","model":"test.light.v1"}],"has_more":false}`}, operationResults...)
|
|
testServer := newDeviceTestServer(t, loadSpecFixture(t), results)
|
|
client := testClient(t, testServer.server.Client())
|
|
client.baseURL = testServer.server.URL
|
|
device, err := NewDevice(context.Background(), client, DeviceSelector{DID: "a"}, WithDeviceDelay(delay), WithDeviceCacheDir(t.TempDir()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return device, testServer
|
|
}
|