Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.

910 líneas
29KB

  1. package control
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "errors"
  6. "net/http"
  7. "net/http/httptest"
  8. "os"
  9. "path/filepath"
  10. "strings"
  11. "testing"
  12. "time"
  13. cfgpkg "github.com/jan/fm-rds-tx/internal/config"
  14. "github.com/jan/fm-rds-tx/internal/ingest"
  15. "github.com/jan/fm-rds-tx/internal/output"
  16. )
  17. func TestHealthz(t *testing.T) {
  18. srv := NewServer(cfgpkg.Default())
  19. rec := httptest.NewRecorder()
  20. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
  21. if rec.Code != 200 {
  22. t.Fatalf("status: %d", rec.Code)
  23. }
  24. }
  25. func TestStatus(t *testing.T) {
  26. srv := NewServer(cfgpkg.Default())
  27. rec := httptest.NewRecorder()
  28. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
  29. if rec.Code != 200 {
  30. t.Fatalf("status: %d", rec.Code)
  31. }
  32. var body map[string]any
  33. json.Unmarshal(rec.Body.Bytes(), &body)
  34. if body["service"] != "fm-rds-tx" {
  35. t.Fatal("missing service")
  36. }
  37. if _, ok := body["preEmphasisTauUS"]; !ok {
  38. t.Fatal("missing preEmphasisTauUS")
  39. }
  40. }
  41. func TestStatusReportsRuntimeIndicator(t *testing.T) {
  42. srv := NewServer(cfgpkg.Default())
  43. srv.SetTXController(&fakeTXController{stats: map[string]any{"runtimeIndicator": "degraded", "runtimeAlert": "late buffers"}})
  44. rec := httptest.NewRecorder()
  45. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
  46. if rec.Code != 200 {
  47. t.Fatalf("status: %d", rec.Code)
  48. }
  49. var body map[string]any
  50. json.Unmarshal(rec.Body.Bytes(), &body)
  51. if body["runtimeIndicator"] != "degraded" {
  52. t.Fatalf("expected runtimeIndicator degraded, got %v", body["runtimeIndicator"])
  53. }
  54. if body["runtimeAlert"] != "late buffers" {
  55. t.Fatalf("expected runtimeAlert late buffers, got %v", body["runtimeAlert"])
  56. }
  57. }
  58. func TestStatusReportsQueueStats(t *testing.T) {
  59. cfg := cfgpkg.Default()
  60. queueStats := output.QueueStats{
  61. Capacity: cfg.Runtime.FrameQueueCapacity,
  62. Depth: 1,
  63. FillLevel: 0.25,
  64. Health: output.QueueHealthLow,
  65. }
  66. srv := NewServer(cfg)
  67. srv.SetTXController(&fakeTXController{stats: map[string]any{"queue": queueStats}})
  68. rec := httptest.NewRecorder()
  69. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
  70. if rec.Code != 200 {
  71. t.Fatalf("status: %d", rec.Code)
  72. }
  73. var body map[string]any
  74. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  75. t.Fatalf("unmarshal queue stats: %v", err)
  76. }
  77. queueRaw, ok := body["queue"]
  78. if !ok {
  79. t.Fatalf("missing queue in status")
  80. }
  81. queueMap, ok := queueRaw.(map[string]any)
  82. if !ok {
  83. t.Fatalf("queue stats type mismatch: %T", queueRaw)
  84. }
  85. if queueMap["capacity"] != float64(queueStats.Capacity) {
  86. t.Fatalf("queue capacity mismatch: want %v got %v", queueStats.Capacity, queueMap["capacity"])
  87. }
  88. if queueMap["health"] != string(queueStats.Health) {
  89. t.Fatalf("queue health mismatch: want %s got %v", queueStats.Health, queueMap["health"])
  90. }
  91. }
  92. func TestStatusReportsRuntimeState(t *testing.T) {
  93. srv := NewServer(cfgpkg.Default())
  94. srv.SetTXController(&fakeTXController{stats: map[string]any{"state": "faulted"}})
  95. rec := httptest.NewRecorder()
  96. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
  97. if rec.Code != 200 {
  98. t.Fatalf("status: %d", rec.Code)
  99. }
  100. var body map[string]any
  101. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  102. t.Fatalf("unmarshal runtime state: %v", err)
  103. }
  104. if body["runtimeState"] != "faulted" {
  105. t.Fatalf("expected runtimeState faulted, got %v", body["runtimeState"])
  106. }
  107. }
  108. func TestDryRunEndpoint(t *testing.T) {
  109. srv := NewServer(cfgpkg.Default())
  110. rec := httptest.NewRecorder()
  111. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/dry-run", nil))
  112. if rec.Code != 200 {
  113. t.Fatalf("status: %d", rec.Code)
  114. }
  115. var body map[string]any
  116. json.Unmarshal(rec.Body.Bytes(), &body)
  117. if body["mode"] != "dry-run" {
  118. t.Fatal("wrong mode")
  119. }
  120. }
  121. func TestConfigPatch(t *testing.T) {
  122. srv := NewServer(cfgpkg.Default())
  123. body := []byte(`{"toneLeftHz":900,"radioText":"hello world","preEmphasisTauUS":75}`)
  124. rec := httptest.NewRecorder()
  125. srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
  126. if rec.Code != 200 {
  127. t.Fatalf("status: %d body=%s", rec.Code, rec.Body.String())
  128. }
  129. }
  130. func TestConfigPatchFailedLiveUpdateDoesNotMutateSnapshot(t *testing.T) {
  131. cfg := cfgpkg.Default()
  132. srv := NewServer(cfg)
  133. srv.SetTXController(&fakeTXController{updateErr: errors.New("boom")})
  134. body := []byte(`{"stereoMode":"SSB"}`)
  135. rec := httptest.NewRecorder()
  136. srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
  137. if rec.Code != http.StatusBadRequest {
  138. t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
  139. }
  140. cfgRec := httptest.NewRecorder()
  141. srv.Handler().ServeHTTP(cfgRec, httptest.NewRequest(http.MethodGet, "/config", nil))
  142. if cfgRec.Code != http.StatusOK {
  143. t.Fatalf("config status: %d", cfgRec.Code)
  144. }
  145. var got cfgpkg.Config
  146. if err := json.Unmarshal(cfgRec.Body.Bytes(), &got); err != nil {
  147. t.Fatalf("unmarshal config: %v", err)
  148. }
  149. if got.FM.StereoMode != cfg.FM.StereoMode {
  150. t.Fatalf("snapshot mutated on failed live update: got %q want %q", got.FM.StereoMode, cfg.FM.StereoMode)
  151. }
  152. }
  153. func TestConfigPatchRejectsOversizeBody(t *testing.T) {
  154. srv := NewServer(cfgpkg.Default())
  155. rec := httptest.NewRecorder()
  156. payload := bytes.Repeat([]byte("x"), maxConfigBodyBytes+32)
  157. body := append([]byte(`{"ps":"`), payload...)
  158. body = append(body, []byte(`"}`)...)
  159. req := newConfigPostRequest(body)
  160. srv.Handler().ServeHTTP(rec, req)
  161. if rec.Code != http.StatusRequestEntityTooLarge {
  162. t.Fatalf("expected 413, got %d response=%q", rec.Code, rec.Body.String())
  163. }
  164. }
  165. func TestConfigPatchRejectsMissingContentType(t *testing.T) {
  166. srv := NewServer(cfgpkg.Default())
  167. rec := httptest.NewRecorder()
  168. req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader([]byte(`{}`)))
  169. srv.Handler().ServeHTTP(rec, req)
  170. if rec.Code != http.StatusUnsupportedMediaType {
  171. t.Fatalf("expected 415 when Content-Type missing, got %d", rec.Code)
  172. }
  173. }
  174. func TestConfigPatchRejectsNonJSONContentType(t *testing.T) {
  175. srv := NewServer(cfgpkg.Default())
  176. rec := httptest.NewRecorder()
  177. req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader([]byte(`{}`)))
  178. req.Header.Set("Content-Type", "text/plain")
  179. srv.Handler().ServeHTTP(rec, req)
  180. if rec.Code != http.StatusUnsupportedMediaType {
  181. t.Fatalf("expected 415 for non-JSON Content-Type, got %d", rec.Code)
  182. }
  183. }
  184. func TestIngestSavePersistsAndSchedulesReload(t *testing.T) {
  185. cfg := cfgpkg.Default()
  186. cfg.Ingest.Kind = "icecast"
  187. cfg.Ingest.Icecast.URL = "https://example.invalid/live"
  188. srv := NewServer(cfg)
  189. dir := t.TempDir()
  190. configPath := filepath.Join(dir, "saved.json")
  191. reloadDone := make(chan struct{}, 1)
  192. srv.SetConfigSaver(func(next cfgpkg.Config) error {
  193. return cfgpkg.Save(configPath, next)
  194. })
  195. srv.SetHardReload(func() {
  196. select {
  197. case reloadDone <- struct{}{}:
  198. default:
  199. }
  200. })
  201. nextIngest := cfgpkg.Default().Ingest
  202. nextIngest.Kind = "srt"
  203. nextIngest.PrebufferMs = 1000
  204. nextIngest.StallTimeoutMs = 2500
  205. nextIngest.Reconnect.Enabled = true
  206. nextIngest.Reconnect.InitialBackoffMs = 500
  207. nextIngest.Reconnect.MaxBackoffMs = 5000
  208. nextIngest.SRT.URL = "srt://0.0.0.0:9000?mode=listener"
  209. body, err := json.Marshal(IngestSaveRequest{Ingest: nextIngest})
  210. if err != nil {
  211. t.Fatalf("marshal body: %v", err)
  212. }
  213. rec := httptest.NewRecorder()
  214. srv.Handler().ServeHTTP(rec, newIngestSavePostRequest(body))
  215. if rec.Code != http.StatusOK {
  216. t.Fatalf("status: %d body=%s", rec.Code, rec.Body.String())
  217. }
  218. select {
  219. case <-reloadDone:
  220. case <-time.After(2 * time.Second):
  221. t.Fatal("expected hard reload callback")
  222. }
  223. saved, err := cfgpkg.Load(configPath)
  224. if err != nil {
  225. t.Fatalf("load saved config: %v", err)
  226. }
  227. if saved.Ingest.Kind != "srt" {
  228. t.Fatalf("expected saved ingest kind srt, got %q", saved.Ingest.Kind)
  229. }
  230. if saved.Ingest.SRT.URL != "srt://0.0.0.0:9000?mode=listener" {
  231. t.Fatalf("expected saved ingest.srt.url, got %q", saved.Ingest.SRT.URL)
  232. }
  233. }
  234. func TestIngestSaveRejectsWhenSaverMissing(t *testing.T) {
  235. cfg := cfgpkg.Default()
  236. cfg.Ingest.Kind = "icecast"
  237. cfg.Ingest.Icecast.URL = "https://example.invalid/live"
  238. srv := NewServer(cfg)
  239. rec := httptest.NewRecorder()
  240. nextIngest := cfgpkg.Default().Ingest
  241. nextIngest.Kind = "icecast"
  242. nextIngest.Icecast.URL = "https://example.invalid/live"
  243. body, err := json.Marshal(IngestSaveRequest{Ingest: nextIngest})
  244. if err != nil {
  245. t.Fatalf("marshal body: %v", err)
  246. }
  247. srv.Handler().ServeHTTP(rec, newIngestSavePostRequest(body))
  248. if rec.Code != http.StatusServiceUnavailable {
  249. t.Fatalf("expected 503, got %d body=%s", rec.Code, rec.Body.String())
  250. }
  251. }
  252. func TestIngestSaveUsesValidationErrors(t *testing.T) {
  253. cfg := cfgpkg.Default()
  254. cfg.Ingest.Kind = "icecast"
  255. cfg.Ingest.Icecast.URL = "https://example.invalid/live"
  256. srv := NewServer(cfg)
  257. dir := t.TempDir()
  258. configPath := filepath.Join(dir, "saved.json")
  259. srv.SetConfigSaver(func(next cfgpkg.Config) error {
  260. return cfgpkg.Save(configPath, next)
  261. })
  262. rec := httptest.NewRecorder()
  263. nextIngest := cfgpkg.Default().Ingest
  264. nextIngest.Kind = "srt"
  265. nextIngest.SRT.URL = ""
  266. body, err := json.Marshal(IngestSaveRequest{Ingest: nextIngest})
  267. if err != nil {
  268. t.Fatalf("marshal body: %v", err)
  269. }
  270. srv.Handler().ServeHTTP(rec, newIngestSavePostRequest(body))
  271. if rec.Code != http.StatusBadRequest {
  272. t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
  273. }
  274. if !strings.Contains(rec.Body.String(), "ingest.srt.url is required") {
  275. t.Fatalf("expected existing validation error, got %q", rec.Body.String())
  276. }
  277. if _, err := os.Stat(configPath); !errors.Is(err, os.ErrNotExist) {
  278. t.Fatalf("expected no config file to be written, stat err=%v", err)
  279. }
  280. }
  281. func TestRuntimeWithoutDriver(t *testing.T) {
  282. srv := NewServer(cfgpkg.Default())
  283. rec := httptest.NewRecorder()
  284. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  285. if rec.Code != 200 {
  286. t.Fatalf("status: %d", rec.Code)
  287. }
  288. var body map[string]any
  289. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  290. t.Fatalf("unmarshal runtime: %v", err)
  291. }
  292. if _, ok := body["ingest"]; ok {
  293. t.Fatalf("expected ingest payload to be absent when ingest runtime is not configured")
  294. }
  295. if _, ok := body["engine"]; ok {
  296. t.Fatalf("expected engine payload to be absent when tx controller is not configured")
  297. }
  298. }
  299. func TestRuntimeIncludesIngestStats(t *testing.T) {
  300. srv := NewServer(cfgpkg.Default())
  301. srv.SetIngestRuntime(&fakeIngestRuntime{
  302. stats: ingest.Stats{
  303. Active: ingest.SourceDescriptor{ID: "stdin-main", Kind: "stdin-pcm"},
  304. Runtime: ingest.RuntimeStats{State: "running"},
  305. },
  306. })
  307. rec := httptest.NewRecorder()
  308. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  309. if rec.Code != http.StatusOK {
  310. t.Fatalf("status: %d", rec.Code)
  311. }
  312. var body map[string]any
  313. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  314. t.Fatalf("unmarshal runtime: %v", err)
  315. }
  316. ingest, ok := body["ingest"].(map[string]any)
  317. if !ok {
  318. t.Fatalf("expected ingest stats, got %T", body["ingest"])
  319. }
  320. active, ok := ingest["active"].(map[string]any)
  321. if !ok {
  322. t.Fatalf("expected ingest.active map, got %T", ingest["active"])
  323. }
  324. if active["id"] != "stdin-main" {
  325. t.Fatalf("unexpected ingest active id: %v", active["id"])
  326. }
  327. }
  328. func TestRuntimeIncludesDetailedIngestSourceAndRuntimeStats(t *testing.T) {
  329. srv := NewServer(cfgpkg.Default())
  330. srv.SetIngestRuntime(&fakeIngestRuntime{
  331. stats: ingest.Stats{
  332. Active: ingest.SourceDescriptor{
  333. ID: "icecast-main",
  334. Kind: "icecast",
  335. Origin: &ingest.SourceOrigin{
  336. Kind: "url",
  337. Endpoint: "http://example.org/live",
  338. },
  339. },
  340. Source: ingest.SourceStats{
  341. State: "reconnecting",
  342. Connected: false,
  343. Reconnects: 3,
  344. LastError: "dial tcp timeout",
  345. },
  346. Runtime: ingest.RuntimeStats{
  347. State: "degraded",
  348. ConvertErrors: 2,
  349. WriteBlocked: true,
  350. },
  351. },
  352. })
  353. rec := httptest.NewRecorder()
  354. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  355. if rec.Code != http.StatusOK {
  356. t.Fatalf("status: %d", rec.Code)
  357. }
  358. var body map[string]any
  359. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  360. t.Fatalf("unmarshal runtime: %v", err)
  361. }
  362. ingestPayload, ok := body["ingest"].(map[string]any)
  363. if !ok {
  364. t.Fatalf("expected ingest payload map, got %T", body["ingest"])
  365. }
  366. source, ok := ingestPayload["source"].(map[string]any)
  367. if !ok {
  368. t.Fatalf("expected ingest.source map, got %T", ingestPayload["source"])
  369. }
  370. if source["state"] != "reconnecting" {
  371. t.Fatalf("source state mismatch: got %v", source["state"])
  372. }
  373. if source["reconnects"] != float64(3) {
  374. t.Fatalf("source reconnects mismatch: got %v", source["reconnects"])
  375. }
  376. if source["lastError"] != "dial tcp timeout" {
  377. t.Fatalf("source lastError mismatch: got %v", source["lastError"])
  378. }
  379. active, ok := ingestPayload["active"].(map[string]any)
  380. if !ok {
  381. t.Fatalf("expected ingest.active map, got %T", ingestPayload["active"])
  382. }
  383. origin, ok := active["origin"].(map[string]any)
  384. if !ok {
  385. t.Fatalf("expected ingest.active.origin map, got %T", active["origin"])
  386. }
  387. if origin["kind"] != "url" {
  388. t.Fatalf("origin kind mismatch: got %v", origin["kind"])
  389. }
  390. if origin["endpoint"] != "http://example.org/live" {
  391. t.Fatalf("origin endpoint mismatch: got %v", origin["endpoint"])
  392. }
  393. runtimePayload, ok := ingestPayload["runtime"].(map[string]any)
  394. if !ok {
  395. t.Fatalf("expected ingest.runtime map, got %T", ingestPayload["runtime"])
  396. }
  397. if runtimePayload["state"] != "degraded" {
  398. t.Fatalf("runtime state mismatch: got %v", runtimePayload["state"])
  399. }
  400. if runtimePayload["convertErrors"] != float64(2) {
  401. t.Fatalf("runtime convertErrors mismatch: got %v", runtimePayload["convertErrors"])
  402. }
  403. if runtimePayload["writeBlocked"] != true {
  404. t.Fatalf("runtime writeBlocked mismatch: got %v", runtimePayload["writeBlocked"])
  405. }
  406. }
  407. func TestRuntimeOmitsEngineWhenControllerReturnsNilStats(t *testing.T) {
  408. srv := NewServer(cfgpkg.Default())
  409. srv.SetTXController(&fakeTXController{returnNilStats: true})
  410. rec := httptest.NewRecorder()
  411. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  412. if rec.Code != http.StatusOK {
  413. t.Fatalf("status: %d", rec.Code)
  414. }
  415. var body map[string]any
  416. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  417. t.Fatalf("unmarshal runtime: %v", err)
  418. }
  419. if _, ok := body["engine"]; ok {
  420. t.Fatalf("expected engine field to be omitted when TXStats returns nil")
  421. }
  422. }
  423. func TestRuntimeReportsFaultHistory(t *testing.T) {
  424. srv := NewServer(cfgpkg.Default())
  425. history := []map[string]any{
  426. {
  427. "time": "2026-04-06T00:00:00Z",
  428. "reason": "queueCritical",
  429. "severity": "faulted",
  430. "message": "queue critical",
  431. },
  432. }
  433. srv.SetTXController(&fakeTXController{stats: map[string]any{"faultHistory": history}})
  434. rec := httptest.NewRecorder()
  435. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  436. if rec.Code != 200 {
  437. t.Fatalf("status: %d", rec.Code)
  438. }
  439. var body map[string]any
  440. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  441. t.Fatalf("unmarshal runtime: %v", err)
  442. }
  443. engineRaw, ok := body["engine"].(map[string]any)
  444. if !ok {
  445. t.Fatalf("runtime engine missing")
  446. }
  447. histRaw, ok := engineRaw["faultHistory"].([]any)
  448. if !ok {
  449. t.Fatalf("faultHistory missing or wrong type: %T", engineRaw["faultHistory"])
  450. }
  451. if len(histRaw) != len(history) {
  452. t.Fatalf("faultHistory length mismatch: want %d got %d", len(history), len(histRaw))
  453. }
  454. }
  455. func TestRuntimeReportsTransitionHistory(t *testing.T) {
  456. srv := NewServer(cfgpkg.Default())
  457. history := []map[string]any{{
  458. "time": "2026-04-06T00:00:00Z",
  459. "from": "running",
  460. "to": "degraded",
  461. "severity": "warn",
  462. }}
  463. srv.SetTXController(&fakeTXController{stats: map[string]any{"transitionHistory": history}})
  464. rec := httptest.NewRecorder()
  465. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  466. if rec.Code != 200 {
  467. t.Fatalf("status: %d", rec.Code)
  468. }
  469. var body map[string]any
  470. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  471. t.Fatalf("unmarshal runtime: %v", err)
  472. }
  473. engineRaw, ok := body["engine"].(map[string]any)
  474. if !ok {
  475. t.Fatalf("runtime engine missing")
  476. }
  477. histRaw, ok := engineRaw["transitionHistory"].([]any)
  478. if !ok {
  479. t.Fatalf("transitionHistory missing or wrong type: %T", engineRaw["transitionHistory"])
  480. }
  481. if len(histRaw) != len(history) {
  482. t.Fatalf("transitionHistory length mismatch: want %d got %d", len(history), len(histRaw))
  483. }
  484. }
  485. func TestRuntimeFaultResetRejectsGet(t *testing.T) {
  486. srv := NewServer(cfgpkg.Default())
  487. rec := httptest.NewRecorder()
  488. req := httptest.NewRequest(http.MethodGet, "/runtime/fault/reset", nil)
  489. srv.Handler().ServeHTTP(rec, req)
  490. if rec.Code != http.StatusMethodNotAllowed {
  491. t.Fatalf("expected 405 for fault reset GET, got %d", rec.Code)
  492. }
  493. }
  494. func TestRuntimeFaultResetRequiresController(t *testing.T) {
  495. srv := NewServer(cfgpkg.Default())
  496. rec := httptest.NewRecorder()
  497. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil)
  498. srv.Handler().ServeHTTP(rec, req)
  499. if rec.Code != http.StatusServiceUnavailable {
  500. t.Fatalf("expected 503 without controller, got %d", rec.Code)
  501. }
  502. }
  503. func TestRuntimeFaultResetControllerError(t *testing.T) {
  504. srv := NewServer(cfgpkg.Default())
  505. srv.SetTXController(&fakeTXController{resetErr: errors.New("boom")})
  506. rec := httptest.NewRecorder()
  507. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil)
  508. srv.Handler().ServeHTTP(rec, req)
  509. if rec.Code != http.StatusConflict {
  510. t.Fatalf("expected 409 when controller rejects, got %d", rec.Code)
  511. }
  512. }
  513. func TestRuntimeFaultResetSuccess(t *testing.T) {
  514. srv := NewServer(cfgpkg.Default())
  515. srv.SetTXController(&fakeTXController{})
  516. rec := httptest.NewRecorder()
  517. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil)
  518. srv.Handler().ServeHTTP(rec, req)
  519. if rec.Code != 200 {
  520. t.Fatalf("expected 200 on success, got %d", rec.Code)
  521. }
  522. var body map[string]any
  523. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  524. t.Fatalf("unmarshal response: %v", err)
  525. }
  526. if ok, _ := body["ok"].(bool); !ok {
  527. t.Fatalf("expected ok true, got %v", body["ok"])
  528. }
  529. }
  530. func TestRuntimeFaultResetRejectsBody(t *testing.T) {
  531. srv := NewServer(cfgpkg.Default())
  532. srv.SetTXController(&fakeTXController{})
  533. rec := httptest.NewRecorder()
  534. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", bytes.NewReader([]byte("nope")))
  535. srv.Handler().ServeHTTP(rec, req)
  536. if rec.Code != http.StatusBadRequest {
  537. t.Fatalf("expected 400 when body present, got %d", rec.Code)
  538. }
  539. if !strings.Contains(rec.Body.String(), "request must not include a body") {
  540. t.Fatalf("unexpected response body: %q", rec.Body.String())
  541. }
  542. }
  543. func TestAudioStreamRequiresSource(t *testing.T) {
  544. srv := NewServer(cfgpkg.Default())
  545. rec := httptest.NewRecorder()
  546. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(nil))
  547. req.Header.Set("Content-Type", "application/octet-stream")
  548. srv.Handler().ServeHTTP(rec, req)
  549. if rec.Code != http.StatusServiceUnavailable {
  550. t.Fatalf("expected 503 when audio stream missing, got %d", rec.Code)
  551. }
  552. }
  553. func TestAudioStreamPushesPCM(t *testing.T) {
  554. cfg := cfgpkg.Default()
  555. srv := NewServer(cfg)
  556. ingress := &fakeAudioIngress{}
  557. srv.SetAudioIngress(ingress)
  558. pcm := []byte{0, 0, 0, 0}
  559. rec := httptest.NewRecorder()
  560. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(pcm))
  561. req.Header.Set("Content-Type", "application/octet-stream")
  562. srv.Handler().ServeHTTP(rec, req)
  563. if rec.Code != 200 {
  564. t.Fatalf("expected 200, got %d", rec.Code)
  565. }
  566. var body map[string]any
  567. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  568. t.Fatalf("unmarshal response: %v", err)
  569. }
  570. if ok, _ := body["ok"].(bool); !ok {
  571. t.Fatalf("expected ok true, got %v", body["ok"])
  572. }
  573. frames, _ := body["frames"].(float64)
  574. if frames != 1 {
  575. t.Fatalf("expected 1 frame, got %v", frames)
  576. }
  577. if ingress.totalFrames != 1 {
  578. t.Fatalf("expected ingress frames=1, got %d", ingress.totalFrames)
  579. }
  580. }
  581. func TestAudioStreamRejectsNonPost(t *testing.T) {
  582. srv := NewServer(cfgpkg.Default())
  583. rec := httptest.NewRecorder()
  584. req := httptest.NewRequest(http.MethodGet, "/audio/stream", nil)
  585. srv.Handler().ServeHTTP(rec, req)
  586. if rec.Code != http.StatusMethodNotAllowed {
  587. t.Fatalf("expected 405 for audio stream GET, got %d", rec.Code)
  588. }
  589. }
  590. func TestAudioStreamRejectsMissingContentType(t *testing.T) {
  591. cfg := cfgpkg.Default()
  592. srv := NewServer(cfg)
  593. srv.SetAudioIngress(&fakeAudioIngress{})
  594. rec := httptest.NewRecorder()
  595. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0}))
  596. srv.Handler().ServeHTTP(rec, req)
  597. if rec.Code != http.StatusUnsupportedMediaType {
  598. t.Fatalf("expected 415 when Content-Type missing, got %d", rec.Code)
  599. }
  600. if !strings.Contains(rec.Body.String(), "Content-Type must be") {
  601. t.Fatalf("unexpected response body: %q", rec.Body.String())
  602. }
  603. }
  604. func TestAudioStreamRejectsUnsupportedContentType(t *testing.T) {
  605. cfg := cfgpkg.Default()
  606. srv := NewServer(cfg)
  607. srv.SetAudioIngress(&fakeAudioIngress{})
  608. rec := httptest.NewRecorder()
  609. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0}))
  610. req.Header.Set("Content-Type", "text/plain")
  611. srv.Handler().ServeHTTP(rec, req)
  612. if rec.Code != http.StatusUnsupportedMediaType {
  613. t.Fatalf("expected 415 for unsupported Content-Type, got %d", rec.Code)
  614. }
  615. if !strings.Contains(rec.Body.String(), "Content-Type must be") {
  616. t.Fatalf("unexpected response body: %q", rec.Body.String())
  617. }
  618. }
  619. func TestAudioStreamRejectsBodyTooLarge(t *testing.T) {
  620. orig := audioStreamBodyLimit
  621. t.Cleanup(func() {
  622. audioStreamBodyLimit = orig
  623. })
  624. audioStreamBodyLimit = 1024
  625. limit := int(audioStreamBodyLimit)
  626. body := make([]byte, limit+1)
  627. srv := NewServer(cfgpkg.Default())
  628. srv.SetAudioIngress(&fakeAudioIngress{})
  629. rec := httptest.NewRecorder()
  630. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(body))
  631. req.Header.Set("Content-Type", "application/octet-stream")
  632. srv.Handler().ServeHTTP(rec, req)
  633. if rec.Code != http.StatusRequestEntityTooLarge {
  634. t.Fatalf("expected 413 for oversized body, got %d", rec.Code)
  635. }
  636. if !strings.Contains(rec.Body.String(), "request body too large") {
  637. t.Fatalf("unexpected response body: %q", rec.Body.String())
  638. }
  639. }
  640. func TestTXStartWithoutController(t *testing.T) {
  641. srv := NewServer(cfgpkg.Default())
  642. rec := httptest.NewRecorder()
  643. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/tx/start", nil))
  644. if rec.Code != http.StatusServiceUnavailable {
  645. t.Fatalf("expected 503, got %d", rec.Code)
  646. }
  647. }
  648. func TestTXStartRejectsBody(t *testing.T) {
  649. srv := NewServer(cfgpkg.Default())
  650. srv.SetTXController(&fakeTXController{})
  651. rec := httptest.NewRecorder()
  652. req := httptest.NewRequest(http.MethodPost, "/tx/start", bytes.NewReader([]byte("body")))
  653. srv.Handler().ServeHTTP(rec, req)
  654. if rec.Code != http.StatusBadRequest {
  655. t.Fatalf("expected 400 when body present, got %d", rec.Code)
  656. }
  657. if !strings.Contains(rec.Body.String(), "request must not include a body") {
  658. t.Fatalf("unexpected response body: %q", rec.Body.String())
  659. }
  660. }
  661. func TestTXStopRejectsBody(t *testing.T) {
  662. srv := NewServer(cfgpkg.Default())
  663. srv.SetTXController(&fakeTXController{})
  664. rec := httptest.NewRecorder()
  665. req := httptest.NewRequest(http.MethodPost, "/tx/stop", bytes.NewReader([]byte("body")))
  666. srv.Handler().ServeHTTP(rec, req)
  667. if rec.Code != http.StatusBadRequest {
  668. t.Fatalf("expected 400 when body present, got %d", rec.Code)
  669. }
  670. if !strings.Contains(rec.Body.String(), "request must not include a body") {
  671. t.Fatalf("unexpected response body: %q", rec.Body.String())
  672. }
  673. }
  674. func TestConfigPatchUpdatesSnapshot(t *testing.T) {
  675. srv := NewServer(cfgpkg.Default())
  676. srv.SetTXController(&fakeTXController{})
  677. rec := httptest.NewRecorder()
  678. body := []byte(`{"outputDrive":1.2}`)
  679. srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
  680. if rec.Code != 200 {
  681. t.Fatalf("status: %d", rec.Code)
  682. }
  683. var resp map[string]any
  684. if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
  685. t.Fatalf("unmarshal response: %v", err)
  686. }
  687. if live, ok := resp["live"].(bool); !ok || !live {
  688. t.Fatalf("expected live true, got %v", resp["live"])
  689. }
  690. rec = httptest.NewRecorder()
  691. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/config", nil))
  692. var cfg cfgpkg.Config
  693. if err := json.NewDecoder(rec.Body).Decode(&cfg); err != nil {
  694. t.Fatalf("decode config: %v", err)
  695. }
  696. if cfg.FM.OutputDrive != 1.2 {
  697. t.Fatalf("expected snapshot to reflect new drive, got %v", cfg.FM.OutputDrive)
  698. }
  699. }
  700. func TestConfigPatchEngineRejectsDoesNotUpdateSnapshot(t *testing.T) {
  701. srv := NewServer(cfgpkg.Default())
  702. srv.SetTXController(&fakeTXController{updateErr: errors.New("boom")})
  703. body := []byte(`{"outputDrive":2.2}`)
  704. rec := httptest.NewRecorder()
  705. srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
  706. if rec.Code != http.StatusBadRequest {
  707. t.Fatalf("expected 400, got %d", rec.Code)
  708. }
  709. rec = httptest.NewRecorder()
  710. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/config", nil))
  711. var cfg cfgpkg.Config
  712. if err := json.NewDecoder(rec.Body).Decode(&cfg); err != nil {
  713. t.Fatalf("decode config: %v", err)
  714. }
  715. if cfg.FM.OutputDrive != cfgpkg.Default().FM.OutputDrive {
  716. t.Fatalf("expected snapshot untouched, got %v", cfg.FM.OutputDrive)
  717. }
  718. }
  719. func TestRuntimeIncludesControlAudit(t *testing.T) {
  720. srv := NewServer(cfgpkg.Default())
  721. counts := controlAuditCounts(t, srv)
  722. keys := []string{"methodNotAllowed", "unsupportedMediaType", "bodyTooLarge", "unexpectedBody"}
  723. for _, key := range keys {
  724. if counts[key] != 0 {
  725. t.Fatalf("expected %s to start at 0, got %d", key, counts[key])
  726. }
  727. }
  728. }
  729. func TestControlAuditTracksMethodNotAllowed(t *testing.T) {
  730. srv := NewServer(cfgpkg.Default())
  731. rec := httptest.NewRecorder()
  732. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/audio/stream", nil))
  733. if rec.Code != http.StatusMethodNotAllowed {
  734. t.Fatalf("expected 405 from audio stream GET, got %d", rec.Code)
  735. }
  736. counts := controlAuditCounts(t, srv)
  737. if counts["methodNotAllowed"] != 1 {
  738. t.Fatalf("expected methodNotAllowed=1, got %d", counts["methodNotAllowed"])
  739. }
  740. }
  741. func TestControlAuditTracksUnsupportedMediaType(t *testing.T) {
  742. srv := NewServer(cfgpkg.Default())
  743. srv.SetAudioIngress(&fakeAudioIngress{})
  744. rec := httptest.NewRecorder()
  745. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0}))
  746. srv.Handler().ServeHTTP(rec, req)
  747. if rec.Code != http.StatusUnsupportedMediaType {
  748. t.Fatalf("expected 415 for audio stream content type, got %d", rec.Code)
  749. }
  750. counts := controlAuditCounts(t, srv)
  751. if counts["unsupportedMediaType"] != 1 {
  752. t.Fatalf("expected unsupportedMediaType=1, got %d", counts["unsupportedMediaType"])
  753. }
  754. }
  755. func TestControlAuditTracksBodyTooLarge(t *testing.T) {
  756. srv := NewServer(cfgpkg.Default())
  757. limit := int(maxConfigBodyBytes)
  758. body := []byte("{\"ps\":\"" + strings.Repeat("x", limit+1) + "\"}")
  759. rec := httptest.NewRecorder()
  760. srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
  761. if rec.Code != http.StatusRequestEntityTooLarge {
  762. t.Fatalf("expected 413 for oversized config body, got %d", rec.Code)
  763. }
  764. counts := controlAuditCounts(t, srv)
  765. if counts["bodyTooLarge"] != 1 {
  766. t.Fatalf("expected bodyTooLarge=1, got %d", counts["bodyTooLarge"])
  767. }
  768. }
  769. func TestControlAuditTracksUnexpectedBody(t *testing.T) {
  770. srv := NewServer(cfgpkg.Default())
  771. srv.SetTXController(&fakeTXController{})
  772. rec := httptest.NewRecorder()
  773. req := httptest.NewRequest(http.MethodPost, "/tx/start", bytes.NewReader([]byte("body")))
  774. srv.Handler().ServeHTTP(rec, req)
  775. if rec.Code != http.StatusBadRequest {
  776. t.Fatalf("expected 400 for unexpected body, got %d", rec.Code)
  777. }
  778. counts := controlAuditCounts(t, srv)
  779. if counts["unexpectedBody"] != 1 {
  780. t.Fatalf("expected unexpectedBody=1, got %d", counts["unexpectedBody"])
  781. }
  782. }
  783. func controlAuditCounts(t *testing.T, srv *Server) map[string]uint64 {
  784. t.Helper()
  785. rec := httptest.NewRecorder()
  786. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  787. if rec.Code != http.StatusOK {
  788. t.Fatalf("runtime request failed: %d", rec.Code)
  789. }
  790. var payload map[string]any
  791. if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
  792. t.Fatalf("unmarshal runtime: %v", err)
  793. }
  794. raw, ok := payload["controlAudit"].(map[string]any)
  795. if !ok {
  796. t.Fatalf("controlAudit missing or wrong type: %T", payload["controlAudit"])
  797. }
  798. counts := map[string]uint64{}
  799. for key, value := range raw {
  800. num, ok := value.(float64)
  801. if !ok {
  802. t.Fatalf("controlAudit %s not numeric: %T", key, value)
  803. }
  804. counts[key] = uint64(num)
  805. }
  806. return counts
  807. }
  808. func newConfigPostRequest(body []byte) *http.Request {
  809. req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body))
  810. req.Header.Set("Content-Type", "application/json")
  811. return req
  812. }
  813. func newIngestSavePostRequest(body []byte) *http.Request {
  814. req := httptest.NewRequest(http.MethodPost, "/config/ingest/save", bytes.NewReader(body))
  815. req.Header.Set("Content-Type", "application/json")
  816. return req
  817. }
  818. type fakeTXController struct {
  819. updateErr error
  820. resetErr error
  821. stats map[string]any
  822. returnNilStats bool
  823. }
  824. type fakeAudioIngress struct {
  825. totalFrames int
  826. }
  827. type fakeIngestRuntime struct {
  828. stats ingest.Stats
  829. }
  830. func (f *fakeAudioIngress) WritePCM16(data []byte) (int, error) {
  831. frames := len(data) / 4
  832. f.totalFrames += frames
  833. return frames, nil
  834. }
  835. func (f *fakeIngestRuntime) Stats() ingest.Stats {
  836. return f.stats
  837. }
  838. func (f *fakeTXController) StartTX() error { return nil }
  839. func (f *fakeTXController) StopTX() error { return nil }
  840. func (f *fakeTXController) TXStats() map[string]any {
  841. if f.returnNilStats {
  842. return nil
  843. }
  844. if f.stats != nil {
  845. return f.stats
  846. }
  847. return map[string]any{}
  848. }
  849. func (f *fakeTXController) UpdateConfig(_ LivePatch) error { return f.updateErr }
  850. func (f *fakeTXController) ResetFault() error { return f.resetErr }