package discovery import ( "context" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/gesellix/bose-soundtouch/pkg/config" "github.com/gesellix/bose-soundtouch/pkg/models" ) func TestNewDiscoveryService(t *testing.T) { timeout := 5 * time.Second service := NewService(timeout) if service.timeout != timeout { t.Errorf("Expected timeout %v, got %v", timeout, service.timeout) } if service.cacheTTL != defaultCacheTTL { t.Errorf("Expected cacheTTL %v, got %v", defaultCacheTTL, service.cacheTTL) } if service.cache == nil { t.Error("Expected cache to be initialized") } } func TestNewDiscoveryServiceWithDefaultTimeout(t *testing.T) { service := NewService(0) if service.timeout != defaultTimeout { t.Errorf("Expected default timeout %v, got %v", defaultTimeout, service.timeout) } } func TestBuildMSearchRequest(t *testing.T) { t.Run("DefaultTimeout", func(t *testing.T) { service := NewService(5 * time.Second) request := service.buildMSearchRequest() expectedLines := []string{ "M-SEARCH * HTTP/1.1", "HOST: 239.255.255.250:1900", "MAN: \"ssdp:discover\"", "ST: urn:schemas-upnp-org:device:MediaRenderer:1", "MX: 5", } for _, expectedLine := range expectedLines { if !contains(request, expectedLine) { t.Errorf("Expected M-SEARCH request to contain '%s'", expectedLine) } } if !contains(request, "\r\n\r\n") { t.Error("Expected M-SEARCH request to end with double CRLF") } }) t.Run("LowTimeout", func(t *testing.T) { // If timeout is less than 1 second, it should still use MX: 1 service := NewService(500 * time.Millisecond) request := service.buildMSearchRequest() if !contains(request, "MX: 1") { t.Errorf("Expected low timeout (500ms) to result in MX: 1, but got something else. Request:\n%s", request) } }) t.Run("VeryHighTimeout", func(t *testing.T) { // MX should probably be capped at 5 for UPnP compatibility service := NewService(10 * time.Second) request := service.buildMSearchRequest() if !contains(request, "MX: 5") { t.Errorf("Expected high timeout (10s) to result in MX: 5, but got something else. Request:\n%s", request) } }) } func TestParseLocationURL_Valid(t *testing.T) { service := NewService(1 * time.Second) location := "http://192.0.2.100:8090/device.xml" device, err := service.parseLocationURL(location, "") if err != nil { t.Fatalf("Expected no error, got: %v", err) } if device.Host != "192.0.2.100" { t.Errorf("Expected host '192.0.2.100', got '%s'", device.Host) } if device.Port != 8090 { t.Errorf("Expected port 8090, got %d", device.Port) } if device.UPnPLocation != location { t.Errorf("Expected UPnP location '%s', got '%s'", location, device.UPnPLocation) } if device.Name == "" { t.Error("Expected device name to be set") } expectedName := "SoundTouch-192.0.2.100" if device.Name != expectedName { t.Errorf("Expected name '%s', got '%s'", expectedName, device.Name) } } func TestParseLocationURL_Invalid(t *testing.T) { service := NewService(1 * time.Second) invalidURLs := []string{ "not-a-url", "ftp://192.0.2.100/device.xml", "http://invalid-format", "", } for _, url := range invalidURLs { _, err := service.parseLocationURL(url, "") if err == nil { t.Errorf("Expected error for invalid URL '%s', got nil", url) } } } func TestParseResponse_ValidMediaRenderer(t *testing.T) { xmlPayload := ` Test Device SoundTouch 10 AABBCCDDEEFF ` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/xml") fmt.Fprint(w, xmlPayload) })) defer server.Close() service := NewService(1 * time.Second) service.httpClient = server.Client() validResponse := fmt.Sprintf(`HTTP/1.1 200 OK Cache-Control: max-age=1800 Location: %s ST: urn:schemas-upnp-org:device:MediaRenderer:1 USN: uuid:12345678-1234-5678-9012-123456789012::urn:schemas-upnp-org:device:MediaRenderer:1 `, server.URL) device, err := service.parseResponse(validResponse) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if device == nil { t.Fatal("Expected device, got nil") } if device.Name != "Test Device" { t.Errorf("Expected name 'Test Device', got '%s'", device.Name) } } func TestParseResponse_NotMediaRenderer(t *testing.T) { service := NewService(1 * time.Second) nonMediaRendererResponse := `HTTP/1.1 200 OK Cache-Control: max-age=1800 Date: Mon, 22 Jun 1998 09:55:21 GMT EXT: Location: http://192.0.2.100:8090/device.xml Server: Linux/3.14.0 UPnP/1.0 SomeDevice/1.0 ST: urn:schemas-upnp-org:device:SomeOtherDevice:1 USN: uuid:12345678-1234-5678-9012-123456789012::urn:schemas-upnp-org:device:SomeOtherDevice:1 ` device, err := service.parseResponse(nonMediaRendererResponse) if err == nil { t.Error("Expected error for non-MediaRenderer device, got nil") } if device != nil { t.Error("Expected nil device for non-MediaRenderer, got device") } expectedError := "not a MediaRenderer device" if !contains(err.Error(), expectedError) { t.Errorf("Expected error to contain '%s', got '%s'", expectedError, err.Error()) } } func TestParseResponse_InvalidHTTP(t *testing.T) { service := NewService(1 * time.Second) invalidResponses := []string{ "not http response", "HTTP/1.1 404 Not Found\r\n\r\n", "", "HTTP/1.1 200 OK\r\nLocation: invalid-location\r\nST: urn:schemas-upnp-org:device:MediaRenderer:1\r\n\r\n", } for _, response := range invalidResponses { device, err := service.parseResponse(response) if err == nil && response != "" { t.Errorf("Expected error for invalid response, got nil for: %s", response) } if device != nil { t.Errorf("Expected nil device for invalid response, got device for: %s", response) } } } func TestParseResponse_NoLocation(t *testing.T) { service := NewService(1 * time.Second) responseWithoutLocation := `HTTP/1.1 200 OK Cache-Control: max-age=1800 Date: Mon, 22 Jun 1998 09:55:21 GMT ST: urn:schemas-upnp-org:device:MediaRenderer:1 ` device, err := service.parseResponse(responseWithoutLocation) if err == nil { t.Error("Expected error for response without location, got nil") } if device != nil { t.Error("Expected nil device for response without location") } expectedError := "no location header found" if !contains(err.Error(), expectedError) { t.Errorf("Expected error to contain '%s', got '%s'", expectedError, err.Error()) } } func TestCacheOperations(t *testing.T) { service := NewService(1 * time.Second) // Test empty cache devices := service.GetCachedDevices() if len(devices) != 0 { t.Errorf("Expected empty cache, got %d devices", len(devices)) } // Add devices to cache testDevices := []*models.DiscoveredDevice{ { Host: "192.0.2.100", Port: 8090, Name: "Device 1", LastSeen: time.Now(), }, { Host: "192.0.2.101", Port: 8090, Name: "Device 2", LastSeen: time.Now(), }, } service.updateCache(testDevices) // Test cached devices retrieval cachedDevices := service.GetCachedDevices() if len(cachedDevices) != 2 { t.Errorf("Expected 2 cached devices, got %d", len(cachedDevices)) } // Test cache clear service.ClearCache() devices = service.GetCachedDevices() if len(devices) != 0 { t.Errorf("Expected empty cache after clear, got %d devices", len(devices)) } } func TestCacheExpiration(t *testing.T) { service := NewService(5 * time.Second) service.cacheTTL = 100 * time.Millisecond // Short TTL for testing // Add device with old timestamp expiredDevice := &models.DiscoveredDevice{ Host: "192.0.2.100", Port: 8090, Name: "Expired Device", LastSeen: time.Now().Add(-200 * time.Millisecond), // Expired } // Add device with recent timestamp freshDevice := &models.DiscoveredDevice{ Host: "192.0.2.101", Port: 8090, Name: "Fresh Device", LastSeen: time.Now(), // Fresh } service.updateCache([]*models.DiscoveredDevice{expiredDevice, freshDevice}) // Wait a bit to ensure expiration time.Sleep(50 * time.Millisecond) // Test that expired device is filtered out devices := service.GetCachedDevices() if len(devices) != 1 { t.Errorf("Expected 1 non-expired device, got %d", len(devices)) } if len(devices) > 0 && devices[0].Host != "192.0.2.101" { t.Errorf("Expected fresh device, got %s", devices[0].Host) } } func TestDiscoverDevices_UseCache(t *testing.T) { service := NewService(5 * time.Second) // Add fresh device to cache freshDevice := &models.DiscoveredDevice{ Host: "192.0.2.100", Port: 8090, Name: "Cached Device", LastSeen: time.Now(), } service.updateCache([]*models.DiscoveredDevice{freshDevice}) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() // Should return cached device without network discovery devices, err := service.DiscoverDevices(ctx) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(devices) != 1 { t.Errorf("Expected 1 cached device, got %d", len(devices)) } if len(devices) > 0 && devices[0].Host != "192.0.2.100" { t.Errorf("Expected cached device host, got %s", devices[0].Host) } } func TestDiscoverDevice_FromCache(t *testing.T) { service := NewService(5 * time.Second) // Add device to cache device := &models.DiscoveredDevice{ Host: "192.0.2.100", Port: 8090, Name: "Test Device", LastSeen: time.Now(), } service.updateCache([]*models.DiscoveredDevice{device}) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() // Discover specific device from cache foundDevice, err := service.DiscoverDevice(ctx, "192.0.2.100") if err != nil { t.Fatalf("Expected no error, got: %v", err) } if foundDevice.Host != "192.0.2.100" { t.Errorf("Expected host '192.0.2.100', got '%s'", foundDevice.Host) } if foundDevice.Name != "Test Device" { t.Errorf("Expected name 'Test Device', got '%s'", foundDevice.Name) } } func TestDiscoverDevice_NotFound(t *testing.T) { service := NewService(100 * time.Millisecond) // Short timeout ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel() // Try to discover non-existent device _, err := service.DiscoverDevice(ctx, "192.0.2.999") if err == nil { t.Error("Expected error for non-existent device, got nil") } expectedError := "device with host 192.0.2.999 not found" if !contains(err.Error(), expectedError) { t.Errorf("Expected error to contain '%s', got '%s'", expectedError, err.Error()) } } func TestNewDiscoveryServiceWithConfig(t *testing.T) { cfg := &config.Config{ DiscoveryTimeout: 15 * time.Second, CacheTTL: 60 * time.Second, UPnPEnabled: false, PreferredDevices: []config.DeviceConfig{ {Name: "Test Device", Host: "192.0.2.100", Port: 8090}, }, } service := NewServiceWithConfig(cfg) if service.timeout != 15*time.Second { t.Errorf("Expected timeout 15s, got %v", service.timeout) } if service.cacheTTL != 60*time.Second { t.Errorf("Expected cache TTL 60s, got %v", service.cacheTTL) } if service.config.UPnPEnabled { t.Error("Expected UPnP to be disabled") } } func TestGetConfiguredDevices(t *testing.T) { cfg := &config.Config{ PreferredDevices: []config.DeviceConfig{ {Name: "Living Room", Host: "192.0.2.100", Port: 8090}, {Name: "Kitchen", Host: "192.0.2.101", Port: 8091}, }, } service := NewServiceWithConfig(cfg) devices := service.getConfiguredDevices() if len(devices) != 2 { t.Errorf("Expected 2 configured devices, got %d", len(devices)) } if devices[0].Name != "Living Room" { t.Errorf("Expected first device name 'Living Room', got '%s'", devices[0].Name) } if devices[0].Host != "192.0.2.100" { t.Errorf("Expected first device host '192.0.2.100', got '%s'", devices[0].Host) } if devices[1].Port != 8091 { t.Errorf("Expected second device port 8091, got %d", devices[1].Port) } } func TestMergeDevices(t *testing.T) { service := NewService(5 * time.Second) existing := []*models.DiscoveredDevice{ {Host: "192.0.2.100", Name: "Device 1", Port: 8090}, {Host: "192.0.2.101", Name: "Device 2", Port: 8090}, } newDevices := []*models.DiscoveredDevice{ {Host: "192.0.2.101", Name: "Duplicate", Port: 8090}, // Duplicate {Host: "192.0.2.102", Name: "Device 3", Port: 8090}, // New } merged := service.mergeDevices(existing, newDevices) if len(merged) != 3 { t.Errorf("Expected 3 merged devices, got %d", len(merged)) } // Check that duplicates are avoided hosts := make(map[string]int) for _, device := range merged { hosts[device.Host]++ } for host, count := range hosts { if count > 1 { t.Errorf("Host %s appears %d times (should be unique)", host, count) } } // Check that all unique hosts are present expectedHosts := []string{"192.0.2.100", "192.0.2.101", "192.0.2.102"} for _, expectedHost := range expectedHosts { if _, exists := hosts[expectedHost]; !exists { t.Errorf("Expected host %s not found in merged devices", expectedHost) } } } func TestDiscoverDevices_ConfiguredOnly(t *testing.T) { cfg := &config.Config{ UPnPEnabled: false, // Disable UPnP PreferredDevices: []config.DeviceConfig{ {Name: "Test Device", Host: "192.0.2.100", Port: 8090}, }, } service := NewServiceWithConfig(cfg) ctx := context.Background() devices, err := service.DiscoverDevices(ctx) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(devices) != 1 { t.Errorf("Expected 1 configured device, got %d", len(devices)) } if devices[0].Name != "Test Device" { t.Errorf("Expected device name 'Test Device', got '%s'", devices[0].Name) } if devices[0].Host != "192.0.2.100" { t.Errorf("Expected device host '192.0.2.100', got '%s'", devices[0].Host) } } // Helper function to check if string contains substring func contains(s, substr string) bool { return strings.Contains(s, substr) }