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.

468 lines
15KB

  1. package control
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "errors"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. "github.com/jan/fm-rds-tx/internal/audio"
  11. cfgpkg "github.com/jan/fm-rds-tx/internal/config"
  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 TestRuntimeReportsFaultHistory(t *testing.T) {
  167. srv := NewServer(cfgpkg.Default())
  168. history := []map[string]any{
  169. {
  170. "time": "2026-04-06T00:00:00Z",
  171. "reason": "queueCritical",
  172. "severity": "faulted",
  173. "message": "queue critical",
  174. },
  175. }
  176. srv.SetTXController(&fakeTXController{stats: map[string]any{"faultHistory": history}})
  177. rec := httptest.NewRecorder()
  178. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  179. if rec.Code != 200 {
  180. t.Fatalf("status: %d", rec.Code)
  181. }
  182. var body map[string]any
  183. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  184. t.Fatalf("unmarshal runtime: %v", err)
  185. }
  186. engineRaw, ok := body["engine"].(map[string]any)
  187. if !ok {
  188. t.Fatalf("runtime engine missing")
  189. }
  190. histRaw, ok := engineRaw["faultHistory"].([]any)
  191. if !ok {
  192. t.Fatalf("faultHistory missing or wrong type: %T", engineRaw["faultHistory"])
  193. }
  194. if len(histRaw) != len(history) {
  195. t.Fatalf("faultHistory length mismatch: want %d got %d", len(history), len(histRaw))
  196. }
  197. }
  198. func TestRuntimeReportsTransitionHistory(t *testing.T) {
  199. srv := NewServer(cfgpkg.Default())
  200. history := []map[string]any{{
  201. "time": "2026-04-06T00:00:00Z",
  202. "from": "running",
  203. "to": "degraded",
  204. "severity": "warn",
  205. }}
  206. srv.SetTXController(&fakeTXController{stats: map[string]any{"transitionHistory": history}})
  207. rec := httptest.NewRecorder()
  208. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  209. if rec.Code != 200 {
  210. t.Fatalf("status: %d", rec.Code)
  211. }
  212. var body map[string]any
  213. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  214. t.Fatalf("unmarshal runtime: %v", err)
  215. }
  216. engineRaw, ok := body["engine"].(map[string]any)
  217. if !ok {
  218. t.Fatalf("runtime engine missing")
  219. }
  220. histRaw, ok := engineRaw["transitionHistory"].([]any)
  221. if !ok {
  222. t.Fatalf("transitionHistory missing or wrong type: %T", engineRaw["transitionHistory"])
  223. }
  224. if len(histRaw) != len(history) {
  225. t.Fatalf("transitionHistory length mismatch: want %d got %d", len(history), len(histRaw))
  226. }
  227. }
  228. func TestRuntimeFaultResetRejectsGet(t *testing.T) {
  229. srv := NewServer(cfgpkg.Default())
  230. rec := httptest.NewRecorder()
  231. req := httptest.NewRequest(http.MethodGet, "/runtime/fault/reset", nil)
  232. srv.Handler().ServeHTTP(rec, req)
  233. if rec.Code != http.StatusMethodNotAllowed {
  234. t.Fatalf("expected 405 for fault reset GET, got %d", rec.Code)
  235. }
  236. }
  237. func TestRuntimeFaultResetRequiresController(t *testing.T) {
  238. srv := NewServer(cfgpkg.Default())
  239. rec := httptest.NewRecorder()
  240. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil)
  241. srv.Handler().ServeHTTP(rec, req)
  242. if rec.Code != http.StatusServiceUnavailable {
  243. t.Fatalf("expected 503 without controller, got %d", rec.Code)
  244. }
  245. }
  246. func TestRuntimeFaultResetControllerError(t *testing.T) {
  247. srv := NewServer(cfgpkg.Default())
  248. srv.SetTXController(&fakeTXController{resetErr: errors.New("boom")})
  249. rec := httptest.NewRecorder()
  250. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil)
  251. srv.Handler().ServeHTTP(rec, req)
  252. if rec.Code != http.StatusConflict {
  253. t.Fatalf("expected 409 when controller rejects, got %d", rec.Code)
  254. }
  255. }
  256. func TestRuntimeFaultResetSuccess(t *testing.T) {
  257. srv := NewServer(cfgpkg.Default())
  258. srv.SetTXController(&fakeTXController{})
  259. rec := httptest.NewRecorder()
  260. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil)
  261. srv.Handler().ServeHTTP(rec, req)
  262. if rec.Code != 200 {
  263. t.Fatalf("expected 200 on success, got %d", rec.Code)
  264. }
  265. var body map[string]any
  266. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  267. t.Fatalf("unmarshal response: %v", err)
  268. }
  269. if ok, _ := body["ok"].(bool); !ok {
  270. t.Fatalf("expected ok true, got %v", body["ok"])
  271. }
  272. }
  273. func TestRuntimeFaultResetRejectsBody(t *testing.T) {
  274. srv := NewServer(cfgpkg.Default())
  275. srv.SetTXController(&fakeTXController{})
  276. rec := httptest.NewRecorder()
  277. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", bytes.NewReader([]byte("nope")))
  278. srv.Handler().ServeHTTP(rec, req)
  279. if rec.Code != http.StatusBadRequest {
  280. t.Fatalf("expected 400 when body present, got %d", rec.Code)
  281. }
  282. if !strings.Contains(rec.Body.String(), "request must not include a body") {
  283. t.Fatalf("unexpected response body: %q", rec.Body.String())
  284. }
  285. }
  286. func TestAudioStreamRequiresSource(t *testing.T) {
  287. srv := NewServer(cfgpkg.Default())
  288. rec := httptest.NewRecorder()
  289. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(nil))
  290. srv.Handler().ServeHTTP(rec, req)
  291. if rec.Code != http.StatusServiceUnavailable {
  292. t.Fatalf("expected 503 when audio stream missing, got %d", rec.Code)
  293. }
  294. }
  295. func TestAudioStreamPushesPCM(t *testing.T) {
  296. cfg := cfgpkg.Default()
  297. srv := NewServer(cfg)
  298. stream := audio.NewStreamSource(256, 44100)
  299. srv.SetStreamSource(stream)
  300. pcm := []byte{0, 0, 0, 0}
  301. rec := httptest.NewRecorder()
  302. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(pcm))
  303. srv.Handler().ServeHTTP(rec, req)
  304. if rec.Code != 200 {
  305. t.Fatalf("expected 200, got %d", rec.Code)
  306. }
  307. var body map[string]any
  308. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  309. t.Fatalf("unmarshal response: %v", err)
  310. }
  311. if ok, _ := body["ok"].(bool); !ok {
  312. t.Fatalf("expected ok true, got %v", body["ok"])
  313. }
  314. frames, _ := body["frames"].(float64)
  315. if frames != 1 {
  316. t.Fatalf("expected 1 frame, got %v", frames)
  317. }
  318. stats, ok := body["stats"].(map[string]any)
  319. if !ok {
  320. t.Fatalf("missing stats: %v", body["stats"])
  321. }
  322. if avail, _ := stats["available"].(float64); avail < 1 {
  323. t.Fatalf("expected stats.available >= 1, got %v", avail)
  324. }
  325. }
  326. func TestAudioStreamRejectsNonPost(t *testing.T) {
  327. srv := NewServer(cfgpkg.Default())
  328. rec := httptest.NewRecorder()
  329. req := httptest.NewRequest(http.MethodGet, "/audio/stream", nil)
  330. srv.Handler().ServeHTTP(rec, req)
  331. if rec.Code != http.StatusMethodNotAllowed {
  332. t.Fatalf("expected 405 for audio stream GET, got %d", rec.Code)
  333. }
  334. }
  335. func TestTXStartWithoutController(t *testing.T) {
  336. srv := NewServer(cfgpkg.Default())
  337. rec := httptest.NewRecorder()
  338. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/tx/start", nil))
  339. if rec.Code != http.StatusServiceUnavailable {
  340. t.Fatalf("expected 503, got %d", rec.Code)
  341. }
  342. }
  343. func TestTXStartRejectsBody(t *testing.T) {
  344. srv := NewServer(cfgpkg.Default())
  345. srv.SetTXController(&fakeTXController{})
  346. rec := httptest.NewRecorder()
  347. req := httptest.NewRequest(http.MethodPost, "/tx/start", bytes.NewReader([]byte("body")))
  348. srv.Handler().ServeHTTP(rec, req)
  349. if rec.Code != http.StatusBadRequest {
  350. t.Fatalf("expected 400 when body present, got %d", rec.Code)
  351. }
  352. if !strings.Contains(rec.Body.String(), "request must not include a body") {
  353. t.Fatalf("unexpected response body: %q", rec.Body.String())
  354. }
  355. }
  356. func TestTXStopRejectsBody(t *testing.T) {
  357. srv := NewServer(cfgpkg.Default())
  358. srv.SetTXController(&fakeTXController{})
  359. rec := httptest.NewRecorder()
  360. req := httptest.NewRequest(http.MethodPost, "/tx/stop", bytes.NewReader([]byte("body")))
  361. srv.Handler().ServeHTTP(rec, req)
  362. if rec.Code != http.StatusBadRequest {
  363. t.Fatalf("expected 400 when body present, got %d", rec.Code)
  364. }
  365. if !strings.Contains(rec.Body.String(), "request must not include a body") {
  366. t.Fatalf("unexpected response body: %q", rec.Body.String())
  367. }
  368. }
  369. func TestConfigPatchUpdatesSnapshot(t *testing.T) {
  370. srv := NewServer(cfgpkg.Default())
  371. srv.SetTXController(&fakeTXController{})
  372. rec := httptest.NewRecorder()
  373. body := []byte(`{"outputDrive":1.2}`)
  374. srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
  375. if rec.Code != 200 {
  376. t.Fatalf("status: %d", rec.Code)
  377. }
  378. var resp map[string]any
  379. if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
  380. t.Fatalf("unmarshal response: %v", err)
  381. }
  382. if live, ok := resp["live"].(bool); !ok || !live {
  383. t.Fatalf("expected live true, got %v", resp["live"])
  384. }
  385. rec = httptest.NewRecorder()
  386. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/config", nil))
  387. var cfg cfgpkg.Config
  388. if err := json.NewDecoder(rec.Body).Decode(&cfg); err != nil {
  389. t.Fatalf("decode config: %v", err)
  390. }
  391. if cfg.FM.OutputDrive != 1.2 {
  392. t.Fatalf("expected snapshot to reflect new drive, got %v", cfg.FM.OutputDrive)
  393. }
  394. }
  395. func TestConfigPatchEngineRejectsDoesNotUpdateSnapshot(t *testing.T) {
  396. srv := NewServer(cfgpkg.Default())
  397. srv.SetTXController(&fakeTXController{updateErr: errors.New("boom")})
  398. body := []byte(`{"outputDrive":2.2}`)
  399. rec := httptest.NewRecorder()
  400. srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
  401. if rec.Code != http.StatusBadRequest {
  402. t.Fatalf("expected 400, got %d", rec.Code)
  403. }
  404. rec = httptest.NewRecorder()
  405. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/config", nil))
  406. var cfg cfgpkg.Config
  407. if err := json.NewDecoder(rec.Body).Decode(&cfg); err != nil {
  408. t.Fatalf("decode config: %v", err)
  409. }
  410. if cfg.FM.OutputDrive != cfgpkg.Default().FM.OutputDrive {
  411. t.Fatalf("expected snapshot untouched, got %v", cfg.FM.OutputDrive)
  412. }
  413. }
  414. func newConfigPostRequest(body []byte) *http.Request {
  415. req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body))
  416. req.Header.Set("Content-Type", "application/json")
  417. return req
  418. }
  419. type fakeTXController struct {
  420. updateErr error
  421. resetErr error
  422. stats map[string]any
  423. }
  424. func (f *fakeTXController) StartTX() error { return nil }
  425. func (f *fakeTXController) StopTX() error { return nil }
  426. func (f *fakeTXController) TXStats() map[string]any {
  427. if f.stats != nil {
  428. return f.stats
  429. }
  430. return map[string]any{}
  431. }
  432. func (f *fakeTXController) UpdateConfig(_ LivePatch) error { return f.updateErr }
  433. func (f *fakeTXController) ResetFault() error { return f.resetErr }