mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 09:06:14 +00:00
The static-analysis CI job runs 'staticcheck ./...' directly. Standalone staticcheck uses //lint:ignore directives, not the //nolint comments that golangci-lint reads. SA1008 (non-canonical header key) on three ETag lines: handlers_etag_test.go:228, :270 mac_mapping_integration_test.go:226 ETag must stay non-canonical — Bose speakers reject 'Etag'. Existing //nolint:canonicalheader / //nolint:staticcheck comments remain for golangci-lint; //lint:ignore SA1008 is added for the standalone staticcheck invocation. U1000 (unused function) on writeBMXUnauthorized in handlers_bmx.go: The auth gate is temporarily disabled; the helper is kept as a restore point. //lint:ignore U1000 replaces //nolint:unused because golangci-lint's staticcheck runner also honours //lint:ignore, making //nolint:unused redundant (nolintlint would complain). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
336 lines
11 KiB
Go
336 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
|
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
func TestMacMappingIntegration_HTTPHandler(t *testing.T) {
|
|
// Create temporary directory
|
|
tmpDir, err := os.MkdirTemp("", "mac-integration-test")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
// Setup test data (same as the issue description)
|
|
accountID := "1234567"
|
|
serialNumber := "I6332527703739342000020"
|
|
macAddress := "001122334455"
|
|
|
|
// Create directory structure using serial number
|
|
deviceDir := filepath.Join(tmpDir, "accounts", accountID, "devices", serialNumber)
|
|
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
|
t.Fatalf("failed to create device dir: %v", err)
|
|
}
|
|
|
|
// Create DeviceInfo.xml with MAC address
|
|
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
|
|
<info deviceID="` + serialNumber + `">
|
|
<name>SoundTouch Device</name>
|
|
<components>
|
|
<component>
|
|
<componentCategory>SCM</componentCategory>
|
|
<softwareVersion>4.8.1</softwareVersion>
|
|
<serialNumber>` + serialNumber + `</serialNumber>
|
|
</component>
|
|
</components>
|
|
<networkInfo type="SCM">
|
|
<macAddress>` + macAddress + `</macAddress>
|
|
<ipAddress>192.0.2.100</ipAddress>
|
|
</networkInfo>
|
|
</info>`
|
|
if err := os.WriteFile(filepath.Join(deviceDir, constants.DeviceInfoFile), []byte(deviceInfoXML), 0644); err != nil {
|
|
t.Fatalf("failed to write DeviceInfo.xml: %v", err)
|
|
}
|
|
|
|
// Create Presets.xml
|
|
presetsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
|
<presets>
|
|
<preset id="1">
|
|
<ContentItem source="SPOTIFY" type="station" location="/station/test123" sourceAccount="spotify_user">
|
|
<itemName>Test Preset</itemName>
|
|
</ContentItem>
|
|
</preset>
|
|
<preset id="2">
|
|
<ContentItem source="TUNEIN" type="station" location="/station/s12345" sourceAccount="">
|
|
<itemName>Radio Station</itemName>
|
|
</ContentItem>
|
|
</preset>
|
|
</presets>`
|
|
if err := os.WriteFile(filepath.Join(deviceDir, constants.PresetsFile), []byte(presetsXML), 0644); err != nil {
|
|
t.Fatalf("failed to write Presets.xml: %v", err)
|
|
}
|
|
|
|
// Set a specific modification time for ETag testing
|
|
pastTime := time.Now().Add(-1 * time.Hour)
|
|
if err := os.Chtimes(filepath.Join(deviceDir, constants.PresetsFile), pastTime, pastTime); err != nil {
|
|
t.Fatalf("failed to set file times: %v", err)
|
|
}
|
|
|
|
// Create Sources.xml (required by marge.PresetsToXML)
|
|
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
|
|
<sources>
|
|
<source source="SPOTIFY" sourceAccount="spotify_user" status="READY" multiroomallowed="true">
|
|
<sourceName>Spotify</sourceName>
|
|
</source>
|
|
<source source="TUNEIN" sourceAccount="" status="READY" multiroomallowed="true">
|
|
<sourceName>TuneIn</sourceName>
|
|
</source>
|
|
</sources>`
|
|
if err := os.WriteFile(filepath.Join(deviceDir, constants.SourcesFile), []byte(sourcesXML), 0644); err != nil {
|
|
t.Fatalf("failed to write Sources.xml: %v", err)
|
|
}
|
|
|
|
// Initialize datastore and server
|
|
ds := datastore.NewDataStore(tmpDir)
|
|
if err := ds.Initialize(); err != nil {
|
|
t.Fatalf("failed to initialize datastore: %v", err)
|
|
}
|
|
|
|
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
|
|
|
// Setup router with the exact same route as in production
|
|
router := chi.NewRouter()
|
|
router.Route("/streaming", func(r chi.Router) {
|
|
r.Get("/account/{account}/device/{device}/presets", server.HandleMargePresets)
|
|
})
|
|
|
|
// Test 1: Request with MAC address (should work due to mapping)
|
|
t.Run("RequestWithMACAddress", func(t *testing.T) {
|
|
requestURL := "/streaming/account/" + accountID + "/device/" + macAddress + "/presets"
|
|
req, err := http.NewRequest("GET", requestURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to create request: %v", err)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200, got %d. Response body: %s", rr.Code, rr.Body.String())
|
|
t.Logf("Request URL: %s", requestURL)
|
|
t.Logf("MAC address: %s", macAddress)
|
|
t.Logf("Serial number: %s", serialNumber)
|
|
return
|
|
}
|
|
|
|
// Verify response contains the expected presets
|
|
var presetsResponse struct {
|
|
Presets []struct {
|
|
ID string `xml:"id,attr"`
|
|
Name string `xml:"ContentItem>itemName"`
|
|
} `xml:"preset"`
|
|
}
|
|
|
|
if err := xml.Unmarshal(rr.Body.Bytes(), &presetsResponse); err != nil {
|
|
t.Errorf("Failed to parse XML response: %v", err)
|
|
t.Logf("Response body: %s", rr.Body.String())
|
|
return
|
|
}
|
|
|
|
if len(presetsResponse.Presets) != 2 {
|
|
t.Errorf("Expected 2 presets, got %d", len(presetsResponse.Presets))
|
|
}
|
|
|
|
t.Logf("✓ Successfully retrieved %d presets using MAC address %s", len(presetsResponse.Presets), macAddress)
|
|
})
|
|
|
|
// Test 2: Request with serial number (should also work)
|
|
t.Run("RequestWithSerialNumber", func(t *testing.T) {
|
|
requestURL := "/streaming/account/" + accountID + "/device/" + serialNumber + "/presets"
|
|
req, err := http.NewRequest("GET", requestURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to create request: %v", err)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200, got %d. Response body: %s", rr.Code, rr.Body.String())
|
|
return
|
|
}
|
|
|
|
t.Logf("✓ Successfully retrieved presets using serial number %s", serialNumber)
|
|
})
|
|
|
|
// Test 3: Request with non-existent device ID
|
|
t.Run("RequestWithNonExistentDevice", func(t *testing.T) {
|
|
requestURL := "/streaming/account/" + accountID + "/device/NONEXISTENT/presets"
|
|
req, err := http.NewRequest("GET", requestURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to create request: %v", err)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200 for non-existent device (empty presets), got %d", rr.Code)
|
|
}
|
|
|
|
t.Logf("✓ Correctly returned empty list for non-existent device")
|
|
})
|
|
|
|
// Test 4: Case sensitivity test
|
|
t.Run("RequestWithLowercaseMAC", func(t *testing.T) {
|
|
lowercaseMAC := "aabbccddeeff"
|
|
requestURL := "/streaming/account/" + accountID + "/device/" + lowercaseMAC + "/presets"
|
|
req, err := http.NewRequest("GET", requestURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to create request: %v", err)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
// This should fail because MAC addresses are case-sensitive
|
|
if rr.Code == http.StatusOK {
|
|
t.Logf("⚠️ Lowercase MAC address worked (might be unexpected): %s", lowercaseMAC)
|
|
} else {
|
|
t.Logf("✓ Lowercase MAC address correctly failed: %s (status: %d)", lowercaseMAC, rr.Code)
|
|
}
|
|
})
|
|
|
|
// Test 5: Verify ETag functionality
|
|
t.Run("RequestWithETag", func(t *testing.T) {
|
|
requestURL := "/streaming/account/" + accountID + "/device/" + macAddress + "/presets"
|
|
|
|
// First request to get ETag
|
|
req1, err := http.NewRequest("GET", requestURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to create request: %v", err)
|
|
}
|
|
|
|
rr1 := httptest.NewRecorder()
|
|
router.ServeHTTP(rr1, req1)
|
|
|
|
if rr1.Code != http.StatusOK {
|
|
t.Errorf("First request failed with status %d", rr1.Code)
|
|
return
|
|
}
|
|
|
|
// Extract ETag from response headers (direct access needed for httptest.ResponseRecorder)
|
|
etag := ""
|
|
//nolint:staticcheck // SA1008: ETag header name must be case-sensitive for test
|
|
//lint:ignore SA1008 ETag header key is intentionally non-canonical; Bose speakers reject the canonicalized form
|
|
if vals, ok := rr1.Header()["ETag"]; ok && len(vals) > 0 {
|
|
etag = vals[0]
|
|
}
|
|
|
|
if etag == "" {
|
|
t.Errorf("No ETag header in response. Available headers: %v", rr1.Header())
|
|
return
|
|
}
|
|
|
|
// Second request with ETag
|
|
req2, err := http.NewRequest("GET", requestURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to create second request: %v", err)
|
|
}
|
|
req2.Header.Set("If-None-Match", etag)
|
|
|
|
rr2 := httptest.NewRecorder()
|
|
router.ServeHTTP(rr2, req2)
|
|
|
|
if rr2.Code != http.StatusNotModified {
|
|
t.Errorf("Expected 304 Not Modified, got %d", rr2.Code)
|
|
return
|
|
}
|
|
|
|
t.Logf("✓ ETag functionality works correctly with MAC address resolution")
|
|
})
|
|
}
|
|
|
|
// TestMacMappingDebug provides debugging information about the mapping state
|
|
func TestMacMappingDebug(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "mac-debug-test")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
// Create multiple devices to test mapping
|
|
devices := []struct {
|
|
account string
|
|
serial string
|
|
mac string
|
|
}{
|
|
{"1234567", "I6332527703739342000020", "001122334455"},
|
|
{"1234567", "J1234567890123456789012", "B92C7B647BA9"},
|
|
{"5678901", "K9876543210987654321098", "C03D8C758CAA"},
|
|
}
|
|
|
|
for _, device := range devices {
|
|
deviceDir := filepath.Join(tmpDir, "accounts", device.account, "devices", device.serial)
|
|
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
|
t.Fatalf("failed to create device dir: %v", err)
|
|
}
|
|
|
|
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
|
|
<info deviceID="` + device.serial + `">
|
|
<name>Device ` + device.serial[0:8] + `</name>
|
|
<components>
|
|
<component>
|
|
<componentCategory>SCM</componentCategory>
|
|
<serialNumber>` + device.serial + `</serialNumber>
|
|
</component>
|
|
</components>
|
|
<networkInfo type="SCM">
|
|
<macAddress>` + device.mac + `</macAddress>
|
|
<ipAddress>192.0.2.100</ipAddress>
|
|
</networkInfo>
|
|
</info>`
|
|
if err := os.WriteFile(filepath.Join(deviceDir, constants.DeviceInfoFile), []byte(deviceInfoXML), 0644); err != nil {
|
|
t.Fatalf("failed to write DeviceInfo.xml: %v", err)
|
|
}
|
|
|
|
// Create minimal Sources.xml for each device
|
|
sourcesXML := `<sources></sources>`
|
|
if err := os.WriteFile(filepath.Join(deviceDir, constants.SourcesFile), []byte(sourcesXML), 0644); err != nil {
|
|
t.Fatalf("failed to write Sources.xml: %v", err)
|
|
}
|
|
}
|
|
|
|
// Initialize datastore
|
|
ds := datastore.NewDataStore(tmpDir)
|
|
if err := ds.Initialize(); err != nil {
|
|
t.Fatalf("failed to initialize datastore: %v", err)
|
|
}
|
|
|
|
// Debug output
|
|
allDevices, err := ds.ListAllDevices()
|
|
if err != nil {
|
|
t.Fatalf("failed to list devices: %v", err)
|
|
}
|
|
|
|
t.Logf("Found %d devices total", len(allDevices))
|
|
for _, dev := range allDevices {
|
|
t.Logf("Device: Account=%s, Serial=%s, MAC=%s",
|
|
dev.AccountID, dev.DeviceSerialNumber, dev.MacAddress)
|
|
}
|
|
|
|
// Test each mapping
|
|
for _, device := range devices {
|
|
resolvedDir := ds.AccountDeviceDir(device.account, device.mac)
|
|
expectedDir := filepath.Join(tmpDir, "accounts", device.account, "devices", device.serial)
|
|
|
|
if resolvedDir == expectedDir {
|
|
t.Logf("✓ MAC %s correctly resolves to serial %s", device.mac, device.serial)
|
|
} else {
|
|
t.Errorf("✗ MAC %s resolution failed: got %s, expected %s",
|
|
device.mac, resolvedDir, expectedDir)
|
|
}
|
|
}
|
|
}
|