package control import ( "bytes" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" cfgpkg "github.com/jan/fm-rds-tx/internal/config" "github.com/jan/fm-rds-tx/internal/audio" "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 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, httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body))) if rec.Code != 200 { t.Fatalf("status: %d body=%s", rec.Code, rec.Body.String()) } } 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) } } 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 TestAudioStreamRequiresSource(t *testing.T) { srv := NewServer(cfgpkg.Default()) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(nil)) 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) stream := audio.NewStreamSource(256, 44100) srv.SetStreamSource(stream) pcm := []byte{0, 0, 0, 0} rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(pcm)) 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) } stats, ok := body["stats"].(map[string]any) if !ok { t.Fatalf("missing stats: %v", body["stats"]) } if avail, _ := stats["available"].(float64); avail < 1 { t.Fatalf("expected stats.available >= 1, got %v", avail) } } 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 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 TestConfigPatchUpdatesSnapshot(t *testing.T) { srv := NewServer(cfgpkg.Default()) srv.SetTXController(&fakeTXController{}) rec := httptest.NewRecorder() body := []byte(`{"outputDrive":1.2}`) srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(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 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, httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(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) } } type fakeTXController struct { updateErr error resetErr error stats map[string]any } func (f *fakeTXController) StartTX() error { return nil } func (f *fakeTXController) StopTX() error { return nil } func (f *fakeTXController) TXStats() map[string]any { 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 }