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

398 рядки
13KB

  1. package control
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "errors"
  6. "net/http"
  7. "net/http/httptest"
  8. "testing"
  9. "github.com/jan/fm-rds-tx/internal/audio"
  10. cfgpkg "github.com/jan/fm-rds-tx/internal/config"
  11. "github.com/jan/fm-rds-tx/internal/output"
  12. )
  13. func TestHealthz(t *testing.T) {
  14. srv := NewServer(cfgpkg.Default())
  15. rec := httptest.NewRecorder()
  16. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
  17. if rec.Code != 200 {
  18. t.Fatalf("status: %d", rec.Code)
  19. }
  20. }
  21. func TestStatus(t *testing.T) {
  22. srv := NewServer(cfgpkg.Default())
  23. rec := httptest.NewRecorder()
  24. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
  25. if rec.Code != 200 {
  26. t.Fatalf("status: %d", rec.Code)
  27. }
  28. var body map[string]any
  29. json.Unmarshal(rec.Body.Bytes(), &body)
  30. if body["service"] != "fm-rds-tx" {
  31. t.Fatal("missing service")
  32. }
  33. if _, ok := body["preEmphasisTauUS"]; !ok {
  34. t.Fatal("missing preEmphasisTauUS")
  35. }
  36. }
  37. func TestStatusReportsRuntimeIndicator(t *testing.T) {
  38. srv := NewServer(cfgpkg.Default())
  39. srv.SetTXController(&fakeTXController{stats: map[string]any{"runtimeIndicator": "degraded", "runtimeAlert": "late buffers"}})
  40. rec := httptest.NewRecorder()
  41. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
  42. if rec.Code != 200 {
  43. t.Fatalf("status: %d", rec.Code)
  44. }
  45. var body map[string]any
  46. json.Unmarshal(rec.Body.Bytes(), &body)
  47. if body["runtimeIndicator"] != "degraded" {
  48. t.Fatalf("expected runtimeIndicator degraded, got %v", body["runtimeIndicator"])
  49. }
  50. if body["runtimeAlert"] != "late buffers" {
  51. t.Fatalf("expected runtimeAlert late buffers, got %v", body["runtimeAlert"])
  52. }
  53. }
  54. func TestStatusReportsQueueStats(t *testing.T) {
  55. cfg := cfgpkg.Default()
  56. queueStats := output.QueueStats{
  57. Capacity: cfg.Runtime.FrameQueueCapacity,
  58. Depth: 1,
  59. FillLevel: 0.25,
  60. Health: output.QueueHealthLow,
  61. }
  62. srv := NewServer(cfg)
  63. srv.SetTXController(&fakeTXController{stats: map[string]any{"queue": queueStats}})
  64. rec := httptest.NewRecorder()
  65. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
  66. if rec.Code != 200 {
  67. t.Fatalf("status: %d", rec.Code)
  68. }
  69. var body map[string]any
  70. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  71. t.Fatalf("unmarshal queue stats: %v", err)
  72. }
  73. queueRaw, ok := body["queue"]
  74. if !ok {
  75. t.Fatalf("missing queue in status")
  76. }
  77. queueMap, ok := queueRaw.(map[string]any)
  78. if !ok {
  79. t.Fatalf("queue stats type mismatch: %T", queueRaw)
  80. }
  81. if queueMap["capacity"] != float64(queueStats.Capacity) {
  82. t.Fatalf("queue capacity mismatch: want %v got %v", queueStats.Capacity, queueMap["capacity"])
  83. }
  84. if queueMap["health"] != string(queueStats.Health) {
  85. t.Fatalf("queue health mismatch: want %s got %v", queueStats.Health, queueMap["health"])
  86. }
  87. }
  88. func TestStatusReportsRuntimeState(t *testing.T) {
  89. srv := NewServer(cfgpkg.Default())
  90. srv.SetTXController(&fakeTXController{stats: map[string]any{"state": "faulted"}})
  91. rec := httptest.NewRecorder()
  92. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
  93. if rec.Code != 200 {
  94. t.Fatalf("status: %d", rec.Code)
  95. }
  96. var body map[string]any
  97. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  98. t.Fatalf("unmarshal runtime state: %v", err)
  99. }
  100. if body["runtimeState"] != "faulted" {
  101. t.Fatalf("expected runtimeState faulted, got %v", body["runtimeState"])
  102. }
  103. }
  104. func TestDryRunEndpoint(t *testing.T) {
  105. srv := NewServer(cfgpkg.Default())
  106. rec := httptest.NewRecorder()
  107. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/dry-run", nil))
  108. if rec.Code != 200 {
  109. t.Fatalf("status: %d", rec.Code)
  110. }
  111. var body map[string]any
  112. json.Unmarshal(rec.Body.Bytes(), &body)
  113. if body["mode"] != "dry-run" {
  114. t.Fatal("wrong mode")
  115. }
  116. }
  117. func TestConfigPatch(t *testing.T) {
  118. srv := NewServer(cfgpkg.Default())
  119. body := []byte(`{"toneLeftHz":900,"radioText":"hello world","preEmphasisTauUS":75}`)
  120. rec := httptest.NewRecorder()
  121. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body)))
  122. if rec.Code != 200 {
  123. t.Fatalf("status: %d body=%s", rec.Code, rec.Body.String())
  124. }
  125. }
  126. func TestConfigPatchRejectsOversizeBody(t *testing.T) {
  127. srv := NewServer(cfgpkg.Default())
  128. rec := httptest.NewRecorder()
  129. payload := bytes.Repeat([]byte("x"), maxConfigBodyBytes+32)
  130. body := append([]byte(`{"ps":"`), payload...)
  131. body = append(body, []byte(`"}`)...)
  132. req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body))
  133. srv.Handler().ServeHTTP(rec, req)
  134. if rec.Code != http.StatusRequestEntityTooLarge {
  135. t.Fatalf("expected 413, got %d response=%q", rec.Code, rec.Body.String())
  136. }
  137. }
  138. func TestRuntimeWithoutDriver(t *testing.T) {
  139. srv := NewServer(cfgpkg.Default())
  140. rec := httptest.NewRecorder()
  141. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  142. if rec.Code != 200 {
  143. t.Fatalf("status: %d", rec.Code)
  144. }
  145. }
  146. func TestRuntimeReportsFaultHistory(t *testing.T) {
  147. srv := NewServer(cfgpkg.Default())
  148. history := []map[string]any{
  149. {
  150. "time": "2026-04-06T00:00:00Z",
  151. "reason": "queueCritical",
  152. "severity": "faulted",
  153. "message": "queue critical",
  154. },
  155. }
  156. srv.SetTXController(&fakeTXController{stats: map[string]any{"faultHistory": history}})
  157. rec := httptest.NewRecorder()
  158. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  159. if rec.Code != 200 {
  160. t.Fatalf("status: %d", rec.Code)
  161. }
  162. var body map[string]any
  163. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  164. t.Fatalf("unmarshal runtime: %v", err)
  165. }
  166. engineRaw, ok := body["engine"].(map[string]any)
  167. if !ok {
  168. t.Fatalf("runtime engine missing")
  169. }
  170. histRaw, ok := engineRaw["faultHistory"].([]any)
  171. if !ok {
  172. t.Fatalf("faultHistory missing or wrong type: %T", engineRaw["faultHistory"])
  173. }
  174. if len(histRaw) != len(history) {
  175. t.Fatalf("faultHistory length mismatch: want %d got %d", len(history), len(histRaw))
  176. }
  177. }
  178. func TestRuntimeReportsTransitionHistory(t *testing.T) {
  179. srv := NewServer(cfgpkg.Default())
  180. history := []map[string]any{{
  181. "time": "2026-04-06T00:00:00Z",
  182. "from": "running",
  183. "to": "degraded",
  184. "severity": "warn",
  185. }}
  186. srv.SetTXController(&fakeTXController{stats: map[string]any{"transitionHistory": history}})
  187. rec := httptest.NewRecorder()
  188. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  189. if rec.Code != 200 {
  190. t.Fatalf("status: %d", rec.Code)
  191. }
  192. var body map[string]any
  193. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  194. t.Fatalf("unmarshal runtime: %v", err)
  195. }
  196. engineRaw, ok := body["engine"].(map[string]any)
  197. if !ok {
  198. t.Fatalf("runtime engine missing")
  199. }
  200. histRaw, ok := engineRaw["transitionHistory"].([]any)
  201. if !ok {
  202. t.Fatalf("transitionHistory missing or wrong type: %T", engineRaw["transitionHistory"])
  203. }
  204. if len(histRaw) != len(history) {
  205. t.Fatalf("transitionHistory length mismatch: want %d got %d", len(history), len(histRaw))
  206. }
  207. }
  208. func TestRuntimeFaultResetRejectsGet(t *testing.T) {
  209. srv := NewServer(cfgpkg.Default())
  210. rec := httptest.NewRecorder()
  211. req := httptest.NewRequest(http.MethodGet, "/runtime/fault/reset", nil)
  212. srv.Handler().ServeHTTP(rec, req)
  213. if rec.Code != http.StatusMethodNotAllowed {
  214. t.Fatalf("expected 405 for fault reset GET, got %d", rec.Code)
  215. }
  216. }
  217. func TestRuntimeFaultResetRequiresController(t *testing.T) {
  218. srv := NewServer(cfgpkg.Default())
  219. rec := httptest.NewRecorder()
  220. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil)
  221. srv.Handler().ServeHTTP(rec, req)
  222. if rec.Code != http.StatusServiceUnavailable {
  223. t.Fatalf("expected 503 without controller, got %d", rec.Code)
  224. }
  225. }
  226. func TestRuntimeFaultResetControllerError(t *testing.T) {
  227. srv := NewServer(cfgpkg.Default())
  228. srv.SetTXController(&fakeTXController{resetErr: errors.New("boom")})
  229. rec := httptest.NewRecorder()
  230. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil)
  231. srv.Handler().ServeHTTP(rec, req)
  232. if rec.Code != http.StatusConflict {
  233. t.Fatalf("expected 409 when controller rejects, got %d", rec.Code)
  234. }
  235. }
  236. func TestRuntimeFaultResetSuccess(t *testing.T) {
  237. srv := NewServer(cfgpkg.Default())
  238. srv.SetTXController(&fakeTXController{})
  239. rec := httptest.NewRecorder()
  240. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil)
  241. srv.Handler().ServeHTTP(rec, req)
  242. if rec.Code != 200 {
  243. t.Fatalf("expected 200 on success, got %d", rec.Code)
  244. }
  245. var body map[string]any
  246. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  247. t.Fatalf("unmarshal response: %v", err)
  248. }
  249. if ok, _ := body["ok"].(bool); !ok {
  250. t.Fatalf("expected ok true, got %v", body["ok"])
  251. }
  252. }
  253. func TestAudioStreamRequiresSource(t *testing.T) {
  254. srv := NewServer(cfgpkg.Default())
  255. rec := httptest.NewRecorder()
  256. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(nil))
  257. srv.Handler().ServeHTTP(rec, req)
  258. if rec.Code != http.StatusServiceUnavailable {
  259. t.Fatalf("expected 503 when audio stream missing, got %d", rec.Code)
  260. }
  261. }
  262. func TestAudioStreamPushesPCM(t *testing.T) {
  263. cfg := cfgpkg.Default()
  264. srv := NewServer(cfg)
  265. stream := audio.NewStreamSource(256, 44100)
  266. srv.SetStreamSource(stream)
  267. pcm := []byte{0, 0, 0, 0}
  268. rec := httptest.NewRecorder()
  269. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(pcm))
  270. srv.Handler().ServeHTTP(rec, req)
  271. if rec.Code != 200 {
  272. t.Fatalf("expected 200, got %d", rec.Code)
  273. }
  274. var body map[string]any
  275. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  276. t.Fatalf("unmarshal response: %v", err)
  277. }
  278. if ok, _ := body["ok"].(bool); !ok {
  279. t.Fatalf("expected ok true, got %v", body["ok"])
  280. }
  281. frames, _ := body["frames"].(float64)
  282. if frames != 1 {
  283. t.Fatalf("expected 1 frame, got %v", frames)
  284. }
  285. stats, ok := body["stats"].(map[string]any)
  286. if !ok {
  287. t.Fatalf("missing stats: %v", body["stats"])
  288. }
  289. if avail, _ := stats["available"].(float64); avail < 1 {
  290. t.Fatalf("expected stats.available >= 1, got %v", avail)
  291. }
  292. }
  293. func TestAudioStreamRejectsNonPost(t *testing.T) {
  294. srv := NewServer(cfgpkg.Default())
  295. rec := httptest.NewRecorder()
  296. req := httptest.NewRequest(http.MethodGet, "/audio/stream", nil)
  297. srv.Handler().ServeHTTP(rec, req)
  298. if rec.Code != http.StatusMethodNotAllowed {
  299. t.Fatalf("expected 405 for audio stream GET, got %d", rec.Code)
  300. }
  301. }
  302. func TestTXStartWithoutController(t *testing.T) {
  303. srv := NewServer(cfgpkg.Default())
  304. rec := httptest.NewRecorder()
  305. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/tx/start", nil))
  306. if rec.Code != http.StatusServiceUnavailable {
  307. t.Fatalf("expected 503, got %d", rec.Code)
  308. }
  309. }
  310. func TestConfigPatchUpdatesSnapshot(t *testing.T) {
  311. srv := NewServer(cfgpkg.Default())
  312. srv.SetTXController(&fakeTXController{})
  313. rec := httptest.NewRecorder()
  314. body := []byte(`{"outputDrive":1.2}`)
  315. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body)))
  316. if rec.Code != 200 {
  317. t.Fatalf("status: %d", rec.Code)
  318. }
  319. var resp map[string]any
  320. if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
  321. t.Fatalf("unmarshal response: %v", err)
  322. }
  323. if live, ok := resp["live"].(bool); !ok || !live {
  324. t.Fatalf("expected live true, got %v", resp["live"])
  325. }
  326. rec = httptest.NewRecorder()
  327. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/config", nil))
  328. var cfg cfgpkg.Config
  329. if err := json.NewDecoder(rec.Body).Decode(&cfg); err != nil {
  330. t.Fatalf("decode config: %v", err)
  331. }
  332. if cfg.FM.OutputDrive != 1.2 {
  333. t.Fatalf("expected snapshot to reflect new drive, got %v", cfg.FM.OutputDrive)
  334. }
  335. }
  336. func TestConfigPatchEngineRejectsDoesNotUpdateSnapshot(t *testing.T) {
  337. srv := NewServer(cfgpkg.Default())
  338. srv.SetTXController(&fakeTXController{updateErr: errors.New("boom")})
  339. body := []byte(`{"outputDrive":2.2}`)
  340. rec := httptest.NewRecorder()
  341. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body)))
  342. if rec.Code != http.StatusBadRequest {
  343. t.Fatalf("expected 400, got %d", rec.Code)
  344. }
  345. rec = httptest.NewRecorder()
  346. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/config", nil))
  347. var cfg cfgpkg.Config
  348. if err := json.NewDecoder(rec.Body).Decode(&cfg); err != nil {
  349. t.Fatalf("decode config: %v", err)
  350. }
  351. if cfg.FM.OutputDrive != cfgpkg.Default().FM.OutputDrive {
  352. t.Fatalf("expected snapshot untouched, got %v", cfg.FM.OutputDrive)
  353. }
  354. }
  355. type fakeTXController struct {
  356. updateErr error
  357. resetErr error
  358. stats map[string]any
  359. }
  360. func (f *fakeTXController) StartTX() error { return nil }
  361. func (f *fakeTXController) StopTX() error { return nil }
  362. func (f *fakeTXController) TXStats() map[string]any {
  363. if f.stats != nil {
  364. return f.stats
  365. }
  366. return map[string]any{}
  367. }
  368. func (f *fakeTXController) UpdateConfig(_ LivePatch) error { return f.updateErr }
  369. func (f *fakeTXController) ResetFault() error { return f.resetErr }