Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

662 Zeilen
21KB

  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. }
  166. func TestRuntimeIncludesIngestStats(t *testing.T) {
  167. srv := NewServer(cfgpkg.Default())
  168. srv.SetIngestRuntime(&fakeIngestRuntime{
  169. stats: ingest.Stats{
  170. Active: ingest.SourceDescriptor{ID: "stdin-main", Kind: "stdin-pcm"},
  171. Runtime: ingest.RuntimeStats{State: "running"},
  172. },
  173. })
  174. rec := httptest.NewRecorder()
  175. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  176. if rec.Code != http.StatusOK {
  177. t.Fatalf("status: %d", rec.Code)
  178. }
  179. var body map[string]any
  180. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  181. t.Fatalf("unmarshal runtime: %v", err)
  182. }
  183. ingest, ok := body["ingest"].(map[string]any)
  184. if !ok {
  185. t.Fatalf("expected ingest stats, got %T", body["ingest"])
  186. }
  187. active, ok := ingest["active"].(map[string]any)
  188. if !ok {
  189. t.Fatalf("expected ingest.active map, got %T", ingest["active"])
  190. }
  191. if active["id"] != "stdin-main" {
  192. t.Fatalf("unexpected ingest active id: %v", active["id"])
  193. }
  194. }
  195. func TestRuntimeReportsFaultHistory(t *testing.T) {
  196. srv := NewServer(cfgpkg.Default())
  197. history := []map[string]any{
  198. {
  199. "time": "2026-04-06T00:00:00Z",
  200. "reason": "queueCritical",
  201. "severity": "faulted",
  202. "message": "queue critical",
  203. },
  204. }
  205. srv.SetTXController(&fakeTXController{stats: map[string]any{"faultHistory": history}})
  206. rec := httptest.NewRecorder()
  207. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  208. if rec.Code != 200 {
  209. t.Fatalf("status: %d", rec.Code)
  210. }
  211. var body map[string]any
  212. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  213. t.Fatalf("unmarshal runtime: %v", err)
  214. }
  215. engineRaw, ok := body["engine"].(map[string]any)
  216. if !ok {
  217. t.Fatalf("runtime engine missing")
  218. }
  219. histRaw, ok := engineRaw["faultHistory"].([]any)
  220. if !ok {
  221. t.Fatalf("faultHistory missing or wrong type: %T", engineRaw["faultHistory"])
  222. }
  223. if len(histRaw) != len(history) {
  224. t.Fatalf("faultHistory length mismatch: want %d got %d", len(history), len(histRaw))
  225. }
  226. }
  227. func TestRuntimeReportsTransitionHistory(t *testing.T) {
  228. srv := NewServer(cfgpkg.Default())
  229. history := []map[string]any{{
  230. "time": "2026-04-06T00:00:00Z",
  231. "from": "running",
  232. "to": "degraded",
  233. "severity": "warn",
  234. }}
  235. srv.SetTXController(&fakeTXController{stats: map[string]any{"transitionHistory": history}})
  236. rec := httptest.NewRecorder()
  237. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  238. if rec.Code != 200 {
  239. t.Fatalf("status: %d", rec.Code)
  240. }
  241. var body map[string]any
  242. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  243. t.Fatalf("unmarshal runtime: %v", err)
  244. }
  245. engineRaw, ok := body["engine"].(map[string]any)
  246. if !ok {
  247. t.Fatalf("runtime engine missing")
  248. }
  249. histRaw, ok := engineRaw["transitionHistory"].([]any)
  250. if !ok {
  251. t.Fatalf("transitionHistory missing or wrong type: %T", engineRaw["transitionHistory"])
  252. }
  253. if len(histRaw) != len(history) {
  254. t.Fatalf("transitionHistory length mismatch: want %d got %d", len(history), len(histRaw))
  255. }
  256. }
  257. func TestRuntimeFaultResetRejectsGet(t *testing.T) {
  258. srv := NewServer(cfgpkg.Default())
  259. rec := httptest.NewRecorder()
  260. req := httptest.NewRequest(http.MethodGet, "/runtime/fault/reset", nil)
  261. srv.Handler().ServeHTTP(rec, req)
  262. if rec.Code != http.StatusMethodNotAllowed {
  263. t.Fatalf("expected 405 for fault reset GET, got %d", rec.Code)
  264. }
  265. }
  266. func TestRuntimeFaultResetRequiresController(t *testing.T) {
  267. srv := NewServer(cfgpkg.Default())
  268. rec := httptest.NewRecorder()
  269. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil)
  270. srv.Handler().ServeHTTP(rec, req)
  271. if rec.Code != http.StatusServiceUnavailable {
  272. t.Fatalf("expected 503 without controller, got %d", rec.Code)
  273. }
  274. }
  275. func TestRuntimeFaultResetControllerError(t *testing.T) {
  276. srv := NewServer(cfgpkg.Default())
  277. srv.SetTXController(&fakeTXController{resetErr: errors.New("boom")})
  278. rec := httptest.NewRecorder()
  279. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil)
  280. srv.Handler().ServeHTTP(rec, req)
  281. if rec.Code != http.StatusConflict {
  282. t.Fatalf("expected 409 when controller rejects, got %d", rec.Code)
  283. }
  284. }
  285. func TestRuntimeFaultResetSuccess(t *testing.T) {
  286. srv := NewServer(cfgpkg.Default())
  287. srv.SetTXController(&fakeTXController{})
  288. rec := httptest.NewRecorder()
  289. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil)
  290. srv.Handler().ServeHTTP(rec, req)
  291. if rec.Code != 200 {
  292. t.Fatalf("expected 200 on success, got %d", rec.Code)
  293. }
  294. var body map[string]any
  295. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  296. t.Fatalf("unmarshal response: %v", err)
  297. }
  298. if ok, _ := body["ok"].(bool); !ok {
  299. t.Fatalf("expected ok true, got %v", body["ok"])
  300. }
  301. }
  302. func TestRuntimeFaultResetRejectsBody(t *testing.T) {
  303. srv := NewServer(cfgpkg.Default())
  304. srv.SetTXController(&fakeTXController{})
  305. rec := httptest.NewRecorder()
  306. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", bytes.NewReader([]byte("nope")))
  307. srv.Handler().ServeHTTP(rec, req)
  308. if rec.Code != http.StatusBadRequest {
  309. t.Fatalf("expected 400 when body present, got %d", rec.Code)
  310. }
  311. if !strings.Contains(rec.Body.String(), "request must not include a body") {
  312. t.Fatalf("unexpected response body: %q", rec.Body.String())
  313. }
  314. }
  315. func TestAudioStreamRequiresSource(t *testing.T) {
  316. srv := NewServer(cfgpkg.Default())
  317. rec := httptest.NewRecorder()
  318. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(nil))
  319. req.Header.Set("Content-Type", "application/octet-stream")
  320. srv.Handler().ServeHTTP(rec, req)
  321. if rec.Code != http.StatusServiceUnavailable {
  322. t.Fatalf("expected 503 when audio stream missing, got %d", rec.Code)
  323. }
  324. }
  325. func TestAudioStreamPushesPCM(t *testing.T) {
  326. cfg := cfgpkg.Default()
  327. srv := NewServer(cfg)
  328. ingress := &fakeAudioIngress{}
  329. srv.SetAudioIngress(ingress)
  330. pcm := []byte{0, 0, 0, 0}
  331. rec := httptest.NewRecorder()
  332. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(pcm))
  333. req.Header.Set("Content-Type", "application/octet-stream")
  334. srv.Handler().ServeHTTP(rec, req)
  335. if rec.Code != 200 {
  336. t.Fatalf("expected 200, got %d", rec.Code)
  337. }
  338. var body map[string]any
  339. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  340. t.Fatalf("unmarshal response: %v", err)
  341. }
  342. if ok, _ := body["ok"].(bool); !ok {
  343. t.Fatalf("expected ok true, got %v", body["ok"])
  344. }
  345. frames, _ := body["frames"].(float64)
  346. if frames != 1 {
  347. t.Fatalf("expected 1 frame, got %v", frames)
  348. }
  349. if ingress.totalFrames != 1 {
  350. t.Fatalf("expected ingress frames=1, got %d", ingress.totalFrames)
  351. }
  352. }
  353. func TestAudioStreamRejectsNonPost(t *testing.T) {
  354. srv := NewServer(cfgpkg.Default())
  355. rec := httptest.NewRecorder()
  356. req := httptest.NewRequest(http.MethodGet, "/audio/stream", nil)
  357. srv.Handler().ServeHTTP(rec, req)
  358. if rec.Code != http.StatusMethodNotAllowed {
  359. t.Fatalf("expected 405 for audio stream GET, got %d", rec.Code)
  360. }
  361. }
  362. func TestAudioStreamRejectsMissingContentType(t *testing.T) {
  363. cfg := cfgpkg.Default()
  364. srv := NewServer(cfg)
  365. srv.SetAudioIngress(&fakeAudioIngress{})
  366. rec := httptest.NewRecorder()
  367. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0}))
  368. srv.Handler().ServeHTTP(rec, req)
  369. if rec.Code != http.StatusUnsupportedMediaType {
  370. t.Fatalf("expected 415 when Content-Type missing, got %d", rec.Code)
  371. }
  372. if !strings.Contains(rec.Body.String(), "Content-Type must be") {
  373. t.Fatalf("unexpected response body: %q", rec.Body.String())
  374. }
  375. }
  376. func TestAudioStreamRejectsUnsupportedContentType(t *testing.T) {
  377. cfg := cfgpkg.Default()
  378. srv := NewServer(cfg)
  379. srv.SetAudioIngress(&fakeAudioIngress{})
  380. rec := httptest.NewRecorder()
  381. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0}))
  382. req.Header.Set("Content-Type", "text/plain")
  383. srv.Handler().ServeHTTP(rec, req)
  384. if rec.Code != http.StatusUnsupportedMediaType {
  385. t.Fatalf("expected 415 for unsupported Content-Type, got %d", rec.Code)
  386. }
  387. if !strings.Contains(rec.Body.String(), "Content-Type must be") {
  388. t.Fatalf("unexpected response body: %q", rec.Body.String())
  389. }
  390. }
  391. func TestAudioStreamRejectsBodyTooLarge(t *testing.T) {
  392. orig := audioStreamBodyLimit
  393. t.Cleanup(func() {
  394. audioStreamBodyLimit = orig
  395. })
  396. audioStreamBodyLimit = 1024
  397. limit := int(audioStreamBodyLimit)
  398. body := make([]byte, limit+1)
  399. srv := NewServer(cfgpkg.Default())
  400. srv.SetAudioIngress(&fakeAudioIngress{})
  401. rec := httptest.NewRecorder()
  402. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(body))
  403. req.Header.Set("Content-Type", "application/octet-stream")
  404. srv.Handler().ServeHTTP(rec, req)
  405. if rec.Code != http.StatusRequestEntityTooLarge {
  406. t.Fatalf("expected 413 for oversized body, got %d", rec.Code)
  407. }
  408. if !strings.Contains(rec.Body.String(), "request body too large") {
  409. t.Fatalf("unexpected response body: %q", rec.Body.String())
  410. }
  411. }
  412. func TestTXStartWithoutController(t *testing.T) {
  413. srv := NewServer(cfgpkg.Default())
  414. rec := httptest.NewRecorder()
  415. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/tx/start", nil))
  416. if rec.Code != http.StatusServiceUnavailable {
  417. t.Fatalf("expected 503, got %d", rec.Code)
  418. }
  419. }
  420. func TestTXStartRejectsBody(t *testing.T) {
  421. srv := NewServer(cfgpkg.Default())
  422. srv.SetTXController(&fakeTXController{})
  423. rec := httptest.NewRecorder()
  424. req := httptest.NewRequest(http.MethodPost, "/tx/start", bytes.NewReader([]byte("body")))
  425. srv.Handler().ServeHTTP(rec, req)
  426. if rec.Code != http.StatusBadRequest {
  427. t.Fatalf("expected 400 when body present, got %d", rec.Code)
  428. }
  429. if !strings.Contains(rec.Body.String(), "request must not include a body") {
  430. t.Fatalf("unexpected response body: %q", rec.Body.String())
  431. }
  432. }
  433. func TestTXStopRejectsBody(t *testing.T) {
  434. srv := NewServer(cfgpkg.Default())
  435. srv.SetTXController(&fakeTXController{})
  436. rec := httptest.NewRecorder()
  437. req := httptest.NewRequest(http.MethodPost, "/tx/stop", bytes.NewReader([]byte("body")))
  438. srv.Handler().ServeHTTP(rec, req)
  439. if rec.Code != http.StatusBadRequest {
  440. t.Fatalf("expected 400 when body present, got %d", rec.Code)
  441. }
  442. if !strings.Contains(rec.Body.String(), "request must not include a body") {
  443. t.Fatalf("unexpected response body: %q", rec.Body.String())
  444. }
  445. }
  446. func TestConfigPatchUpdatesSnapshot(t *testing.T) {
  447. srv := NewServer(cfgpkg.Default())
  448. srv.SetTXController(&fakeTXController{})
  449. rec := httptest.NewRecorder()
  450. body := []byte(`{"outputDrive":1.2}`)
  451. srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
  452. if rec.Code != 200 {
  453. t.Fatalf("status: %d", rec.Code)
  454. }
  455. var resp map[string]any
  456. if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
  457. t.Fatalf("unmarshal response: %v", err)
  458. }
  459. if live, ok := resp["live"].(bool); !ok || !live {
  460. t.Fatalf("expected live true, got %v", resp["live"])
  461. }
  462. rec = httptest.NewRecorder()
  463. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/config", nil))
  464. var cfg cfgpkg.Config
  465. if err := json.NewDecoder(rec.Body).Decode(&cfg); err != nil {
  466. t.Fatalf("decode config: %v", err)
  467. }
  468. if cfg.FM.OutputDrive != 1.2 {
  469. t.Fatalf("expected snapshot to reflect new drive, got %v", cfg.FM.OutputDrive)
  470. }
  471. }
  472. func TestConfigPatchEngineRejectsDoesNotUpdateSnapshot(t *testing.T) {
  473. srv := NewServer(cfgpkg.Default())
  474. srv.SetTXController(&fakeTXController{updateErr: errors.New("boom")})
  475. body := []byte(`{"outputDrive":2.2}`)
  476. rec := httptest.NewRecorder()
  477. srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
  478. if rec.Code != http.StatusBadRequest {
  479. t.Fatalf("expected 400, got %d", rec.Code)
  480. }
  481. rec = httptest.NewRecorder()
  482. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/config", nil))
  483. var cfg cfgpkg.Config
  484. if err := json.NewDecoder(rec.Body).Decode(&cfg); err != nil {
  485. t.Fatalf("decode config: %v", err)
  486. }
  487. if cfg.FM.OutputDrive != cfgpkg.Default().FM.OutputDrive {
  488. t.Fatalf("expected snapshot untouched, got %v", cfg.FM.OutputDrive)
  489. }
  490. }
  491. func TestRuntimeIncludesControlAudit(t *testing.T) {
  492. srv := NewServer(cfgpkg.Default())
  493. counts := controlAuditCounts(t, srv)
  494. keys := []string{"methodNotAllowed", "unsupportedMediaType", "bodyTooLarge", "unexpectedBody"}
  495. for _, key := range keys {
  496. if counts[key] != 0 {
  497. t.Fatalf("expected %s to start at 0, got %d", key, counts[key])
  498. }
  499. }
  500. }
  501. func TestControlAuditTracksMethodNotAllowed(t *testing.T) {
  502. srv := NewServer(cfgpkg.Default())
  503. rec := httptest.NewRecorder()
  504. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/audio/stream", nil))
  505. if rec.Code != http.StatusMethodNotAllowed {
  506. t.Fatalf("expected 405 from audio stream GET, got %d", rec.Code)
  507. }
  508. counts := controlAuditCounts(t, srv)
  509. if counts["methodNotAllowed"] != 1 {
  510. t.Fatalf("expected methodNotAllowed=1, got %d", counts["methodNotAllowed"])
  511. }
  512. }
  513. func TestControlAuditTracksUnsupportedMediaType(t *testing.T) {
  514. srv := NewServer(cfgpkg.Default())
  515. srv.SetAudioIngress(&fakeAudioIngress{})
  516. rec := httptest.NewRecorder()
  517. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0}))
  518. srv.Handler().ServeHTTP(rec, req)
  519. if rec.Code != http.StatusUnsupportedMediaType {
  520. t.Fatalf("expected 415 for audio stream content type, got %d", rec.Code)
  521. }
  522. counts := controlAuditCounts(t, srv)
  523. if counts["unsupportedMediaType"] != 1 {
  524. t.Fatalf("expected unsupportedMediaType=1, got %d", counts["unsupportedMediaType"])
  525. }
  526. }
  527. func TestControlAuditTracksBodyTooLarge(t *testing.T) {
  528. srv := NewServer(cfgpkg.Default())
  529. limit := int(maxConfigBodyBytes)
  530. body := []byte("{\"ps\":\"" + strings.Repeat("x", limit+1) + "\"}")
  531. rec := httptest.NewRecorder()
  532. srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
  533. if rec.Code != http.StatusRequestEntityTooLarge {
  534. t.Fatalf("expected 413 for oversized config body, got %d", rec.Code)
  535. }
  536. counts := controlAuditCounts(t, srv)
  537. if counts["bodyTooLarge"] != 1 {
  538. t.Fatalf("expected bodyTooLarge=1, got %d", counts["bodyTooLarge"])
  539. }
  540. }
  541. func TestControlAuditTracksUnexpectedBody(t *testing.T) {
  542. srv := NewServer(cfgpkg.Default())
  543. srv.SetTXController(&fakeTXController{})
  544. rec := httptest.NewRecorder()
  545. req := httptest.NewRequest(http.MethodPost, "/tx/start", bytes.NewReader([]byte("body")))
  546. srv.Handler().ServeHTTP(rec, req)
  547. if rec.Code != http.StatusBadRequest {
  548. t.Fatalf("expected 400 for unexpected body, got %d", rec.Code)
  549. }
  550. counts := controlAuditCounts(t, srv)
  551. if counts["unexpectedBody"] != 1 {
  552. t.Fatalf("expected unexpectedBody=1, got %d", counts["unexpectedBody"])
  553. }
  554. }
  555. func controlAuditCounts(t *testing.T, srv *Server) map[string]uint64 {
  556. t.Helper()
  557. rec := httptest.NewRecorder()
  558. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  559. if rec.Code != http.StatusOK {
  560. t.Fatalf("runtime request failed: %d", rec.Code)
  561. }
  562. var payload map[string]any
  563. if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
  564. t.Fatalf("unmarshal runtime: %v", err)
  565. }
  566. raw, ok := payload["controlAudit"].(map[string]any)
  567. if !ok {
  568. t.Fatalf("controlAudit missing or wrong type: %T", payload["controlAudit"])
  569. }
  570. counts := map[string]uint64{}
  571. for key, value := range raw {
  572. num, ok := value.(float64)
  573. if !ok {
  574. t.Fatalf("controlAudit %s not numeric: %T", key, value)
  575. }
  576. counts[key] = uint64(num)
  577. }
  578. return counts
  579. }
  580. func newConfigPostRequest(body []byte) *http.Request {
  581. req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body))
  582. req.Header.Set("Content-Type", "application/json")
  583. return req
  584. }
  585. type fakeTXController struct {
  586. updateErr error
  587. resetErr error
  588. stats map[string]any
  589. }
  590. type fakeAudioIngress struct {
  591. totalFrames int
  592. }
  593. type fakeIngestRuntime struct {
  594. stats ingest.Stats
  595. }
  596. func (f *fakeAudioIngress) WritePCM16(data []byte) (int, error) {
  597. frames := len(data) / 4
  598. f.totalFrames += frames
  599. return frames, nil
  600. }
  601. func (f *fakeIngestRuntime) Stats() ingest.Stats {
  602. return f.stats
  603. }
  604. func (f *fakeTXController) StartTX() error { return nil }
  605. func (f *fakeTXController) StopTX() error { return nil }
  606. func (f *fakeTXController) TXStats() map[string]any {
  607. if f.stats != nil {
  608. return f.stats
  609. }
  610. return map[string]any{}
  611. }
  612. func (f *fakeTXController) UpdateConfig(_ LivePatch) error { return f.updateErr }
  613. func (f *fakeTXController) ResetFault() error { return f.resetErr }