Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

752 строки
24KB

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