Files
Bose-SoundTouch/pkg/service/setup/wifi_provision_test.go
T
Tobias GesellchenandClaude Opus 4.7 feadc478d5 test: sweep example data in test files to RFC-5737 + placeholders
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>
2026-05-17 22:05:13 +02:00

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, "&quot;") {
t.Errorf("expected &quot; 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)
}
}