package fakespeaker import ( "bytes" "context" "encoding/xml" "io" "net/http" "strings" "testing" "time" ) func TestFakeSpeakerServesFixtures(t *testing.T) { s, err := Start(Config{}) if err != nil { t.Fatalf("start: %v", err) } t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _ = s.Stop(ctx) }) cases := []struct { path string root string }{ {"/info", "info"}, {"/presets", "presets"}, {"/recents", "recents"}, {"/networkInfo", "networkInfo"}, {"/sources", "sources"}, {"/supportedURLs", "supportedURLs"}, {"/getGroup", "group"}, {"/removeGroup", "group"}, {"/now_playing", "nowPlaying"}, } for _, tc := range cases { t.Run(tc.path, func(t *testing.T) { resp, err := http.Get("http://" + s.HTTPAddr() + tc.path) //nolint:noctx if err != nil { t.Fatalf("get %s: %v", tc.path, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read body: %v", err) } var root struct { XMLName xml.Name } if err := xml.Unmarshal(body, &root); err != nil { t.Fatalf("parse XML: %v\n%s", err, body) } if root.XMLName.Local != tc.root { t.Fatalf("root element = %q, want %q", root.XMLName.Local, tc.root) } }) } } func TestFakeSpeakerAddGroupEchoesWithGroupOK(t *testing.T) { s, err := Start(Config{}) if err != nil { t.Fatalf("start: %v", err) } t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _ = s.Stop(ctx) }) posted := ` TEST DEADBEEFCAFE DEADBEEFCAFELEFT127.0.0.1 0000DEADBEEFRIGHT127.0.0.2 ` resp, err := http.Post("http://"+s.HTTPAddr()+"/addGroup", "application/xml", strings.NewReader(posted)) //nolint:noctx if err != nil { t.Fatalf("post: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read body: %v", err) } // Echo: the posted name + roles survive in the response. if !bytes.Contains(body, []byte("TEST")) { t.Errorf("response missing posted ; body:\n%s", body) } if !bytes.Contains(body, []byte("DEADBEEFCAFE")) { t.Errorf("response missing posted ; body:\n%s", body) } // Success marker: GROUP_OK appears before . statusIdx := bytes.Index(body, []byte("GROUP_OK")) if statusIdx < 0 { t.Fatalf("response missing GROUP_OK; body:\n%s", body) } closeIdx := bytes.LastIndex(body, []byte("")) if closeIdx < 0 || statusIdx >= closeIdx { t.Errorf(" not nested inside ...; body:\n%s", body) } } func TestFakeSpeakerUpdateGroupEchoesWithGroupOK(t *testing.T) { s, err := Start(Config{}) if err != nil { t.Fatalf("start: %v", err) } t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _ = s.Stop(ctx) }) posted := ` RENAMED DEADBEEFCAFE ` resp, err := http.Post("http://"+s.HTTPAddr()+"/updateGroup", "application/xml", strings.NewReader(posted)) //nolint:noctx if err != nil { t.Fatalf("post: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read body: %v", err) } if !bytes.Contains(body, []byte("RENAMED")) { t.Errorf("response missing posted ; body:\n%s", body) } if !bytes.Contains(body, []byte("GROUP_OK")) { t.Errorf("response missing GROUP_OK; body:\n%s", body) } } func TestFakeSpeakerFixtureOverride_ReplacesEmbeddedBody(t *testing.T) { custom := []byte(` Custom Override `) s, err := Start(Config{ FixtureOverrides: map[string][]byte{ "/presets": custom, }, }) if err != nil { t.Fatalf("start: %v", err) } t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _ = s.Stop(ctx) }) // Overridden route returns the custom body verbatim. resp, err := http.Get("http://" + s.HTTPAddr() + "/presets") //nolint:noctx if err != nil { t.Fatalf("get /presets: %v", err) } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read body: %v", err) } if !bytes.Equal(body, custom) { t.Errorf("/presets body mismatch.\ngot:\n%s\nwant:\n%s", body, custom) } // Non-overridden route still serves the embedded default. resp2, err := http.Get("http://" + s.HTTPAddr() + "/info") //nolint:noctx if err != nil { t.Fatalf("get /info: %v", err) } defer func() { _ = resp2.Body.Close() }() body2, err := io.ReadAll(resp2.Body) if err != nil { t.Fatalf("read /info body: %v", err) } if !bytes.Contains(body2, []byte(`deviceID="DEADBEEFCAFE"`)) { t.Errorf("/info default fixture missing expected deviceID; body:\n%s", body2) } } func TestFakeSpeakerNotificationRecorder(t *testing.T) { s, err := Start(Config{}) if err != nil { t.Fatalf("start: %v", err) } t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _ = s.Stop(ctx) }) body := `` req, err := http.NewRequest(http.MethodPost, "http://"+s.HTTPAddr()+"/notification", strings.NewReader(body)) if err != nil { t.Fatalf("build request: %v", err) } req.Header.Set("Content-Type", "application/xml") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("post: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Errorf("status = %d, want 200", resp.StatusCode) } got := s.Notifications() if len(got) != 1 { t.Fatalf("Notifications() returned %d entries, want 1", len(got)) } // The test POSTs the request body verbatim; the recorder must // return it byte-identical. (Wire-shape variation — self-closing // vs long-form sourcesUpdated — happens upstream in // pkg/client.NotifySourcesUpdated, not here.) if string(got[0].Body) != body { t.Errorf("body = %q, want %q", got[0].Body, body) } if got[0].ContentType != "application/xml" { t.Errorf("ContentType = %q, want application/xml", got[0].ContentType) } // GET on the same path is a 405 — real speakers don't expose it. getResp, err := http.Get("http://" + s.HTTPAddr() + "/notification") //nolint:noctx if err != nil { t.Fatalf("get: %v", err) } defer func() { _ = getResp.Body.Close() }() if getResp.StatusCode != http.StatusMethodNotAllowed { t.Errorf("GET status = %d, want 405", getResp.StatusCode) } if got := getResp.Header.Get("Allow"); got != "POST" { t.Errorf("Allow header = %q, want POST", got) } } func TestFakeSpeakerRemoveGroupRejectsNonGET(t *testing.T) { s, err := Start(Config{}) if err != nil { t.Fatalf("start: %v", err) } t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _ = s.Stop(ctx) }) resp, err := http.Post("http://"+s.HTTPAddr()+"/removeGroup", "application/xml", strings.NewReader("")) //nolint:noctx if err != nil { t.Fatalf("post: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusMethodNotAllowed { t.Fatalf("status = %d, want 405", resp.StatusCode) } if got := resp.Header.Get("Allow"); got != "GET" { t.Errorf("Allow header = %q, want %q", got, "GET") } }