package control import ( "bytes" "encoding/json" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" cfgpkg "github.com/jan/fm-rds-tx/internal/config" "github.com/jan/fm-rds-tx/internal/ingest" "github.com/jan/fm-rds-tx/internal/output" ) func TestHealthz(t *testing.T) { srv := NewServer(cfgpkg.Default()) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) if rec.Code != 200 { t.Fatalf("status: %d", rec.Code) } } func TestStatus(t *testing.T) { srv := NewServer(cfgpkg.Default()) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil)) if rec.Code != 200 { t.Fatalf("status: %d", rec.Code) } var body map[string]any json.Unmarshal(rec.Body.Bytes(), &body) if body["service"] != "fm-rds-tx" { t.Fatal("missing service") } if _, ok := body["preEmphasisTauUS"]; !ok { t.Fatal("missing preEmphasisTauUS") } } func TestStatusReportsRuntimeIndicator(t *testing.T) { srv := NewServer(cfgpkg.Default()) srv.SetTXController(&fakeTXController{stats: map[string]any{"runtimeIndicator": "degraded", "runtimeAlert": "late buffers"}}) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil)) if rec.Code != 200 { t.Fatalf("status: %d", rec.Code) } var body map[string]any json.Unmarshal(rec.Body.Bytes(), &body) if body["runtimeIndicator"] != "degraded" { t.Fatalf("expected runtimeIndicator degraded, got %v", body["runtimeIndicator"]) } if body["runtimeAlert"] != "late buffers" { t.Fatalf("expected runtimeAlert late buffers, got %v", body["runtimeAlert"]) } } func TestStatusReportsQueueStats(t *testing.T) { cfg := cfgpkg.Default() queueStats := output.QueueStats{ Capacity: cfg.Runtime.FrameQueueCapacity, Depth: 1, FillLevel: 0.25, Health: output.QueueHealthLow, } srv := NewServer(cfg) srv.SetTXController(&fakeTXController{stats: map[string]any{"queue": queueStats}}) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil)) if rec.Code != 200 { t.Fatalf("status: %d", rec.Code) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("unmarshal queue stats: %v", err) } queueRaw, ok := body["queue"] if !ok { t.Fatalf("missing queue in status") } queueMap, ok := queueRaw.(map[string]any) if !ok { t.Fatalf("queue stats type mismatch: %T", queueRaw) } if queueMap["capacity"] != float64(queueStats.Capacity) { t.Fatalf("queue capacity mismatch: want %v got %v", queueStats.Capacity, queueMap["capacity"]) } if queueMap["health"] != string(queueStats.Health) { t.Fatalf("queue health mismatch: want %s got %v", queueStats.Health, queueMap["health"]) } } func TestStatusReportsRuntimeState(t *testing.T) { srv := NewServer(cfgpkg.Default()) srv.SetTXController(&fakeTXController{stats: map[string]any{"state": "faulted"}}) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil)) if rec.Code != 200 { t.Fatalf("status: %d", rec.Code) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("unmarshal runtime state: %v", err) } if body["runtimeState"] != "faulted" { t.Fatalf("expected runtimeState faulted, got %v", body["runtimeState"]) } } func TestDryRunEndpoint(t *testing.T) { srv := NewServer(cfgpkg.Default()) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/dry-run", nil)) if rec.Code != 200 { t.Fatalf("status: %d", rec.Code) } var body map[string]any json.Unmarshal(rec.Body.Bytes(), &body) if body["mode"] != "dry-run" { t.Fatal("wrong mode") } } func TestConfigPatch(t *testing.T) { srv := NewServer(cfgpkg.Default()) body := []byte(`{"toneLeftHz":900,"radioText":"hello world","preEmphasisTauUS":75}`) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, newConfigPostRequest(body)) if rec.Code != 200 { t.Fatalf("status: %d body=%s", rec.Code, rec.Body.String()) } } func TestConfigPatchFailedLiveUpdateDoesNotMutateSnapshot(t *testing.T) { cfg := cfgpkg.Default() srv := NewServer(cfg) srv.SetTXController(&fakeTXController{updateErr: errors.New("boom")}) body := []byte(`{"stereoMode":"SSB"}`) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, newConfigPostRequest(body)) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String()) } cfgRec := httptest.NewRecorder() srv.Handler().ServeHTTP(cfgRec, httptest.NewRequest(http.MethodGet, "/config", nil)) if cfgRec.Code != http.StatusOK { t.Fatalf("config status: %d", cfgRec.Code) } var got cfgpkg.Config if err := json.Unmarshal(cfgRec.Body.Bytes(), &got); err != nil { t.Fatalf("unmarshal config: %v", err) } if got.FM.StereoMode != cfg.FM.StereoMode { t.Fatalf("snapshot mutated on failed live update: got %q want %q", got.FM.StereoMode, cfg.FM.StereoMode) } } func TestConfigPatchRejectsOversizeBody(t *testing.T) { srv := NewServer(cfgpkg.Default()) rec := httptest.NewRecorder() payload := bytes.Repeat([]byte("x"), maxConfigBodyBytes+32) body := append([]byte(`{"ps":"`), payload...) body = append(body, []byte(`"}`)...) req := newConfigPostRequest(body) srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusRequestEntityTooLarge { t.Fatalf("expected 413, got %d response=%q", rec.Code, rec.Body.String()) } } func TestConfigPatchRejectsMissingContentType(t *testing.T) { srv := NewServer(cfgpkg.Default()) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader([]byte(`{}`))) srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnsupportedMediaType { t.Fatalf("expected 415 when Content-Type missing, got %d", rec.Code) } } func TestConfigPatchRejectsNonJSONContentType(t *testing.T) { srv := NewServer(cfgpkg.Default()) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader([]byte(`{}`))) req.Header.Set("Content-Type", "text/plain") srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnsupportedMediaType { t.Fatalf("expected 415 for non-JSON Content-Type, got %d", rec.Code) } } func TestIngestSavePersistsAndSchedulesReload(t *testing.T) { cfg := cfgpkg.Default() cfg.Ingest.Kind = "icecast" cfg.Ingest.Icecast.URL = "https://example.invalid/live" srv := NewServer(cfg) dir := t.TempDir() configPath := filepath.Join(dir, "saved.json") reloadDone := make(chan struct{}, 1) srv.SetConfigSaver(func(next cfgpkg.Config) error { return cfgpkg.Save(configPath, next) }) srv.SetHardReload(func() { select { case reloadDone <- struct{}{}: default: } }) nextIngest := cfgpkg.Default().Ingest nextIngest.Kind = "srt" nextIngest.PrebufferMs = 1000 nextIngest.StallTimeoutMs = 2500 nextIngest.Reconnect.Enabled = true nextIngest.Reconnect.InitialBackoffMs = 500 nextIngest.Reconnect.MaxBackoffMs = 5000 nextIngest.SRT.URL = "srt://0.0.0.0:9000?mode=listener" body, err := json.Marshal(IngestSaveRequest{Ingest: nextIngest}) if err != nil { t.Fatalf("marshal body: %v", err) } rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, newIngestSavePostRequest(body)) if rec.Code != http.StatusOK { t.Fatalf("status: %d body=%s", rec.Code, rec.Body.String()) } select { case <-reloadDone: case <-time.After(2 * time.Second): t.Fatal("expected hard reload callback") } saved, err := cfgpkg.Load(configPath) if err != nil { t.Fatalf("load saved config: %v", err) } if saved.Ingest.Kind != "srt" { t.Fatalf("expected saved ingest kind srt, got %q", saved.Ingest.Kind) } if saved.Ingest.SRT.URL != "srt://0.0.0.0:9000?mode=listener" { t.Fatalf("expected saved ingest.srt.url, got %q", saved.Ingest.SRT.URL) } } func TestIngestSaveRejectsWhenSaverMissing(t *testing.T) { cfg := cfgpkg.Default() cfg.Ingest.Kind = "icecast" cfg.Ingest.Icecast.URL = "https://example.invalid/live" srv := NewServer(cfg) rec := httptest.NewRecorder() nextIngest := cfgpkg.Default().Ingest nextIngest.Kind = "icecast" nextIngest.Icecast.URL = "https://example.invalid/live" body, err := json.Marshal(IngestSaveRequest{Ingest: nextIngest}) if err != nil { t.Fatalf("marshal body: %v", err) } srv.Handler().ServeHTTP(rec, newIngestSavePostRequest(body)) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503, got %d body=%s", rec.Code, rec.Body.String()) } } func TestIngestSaveUsesValidationErrors(t *testing.T) { cfg := cfgpkg.Default() cfg.Ingest.Kind = "icecast" cfg.Ingest.Icecast.URL = "https://example.invalid/live" srv := NewServer(cfg) dir := t.TempDir() configPath := filepath.Join(dir, "saved.json") srv.SetConfigSaver(func(next cfgpkg.Config) error { return cfgpkg.Save(configPath, next) }) rec := httptest.NewRecorder() nextIngest := cfgpkg.Default().Ingest nextIngest.Kind = "srt" nextIngest.SRT.URL = "" body, err := json.Marshal(IngestSaveRequest{Ingest: nextIngest}) if err != nil { t.Fatalf("marshal body: %v", err) } srv.Handler().ServeHTTP(rec, newIngestSavePostRequest(body)) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "ingest.srt.url is required") { t.Fatalf("expected existing validation error, got %q", rec.Body.String()) } if _, err := os.Stat(configPath); !errors.Is(err, os.ErrNotExist) { t.Fatalf("expected no config file to be written, stat err=%v", err) } } func TestRuntimeWithoutDriver(t *testing.T) { srv := NewServer(cfgpkg.Default()) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil)) if rec.Code != 200 { t.Fatalf("status: %d", rec.Code) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("unmarshal runtime: %v", err) } if _, ok := body["ingest"]; ok { t.Fatalf("expected ingest payload to be absent when ingest runtime is not configured") } if _, ok := body["engine"]; ok { t.Fatalf("expected engine payload to be absent when tx controller is not configured") } } func TestRuntimeIncludesIngestStats(t *testing.T) { srv := NewServer(cfgpkg.Default()) srv.SetIngestRuntime(&fakeIngestRuntime{ stats: ingest.Stats{ Active: ingest.SourceDescriptor{ID: "stdin-main", Kind: "stdin-pcm"}, Runtime: ingest.RuntimeStats{State: "running"}, }, }) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil)) if rec.Code != http.StatusOK { t.Fatalf("status: %d", rec.Code) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("unmarshal runtime: %v", err) } ingest, ok := body["ingest"].(map[string]any) if !ok { t.Fatalf("expected ingest stats, got %T", body["ingest"]) } active, ok := ingest["active"].(map[string]any) if !ok { t.Fatalf("expected ingest.active map, got %T", ingest["active"]) } if active["id"] != "stdin-main" { t.Fatalf("unexpected ingest active id: %v", active["id"]) } } func TestRuntimeIncludesDetailedIngestSourceAndRuntimeStats(t *testing.T) { srv := NewServer(cfgpkg.Default()) srv.SetIngestRuntime(&fakeIngestRuntime{ stats: ingest.Stats{ Active: ingest.SourceDescriptor{ ID: "icecast-main", Kind: "icecast", Origin: &ingest.SourceOrigin{ Kind: "url", Endpoint: "http://example.org/live", }, }, Source: ingest.SourceStats{ State: "reconnecting", Connected: false, Reconnects: 3, LastError: "dial tcp timeout", }, Runtime: ingest.RuntimeStats{ State: "degraded", ConvertErrors: 2, WriteBlocked: true, }, }, }) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil)) if rec.Code != http.StatusOK { t.Fatalf("status: %d", rec.Code) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("unmarshal runtime: %v", err) } ingestPayload, ok := body["ingest"].(map[string]any) if !ok { t.Fatalf("expected ingest payload map, got %T", body["ingest"]) } source, ok := ingestPayload["source"].(map[string]any) if !ok { t.Fatalf("expected ingest.source map, got %T", ingestPayload["source"]) } if source["state"] != "reconnecting" { t.Fatalf("source state mismatch: got %v", source["state"]) } if source["reconnects"] != float64(3) { t.Fatalf("source reconnects mismatch: got %v", source["reconnects"]) } if source["lastError"] != "dial tcp timeout" { t.Fatalf("source lastError mismatch: got %v", source["lastError"]) } active, ok := ingestPayload["active"].(map[string]any) if !ok { t.Fatalf("expected ingest.active map, got %T", ingestPayload["active"]) } origin, ok := active["origin"].(map[string]any) if !ok { t.Fatalf("expected ingest.active.origin map, got %T", active["origin"]) } if origin["kind"] != "url" { t.Fatalf("origin kind mismatch: got %v", origin["kind"]) } if origin["endpoint"] != "http://example.org/live" { t.Fatalf("origin endpoint mismatch: got %v", origin["endpoint"]) } runtimePayload, ok := ingestPayload["runtime"].(map[string]any) if !ok { t.Fatalf("expected ingest.runtime map, got %T", ingestPayload["runtime"]) } if runtimePayload["state"] != "degraded" { t.Fatalf("runtime state mismatch: got %v", runtimePayload["state"]) } if runtimePayload["convertErrors"] != float64(2) { t.Fatalf("runtime convertErrors mismatch: got %v", runtimePayload["convertErrors"]) } if runtimePayload["writeBlocked"] != true { t.Fatalf("runtime writeBlocked mismatch: got %v", runtimePayload["writeBlocked"]) } } func TestRuntimeOmitsEngineWhenControllerReturnsNilStats(t *testing.T) { srv := NewServer(cfgpkg.Default()) srv.SetTXController(&fakeTXController{returnNilStats: true}) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil)) if rec.Code != http.StatusOK { t.Fatalf("status: %d", rec.Code) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("unmarshal runtime: %v", err) } if _, ok := body["engine"]; ok { t.Fatalf("expected engine field to be omitted when TXStats returns nil") } } func TestRuntimeReportsFaultHistory(t *testing.T) { srv := NewServer(cfgpkg.Default()) history := []map[string]any{ { "time": "2026-04-06T00:00:00Z", "reason": "queueCritical", "severity": "faulted", "message": "queue critical", }, } srv.SetTXController(&fakeTXController{stats: map[string]any{"faultHistory": history}}) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil)) if rec.Code != 200 { t.Fatalf("status: %d", rec.Code) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("unmarshal runtime: %v", err) } engineRaw, ok := body["engine"].(map[string]any) if !ok { t.Fatalf("runtime engine missing") } histRaw, ok := engineRaw["faultHistory"].([]any) if !ok { t.Fatalf("faultHistory missing or wrong type: %T", engineRaw["faultHistory"]) } if len(histRaw) != len(history) { t.Fatalf("faultHistory length mismatch: want %d got %d", len(history), len(histRaw)) } } func TestRuntimeReportsTransitionHistory(t *testing.T) { srv := NewServer(cfgpkg.Default()) history := []map[string]any{{ "time": "2026-04-06T00:00:00Z", "from": "running", "to": "degraded", "severity": "warn", }} srv.SetTXController(&fakeTXController{stats: map[string]any{"transitionHistory": history}}) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil)) if rec.Code != 200 { t.Fatalf("status: %d", rec.Code) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("unmarshal runtime: %v", err) } engineRaw, ok := body["engine"].(map[string]any) if !ok { t.Fatalf("runtime engine missing") } histRaw, ok := engineRaw["transitionHistory"].([]any) if !ok { t.Fatalf("transitionHistory missing or wrong type: %T", engineRaw["transitionHistory"]) } if len(histRaw) != len(history) { t.Fatalf("transitionHistory length mismatch: want %d got %d", len(history), len(histRaw)) } } func TestRuntimeFaultResetRejectsGet(t *testing.T) { srv := NewServer(cfgpkg.Default()) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/runtime/fault/reset", nil) srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("expected 405 for fault reset GET, got %d", rec.Code) } } func TestRuntimeFaultResetRequiresController(t *testing.T) { srv := NewServer(cfgpkg.Default()) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil) srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503 without controller, got %d", rec.Code) } } func TestRuntimeFaultResetControllerError(t *testing.T) { srv := NewServer(cfgpkg.Default()) srv.SetTXController(&fakeTXController{resetErr: errors.New("boom")}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil) srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusConflict { t.Fatalf("expected 409 when controller rejects, got %d", rec.Code) } } func TestRuntimeFaultResetSuccess(t *testing.T) { srv := NewServer(cfgpkg.Default()) srv.SetTXController(&fakeTXController{}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil) srv.Handler().ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("expected 200 on success, got %d", rec.Code) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("unmarshal response: %v", err) } if ok, _ := body["ok"].(bool); !ok { t.Fatalf("expected ok true, got %v", body["ok"]) } } func TestRuntimeFaultResetRejectsBody(t *testing.T) { srv := NewServer(cfgpkg.Default()) srv.SetTXController(&fakeTXController{}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", bytes.NewReader([]byte("nope"))) srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 when body present, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "request must not include a body") { t.Fatalf("unexpected response body: %q", rec.Body.String()) } } func TestAudioStreamRequiresSource(t *testing.T) { srv := NewServer(cfgpkg.Default()) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(nil)) req.Header.Set("Content-Type", "application/octet-stream") srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503 when audio stream missing, got %d", rec.Code) } } func TestAudioStreamPushesPCM(t *testing.T) { cfg := cfgpkg.Default() srv := NewServer(cfg) ingress := &fakeAudioIngress{} srv.SetAudioIngress(ingress) pcm := []byte{0, 0, 0, 0} rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(pcm)) req.Header.Set("Content-Type", "application/octet-stream") srv.Handler().ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("expected 200, got %d", rec.Code) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("unmarshal response: %v", err) } if ok, _ := body["ok"].(bool); !ok { t.Fatalf("expected ok true, got %v", body["ok"]) } frames, _ := body["frames"].(float64) if frames != 1 { t.Fatalf("expected 1 frame, got %v", frames) } if ingress.totalFrames != 1 { t.Fatalf("expected ingress frames=1, got %d", ingress.totalFrames) } } func TestAudioStreamRejectsNonPost(t *testing.T) { srv := NewServer(cfgpkg.Default()) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/audio/stream", nil) srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("expected 405 for audio stream GET, got %d", rec.Code) } } func TestAudioStreamRejectsMissingContentType(t *testing.T) { cfg := cfgpkg.Default() srv := NewServer(cfg) srv.SetAudioIngress(&fakeAudioIngress{}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0})) srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnsupportedMediaType { t.Fatalf("expected 415 when Content-Type missing, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Content-Type must be") { t.Fatalf("unexpected response body: %q", rec.Body.String()) } } func TestAudioStreamRejectsUnsupportedContentType(t *testing.T) { cfg := cfgpkg.Default() srv := NewServer(cfg) srv.SetAudioIngress(&fakeAudioIngress{}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0})) req.Header.Set("Content-Type", "text/plain") srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnsupportedMediaType { t.Fatalf("expected 415 for unsupported Content-Type, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Content-Type must be") { t.Fatalf("unexpected response body: %q", rec.Body.String()) } } func TestAudioStreamRejectsBodyTooLarge(t *testing.T) { orig := audioStreamBodyLimit t.Cleanup(func() { audioStreamBodyLimit = orig }) audioStreamBodyLimit = 1024 limit := int(audioStreamBodyLimit) body := make([]byte, limit+1) srv := NewServer(cfgpkg.Default()) srv.SetAudioIngress(&fakeAudioIngress{}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/octet-stream") srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusRequestEntityTooLarge { t.Fatalf("expected 413 for oversized body, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "request body too large") { t.Fatalf("unexpected response body: %q", rec.Body.String()) } } func TestTXStartWithoutController(t *testing.T) { srv := NewServer(cfgpkg.Default()) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/tx/start", nil)) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503, got %d", rec.Code) } } func TestTXStartRejectsBody(t *testing.T) { srv := NewServer(cfgpkg.Default()) srv.SetTXController(&fakeTXController{}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/tx/start", bytes.NewReader([]byte("body"))) srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 when body present, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "request must not include a body") { t.Fatalf("unexpected response body: %q", rec.Body.String()) } } func TestTXStopRejectsBody(t *testing.T) { srv := NewServer(cfgpkg.Default()) srv.SetTXController(&fakeTXController{}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/tx/stop", bytes.NewReader([]byte("body"))) srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 when body present, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "request must not include a body") { t.Fatalf("unexpected response body: %q", rec.Body.String()) } } func TestConfigPatchUpdatesSnapshot(t *testing.T) { srv := NewServer(cfgpkg.Default()) srv.SetTXController(&fakeTXController{}) rec := httptest.NewRecorder() body := []byte(`{"outputDrive":1.2}`) srv.Handler().ServeHTTP(rec, newConfigPostRequest(body)) if rec.Code != 200 { t.Fatalf("status: %d", rec.Code) } var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal response: %v", err) } if live, ok := resp["live"].(bool); !ok || !live { t.Fatalf("expected live true, got %v", resp["live"]) } rec = httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/config", nil)) var cfg cfgpkg.Config if err := json.NewDecoder(rec.Body).Decode(&cfg); err != nil { t.Fatalf("decode config: %v", err) } if cfg.FM.OutputDrive != 1.2 { t.Fatalf("expected snapshot to reflect new drive, got %v", cfg.FM.OutputDrive) } } func TestConfigPatchPersistsRestartRequiredFieldsWhenSaverConfigured(t *testing.T) { cfg := cfgpkg.Default() srv := NewServer(cfg) dir := t.TempDir() configPath := filepath.Join(dir, "saved.json") if err := cfgpkg.Save(configPath, cfg); err != nil { t.Fatalf("seed config: %v", err) } srv.SetConfigSaver(func(next cfgpkg.Config) error { return cfgpkg.Save(configPath, next) }) rec := httptest.NewRecorder() body := []byte(`{ "preEmphasisTauUS":75, "audioGain":1.5, "pi":"BEEF", "pty":10, "ms":false, "ctEnabled":false, "rtPlusEnabled":false, "rtPlusSeparator":"/", "ptyn":"ALTROCK", "lps":"My Radio Station", "ertEnabled":true, "ert":"Grüezi mitenand", "rds2Enabled":true, "stationLogoPath":"C:\\logo.png", "af":[93.3,95.7], "bs412Enabled":true, "bs412ThresholdDBr":0.5, "mpxGain":1.3, "compositeClipperIterations":4, "compositeClipperSoftKnee":0.22, "compositeClipperLookaheadMs":1.4 }`) srv.Handler().ServeHTTP(rec, newConfigPostRequest(body)) if rec.Code != http.StatusOK { t.Fatalf("status: %d body=%s", rec.Code, rec.Body.String()) } saved, err := cfgpkg.Load(configPath) if err != nil { t.Fatalf("load saved config: %v", err) } if saved.FM.PreEmphasisTauUS != 75 { t.Fatalf("expected saved preEmphasisTauUS=75, got %v", saved.FM.PreEmphasisTauUS) } if saved.Audio.Gain != 1.5 { t.Fatalf("expected saved audio.gain=1.5, got %v", saved.Audio.Gain) } if saved.RDS.PI != "BEEF" { t.Fatalf("expected saved rds.pi=BEEF, got %q", saved.RDS.PI) } if saved.RDS.PTY != 10 { t.Fatalf("expected saved rds.pty=10, got %v", saved.RDS.PTY) } if saved.RDS.MS != false { t.Fatalf("expected saved rds.ms=false, got %v", saved.RDS.MS) } if saved.RDS.CTEnabled != false { t.Fatalf("expected saved rds.ctEnabled=false, got %v", saved.RDS.CTEnabled) } if saved.RDS.RTPlusEnabled != false { t.Fatalf("expected saved rds.rtPlusEnabled=false, got %v", saved.RDS.RTPlusEnabled) } if saved.RDS.RTPlusSeparator != "/" { t.Fatalf("expected saved rds.rtPlusSeparator='/', got %q", saved.RDS.RTPlusSeparator) } if saved.RDS.PTYN != "ALTROCK" { t.Fatalf("expected saved rds.ptyn=ALTROCK, got %q", saved.RDS.PTYN) } if saved.RDS.LPS != "My Radio Station" { t.Fatalf("expected saved rds.lps, got %q", saved.RDS.LPS) } if saved.RDS.ERTEnabled != true { t.Fatalf("expected saved rds.ertEnabled=true, got %v", saved.RDS.ERTEnabled) } if saved.RDS.ERT != "Grüezi mitenand" { t.Fatalf("expected saved rds.ert, got %q", saved.RDS.ERT) } if saved.RDS.RDS2Enabled != true { t.Fatalf("expected saved rds.rds2Enabled=true, got %v", saved.RDS.RDS2Enabled) } if saved.RDS.StationLogoPath != "C:\\logo.png" { t.Fatalf("expected saved rds.stationLogoPath, got %q", saved.RDS.StationLogoPath) } if len(saved.RDS.AF) != 2 || saved.RDS.AF[0] != 93.3 || saved.RDS.AF[1] != 95.7 { t.Fatalf("expected saved rds.af=[93.3 95.7], got %v", saved.RDS.AF) } if !saved.FM.BS412Enabled { t.Fatalf("expected saved bs412Enabled=true, got false") } if saved.FM.BS412ThresholdDBr != 0.5 { t.Fatalf("expected saved bs412ThresholdDBr=0.5, got %v", saved.FM.BS412ThresholdDBr) } if saved.FM.MpxGain != 1.3 { t.Fatalf("expected saved fm.mpxGain=1.3, got %v", saved.FM.MpxGain) } if saved.FM.CompositeClipper.Iterations != 4 { t.Fatalf("expected saved compositeClipper.iterations=4, got %v", saved.FM.CompositeClipper.Iterations) } if saved.FM.CompositeClipper.SoftKnee != 0.22 { t.Fatalf("expected saved compositeClipper.softKnee=0.22, got %v", saved.FM.CompositeClipper.SoftKnee) } if saved.FM.CompositeClipper.LookaheadMs != 1.4 { t.Fatalf("expected saved compositeClipper.lookaheadMs=1.4, got %v", saved.FM.CompositeClipper.LookaheadMs) } } func TestConfigPatchEngineRejectsDoesNotUpdateSnapshot(t *testing.T) { srv := NewServer(cfgpkg.Default()) srv.SetTXController(&fakeTXController{updateErr: errors.New("boom")}) body := []byte(`{"outputDrive":2.2}`) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, newConfigPostRequest(body)) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) } rec = httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/config", nil)) var cfg cfgpkg.Config if err := json.NewDecoder(rec.Body).Decode(&cfg); err != nil { t.Fatalf("decode config: %v", err) } if cfg.FM.OutputDrive != cfgpkg.Default().FM.OutputDrive { t.Fatalf("expected snapshot untouched, got %v", cfg.FM.OutputDrive) } } func TestRuntimeIncludesControlAudit(t *testing.T) { srv := NewServer(cfgpkg.Default()) counts := controlAuditCounts(t, srv) keys := []string{"methodNotAllowed", "unsupportedMediaType", "bodyTooLarge", "unexpectedBody"} for _, key := range keys { if counts[key] != 0 { t.Fatalf("expected %s to start at 0, got %d", key, counts[key]) } } } func TestControlAuditTracksMethodNotAllowed(t *testing.T) { srv := NewServer(cfgpkg.Default()) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/audio/stream", nil)) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("expected 405 from audio stream GET, got %d", rec.Code) } counts := controlAuditCounts(t, srv) if counts["methodNotAllowed"] != 1 { t.Fatalf("expected methodNotAllowed=1, got %d", counts["methodNotAllowed"]) } } func TestControlAuditTracksUnsupportedMediaType(t *testing.T) { srv := NewServer(cfgpkg.Default()) srv.SetAudioIngress(&fakeAudioIngress{}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0})) srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnsupportedMediaType { t.Fatalf("expected 415 for audio stream content type, got %d", rec.Code) } counts := controlAuditCounts(t, srv) if counts["unsupportedMediaType"] != 1 { t.Fatalf("expected unsupportedMediaType=1, got %d", counts["unsupportedMediaType"]) } } func TestControlAuditTracksBodyTooLarge(t *testing.T) { srv := NewServer(cfgpkg.Default()) limit := int(maxConfigBodyBytes) body := []byte("{\"ps\":\"" + strings.Repeat("x", limit+1) + "\"}") rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, newConfigPostRequest(body)) if rec.Code != http.StatusRequestEntityTooLarge { t.Fatalf("expected 413 for oversized config body, got %d", rec.Code) } counts := controlAuditCounts(t, srv) if counts["bodyTooLarge"] != 1 { t.Fatalf("expected bodyTooLarge=1, got %d", counts["bodyTooLarge"]) } } func TestControlAuditTracksUnexpectedBody(t *testing.T) { srv := NewServer(cfgpkg.Default()) srv.SetTXController(&fakeTXController{}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/tx/start", bytes.NewReader([]byte("body"))) srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 for unexpected body, got %d", rec.Code) } counts := controlAuditCounts(t, srv) if counts["unexpectedBody"] != 1 { t.Fatalf("expected unexpectedBody=1, got %d", counts["unexpectedBody"]) } } func controlAuditCounts(t *testing.T, srv *Server) map[string]uint64 { t.Helper() rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil)) if rec.Code != http.StatusOK { t.Fatalf("runtime request failed: %d", rec.Code) } var payload map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("unmarshal runtime: %v", err) } raw, ok := payload["controlAudit"].(map[string]any) if !ok { t.Fatalf("controlAudit missing or wrong type: %T", payload["controlAudit"]) } counts := map[string]uint64{} for key, value := range raw { num, ok := value.(float64) if !ok { t.Fatalf("controlAudit %s not numeric: %T", key, value) } counts[key] = uint64(num) } return counts } func newConfigPostRequest(body []byte) *http.Request { req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") return req } func newIngestSavePostRequest(body []byte) *http.Request { req := httptest.NewRequest(http.MethodPost, "/config/ingest/save", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") return req } type fakeTXController struct { updateErr error resetErr error stats map[string]any returnNilStats bool } type fakeAudioIngress struct { totalFrames int } type fakeIngestRuntime struct { stats ingest.Stats } func (f *fakeAudioIngress) WritePCM16(data []byte) (int, error) { frames := len(data) / 4 f.totalFrames += frames return frames, nil } func (f *fakeIngestRuntime) Stats() ingest.Stats { return f.stats } func (f *fakeTXController) StartTX() error { return nil } func (f *fakeTXController) StopTX() error { return nil } func (f *fakeTXController) TXStats() map[string]any { if f.returnNilStats { return nil } if f.stats != nil { return f.stats } return map[string]any{} } func (f *fakeTXController) UpdateConfig(_ LivePatch) error { return f.updateErr } func (f *fakeTXController) ResetFault() error { return f.resetErr }