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.

884 Zeilen
29KB

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