|
- package control
-
- import (
- "bytes"
- "encoding/json"
- "errors"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-
- 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 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 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"},
- 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"])
- }
- 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 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
- }
-
- 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 }
|