Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

773 lines
25KB

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