1package service23import (4 "encoding/json"5 "errors"6 "fmt"7 "net/http"8 "net/http/httptest"9 "os"10 "path/filepath"11 "slices"12 "strings"13 "testing"1415 "airwaves/internal/dvr"16 "airwaves/internal/geo"17 "airwaves/internal/store"18)1920// TestNoAntenna: a server with custom channels only has no tuner (even21// when given one), lineup, listings, reception or recording; its snapshot22// is the custom channels, and Info says there's no antenna.23func TestNoAntenna(t *testing.T) {24 ctx := t.Context()25 s, err := New(Options{26 Name: "friend", Mode: "server", NoAntenna: true, CacheDir: t.TempDir(),27 HDHomeRun: "http://127.0.0.1:1", DVRPath: filepath.Join(t.TempDir(), "dvr.json"), RecordingsDir: t.TempDir(),28 WeatherStarPort: 8090, Config: Config{Lat: 39.74, Lon: -104.99},29 })30 if err != nil {31 t.Fatal(err)32 }33 if s.tuners != nil || s.dvr != nil {34 t.Error("a tuner or recording without an antenna")35 }3637 info, _ := s.Info(ctx)38 if info.Antenna || info.DVR || info.Tuner.Kind != "none" || !info.WeatherStar {39 t.Errorf("info = %+v", info)40 }41 if raw, _ := json.Marshal(info); !strings.Contains(string(raw), `"antenna":false`) {42 t.Errorf("info JSON: %s", raw)43 }4445 snap, err := s.Snapshot(ctx, false)46 if err != nil {47 t.Fatal(err)48 }49 if snap.Report.Point != (geo.Point{Lat: 39.74, Lon: -104.99}) || snap.Guide != nil {50 t.Errorf("report point %+v, guide %v", snap.Report.Point, snap.Guide)51 }52 raw, _ := json.Marshal(snap)53 for _, want := range []string{`"stations":[]`, `"channels":[]`, `"sources":[]`, `"warnings":[]`, `"kind":"weather"`, `"antenna":{"tuner":{"ready":false`} {54 if !strings.Contains(string(raw), want) {55 t.Errorf("snapshot JSON lacks %s: %s", want, raw)56 }57 }5859 if s.streamErr == nil {60 if _, err := s.Tune(ctx, "mac", "7.1"); !errors.Is(err, ErrNoAntenna) {61 t.Errorf("tune an antenna channel: %v", err)62 }63 }64 if _, err := s.Signal(ctx); !errors.Is(err, ErrNoAntenna) {65 t.Errorf("signal: %v", err)66 }67 if _, err := s.Measure(ctx); !errors.Is(err, ErrNoAntenna) {68 t.Errorf("measure: %v", err)69 }70 if snap.Antenna == nil || len(snap.Antenna.Channels) != 0 || snap.Antenna.Tuner.Ready || snap.Antenna.Tuner.Reason != ErrNoAntenna.Error() {71 t.Errorf("antenna = %+v", snap.Antenna)72 }73 if st, err := s.DVR(ctx); err != nil || st.Available || st.Reason != ErrNoAntenna.Error() {74 t.Errorf("dvr = %+v, %v", st, err)75 }76 if _, err := s.Record(ctx, dvr.Request{Channel: "7.1"}); !errors.Is(err, ErrNoAntenna) {77 t.Errorf("record an antenna channel: %v", err)78 }79 if _, err := s.Record(ctx, dvr.Request{Channel: "1.1"}); !errors.Is(err, ErrNotRecordable) {80 t.Errorf("record the weather channel: %v", err)81 }82 if got := s.AntennaChannels(ctx); len(got) != 0 {83 t.Errorf("antenna channels = %v", got)84 }85 if lineup, err := s.HDHR().Lineup(ctx); err != nil || len(lineup) != 0 {86 t.Errorf("HDHomeRun lineup = %v, %v (no Chromium, so no weather channel either)", lineup, err)87 }88 if err := s.HDHR().Stream(ctx, "7.1", nil); err == nil {89 t.Error("HDHomeRun streamed an antenna channel")90 }91 if cfg, _ := s.Config(ctx); cfg.ZIP != "" {92 t.Errorf("ZIP %q made up without an antenna", cfg.ZIP)93 }9495 // Nor with an antenna (the default): airwavesd asks for one.96 if a, _ := New(Options{Mode: "server", CacheDir: t.TempDir()}); a.cfg.ZIP != "" {97 t.Errorf("antenna ZIP = %q", a.cfg.ZIP)98 } else if info, _ := a.Info(ctx); !info.Antenna {99 t.Errorf("antenna info = %+v", info)100 }101}102103// TestNoAntennaLocation: the location is only for the weather; without one104// there's no weather channel and the weather says what to set.105func TestNoAntennaLocation(t *testing.T) {106 ctx := t.Context()107 s, err := New(Options{Mode: "server", NoAntenna: true, CacheDir: t.TempDir(), WeatherStarPort: 8090})108 if err != nil {109 t.Fatal(err)110 }111 if info, _ := s.Info(ctx); info.WeatherStar {112 t.Error("weather display offered without a location")113 }114 snap, err := s.Snapshot(ctx, false)115 if err != nil {116 t.Fatal(err)117 }118 if len(snap.Custom) != 0 || !slices.Contains(snap.Report.Warnings, ErrNoLocation.Error()) {119 t.Errorf("custom %+v, warnings %q", snap.Custom, snap.Report.Warnings)120 }121 if _, err := s.Weather(ctx); !errors.Is(err, ErrNoLocation) {122 t.Errorf("weather: %v", err)123 }124125 // A ZIP code is looked up (here, from the cache) for its centroid.126 dir := t.TempDir()127 cache, err := store.OpenCache(dir)128 if err != nil {129 t.Fatal(err)130 }131 place := geo.Place{ZIP: "12345", Name: "Schenectady", State: "NY", Point: geo.Point{Lat: 42.81, Lon: -73.94}}132 if err := cache.Put("zip-12345", place); err != nil {133 t.Fatal(err)134 }135 s, err = New(Options{Mode: "server", NoAntenna: true, CacheDir: dir, WeatherStarPort: 8090, Config: Config{ZIP: "12345"}})136 if err != nil {137 t.Fatal(err)138 }139 snap, err = s.Snapshot(ctx, false)140 if err != nil {141 t.Fatal(err)142 }143 if snap.Report.Place != place || snap.Report.Point != place.Point || len(snap.Report.Warnings) != 0 ||144 len(snap.Custom) != 1 || snap.Custom[0].Kind != "weather" {145 t.Errorf("report %+v, custom %+v", snap.Report, snap.Custom)146 }147}148149// TestWeatherStarElsewhere: a display in a container of its own counts only150// while it answers, and clients are sent to it on the port they can reach.151func TestWeatherStarElsewhere(t *testing.T) {152 ctx := t.Context()153 display := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {154 if r.URL.Path == "/music/default/" {155 fmt.Fprint(w, `<a href="Track%201.mp3">Track 1.mp3</a>`)156 }157 }))158 defer display.Close()159 s, err := New(Options{160 Mode: "server", NoAntenna: true, CacheDir: t.TempDir(), Config: Config{Lat: 39.74, Lon: -104.99},161 WeatherStarPort: 8090, WeatherStarURL: display.URL + "/",162 })163 if err != nil {164 t.Fatal(err)165 }166 if s.wxOn() {167 t.Error("display counted before it answered")168 }169 s.checkWeatherStar(ctx)170 if !s.wxOn() {171 t.Fatal("display not counted once it answered")172 }173 if info, _ := s.Info(ctx); !info.WeatherStar {174 t.Errorf("info = %+v", info)175 }176177 h := s.WeatherHandler()178 req := httptest.NewRequest(http.MethodGet, "http://tv.local:8089/weatherstar", nil)179 rec := httptest.NewRecorder()180 h.ServeHTTP(rec, req)181 if loc := rec.Header().Get("Location"); rec.Code != http.StatusFound || !strings.HasPrefix(loc, "http://tv.local:8090/?latLon=") {182 t.Errorf("redirect %d to %q", rec.Code, loc)183 }184 if page, err := s.wx.PageURL(ctx); err != nil || !strings.HasPrefix(page, display.URL+"/?latLon=") {185 t.Errorf("page for the renderer = %q, %v", page, err)186 }187 req = httptest.NewRequest(http.MethodGet, "http://tv.local:8089/weatherstar/music", nil)188 rec = httptest.NewRecorder()189 h.ServeHTTP(rec, req)190 if body := rec.Body.String(); !strings.Contains(body, `"http://tv.local:8090/music/default/Track%201.mp3"`) {191 t.Errorf("music = %s", body)192 }193194 display.Close()195 s.checkWeatherStar(ctx)196 if s.wxOn() || len(s.custom()) != 0 {197 t.Error("display still counted once it stopped answering")198 }199 rec = httptest.NewRecorder()200 h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "http://tv.local:8089/weatherstar", nil))201 if rec.Code != http.StatusNotFound {202 t.Errorf("redirect to a display that's gone: %d", rec.Code)203 }204}205206// TestConfigSurvives: a damaged config is read from its backup, and with207// none the server starts with the defaults rather than not at all.208func TestConfigSurvives(t *testing.T) {209 ctx := t.Context()210 dir := t.TempDir()211 path := filepath.Join(dir, "config.json")212 open := func() *Service {213 t.Helper()214 s, err := New(Options{Mode: "server", NoAntenna: true, CacheDir: dir, ConfigPath: path})215 if err != nil {216 t.Fatal(err)217 }218 t.Cleanup(s.Close)219 return s220 }221 s := open()222 for _, h := range []int{48, 36} {223 if _, err := s.SetConfig(ctx, Config{GuideHours: h}); err != nil {224 t.Fatal(err)225 }226 }227 if err := os.WriteFile(path, []byte(`{"guideHours": 3`), 0o644); err != nil {228 t.Fatal(err)229 }230 if cfg, _ := open().Config(ctx); cfg.GuideHours != 48 {231 t.Errorf("from the backup: %+v", cfg)232 }233234 for _, p := range []string{path, path + ".bak"} {235 if err := os.WriteFile(p, []byte("nonsense"), 0o644); err != nil {236 t.Fatal(err)237 }238 }239 if err := os.RemoveAll(filepath.Join(dir, "backups")); err != nil {240 t.Fatal(err)241 }242 if cfg, _ := open().Config(ctx); cfg.GuideHours != 24 {243 t.Errorf("with no backup: %+v", cfg)244 }245}