Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.

1131 satır
37KB

  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. offpkg "github.com/jan/fm-rds-tx/internal/offline"
  16. "github.com/jan/fm-rds-tx/internal/output"
  17. )
  18. func TestHealthz(t *testing.T) {
  19. srv := NewServer(cfgpkg.Default())
  20. rec := httptest.NewRecorder()
  21. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
  22. if rec.Code != 200 {
  23. t.Fatalf("status: %d", rec.Code)
  24. }
  25. }
  26. func TestStatus(t *testing.T) {
  27. srv := NewServer(cfgpkg.Default())
  28. rec := httptest.NewRecorder()
  29. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
  30. if rec.Code != 200 {
  31. t.Fatalf("status: %d", rec.Code)
  32. }
  33. var body map[string]any
  34. json.Unmarshal(rec.Body.Bytes(), &body)
  35. if body["service"] != "fm-rds-tx" {
  36. t.Fatal("missing service")
  37. }
  38. if _, ok := body["preEmphasisTauUS"]; !ok {
  39. t.Fatal("missing preEmphasisTauUS")
  40. }
  41. }
  42. func TestStatusReportsRuntimeIndicator(t *testing.T) {
  43. srv := NewServer(cfgpkg.Default())
  44. srv.SetTXController(&fakeTXController{stats: map[string]any{"runtimeIndicator": "degraded", "runtimeAlert": "late buffers"}})
  45. rec := httptest.NewRecorder()
  46. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
  47. if rec.Code != 200 {
  48. t.Fatalf("status: %d", rec.Code)
  49. }
  50. var body map[string]any
  51. json.Unmarshal(rec.Body.Bytes(), &body)
  52. if body["runtimeIndicator"] != "degraded" {
  53. t.Fatalf("expected runtimeIndicator degraded, got %v", body["runtimeIndicator"])
  54. }
  55. if body["runtimeAlert"] != "late buffers" {
  56. t.Fatalf("expected runtimeAlert late buffers, got %v", body["runtimeAlert"])
  57. }
  58. }
  59. func TestStatusReportsQueueStats(t *testing.T) {
  60. cfg := cfgpkg.Default()
  61. queueStats := output.QueueStats{
  62. Capacity: cfg.Runtime.FrameQueueCapacity,
  63. Depth: 1,
  64. FillLevel: 0.25,
  65. Health: output.QueueHealthLow,
  66. }
  67. srv := NewServer(cfg)
  68. srv.SetTXController(&fakeTXController{stats: map[string]any{"queue": queueStats}})
  69. rec := httptest.NewRecorder()
  70. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
  71. if rec.Code != 200 {
  72. t.Fatalf("status: %d", rec.Code)
  73. }
  74. var body map[string]any
  75. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  76. t.Fatalf("unmarshal queue stats: %v", err)
  77. }
  78. queueRaw, ok := body["queue"]
  79. if !ok {
  80. t.Fatalf("missing queue in status")
  81. }
  82. queueMap, ok := queueRaw.(map[string]any)
  83. if !ok {
  84. t.Fatalf("queue stats type mismatch: %T", queueRaw)
  85. }
  86. if queueMap["capacity"] != float64(queueStats.Capacity) {
  87. t.Fatalf("queue capacity mismatch: want %v got %v", queueStats.Capacity, queueMap["capacity"])
  88. }
  89. if queueMap["health"] != string(queueStats.Health) {
  90. t.Fatalf("queue health mismatch: want %s got %v", queueStats.Health, queueMap["health"])
  91. }
  92. }
  93. func TestStatusReportsRuntimeState(t *testing.T) {
  94. srv := NewServer(cfgpkg.Default())
  95. srv.SetTXController(&fakeTXController{stats: map[string]any{"state": "faulted"}})
  96. rec := httptest.NewRecorder()
  97. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
  98. if rec.Code != 200 {
  99. t.Fatalf("status: %d", rec.Code)
  100. }
  101. var body map[string]any
  102. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  103. t.Fatalf("unmarshal runtime state: %v", err)
  104. }
  105. if body["runtimeState"] != "faulted" {
  106. t.Fatalf("expected runtimeState faulted, got %v", body["runtimeState"])
  107. }
  108. }
  109. func TestDryRunEndpoint(t *testing.T) {
  110. srv := NewServer(cfgpkg.Default())
  111. rec := httptest.NewRecorder()
  112. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/dry-run", nil))
  113. if rec.Code != 200 {
  114. t.Fatalf("status: %d", rec.Code)
  115. }
  116. var body map[string]any
  117. json.Unmarshal(rec.Body.Bytes(), &body)
  118. if body["mode"] != "dry-run" {
  119. t.Fatal("wrong mode")
  120. }
  121. }
  122. func TestConfigPatch(t *testing.T) {
  123. srv := NewServer(cfgpkg.Default())
  124. body := []byte(`{"toneLeftHz":900,"radioText":"hello world","preEmphasisTauUS":75}`)
  125. rec := httptest.NewRecorder()
  126. srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
  127. if rec.Code != 200 {
  128. t.Fatalf("status: %d body=%s", rec.Code, rec.Body.String())
  129. }
  130. }
  131. func TestConfigPatchFailedLiveUpdateDoesNotMutateSnapshot(t *testing.T) {
  132. cfg := cfgpkg.Default()
  133. srv := NewServer(cfg)
  134. srv.SetTXController(&fakeTXController{updateErr: errors.New("boom")})
  135. body := []byte(`{"stereoMode":"SSB"}`)
  136. rec := httptest.NewRecorder()
  137. srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
  138. if rec.Code != http.StatusBadRequest {
  139. t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
  140. }
  141. cfgRec := httptest.NewRecorder()
  142. srv.Handler().ServeHTTP(cfgRec, httptest.NewRequest(http.MethodGet, "/config", nil))
  143. if cfgRec.Code != http.StatusOK {
  144. t.Fatalf("config status: %d", cfgRec.Code)
  145. }
  146. var got cfgpkg.Config
  147. if err := json.Unmarshal(cfgRec.Body.Bytes(), &got); err != nil {
  148. t.Fatalf("unmarshal config: %v", err)
  149. }
  150. if got.FM.StereoMode != cfg.FM.StereoMode {
  151. t.Fatalf("snapshot mutated on failed live update: got %q want %q", got.FM.StereoMode, cfg.FM.StereoMode)
  152. }
  153. }
  154. func TestConfigPatchRejectsOversizeBody(t *testing.T) {
  155. srv := NewServer(cfgpkg.Default())
  156. rec := httptest.NewRecorder()
  157. payload := bytes.Repeat([]byte("x"), maxConfigBodyBytes+32)
  158. body := append([]byte(`{"ps":"`), payload...)
  159. body = append(body, []byte(`"}`)...)
  160. req := newConfigPostRequest(body)
  161. srv.Handler().ServeHTTP(rec, req)
  162. if rec.Code != http.StatusRequestEntityTooLarge {
  163. t.Fatalf("expected 413, got %d response=%q", rec.Code, rec.Body.String())
  164. }
  165. }
  166. func TestConfigPatchRejectsMissingContentType(t *testing.T) {
  167. srv := NewServer(cfgpkg.Default())
  168. rec := httptest.NewRecorder()
  169. req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader([]byte(`{}`)))
  170. srv.Handler().ServeHTTP(rec, req)
  171. if rec.Code != http.StatusUnsupportedMediaType {
  172. t.Fatalf("expected 415 when Content-Type missing, got %d", rec.Code)
  173. }
  174. }
  175. func TestConfigPatchRejectsNonJSONContentType(t *testing.T) {
  176. srv := NewServer(cfgpkg.Default())
  177. rec := httptest.NewRecorder()
  178. req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader([]byte(`{}`)))
  179. req.Header.Set("Content-Type", "text/plain")
  180. srv.Handler().ServeHTTP(rec, req)
  181. if rec.Code != http.StatusUnsupportedMediaType {
  182. t.Fatalf("expected 415 for non-JSON Content-Type, got %d", rec.Code)
  183. }
  184. }
  185. func TestIngestSavePersistsAndSchedulesReload(t *testing.T) {
  186. cfg := cfgpkg.Default()
  187. cfg.Ingest.Kind = "icecast"
  188. cfg.Ingest.Icecast.URL = "https://example.invalid/live"
  189. srv := NewServer(cfg)
  190. dir := t.TempDir()
  191. configPath := filepath.Join(dir, "saved.json")
  192. reloadDone := make(chan struct{}, 1)
  193. srv.SetConfigSaver(func(next cfgpkg.Config) error {
  194. return cfgpkg.Save(configPath, next)
  195. })
  196. srv.SetHardReload(func() {
  197. select {
  198. case reloadDone <- struct{}{}:
  199. default:
  200. }
  201. })
  202. nextIngest := cfgpkg.Default().Ingest
  203. nextIngest.Kind = "srt"
  204. nextIngest.PrebufferMs = 1000
  205. nextIngest.StallTimeoutMs = 2500
  206. nextIngest.Reconnect.Enabled = true
  207. nextIngest.Reconnect.InitialBackoffMs = 500
  208. nextIngest.Reconnect.MaxBackoffMs = 5000
  209. nextIngest.SRT.URL = "srt://0.0.0.0:9000?mode=listener"
  210. body, err := json.Marshal(IngestSaveRequest{Ingest: nextIngest})
  211. if err != nil {
  212. t.Fatalf("marshal body: %v", err)
  213. }
  214. rec := httptest.NewRecorder()
  215. srv.Handler().ServeHTTP(rec, newIngestSavePostRequest(body))
  216. if rec.Code != http.StatusOK {
  217. t.Fatalf("status: %d body=%s", rec.Code, rec.Body.String())
  218. }
  219. select {
  220. case <-reloadDone:
  221. case <-time.After(2 * time.Second):
  222. t.Fatal("expected hard reload callback")
  223. }
  224. saved, err := cfgpkg.Load(configPath)
  225. if err != nil {
  226. t.Fatalf("load saved config: %v", err)
  227. }
  228. if saved.Ingest.Kind != "srt" {
  229. t.Fatalf("expected saved ingest kind srt, got %q", saved.Ingest.Kind)
  230. }
  231. if saved.Ingest.SRT.URL != "srt://0.0.0.0:9000?mode=listener" {
  232. t.Fatalf("expected saved ingest.srt.url, got %q", saved.Ingest.SRT.URL)
  233. }
  234. }
  235. func TestIngestSaveRejectsWhenSaverMissing(t *testing.T) {
  236. cfg := cfgpkg.Default()
  237. cfg.Ingest.Kind = "icecast"
  238. cfg.Ingest.Icecast.URL = "https://example.invalid/live"
  239. srv := NewServer(cfg)
  240. rec := httptest.NewRecorder()
  241. nextIngest := cfgpkg.Default().Ingest
  242. nextIngest.Kind = "icecast"
  243. nextIngest.Icecast.URL = "https://example.invalid/live"
  244. body, err := json.Marshal(IngestSaveRequest{Ingest: nextIngest})
  245. if err != nil {
  246. t.Fatalf("marshal body: %v", err)
  247. }
  248. srv.Handler().ServeHTTP(rec, newIngestSavePostRequest(body))
  249. if rec.Code != http.StatusServiceUnavailable {
  250. t.Fatalf("expected 503, got %d body=%s", rec.Code, rec.Body.String())
  251. }
  252. }
  253. func TestIngestSaveUsesValidationErrors(t *testing.T) {
  254. cfg := cfgpkg.Default()
  255. cfg.Ingest.Kind = "icecast"
  256. cfg.Ingest.Icecast.URL = "https://example.invalid/live"
  257. srv := NewServer(cfg)
  258. dir := t.TempDir()
  259. configPath := filepath.Join(dir, "saved.json")
  260. srv.SetConfigSaver(func(next cfgpkg.Config) error {
  261. return cfgpkg.Save(configPath, next)
  262. })
  263. rec := httptest.NewRecorder()
  264. nextIngest := cfgpkg.Default().Ingest
  265. nextIngest.Kind = "srt"
  266. nextIngest.SRT.URL = ""
  267. body, err := json.Marshal(IngestSaveRequest{Ingest: nextIngest})
  268. if err != nil {
  269. t.Fatalf("marshal body: %v", err)
  270. }
  271. srv.Handler().ServeHTTP(rec, newIngestSavePostRequest(body))
  272. if rec.Code != http.StatusBadRequest {
  273. t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
  274. }
  275. if !strings.Contains(rec.Body.String(), "ingest.srt.url is required") {
  276. t.Fatalf("expected existing validation error, got %q", rec.Body.String())
  277. }
  278. if _, err := os.Stat(configPath); !errors.Is(err, os.ErrNotExist) {
  279. t.Fatalf("expected no config file to be written, stat err=%v", err)
  280. }
  281. }
  282. func TestRuntimeWithoutDriver(t *testing.T) {
  283. srv := NewServer(cfgpkg.Default())
  284. rec := httptest.NewRecorder()
  285. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  286. if rec.Code != 200 {
  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. if _, ok := body["ingest"]; ok {
  294. t.Fatalf("expected ingest payload to be absent when ingest runtime is not configured")
  295. }
  296. if _, ok := body["engine"]; ok {
  297. t.Fatalf("expected engine payload to be absent when tx controller is not configured")
  298. }
  299. }
  300. func TestRuntimeIncludesIngestStats(t *testing.T) {
  301. srv := NewServer(cfgpkg.Default())
  302. srv.SetIngestRuntime(&fakeIngestRuntime{
  303. stats: ingest.Stats{
  304. Active: ingest.SourceDescriptor{ID: "stdin-main", Kind: "stdin-pcm"},
  305. Runtime: ingest.RuntimeStats{State: "running"},
  306. },
  307. })
  308. rec := httptest.NewRecorder()
  309. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  310. if rec.Code != http.StatusOK {
  311. t.Fatalf("status: %d", rec.Code)
  312. }
  313. var body map[string]any
  314. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  315. t.Fatalf("unmarshal runtime: %v", err)
  316. }
  317. ingest, ok := body["ingest"].(map[string]any)
  318. if !ok {
  319. t.Fatalf("expected ingest stats, got %T", body["ingest"])
  320. }
  321. active, ok := ingest["active"].(map[string]any)
  322. if !ok {
  323. t.Fatalf("expected ingest.active map, got %T", ingest["active"])
  324. }
  325. if active["id"] != "stdin-main" {
  326. t.Fatalf("unexpected ingest active id: %v", active["id"])
  327. }
  328. }
  329. func TestRuntimeIncludesDetailedIngestSourceAndRuntimeStats(t *testing.T) {
  330. srv := NewServer(cfgpkg.Default())
  331. srv.SetIngestRuntime(&fakeIngestRuntime{
  332. stats: ingest.Stats{
  333. Active: ingest.SourceDescriptor{
  334. ID: "icecast-main",
  335. Kind: "icecast",
  336. Origin: &ingest.SourceOrigin{
  337. Kind: "url",
  338. Endpoint: "http://example.org/live",
  339. },
  340. },
  341. Source: ingest.SourceStats{
  342. State: "reconnecting",
  343. Connected: false,
  344. Reconnects: 3,
  345. LastError: "dial tcp timeout",
  346. },
  347. Runtime: ingest.RuntimeStats{
  348. State: "degraded",
  349. ConvertErrors: 2,
  350. WriteBlocked: true,
  351. },
  352. },
  353. })
  354. rec := httptest.NewRecorder()
  355. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  356. if rec.Code != http.StatusOK {
  357. t.Fatalf("status: %d", rec.Code)
  358. }
  359. var body map[string]any
  360. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  361. t.Fatalf("unmarshal runtime: %v", err)
  362. }
  363. ingestPayload, ok := body["ingest"].(map[string]any)
  364. if !ok {
  365. t.Fatalf("expected ingest payload map, got %T", body["ingest"])
  366. }
  367. source, ok := ingestPayload["source"].(map[string]any)
  368. if !ok {
  369. t.Fatalf("expected ingest.source map, got %T", ingestPayload["source"])
  370. }
  371. if source["state"] != "reconnecting" {
  372. t.Fatalf("source state mismatch: got %v", source["state"])
  373. }
  374. if source["reconnects"] != float64(3) {
  375. t.Fatalf("source reconnects mismatch: got %v", source["reconnects"])
  376. }
  377. if source["lastError"] != "dial tcp timeout" {
  378. t.Fatalf("source lastError mismatch: got %v", source["lastError"])
  379. }
  380. active, ok := ingestPayload["active"].(map[string]any)
  381. if !ok {
  382. t.Fatalf("expected ingest.active map, got %T", ingestPayload["active"])
  383. }
  384. origin, ok := active["origin"].(map[string]any)
  385. if !ok {
  386. t.Fatalf("expected ingest.active.origin map, got %T", active["origin"])
  387. }
  388. if origin["kind"] != "url" {
  389. t.Fatalf("origin kind mismatch: got %v", origin["kind"])
  390. }
  391. if origin["endpoint"] != "http://example.org/live" {
  392. t.Fatalf("origin endpoint mismatch: got %v", origin["endpoint"])
  393. }
  394. runtimePayload, ok := ingestPayload["runtime"].(map[string]any)
  395. if !ok {
  396. t.Fatalf("expected ingest.runtime map, got %T", ingestPayload["runtime"])
  397. }
  398. if runtimePayload["state"] != "degraded" {
  399. t.Fatalf("runtime state mismatch: got %v", runtimePayload["state"])
  400. }
  401. if runtimePayload["convertErrors"] != float64(2) {
  402. t.Fatalf("runtime convertErrors mismatch: got %v", runtimePayload["convertErrors"])
  403. }
  404. if runtimePayload["writeBlocked"] != true {
  405. t.Fatalf("runtime writeBlocked mismatch: got %v", runtimePayload["writeBlocked"])
  406. }
  407. }
  408. func TestRuntimeOmitsEngineWhenControllerReturnsNilStats(t *testing.T) {
  409. srv := NewServer(cfgpkg.Default())
  410. srv.SetTXController(&fakeTXController{returnNilStats: true})
  411. rec := httptest.NewRecorder()
  412. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  413. if rec.Code != http.StatusOK {
  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. if _, ok := body["engine"]; ok {
  421. t.Fatalf("expected engine field to be omitted when TXStats returns nil")
  422. }
  423. }
  424. func TestRuntimeReportsFaultHistory(t *testing.T) {
  425. srv := NewServer(cfgpkg.Default())
  426. history := []map[string]any{
  427. {
  428. "time": "2026-04-06T00:00:00Z",
  429. "reason": "queueCritical",
  430. "severity": "faulted",
  431. "message": "queue critical",
  432. },
  433. }
  434. srv.SetTXController(&fakeTXController{stats: map[string]any{"faultHistory": history}})
  435. rec := httptest.NewRecorder()
  436. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  437. if rec.Code != 200 {
  438. t.Fatalf("status: %d", rec.Code)
  439. }
  440. var body map[string]any
  441. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  442. t.Fatalf("unmarshal runtime: %v", err)
  443. }
  444. engineRaw, ok := body["engine"].(map[string]any)
  445. if !ok {
  446. t.Fatalf("runtime engine missing")
  447. }
  448. histRaw, ok := engineRaw["faultHistory"].([]any)
  449. if !ok {
  450. t.Fatalf("faultHistory missing or wrong type: %T", engineRaw["faultHistory"])
  451. }
  452. if len(histRaw) != len(history) {
  453. t.Fatalf("faultHistory length mismatch: want %d got %d", len(history), len(histRaw))
  454. }
  455. }
  456. func TestRuntimeReportsTransitionHistory(t *testing.T) {
  457. srv := NewServer(cfgpkg.Default())
  458. history := []map[string]any{{
  459. "time": "2026-04-06T00:00:00Z",
  460. "from": "running",
  461. "to": "degraded",
  462. "severity": "warn",
  463. }}
  464. srv.SetTXController(&fakeTXController{stats: map[string]any{"transitionHistory": history}})
  465. rec := httptest.NewRecorder()
  466. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  467. if rec.Code != 200 {
  468. t.Fatalf("status: %d", rec.Code)
  469. }
  470. var body map[string]any
  471. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  472. t.Fatalf("unmarshal runtime: %v", err)
  473. }
  474. engineRaw, ok := body["engine"].(map[string]any)
  475. if !ok {
  476. t.Fatalf("runtime engine missing")
  477. }
  478. histRaw, ok := engineRaw["transitionHistory"].([]any)
  479. if !ok {
  480. t.Fatalf("transitionHistory missing or wrong type: %T", engineRaw["transitionHistory"])
  481. }
  482. if len(histRaw) != len(history) {
  483. t.Fatalf("transitionHistory length mismatch: want %d got %d", len(history), len(histRaw))
  484. }
  485. }
  486. func TestRuntimeFaultResetRejectsGet(t *testing.T) {
  487. srv := NewServer(cfgpkg.Default())
  488. rec := httptest.NewRecorder()
  489. req := httptest.NewRequest(http.MethodGet, "/runtime/fault/reset", nil)
  490. srv.Handler().ServeHTTP(rec, req)
  491. if rec.Code != http.StatusMethodNotAllowed {
  492. t.Fatalf("expected 405 for fault reset GET, got %d", rec.Code)
  493. }
  494. }
  495. func TestRuntimeFaultResetRequiresController(t *testing.T) {
  496. srv := NewServer(cfgpkg.Default())
  497. rec := httptest.NewRecorder()
  498. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil)
  499. srv.Handler().ServeHTTP(rec, req)
  500. if rec.Code != http.StatusServiceUnavailable {
  501. t.Fatalf("expected 503 without controller, got %d", rec.Code)
  502. }
  503. }
  504. func TestRuntimeFaultResetControllerError(t *testing.T) {
  505. srv := NewServer(cfgpkg.Default())
  506. srv.SetTXController(&fakeTXController{resetErr: errors.New("boom")})
  507. rec := httptest.NewRecorder()
  508. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil)
  509. srv.Handler().ServeHTTP(rec, req)
  510. if rec.Code != http.StatusConflict {
  511. t.Fatalf("expected 409 when controller rejects, got %d", rec.Code)
  512. }
  513. }
  514. func TestRuntimeFaultResetSuccess(t *testing.T) {
  515. srv := NewServer(cfgpkg.Default())
  516. srv.SetTXController(&fakeTXController{})
  517. rec := httptest.NewRecorder()
  518. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", nil)
  519. srv.Handler().ServeHTTP(rec, req)
  520. if rec.Code != 200 {
  521. t.Fatalf("expected 200 on success, got %d", rec.Code)
  522. }
  523. var body map[string]any
  524. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  525. t.Fatalf("unmarshal response: %v", err)
  526. }
  527. if ok, _ := body["ok"].(bool); !ok {
  528. t.Fatalf("expected ok true, got %v", body["ok"])
  529. }
  530. }
  531. func TestRuntimeFaultResetRejectsBody(t *testing.T) {
  532. srv := NewServer(cfgpkg.Default())
  533. srv.SetTXController(&fakeTXController{})
  534. rec := httptest.NewRecorder()
  535. req := httptest.NewRequest(http.MethodPost, "/runtime/fault/reset", bytes.NewReader([]byte("nope")))
  536. srv.Handler().ServeHTTP(rec, req)
  537. if rec.Code != http.StatusBadRequest {
  538. t.Fatalf("expected 400 when body present, got %d", rec.Code)
  539. }
  540. if !strings.Contains(rec.Body.String(), "request must not include a body") {
  541. t.Fatalf("unexpected response body: %q", rec.Body.String())
  542. }
  543. }
  544. func TestAudioStreamRequiresSource(t *testing.T) {
  545. srv := NewServer(cfgpkg.Default())
  546. rec := httptest.NewRecorder()
  547. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(nil))
  548. req.Header.Set("Content-Type", "application/octet-stream")
  549. srv.Handler().ServeHTTP(rec, req)
  550. if rec.Code != http.StatusServiceUnavailable {
  551. t.Fatalf("expected 503 when audio stream missing, got %d", rec.Code)
  552. }
  553. }
  554. func TestAudioStreamPushesPCM(t *testing.T) {
  555. cfg := cfgpkg.Default()
  556. srv := NewServer(cfg)
  557. ingress := &fakeAudioIngress{}
  558. srv.SetAudioIngress(ingress)
  559. pcm := []byte{0, 0, 0, 0}
  560. rec := httptest.NewRecorder()
  561. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(pcm))
  562. req.Header.Set("Content-Type", "application/octet-stream")
  563. srv.Handler().ServeHTTP(rec, req)
  564. if rec.Code != 200 {
  565. t.Fatalf("expected 200, got %d", rec.Code)
  566. }
  567. var body map[string]any
  568. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  569. t.Fatalf("unmarshal response: %v", err)
  570. }
  571. if ok, _ := body["ok"].(bool); !ok {
  572. t.Fatalf("expected ok true, got %v", body["ok"])
  573. }
  574. frames, _ := body["frames"].(float64)
  575. if frames != 1 {
  576. t.Fatalf("expected 1 frame, got %v", frames)
  577. }
  578. if ingress.totalFrames != 1 {
  579. t.Fatalf("expected ingress frames=1, got %d", ingress.totalFrames)
  580. }
  581. }
  582. func TestAudioStreamRejectsNonPost(t *testing.T) {
  583. srv := NewServer(cfgpkg.Default())
  584. rec := httptest.NewRecorder()
  585. req := httptest.NewRequest(http.MethodGet, "/audio/stream", nil)
  586. srv.Handler().ServeHTTP(rec, req)
  587. if rec.Code != http.StatusMethodNotAllowed {
  588. t.Fatalf("expected 405 for audio stream GET, got %d", rec.Code)
  589. }
  590. }
  591. func TestAudioStreamRejectsMissingContentType(t *testing.T) {
  592. cfg := cfgpkg.Default()
  593. srv := NewServer(cfg)
  594. srv.SetAudioIngress(&fakeAudioIngress{})
  595. rec := httptest.NewRecorder()
  596. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0}))
  597. srv.Handler().ServeHTTP(rec, req)
  598. if rec.Code != http.StatusUnsupportedMediaType {
  599. t.Fatalf("expected 415 when Content-Type missing, got %d", rec.Code)
  600. }
  601. if !strings.Contains(rec.Body.String(), "Content-Type must be") {
  602. t.Fatalf("unexpected response body: %q", rec.Body.String())
  603. }
  604. }
  605. func TestAudioStreamRejectsUnsupportedContentType(t *testing.T) {
  606. cfg := cfgpkg.Default()
  607. srv := NewServer(cfg)
  608. srv.SetAudioIngress(&fakeAudioIngress{})
  609. rec := httptest.NewRecorder()
  610. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0}))
  611. req.Header.Set("Content-Type", "text/plain")
  612. srv.Handler().ServeHTTP(rec, req)
  613. if rec.Code != http.StatusUnsupportedMediaType {
  614. t.Fatalf("expected 415 for unsupported Content-Type, got %d", rec.Code)
  615. }
  616. if !strings.Contains(rec.Body.String(), "Content-Type must be") {
  617. t.Fatalf("unexpected response body: %q", rec.Body.String())
  618. }
  619. }
  620. func TestAudioStreamRejectsBodyTooLarge(t *testing.T) {
  621. orig := audioStreamBodyLimit
  622. t.Cleanup(func() {
  623. audioStreamBodyLimit = orig
  624. })
  625. audioStreamBodyLimit = 1024
  626. limit := int(audioStreamBodyLimit)
  627. body := make([]byte, limit+1)
  628. srv := NewServer(cfgpkg.Default())
  629. srv.SetAudioIngress(&fakeAudioIngress{})
  630. rec := httptest.NewRecorder()
  631. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(body))
  632. req.Header.Set("Content-Type", "application/octet-stream")
  633. srv.Handler().ServeHTTP(rec, req)
  634. if rec.Code != http.StatusRequestEntityTooLarge {
  635. t.Fatalf("expected 413 for oversized body, got %d", rec.Code)
  636. }
  637. if !strings.Contains(rec.Body.String(), "request body too large") {
  638. t.Fatalf("unexpected response body: %q", rec.Body.String())
  639. }
  640. }
  641. func TestTXStartWithoutController(t *testing.T) {
  642. srv := NewServer(cfgpkg.Default())
  643. rec := httptest.NewRecorder()
  644. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/tx/start", nil))
  645. if rec.Code != http.StatusServiceUnavailable {
  646. t.Fatalf("expected 503, got %d", rec.Code)
  647. }
  648. }
  649. func TestTXStartRejectsBody(t *testing.T) {
  650. srv := NewServer(cfgpkg.Default())
  651. srv.SetTXController(&fakeTXController{})
  652. rec := httptest.NewRecorder()
  653. req := httptest.NewRequest(http.MethodPost, "/tx/start", bytes.NewReader([]byte("body")))
  654. srv.Handler().ServeHTTP(rec, req)
  655. if rec.Code != http.StatusBadRequest {
  656. t.Fatalf("expected 400 when body present, got %d", rec.Code)
  657. }
  658. if !strings.Contains(rec.Body.String(), "request must not include a body") {
  659. t.Fatalf("unexpected response body: %q", rec.Body.String())
  660. }
  661. }
  662. func TestTXStopRejectsBody(t *testing.T) {
  663. srv := NewServer(cfgpkg.Default())
  664. srv.SetTXController(&fakeTXController{})
  665. rec := httptest.NewRecorder()
  666. req := httptest.NewRequest(http.MethodPost, "/tx/stop", bytes.NewReader([]byte("body")))
  667. srv.Handler().ServeHTTP(rec, req)
  668. if rec.Code != http.StatusBadRequest {
  669. t.Fatalf("expected 400 when body present, got %d", rec.Code)
  670. }
  671. if !strings.Contains(rec.Body.String(), "request must not include a body") {
  672. t.Fatalf("unexpected response body: %q", rec.Body.String())
  673. }
  674. }
  675. func TestConfigPatchUpdatesSnapshot(t *testing.T) {
  676. srv := NewServer(cfgpkg.Default())
  677. srv.SetTXController(&fakeTXController{})
  678. rec := httptest.NewRecorder()
  679. body := []byte(`{"outputDrive":1.2}`)
  680. srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
  681. if rec.Code != 200 {
  682. t.Fatalf("status: %d", rec.Code)
  683. }
  684. var resp map[string]any
  685. if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
  686. t.Fatalf("unmarshal response: %v", err)
  687. }
  688. if live, ok := resp["live"].(bool); !ok || !live {
  689. t.Fatalf("expected live true, got %v", resp["live"])
  690. }
  691. rec = httptest.NewRecorder()
  692. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/config", nil))
  693. var cfg cfgpkg.Config
  694. if err := json.NewDecoder(rec.Body).Decode(&cfg); err != nil {
  695. t.Fatalf("decode config: %v", err)
  696. }
  697. if cfg.FM.OutputDrive != 1.2 {
  698. t.Fatalf("expected snapshot to reflect new drive, got %v", cfg.FM.OutputDrive)
  699. }
  700. }
  701. func TestConfigPatchPersistsRestartRequiredFieldsWhenSaverConfigured(t *testing.T) {
  702. cfg := cfgpkg.Default()
  703. srv := NewServer(cfg)
  704. dir := t.TempDir()
  705. configPath := filepath.Join(dir, "saved.json")
  706. if err := cfgpkg.Save(configPath, cfg); err != nil {
  707. t.Fatalf("seed config: %v", err)
  708. }
  709. srv.SetConfigSaver(func(next cfgpkg.Config) error {
  710. return cfgpkg.Save(configPath, next)
  711. })
  712. rec := httptest.NewRecorder()
  713. body := []byte(`{
  714. "preEmphasisTauUS":75,
  715. "audioGain":1.5,
  716. "pi":"BEEF",
  717. "pty":10,
  718. "ms":false,
  719. "ctEnabled":false,
  720. "rtPlusEnabled":false,
  721. "rtPlusSeparator":"/",
  722. "ptyn":"ALTROCK",
  723. "lps":"My Radio Station",
  724. "ertEnabled":true,
  725. "ert":"Grüezi mitenand",
  726. "rds2Enabled":true,
  727. "stationLogoPath":"C:\\logo.png",
  728. "af":[93.3,95.7],
  729. "bs412Enabled":true,
  730. "bs412ThresholdDBr":0.5,
  731. "mpxGain":1.3,
  732. "compositeClipperIterations":4,
  733. "compositeClipperSoftKnee":0.22,
  734. "compositeClipperLookaheadMs":1.4
  735. }`)
  736. srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
  737. if rec.Code != http.StatusOK {
  738. t.Fatalf("status: %d body=%s", rec.Code, rec.Body.String())
  739. }
  740. saved, err := cfgpkg.Load(configPath)
  741. if err != nil {
  742. t.Fatalf("load saved config: %v", err)
  743. }
  744. if saved.FM.PreEmphasisTauUS != 75 {
  745. t.Fatalf("expected saved preEmphasisTauUS=75, got %v", saved.FM.PreEmphasisTauUS)
  746. }
  747. if saved.Audio.Gain != 1.5 {
  748. t.Fatalf("expected saved audio.gain=1.5, got %v", saved.Audio.Gain)
  749. }
  750. if saved.RDS.PI != "BEEF" {
  751. t.Fatalf("expected saved rds.pi=BEEF, got %q", saved.RDS.PI)
  752. }
  753. if saved.RDS.PTY != 10 {
  754. t.Fatalf("expected saved rds.pty=10, got %v", saved.RDS.PTY)
  755. }
  756. if saved.RDS.MS != false {
  757. t.Fatalf("expected saved rds.ms=false, got %v", saved.RDS.MS)
  758. }
  759. if saved.RDS.CTEnabled != false {
  760. t.Fatalf("expected saved rds.ctEnabled=false, got %v", saved.RDS.CTEnabled)
  761. }
  762. if saved.RDS.RTPlusEnabled != false {
  763. t.Fatalf("expected saved rds.rtPlusEnabled=false, got %v", saved.RDS.RTPlusEnabled)
  764. }
  765. if saved.RDS.RTPlusSeparator != "/" {
  766. t.Fatalf("expected saved rds.rtPlusSeparator='/', got %q", saved.RDS.RTPlusSeparator)
  767. }
  768. if saved.RDS.PTYN != "ALTROCK" {
  769. t.Fatalf("expected saved rds.ptyn=ALTROCK, got %q", saved.RDS.PTYN)
  770. }
  771. if saved.RDS.LPS != "My Radio Station" {
  772. t.Fatalf("expected saved rds.lps, got %q", saved.RDS.LPS)
  773. }
  774. if saved.RDS.ERTEnabled != true {
  775. t.Fatalf("expected saved rds.ertEnabled=true, got %v", saved.RDS.ERTEnabled)
  776. }
  777. if saved.RDS.ERT != "Grüezi mitenand" {
  778. t.Fatalf("expected saved rds.ert, got %q", saved.RDS.ERT)
  779. }
  780. if saved.RDS.RDS2Enabled != true {
  781. t.Fatalf("expected saved rds.rds2Enabled=true, got %v", saved.RDS.RDS2Enabled)
  782. }
  783. if saved.RDS.StationLogoPath != "C:\\logo.png" {
  784. t.Fatalf("expected saved rds.stationLogoPath, got %q", saved.RDS.StationLogoPath)
  785. }
  786. if len(saved.RDS.AF) != 2 || saved.RDS.AF[0] != 93.3 || saved.RDS.AF[1] != 95.7 {
  787. t.Fatalf("expected saved rds.af=[93.3 95.7], got %v", saved.RDS.AF)
  788. }
  789. if !saved.FM.BS412Enabled {
  790. t.Fatalf("expected saved bs412Enabled=true, got false")
  791. }
  792. if saved.FM.BS412ThresholdDBr != 0.5 {
  793. t.Fatalf("expected saved bs412ThresholdDBr=0.5, got %v", saved.FM.BS412ThresholdDBr)
  794. }
  795. if saved.FM.MpxGain != 1.3 {
  796. t.Fatalf("expected saved fm.mpxGain=1.3, got %v", saved.FM.MpxGain)
  797. }
  798. if saved.FM.CompositeClipper.Iterations != 4 {
  799. t.Fatalf("expected saved compositeClipper.iterations=4, got %v", saved.FM.CompositeClipper.Iterations)
  800. }
  801. if saved.FM.CompositeClipper.SoftKnee != 0.22 {
  802. t.Fatalf("expected saved compositeClipper.softKnee=0.22, got %v", saved.FM.CompositeClipper.SoftKnee)
  803. }
  804. if saved.FM.CompositeClipper.LookaheadMs != 1.4 {
  805. t.Fatalf("expected saved compositeClipper.lookaheadMs=1.4, got %v", saved.FM.CompositeClipper.LookaheadMs)
  806. }
  807. }
  808. func TestConfigPatchEngineRejectsDoesNotUpdateSnapshot(t *testing.T) {
  809. srv := NewServer(cfgpkg.Default())
  810. srv.SetTXController(&fakeTXController{updateErr: errors.New("boom")})
  811. body := []byte(`{"outputDrive":2.2}`)
  812. rec := httptest.NewRecorder()
  813. srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
  814. if rec.Code != http.StatusBadRequest {
  815. t.Fatalf("expected 400, got %d", rec.Code)
  816. }
  817. rec = httptest.NewRecorder()
  818. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/config", nil))
  819. var cfg cfgpkg.Config
  820. if err := json.NewDecoder(rec.Body).Decode(&cfg); err != nil {
  821. t.Fatalf("decode config: %v", err)
  822. }
  823. if cfg.FM.OutputDrive != cfgpkg.Default().FM.OutputDrive {
  824. t.Fatalf("expected snapshot untouched, got %v", cfg.FM.OutputDrive)
  825. }
  826. }
  827. func TestRuntimeIncludesControlAudit(t *testing.T) {
  828. srv := NewServer(cfgpkg.Default())
  829. counts := controlAuditCounts(t, srv)
  830. keys := []string{"methodNotAllowed", "unsupportedMediaType", "bodyTooLarge", "unexpectedBody"}
  831. for _, key := range keys {
  832. if counts[key] != 0 {
  833. t.Fatalf("expected %s to start at 0, got %d", key, counts[key])
  834. }
  835. }
  836. }
  837. func TestControlAuditTracksMethodNotAllowed(t *testing.T) {
  838. srv := NewServer(cfgpkg.Default())
  839. rec := httptest.NewRecorder()
  840. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/audio/stream", nil))
  841. if rec.Code != http.StatusMethodNotAllowed {
  842. t.Fatalf("expected 405 from audio stream GET, got %d", rec.Code)
  843. }
  844. counts := controlAuditCounts(t, srv)
  845. if counts["methodNotAllowed"] != 1 {
  846. t.Fatalf("expected methodNotAllowed=1, got %d", counts["methodNotAllowed"])
  847. }
  848. }
  849. func TestControlAuditTracksUnsupportedMediaType(t *testing.T) {
  850. srv := NewServer(cfgpkg.Default())
  851. srv.SetAudioIngress(&fakeAudioIngress{})
  852. rec := httptest.NewRecorder()
  853. req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0}))
  854. srv.Handler().ServeHTTP(rec, req)
  855. if rec.Code != http.StatusUnsupportedMediaType {
  856. t.Fatalf("expected 415 for audio stream content type, got %d", rec.Code)
  857. }
  858. counts := controlAuditCounts(t, srv)
  859. if counts["unsupportedMediaType"] != 1 {
  860. t.Fatalf("expected unsupportedMediaType=1, got %d", counts["unsupportedMediaType"])
  861. }
  862. }
  863. func TestControlAuditTracksBodyTooLarge(t *testing.T) {
  864. srv := NewServer(cfgpkg.Default())
  865. limit := int(maxConfigBodyBytes)
  866. body := []byte("{\"ps\":\"" + strings.Repeat("x", limit+1) + "\"}")
  867. rec := httptest.NewRecorder()
  868. srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
  869. if rec.Code != http.StatusRequestEntityTooLarge {
  870. t.Fatalf("expected 413 for oversized config body, got %d", rec.Code)
  871. }
  872. counts := controlAuditCounts(t, srv)
  873. if counts["bodyTooLarge"] != 1 {
  874. t.Fatalf("expected bodyTooLarge=1, got %d", counts["bodyTooLarge"])
  875. }
  876. }
  877. func TestControlAuditTracksUnexpectedBody(t *testing.T) {
  878. srv := NewServer(cfgpkg.Default())
  879. srv.SetTXController(&fakeTXController{})
  880. rec := httptest.NewRecorder()
  881. req := httptest.NewRequest(http.MethodPost, "/tx/start", bytes.NewReader([]byte("body")))
  882. srv.Handler().ServeHTTP(rec, req)
  883. if rec.Code != http.StatusBadRequest {
  884. t.Fatalf("expected 400 for unexpected body, got %d", rec.Code)
  885. }
  886. counts := controlAuditCounts(t, srv)
  887. if counts["unexpectedBody"] != 1 {
  888. t.Fatalf("expected unexpectedBody=1, got %d", counts["unexpectedBody"])
  889. }
  890. }
  891. func controlAuditCounts(t *testing.T, srv *Server) map[string]uint64 {
  892. t.Helper()
  893. rec := httptest.NewRecorder()
  894. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
  895. if rec.Code != http.StatusOK {
  896. t.Fatalf("runtime request failed: %d", rec.Code)
  897. }
  898. var payload map[string]any
  899. if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
  900. t.Fatalf("unmarshal runtime: %v", err)
  901. }
  902. raw, ok := payload["controlAudit"].(map[string]any)
  903. if !ok {
  904. t.Fatalf("controlAudit missing or wrong type: %T", payload["controlAudit"])
  905. }
  906. counts := map[string]uint64{}
  907. for key, value := range raw {
  908. num, ok := value.(float64)
  909. if !ok {
  910. t.Fatalf("controlAudit %s not numeric: %T", key, value)
  911. }
  912. counts[key] = uint64(num)
  913. }
  914. return counts
  915. }
  916. func newConfigPostRequest(body []byte) *http.Request {
  917. req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body))
  918. req.Header.Set("Content-Type", "application/json")
  919. return req
  920. }
  921. func newIngestSavePostRequest(body []byte) *http.Request {
  922. req := httptest.NewRequest(http.MethodPost, "/config/ingest/save", bytes.NewReader(body))
  923. req.Header.Set("Content-Type", "application/json")
  924. return req
  925. }
  926. type fakeTXController struct {
  927. updateErr error
  928. resetErr error
  929. stats map[string]any
  930. returnNilStats bool
  931. }
  932. type fakeAudioIngress struct {
  933. totalFrames int
  934. }
  935. type fakeIngestRuntime struct {
  936. stats ingest.Stats
  937. }
  938. func (f *fakeAudioIngress) WritePCM16(data []byte) (int, error) {
  939. frames := len(data) / 4
  940. f.totalFrames += frames
  941. return frames, nil
  942. }
  943. func (f *fakeIngestRuntime) Stats() ingest.Stats {
  944. return f.stats
  945. }
  946. func (f *fakeTXController) StartTX() error { return nil }
  947. func (f *fakeTXController) StopTX() error { return nil }
  948. func (f *fakeTXController) TXStats() map[string]any {
  949. if f.returnNilStats {
  950. return nil
  951. }
  952. if f.stats != nil {
  953. return f.stats
  954. }
  955. return map[string]any{}
  956. }
  957. func (f *fakeTXController) UpdateConfig(_ LivePatch) error { return f.updateErr }
  958. func (f *fakeTXController) ResetFault() error { return f.resetErr }
  959. func TestMeasurementsNoDataWhenMissing(t *testing.T) {
  960. srv := NewServer(cfgpkg.Default())
  961. srv.SetTXController(&fakeTXController{stats: map[string]any{"state": "idle"}})
  962. rec := httptest.NewRecorder()
  963. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/measurements", nil))
  964. if rec.Code != http.StatusOK {
  965. t.Fatalf("status: %d", rec.Code)
  966. }
  967. var body map[string]any
  968. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  969. t.Fatalf("unmarshal: %v", err)
  970. }
  971. if body["noData"] != true {
  972. t.Fatalf("expected noData=true, got %v", body["noData"])
  973. }
  974. if body["stale"] != true {
  975. t.Fatalf("expected stale=true, got %v", body["stale"])
  976. }
  977. }
  978. func TestMeasurementsRunningFreshSnapshotIsNotStale(t *testing.T) {
  979. srv := NewServer(cfgpkg.Default())
  980. srv.SetTXController(&fakeTXController{stats: map[string]any{
  981. "state": "running",
  982. "measurement": &offpkg.MeasurementSnapshot{Timestamp: time.Now(), Sequence: 7},
  983. }})
  984. rec := httptest.NewRecorder()
  985. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/measurements", nil))
  986. if rec.Code != http.StatusOK {
  987. t.Fatalf("status: %d", rec.Code)
  988. }
  989. var body map[string]any
  990. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  991. t.Fatalf("unmarshal: %v", err)
  992. }
  993. if body["noData"] != false {
  994. t.Fatalf("expected noData=false, got %v", body["noData"])
  995. }
  996. if body["stale"] != false {
  997. t.Fatalf("expected stale=false, got %v", body["stale"])
  998. }
  999. }
  1000. func TestMeasurementsIdleSnapshotIsStale(t *testing.T) {
  1001. srv := NewServer(cfgpkg.Default())
  1002. srv.SetTXController(&fakeTXController{stats: map[string]any{
  1003. "state": "idle",
  1004. "measurement": &offpkg.MeasurementSnapshot{Timestamp: time.Now(), Sequence: 9},
  1005. }})
  1006. rec := httptest.NewRecorder()
  1007. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/measurements", nil))
  1008. if rec.Code != http.StatusOK {
  1009. t.Fatalf("status: %d", rec.Code)
  1010. }
  1011. var body map[string]any
  1012. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  1013. t.Fatalf("unmarshal: %v", err)
  1014. }
  1015. if body["noData"] != false {
  1016. t.Fatalf("expected noData=false, got %v", body["noData"])
  1017. }
  1018. if body["stale"] != true {
  1019. t.Fatalf("expected stale=true, got %v", body["stale"])
  1020. }
  1021. }
  1022. func TestMeasurementsOldSnapshotIsStale(t *testing.T) {
  1023. srv := NewServer(cfgpkg.Default())
  1024. srv.SetTXController(&fakeTXController{stats: map[string]any{
  1025. "state": "running",
  1026. "measurement": &offpkg.MeasurementSnapshot{Timestamp: time.Now().Add(-3 * time.Second), Sequence: 11},
  1027. }})
  1028. rec := httptest.NewRecorder()
  1029. srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/measurements", nil))
  1030. if rec.Code != http.StatusOK {
  1031. t.Fatalf("status: %d", rec.Code)
  1032. }
  1033. var body map[string]any
  1034. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  1035. t.Fatalf("unmarshal: %v", err)
  1036. }
  1037. if body["stale"] != true {
  1038. t.Fatalf("expected stale=true, got %v", body["stale"])
  1039. }
  1040. }
  1041. func TestTelemetryUnsubscribeDuringPublishDoesNotPanic(t *testing.T) {
  1042. hub := NewTelemetryHub()
  1043. sub, unsubscribe := hub.Subscribe()
  1044. done := make(chan struct{})
  1045. go func() {
  1046. defer close(done)
  1047. unsubscribe()
  1048. }()
  1049. for i := 0; i < 100; i++ {
  1050. hub.PublishMeasurement(&offpkg.MeasurementSnapshot{Timestamp: time.Now(), Sequence: uint64(i + 1)})
  1051. }
  1052. select {
  1053. case <-done:
  1054. case <-time.After(2 * time.Second):
  1055. t.Fatal("unsubscribe did not complete")
  1056. }
  1057. select {
  1058. case <-sub.done:
  1059. case <-time.After(2 * time.Second):
  1060. t.Fatal("subscriber done not closed")
  1061. }
  1062. }