Files
Bose-SoundTouch/pkg/service/soundtouchweb/handler_test.go
T
Tobias GesellchenandClaude Opus 4.7 42257aeebc refactor(soundtouch-web): relocate handlers/webtypes to pkg/service/soundtouchweb
Mechanical relocation only — zero semantic change. Sets up the package
layout that the future Preact-UI rewrite (branch `app`) wants, while
preserving every line of main's current logic. Subsequent commits will
land the additive parts (frontend rewrite, recents, zones, bass control)
on top of this clean base.

Moves (`git mv`, content unchanged except package decl):

  cmd/soundtouch-web/handlers/handlers.go      → pkg/service/soundtouchweb/handler.go
  cmd/soundtouch-web/handlers/handlers_test.go → pkg/service/soundtouchweb/handler_test.go
  cmd/soundtouch-web/handlers/websocket.go     → pkg/service/soundtouchweb/websocket.go
  cmd/soundtouch-web/handlers/registry_test.go → pkg/service/soundtouchweb/registry_test.go
  cmd/soundtouch-web/webtypes/types.go         → pkg/service/soundtouchweb/webtypes/types.go
  cmd/soundtouch-web/webtypes/types_test.go    → pkg/service/soundtouchweb/webtypes/types_test.go
  cmd/soundtouch-web/webtypes/status_test.go   → pkg/service/soundtouchweb/webtypes/status_test.go
  cmd/soundtouch-web/static/img/tunein-{dark,mono}.svg → pkg/service/soundtouchweb/static/img/

Adjustments:

- `package handlers` → `package soundtouchweb` in the 4 moved handler-tier
  files (plus their package-doc comments).
- Import paths rewritten in cmd/soundtouch-web/{main.go,spa_test.go} and
  in the moved files themselves: cmd/soundtouch-web/{handlers,webtypes}
  → pkg/service/soundtouchweb/{,webtypes}.
- `handlers.` selector renamed to `soundtouchweb.` in the callers.
- `.golangci.yml` errcheck waiver extended from `cmd/.*\.go` to also
  cover `pkg/service/soundtouchweb/.*\.go`. Same code that the
  cmd-tier waiver applied to; same waiver follows it. Documented as
  a carry-over with the intent to tighten in a follow-up review.

Not changed:

- `cmd/soundtouch-web/main.go` keeps the `//go:embed static` pointing at
  the still-vanilla `cmd/soundtouch-web/static/`. The frontend rewrite
  (Preact UI) lands in a later commit; this one is mechanical.
- `cmd/soundtouch-web/resolve_bind_addr_test.go` stays put — it tests
  main.go-local flag plumbing.

go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails, untracked-file issue, unrelated).
golangci-lint run ./... 0 issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:34:26 +02:00

579 lines
15 KiB
Go

// Package handlers contains tests for HTTP handlers.
package soundtouchweb
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
"github.com/go-chi/chi/v5"
)
func createTestApp() *WebApp {
app := NewWebApp()
// Add test device with minimal data
deviceInfo := &models.DeviceInfo{
Name: "Test Speaker",
Type: "SoundTouch 30",
NetworkInfo: []models.NetworkInfo{
{MacAddress: "TEST123", IPAddress: "192.0.2.100"},
},
}
device := webtypes.NewDeviceConnection(nil, deviceInfo)
device.SetStatus(&webtypes.DeviceStatus{
Volume: &models.Volume{ActualVolume: 50, MuteEnabled: false},
Bass: &models.Bass{ActualBass: 0},
IsConnected: true,
LastActivity: time.Now(),
})
app.AddDevice("test-device", device)
return app
}
func withChiParams(r *http.Request, params map[string]string) *http.Request {
rctx := chi.NewRouteContext()
for k, v := range params {
rctx.URLParams.Add(k, v)
}
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
func TestNewWebApp(t *testing.T) {
app := NewWebApp()
// Use require-style checks that satisfy static analyzer
if app == nil {
t.Fatal("NewWebApp returned nil")
}
if count := app.DeviceCount(); count != 0 {
t.Errorf("Expected empty device registry, got %d devices", count)
}
}
func TestHandleAPIDevices(t *testing.T) {
app := createTestApp()
req := httptest.NewRequest("GET", "/api/devices", nil)
w := httptest.NewRecorder()
app.HandleAPIDevices(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
contentType := w.Header().Get("Content-Type")
if !strings.Contains(contentType, "application/json") {
t.Errorf("Expected JSON content type, got %s", contentType)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if !response.Success {
t.Errorf("Expected success=true, got false")
}
// Check that devices data is present
data, ok := response.Data.(map[string]interface{})
if !ok {
t.Fatalf("Expected data to be map[string]interface{}")
}
if _, exists := data["test-device"]; !exists {
t.Errorf("Expected 'test-device' in response data")
}
}
func TestHandleAPIDevice(t *testing.T) {
app := createTestApp()
tests := []struct {
name string
path string
chiID string
expectedStatus int
expectSuccess bool
}{
{
name: "valid device",
path: "/api/device/test-device",
chiID: "test-device",
expectedStatus: http.StatusOK,
expectSuccess: true,
},
{
name: "missing device ID",
path: "/api/device/",
chiID: "",
expectedStatus: http.StatusBadRequest,
expectSuccess: false,
},
{
name: "unknown device",
path: "/api/device/unknown",
chiID: "unknown",
expectedStatus: http.StatusNotFound,
expectSuccess: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", tt.path, nil)
if tt.chiID != "" {
req = withChiParams(req, map[string]string{"id": tt.chiID})
}
w := httptest.NewRecorder()
app.HandleAPIDevice(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
}
contentType := w.Header().Get("Content-Type")
if !strings.Contains(contentType, "application/json") {
t.Errorf("Expected JSON content type, got %s", contentType)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Success != tt.expectSuccess {
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
}
})
}
}
func TestHandleAPIControl_InvalidDevice(t *testing.T) {
app := createTestApp()
req := httptest.NewRequest("GET", "/api/control/unknown-device/play", nil)
req = withChiParams(req, map[string]string{"id": "unknown-device", "action": "play"})
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("Expected status 404, got %d", w.Code)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Success {
t.Errorf("Expected success=false, got true")
}
if response.Error != "Device not found" {
t.Errorf("Expected 'Device not found' error, got '%s'", response.Error)
}
}
func TestHandleAPIControl_InvalidPath(t *testing.T) {
app := createTestApp()
tests := []struct {
name string
path string
}{
{"missing action", "/api/control/test-device"},
{"missing device and action", "/api/control/"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", tt.path, nil)
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Success {
t.Errorf("Expected success=false, got true")
}
})
}
}
func TestHandleAPIControl_VolumeValidation(t *testing.T) {
app := createTestApp()
tests := []struct {
name string
method string
body string
expectedStatus int
expectSuccess bool
}{
{
name: "invalid method",
method: "GET",
body: "",
expectedStatus: http.StatusMethodNotAllowed,
expectSuccess: false,
},
{
name: "invalid JSON",
method: "POST",
body: `invalid json`,
expectedStatus: http.StatusBadRequest,
expectSuccess: false,
},
{
name: "volume too low",
method: "POST",
body: `{"level": -1}`,
expectedStatus: http.StatusBadRequest,
expectSuccess: false,
},
{
name: "volume too high",
method: "POST",
body: `{"level": 101}`,
expectedStatus: http.StatusBadRequest,
expectSuccess: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var req *http.Request
if tt.body != "" {
req = httptest.NewRequest(tt.method, "/api/control/test-device/volume", strings.NewReader(tt.body))
} else {
req = httptest.NewRequest(tt.method, "/api/control/test-device/volume", nil)
}
req = withChiParams(req, map[string]string{"id": "test-device", "action": "volume"})
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Success != tt.expectSuccess {
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
}
})
}
}
func TestHandleAPIControl_BassValidation(t *testing.T) {
app := createTestApp()
tests := []struct {
name string
method string
body string
expectedStatus int
expectSuccess bool
}{
{
name: "bass too low",
method: "POST",
body: `{"level": -10}`,
expectedStatus: http.StatusBadRequest,
expectSuccess: false,
},
{
name: "bass too high",
method: "POST",
body: `{"level": 10}`,
expectedStatus: http.StatusBadRequest,
expectSuccess: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, "/api/control/test-device/bass", strings.NewReader(tt.body))
req = withChiParams(req, map[string]string{"id": "test-device", "action": "bass"})
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Success != tt.expectSuccess {
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
}
})
}
}
func TestHandleAPIControl_PresetValidation(t *testing.T) {
app := createTestApp()
tests := []struct {
name string
query string
expectedStatus int
expectSuccess bool
}{
{
name: "missing preset ID",
query: "",
expectedStatus: http.StatusBadRequest,
expectSuccess: false,
},
{
name: "invalid preset ID",
query: "?id=abc",
expectedStatus: http.StatusBadRequest,
expectSuccess: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/control/test-device/preset"+tt.query, nil)
req = withChiParams(req, map[string]string{"id": "test-device", "action": "preset"})
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Success != tt.expectSuccess {
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
}
})
}
}
func TestHandleAPIControl_SourceValidation(t *testing.T) {
app := createTestApp()
req := httptest.NewRequest("GET", "/api/control/test-device/source", nil)
req = withChiParams(req, map[string]string{"id": "test-device", "action": "source"})
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Success {
t.Errorf("Expected success=false, got true")
}
if response.Error != "Source name required" {
t.Errorf("Expected 'Source name required' error, got '%s'", response.Error)
}
}
func TestHandleAPIDiscover(t *testing.T) {
app := createTestApp()
tests := []struct {
name string
method string
expectedStatus int
expectSuccess bool
}{
{
name: "valid POST request",
method: "POST",
expectedStatus: http.StatusOK,
expectSuccess: true,
},
{
name: "invalid GET request",
method: "GET",
expectedStatus: http.StatusMethodNotAllowed,
expectSuccess: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, "/api/discover", nil)
w := httptest.NewRecorder()
app.HandleAPIDiscover(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Success != tt.expectSuccess {
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
}
})
}
}
func TestSendError(t *testing.T) {
app := createTestApp()
w := httptest.NewRecorder()
app.sendError(w, "Test error", http.StatusBadRequest)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Success {
t.Errorf("Expected success=false, got true")
}
if response.Error != "Test error" {
t.Errorf("Expected 'Test error', got '%s'", response.Error)
}
contentType := w.Header().Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Expected Content-Type 'application/json', got '%s'", contentType)
}
}
func TestHandleWebSocket_InvalidUpgrade(t *testing.T) {
app := createTestApp()
// Test without proper WebSocket headers (should fail gracefully)
req := httptest.NewRequest("GET", "/ws", nil)
w := httptest.NewRecorder()
// This will fail because it's not a real WebSocket upgrade, but should not panic
app.HandleWebSocket(w, req)
// We're just checking that the handler doesn't panic
// The actual upgrade will fail in test environment without proper headers
}
func TestHandleAPIControl_UnsupportedAction(t *testing.T) {
app := createTestApp()
req := httptest.NewRequest("GET", "/api/control/test-device/unsupported", nil)
req = withChiParams(req, map[string]string{"id": "test-device", "action": "unsupported"})
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Success {
t.Errorf("Expected success=false, got true")
}
if response.Error != "Unknown action" {
t.Errorf("Expected 'Unknown action' error, got '%s'", response.Error)
}
}
// Benchmark tests
func BenchmarkHandleAPIDevices(b *testing.B) {
app := createTestApp()
// Add more devices for realistic benchmarking
for i := 0; i < 10; i++ {
deviceID := "device-" + string(rune('0'+i))
conn := webtypes.NewDeviceConnection(&client.Client{}, &models.DeviceInfo{Name: "Test Device " + deviceID})
conn.SetStatus(&webtypes.DeviceStatus{IsConnected: true})
app.AddDevice(deviceID, conn)
}
req := httptest.NewRequest("GET", "/api/devices", nil)
b.ResetTimer()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
app.HandleAPIDevices(w, req)
}
}
func BenchmarkHandleAPIDevice(b *testing.B) {
app := createTestApp()
req := httptest.NewRequest("GET", "/api/device/test-device", nil)
req = withChiParams(req, map[string]string{"id": "test-device"})
b.ResetTimer()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
app.HandleAPIDevice(w, req)
}
}
func BenchmarkSendError(b *testing.B) {
app := createTestApp()
b.ResetTimer()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
app.sendError(w, "Test error", http.StatusBadRequest)
}
}