mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
Mirrors the .md/.txt sweep across all tracked _test.go, testdata XML,
and .http integration files. Test files are self-contained (producer
+ assertion in the same file), so the matched-pair swap stays green
under `go test ./...`.
Mapping applied:
192.168.178.[0-9]+ → 192.0.2.[same]
192.168.1.[0-9]+ → 192.0.2.[same]
Sound Machinechen → Living Room SoundTouch
A Sound Machine → Kitchen SoundTouch
A81B6A536A98 + case/separator variants → AABBCCDDEEFF (etc.)
A81B6A849D99 → AABBCCDDEE01
A81B6A849D88 → AABBCCDDEE03
A81B6A536A09 → AABBCCDDEE04
884AEAEEBD27 → AABBCCDDEE02
3230304 → 1000001
9569497 → 1000002
Two semantic fixes alongside the bulk swap:
- pkg/service/zeroconf/zeroconf_test.go: the "private 192" and
"strips query" cases pin acceptance of RFC-1918 192.168/16. They
must use a real 192.168 value; doc-range IPs would (correctly) be
rejected by validateZcBaseURL. Switched to 192.168.10.10 — generic
enough not to match any home LAN default, real enough for the
validator. Added a comment explaining why this single test still
carries a 192.168 literal.
- pkg/service/setup/setup_test.go: TestTestDNSRedirection mocks the
device's `od -An -tu1` byte output, which is space-separated
octets ("192 168 1 100"). My sed only matched the dot-separated
form, so the mock was returning the old IP while the test
assertions had moved to the doc range. Updated to " 192 0 2 100".
go build ./... clean. go test ./... clean (only TestDocsConsistency
remains failing, which is a pre-existing/untracked-file issue).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
269 lines
7.0 KiB
Go
269 lines
7.0 KiB
Go
package setup
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/models"
|
|
)
|
|
|
|
func TestPushWiFiCredentials_BuildsCanonicalRequest(t *testing.T) {
|
|
var (
|
|
gotMethod string
|
|
gotPath string
|
|
gotCT string
|
|
gotBody string
|
|
)
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotMethod = r.Method
|
|
gotPath = r.URL.Path
|
|
gotCT = r.Header.Get("Content-Type")
|
|
|
|
b, _ := io.ReadAll(r.Body)
|
|
gotBody = string(b)
|
|
|
|
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?><AddWirelessProfileResponse />`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
apHost := strings.TrimPrefix(srv.URL, "http://")
|
|
|
|
err := PushWiFiCredentials(context.Background(), PushWiFiCredentialsParams{
|
|
APHost: apHost,
|
|
SSID: "MyHomeNetwork",
|
|
Password: "s3cret",
|
|
// Security and HTTPClient default
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("PushWiFiCredentials: %v", err)
|
|
}
|
|
|
|
if gotMethod != http.MethodPost {
|
|
t.Errorf("method = %s, want POST", gotMethod)
|
|
}
|
|
|
|
if gotPath != "/addWirelessProfile" {
|
|
t.Errorf("path = %s, want /addWirelessProfile", gotPath)
|
|
}
|
|
|
|
if gotCT != "text/xml" {
|
|
t.Errorf("content-type = %s, want text/xml", gotCT)
|
|
}
|
|
|
|
if !strings.Contains(gotBody, `ssid="MyHomeNetwork"`) {
|
|
t.Errorf("body missing ssid: %s", gotBody)
|
|
}
|
|
|
|
if !strings.Contains(gotBody, `password="s3cret"`) {
|
|
t.Errorf("body missing password: %s", gotBody)
|
|
}
|
|
|
|
if !strings.Contains(gotBody, `securityType="wpa_or_wpa2"`) {
|
|
t.Errorf("body should default to wpa_or_wpa2 security, got: %s", gotBody)
|
|
}
|
|
}
|
|
|
|
func TestPushWiFiCredentials_EscapesQuotesInCredentials(t *testing.T) {
|
|
var gotBody string
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
b, _ := io.ReadAll(r.Body)
|
|
gotBody = string(b)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
err := PushWiFiCredentials(context.Background(), PushWiFiCredentialsParams{
|
|
APHost: strings.TrimPrefix(srv.URL, "http://"),
|
|
SSID: `net "with quote`,
|
|
Password: `pa<ss>`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("PushWiFiCredentials: %v", err)
|
|
}
|
|
|
|
// Quotes must be escaped so they don't break the attribute context.
|
|
if strings.Contains(gotBody, `ssid="net "with quote"`) {
|
|
t.Errorf("quotes in SSID must be escaped, got: %s", gotBody)
|
|
}
|
|
|
|
if !strings.Contains(gotBody, """) {
|
|
t.Errorf("expected " escape in body: %s", gotBody)
|
|
}
|
|
}
|
|
|
|
func TestPushWiFiCredentials_RequiresSSID(t *testing.T) {
|
|
err := PushWiFiCredentials(context.Background(), PushWiFiCredentialsParams{Password: "x"})
|
|
if err == nil || !strings.Contains(err.Error(), "SSID") {
|
|
t.Errorf("err = %v, want SSID-required error", err)
|
|
}
|
|
}
|
|
|
|
func TestPushWiFiCredentials_SurfacesHTTPErrors(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write([]byte("nope"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
err := PushWiFiCredentials(context.Background(), PushWiFiCredentialsParams{
|
|
APHost: strings.TrimPrefix(srv.URL, "http://"),
|
|
SSID: "X",
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "403") {
|
|
t.Errorf("err = %v, want to surface HTTP 403", err)
|
|
}
|
|
}
|
|
|
|
func TestWaitForAP_ReturnsOnceInfoSucceeds(t *testing.T) {
|
|
var calls atomic.Int32
|
|
|
|
httpGet := func(_ string) (*http.Response, error) {
|
|
c := calls.Add(1)
|
|
if c < 3 {
|
|
return nil, errors.New("no route to host")
|
|
}
|
|
|
|
body := `<info deviceID="AABBCCDDEEFF"><name>Bose SoundTouch DE4803</name><margeAccountUUID></margeAccountUUID></info>`
|
|
|
|
return &http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(strings.NewReader(body)),
|
|
Header: http.Header{},
|
|
}, nil
|
|
}
|
|
|
|
cfg := PollConfig{Interval: 5 * time.Millisecond, Timeout: 500 * time.Millisecond}
|
|
|
|
info, err := WaitForAP(context.Background(), "", cfg, httpGet)
|
|
if err != nil {
|
|
t.Fatalf("WaitForAP: %v", err)
|
|
}
|
|
|
|
if info.DeviceID != "AABBCCDDEEFF" {
|
|
t.Errorf("DeviceID = %q, want AABBCCDDEEFF", info.DeviceID)
|
|
}
|
|
|
|
if calls.Load() < 3 {
|
|
t.Errorf("expected at least 3 polls, got %d", calls.Load())
|
|
}
|
|
}
|
|
|
|
func TestWaitForAP_TimesOut(t *testing.T) {
|
|
httpGet := func(_ string) (*http.Response, error) {
|
|
return nil, errors.New("network unreachable")
|
|
}
|
|
|
|
cfg := PollConfig{Interval: 5 * time.Millisecond, Timeout: 30 * time.Millisecond}
|
|
|
|
_, err := WaitForAP(context.Background(), "", cfg, httpGet)
|
|
if err == nil || !strings.Contains(err.Error(), "did not respond") {
|
|
t.Errorf("err = %v, want timeout error", err)
|
|
}
|
|
}
|
|
|
|
func TestWaitForAP_RespectsContextCancellation(t *testing.T) {
|
|
httpGet := func(_ string) (*http.Response, error) {
|
|
return nil, errors.New("network unreachable")
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
go func() {
|
|
time.Sleep(15 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
|
|
cfg := PollConfig{Interval: 5 * time.Millisecond, Timeout: 5 * time.Second}
|
|
|
|
_, err := WaitForAP(ctx, "", cfg, httpGet)
|
|
if err == nil || !strings.Contains(err.Error(), "context canceled") {
|
|
t.Errorf("err = %v, want context-cancellation error", err)
|
|
}
|
|
}
|
|
|
|
// stubMDNS is a controllable MDNSDiscoverer.
|
|
type stubMDNS struct {
|
|
results [][]*models.DiscoveredDevice
|
|
call atomic.Int32
|
|
}
|
|
|
|
func (s *stubMDNS) DiscoverDevices(_ context.Context) ([]*models.DiscoveredDevice, error) {
|
|
i := s.call.Add(1) - 1
|
|
if int(i) >= len(s.results) {
|
|
return nil, fmt.Errorf("exhausted")
|
|
}
|
|
|
|
return s.results[i], nil
|
|
}
|
|
|
|
func TestWaitForOnline_MatchesSubstringInNameOrSerial(t *testing.T) {
|
|
stub := &stubMDNS{
|
|
results: [][]*models.DiscoveredDevice{
|
|
nil, // first poll: nothing yet
|
|
{
|
|
{Name: "Other Bose Speaker", SerialNo: "AAAAAAAAAAAA", Host: "192.0.2.50"},
|
|
{Name: "Bose SoundTouch DE4803", SerialNo: "506583DE4803", Host: "192.0.2.42"},
|
|
},
|
|
},
|
|
}
|
|
|
|
cfg := PollConfig{Interval: 5 * time.Millisecond, Timeout: 500 * time.Millisecond}
|
|
|
|
d, err := WaitForOnline(context.Background(), "DE4803", cfg, stub)
|
|
if err != nil {
|
|
t.Fatalf("WaitForOnline: %v", err)
|
|
}
|
|
|
|
if d.Host != "192.0.2.42" {
|
|
t.Errorf("Host = %q, want 192.0.2.42", d.Host)
|
|
}
|
|
}
|
|
|
|
func TestWaitForOnline_EmptyMatcherReturnsFirst(t *testing.T) {
|
|
stub := &stubMDNS{
|
|
results: [][]*models.DiscoveredDevice{
|
|
{
|
|
{Name: "Bose SoundTouch DE4803", Host: "192.0.2.42"},
|
|
},
|
|
},
|
|
}
|
|
|
|
cfg := PollConfig{Interval: 5 * time.Millisecond, Timeout: 500 * time.Millisecond}
|
|
|
|
d, err := WaitForOnline(context.Background(), "", cfg, stub)
|
|
if err != nil {
|
|
t.Fatalf("WaitForOnline: %v", err)
|
|
}
|
|
|
|
if d.Host != "192.0.2.42" {
|
|
t.Errorf("Host = %q, want 192.0.2.42", d.Host)
|
|
}
|
|
}
|
|
|
|
func TestWaitForOnline_TimesOutWhenNoMatch(t *testing.T) {
|
|
stub := &stubMDNS{
|
|
results: [][]*models.DiscoveredDevice{
|
|
{{Name: "Wrong One", SerialNo: "X", Host: "192.0.2.99"}},
|
|
{{Name: "Wrong One", SerialNo: "X", Host: "192.0.2.99"}},
|
|
{{Name: "Wrong One", SerialNo: "X", Host: "192.0.2.99"}},
|
|
},
|
|
}
|
|
|
|
cfg := PollConfig{Interval: 5 * time.Millisecond, Timeout: 25 * time.Millisecond}
|
|
|
|
_, err := WaitForOnline(context.Background(), "DE4803", cfg, stub)
|
|
if err == nil || !strings.Contains(err.Error(), "no speaker matching") {
|
|
t.Errorf("err = %v, want no-match timeout error", err)
|
|
}
|
|
}
|