clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

main
airwaves / internal / api / api_test.go
api_test.go 12.9K
1package api23import (4 "context"5 "errors"6 "fmt"7 "io"8 "net/http"9 "net/http/httptest"10 "strings"11 "testing"12 "time"1314 "airwaves/internal/dvr"15 "airwaves/internal/guide"16 "airwaves/internal/lineup"17 "airwaves/internal/podcast"18 "airwaves/internal/service"19 "airwaves/internal/signal"20 "airwaves/internal/stream"21 "airwaves/internal/weather"22)2324// fake records calls and returns canned values.25type fake struct {26 tuned, stopped, deleted, progress string27 cfg service.Config28 measured int29}3031func (f *fake) Info(context.Context) (service.Info, error) {32 return service.Info{Name: "nas", Mode: "server", DVR: true}, nil33}34func (f *fake) Config(context.Context) (service.Config, error) { return f.cfg, nil }35func (f *fake) SetConfig(_ context.Context, c service.Config) (service.Config, error) {36 f.cfg = c37 return c, nil38}3940func (f *fake) Snapshot(context.Context, bool) (*service.Snapshot, error) {41 return &service.Snapshot{Report: &lineup.Report{Channels: []lineup.Channel{{Number: "7.1"}}}, Custom: []service.CustomChannel{42 {Number: "1.1", Name: "Airwaves Weather", Kind: "weather", CallSign: "WX", Category: "Weather", Programs: []guide.Program{}},43 {Number: "1.4", Name: "Cartoons", Kind: "jellyfin", CallSign: "TOON", Category: "Kids", Logo: "/channel-logos/1.4?v=x"},44 }}, nil45}4647func (f *fake) Signal(context.Context) (*service.SignalReport, error) {48 strength := 85.049 return &service.SignalReport{50 Tuner: service.TunerInfo{Ready: true, Tuners: 2, Standards: []string{"ATSC 1.0"}},51 Channels: []service.ChannelSignal{{Number: "20.1", RF: 31, Signal: &signal.Reading{Lock: true, StrengthPct: &strength}}},52 }, nil53}5455func (f *fake) ScanChannels(context.Context) (service.TunerInfo, error) {56 return service.TunerInfo{Ready: true, Tuners: 2, Scanning: true}, nil57}5859func (f *fake) StopRecording(context.Context, string) error { return nil }6061func (f *fake) ExtendRecording(_ context.Context, _ string, minutes int) (time.Time, error) {62 return time.Date(2026, 10, 4, 20, 3+minutes, 0, 0, time.UTC), nil63}6465func (f *fake) Measure(context.Context) (*service.SweepStatus, error) {66 f.measured++67 if f.measured > 1 {68 return nil, errors.New("tuner unplugged")69 }70 return &service.SweepStatus{Running: true, Total: 12}, nil71}7273func (f *fake) Weather(context.Context) (*weather.Report, error) {74 return &weather.Report{Alerts: []weather.Alert{{Event: "Winter Storm Warning"}}}, nil75}7677func (f *fake) Tune(_ context.Context, client, number string) (*stream.Playback, error) {78 f.tuned = client + ":" + number79 return &stream.Playback{ID: "abc", Path: "/live/abc/index.m3u8"}, nil80}81func (f *fake) Stop(_ context.Context, client string) error { f.stopped = client; return nil }82func (f *fake) Heartbeat(_ context.Context, client string) (bool, error) {83 return client == "mac" && f.stopped == "", nil84}85func (f *fake) DVR(context.Context) (*dvr.State, error) { return &dvr.State{Available: true}, nil }86func (f *fake) Record(_ context.Context, r dvr.Request) (*dvr.Rule, error) {87 return &dvr.Rule{ID: "r1", Kind: r.Kind, Channel: r.Channel}, nil88}89func (f *fake) DeleteRule(context.Context, string) error { return nil }90func (f *fake) DeleteRecording(_ context.Context, id string) error {91 f.deleted = id92 return nil93}9495func (f *fake) PlayRecording(_ context.Context, _, _ string, from float64) (*stream.Playback, error) {96 return &stream.Playback{Path: "/live/rec/master.m3u8", Offset: from}, nil97}9899func (f *fake) SaveProgress(_ context.Context, id string, pos, _ float64) error {100 f.progress = fmt.Sprintf("%s@%.0f", id, pos)101 return nil102}103104func (f *fake) MarkWatched(_ context.Context, id string, w bool) error {105 f.progress = fmt.Sprintf("%s watched=%v", id, w)106 return nil107}108109func (f *fake) UpdateRule(_ context.Context, id string, u dvr.RuleUpdate) (*dvr.Rule, error) {110 return &dvr.Rule{ID: id, Keep: u.Keep}, nil111}112113func (f *fake) SetDVRPrefs(_ context.Context, p dvr.Prefs) (dvr.Prefs, error) { return p, nil }114115func (f *fake) Podcasts(context.Context) (*podcast.State, error) {116 return &podcast.State{Shows: []podcast.Show{{ID: "s1", Title: "Show", Episodes: []podcast.Episode{{ID: "e1", ShowID: "s1", Duration: 3600}}}}}, nil117}118119func (f *fake) RefreshPodcasts(ctx context.Context) (*podcast.State, error) {120 st, _ := f.Podcasts(ctx)121 st.Shows[0].Episodes = append([]podcast.Episode{{ID: "e2", ShowID: "s1", Duration: 1800}}, st.Shows[0].Episodes...)122 return st, nil123}124125func (f *fake) SearchPodcasts(_ context.Context, q string) ([]podcast.Found, error) {126 return []podcast.Found{{ID: "UCa", Title: q}}, nil127}128129func (f *fake) AddPodcast(_ context.Context, u string, minMinutes float64) (*podcast.Show, error) {130 if !strings.Contains(u, "youtube.com") {131 return nil, fmt.Errorf("%w: give a YouTube channel or playlist", podcast.ErrBadShow)132 }133 return &podcast.Show{ID: "s2", URL: u, MinMinutes: minMinutes}, nil134}135136func (f *fake) RemovePodcast(_ context.Context, id string) error {137 if id != "s1" {138 return podcast.ErrNotFound139 }140 f.deleted = id141 return nil142}143144func (f *fake) PlayEpisode(_ context.Context, client, id string, from float64) (*stream.Playback, error) {145 f.tuned = client + ":" + id146 return &stream.Playback{Path: "/live/ep/master.m3u8", Offset: from}, nil147}148149func (f *fake) EpisodeProgress(_ context.Context, id string, pos, dur float64, watched bool) error {150 f.progress = fmt.Sprintf("%s@%.0f/%.0f watched=%v", id, pos, dur, watched)151 return nil152}153154// TestPodcasts: the Podcasts app's calls, through the client.155func TestPodcasts(t *testing.T) {156 be := &fake{}157 srv := httptest.NewServer((&Server{Backend: be}).Handler())158 defer srv.Close()159 ctx := context.Background()160 c := NewClient(srv.URL, "")161 if st, err := c.Podcasts(ctx); err != nil || len(st.Shows) != 1 || st.Shows[0].Episodes[0].Duration != 3600 {162 t.Errorf("podcasts: %+v, %v", st, err)163 }164 if st, err := c.RefreshPodcasts(ctx); err != nil || len(st.Shows[0].Episodes) != 2 || st.Shows[0].Episodes[0].ID != "e2" {165 t.Errorf("refresh: %+v, %v", st, err)166 }167 if found, err := c.SearchPodcasts(ctx, "sad boyz"); err != nil || len(found) != 1 || found[0].Title != "sad boyz" {168 t.Errorf("search: %+v, %v", found, err)169 }170 if s, err := c.AddPodcast(ctx, "https://www.youtube.com/@x/streams", 30); err != nil || s.ID != "s2" || s.MinMinutes != 30 {171 t.Errorf("add: %+v, %v", s, err)172 }173 if _, err := c.AddPodcast(ctx, "https://example.com", 0); err == nil || !strings.Contains(err.Error(), "YouTube channel") {174 t.Errorf("add something else: %v", err)175 }176 resp, err := http.Post(srv.URL+"/api/podcasts/shows", "application/json", strings.NewReader(`{"url":"nope"}`))177 if err != nil || resp.StatusCode != http.StatusBadRequest {178 t.Errorf("not a show: %v %v", resp.StatusCode, err)179 }180 if err := c.RemovePodcast(ctx, "s1"); err != nil || be.deleted != "s1" {181 t.Errorf("remove: %v", err)182 }183 req, _ := http.NewRequest(http.MethodDelete, srv.URL+"/api/podcasts/shows/zz", nil)184 if resp, err := http.DefaultClient.Do(req); err != nil || resp.StatusCode != http.StatusNotFound {185 t.Errorf("remove a show there isn't: %v %v", resp.StatusCode, err)186 }187 if pb, err := c.PlayEpisode(ctx, "mac", "e1", 600); err != nil || pb.Offset != 600 || be.tuned != "mac:e1" || pb.URL != srv.URL+"/live/ep/master.m3u8" {188 t.Errorf("play: %+v, %v", pb, err)189 }190 if err := c.EpisodeProgress(ctx, "e1", 600, 3600, true); err != nil || be.progress != "e1@600/3600 watched=true" {191 t.Errorf("progress: %v, backend saw %q", err, be.progress)192 }193}194195func TestClientServerRoundTrip(t *testing.T) {196 be := &fake{}197 srv := httptest.NewServer((&Server{Backend: be, Token: "s3cret"}).Handler())198 defer srv.Close()199 ctx := context.Background()200201 if _, err := NewClient(srv.URL, "wrong").Info(ctx); err == nil || !strings.Contains(err.Error(), "token") {202 t.Fatalf("wrong token accepted: %v", err)203 }204 c := NewClient(srv.URL, "s3cret")205206 snap, err := c.Snapshot(ctx, false)207 if err != nil || len(snap.Report.Channels) != 1 {208 t.Fatalf("snapshot = %+v, %v", snap, err)209 }210 if len(snap.Custom) != 2 || snap.Custom[0].Kind != "weather" || snap.Custom[0].CallSign != "WX" ||211 snap.Custom[1].Logo != "/channel-logos/1.4?v=x" || snap.Custom[1].Category != "Kids" {212 t.Errorf("custom channels = %+v", snap.Custom)213 }214 pb, err := c.Tune(ctx, "mac", "7.1")215 if err != nil {216 t.Fatal(err)217 }218 if be.tuned != "mac:7.1" || pb.URL != srv.URL+"/live/abc/index.m3u8" {219 t.Errorf("tune: backend saw %q, playback URL %q", be.tuned, pb.URL)220 }221 // An app changes the hours of listings, never the location.222 be.cfg = service.Config{ZIP: "80302", RadiusKm: 160, GuideHours: 24}223 if got, err := c.SetConfig(ctx, service.Config{ZIP: "10001", Lat: 40.7, Lon: -74, GuideHours: 48}); err != nil ||224 be.cfg != (service.Config{ZIP: "80302", RadiusKm: 160, GuideHours: 48}) || got != be.cfg {225 t.Errorf("set config: %+v, %v, backend has %+v", got, err, be.cfg)226 }227 if err := c.DeleteRecording(ctx, "e/1"); err != nil || be.deleted != "e/1" {228 t.Errorf("delete: %v, backend saw %q", err, be.deleted)229 }230 if on, err := c.Heartbeat(ctx, "mac"); err != nil || !on {231 t.Errorf("heartbeat: %v, %v", on, err)232 }233 if err := c.Stop(ctx, "mac"); err != nil || be.stopped != "mac" {234 t.Errorf("stop: %v, backend saw %q", err, be.stopped)235 }236 if on, err := c.Heartbeat(ctx, "mac"); err != nil || on {237 t.Errorf("heartbeat after stop: %v, %v", on, err)238 }239 if pb, err := c.PlayRecording(ctx, "mac", "rec1", 754); err != nil || pb.Offset != 754 {240 t.Errorf("play recording: %+v, %v", pb, err)241 }242 if err := c.SaveProgress(ctx, "rec1", 754, 3600); err != nil || be.progress != "rec1@754" {243 t.Errorf("progress: %v, backend saw %q", err, be.progress)244 }245 if err := c.MarkWatched(ctx, "rec1", true); err != nil || be.progress != "rec1 watched=true" {246 t.Errorf("watched: %v, backend saw %q", err, be.progress)247 }248 if r, err := c.UpdateRule(ctx, "r9", dvr.RuleUpdate{Keep: 5}); err != nil || r.Keep != 5 {249 t.Errorf("update rule: %+v, %v", r, err)250 }251 if wx, err := c.Weather(ctx); err != nil || len(wx.Alerts) != 1 {252 t.Errorf("weather: %+v, %v", wx, err)253 }254 if sig, err := c.Signal(ctx); err != nil || !sig.Tuner.Ready || len(sig.Channels) != 1 || sig.Channels[0].RF != 31 ||255 !sig.Channels[0].Signal.Lock || *sig.Channels[0].Signal.StrengthPct != 85 || sig.Channels[0].Signal.QualityPct != nil {256 t.Errorf("signal: %+v, %v", sig, err)257 }258 if until, err := c.ExtendRecording(ctx, "rec1", 30); err != nil || !until.Equal(time.Date(2026, 10, 4, 20, 33, 0, 0, time.UTC)) {259 t.Errorf("extend: %s, %v", until, err)260 }261 if err := c.StopRecording(ctx, "rec1"); err != nil {262 t.Errorf("stop recording: %v", err)263 }264 if ti, err := c.ScanChannels(ctx); err != nil || !ti.Scanning {265 t.Errorf("scan: %+v, %v", ti, err)266 }267 if st, err := c.Measure(ctx); err != nil || !st.Running || st.Total != 12 {268 t.Errorf("measure: %+v, %v", st, err)269 }270 if _, err := c.Measure(ctx); err == nil || err.Error() != "tuner unplugged" {271 t.Errorf("backend error not passed through: %v", err)272 }273}274275// TestNoEstimates: the reception estimates the app used to ask for, and276// the antenna presets they were for, are gone.277func TestNoEstimates(t *testing.T) {278 srv := httptest.NewServer((&Server{Backend: &fake{}}).Handler())279 defer srv.Close()280 for _, path := range []string{"/api/preview?zip=80302", "/api/profile?facility=1&rf=2", "/api/presets"} {281 resp, err := http.Get(srv.URL + path)282 if err != nil {283 t.Fatal(err)284 }285 resp.Body.Close()286 if resp.StatusCode != http.StatusNotFound {287 t.Errorf("%s: %d", path, resp.StatusCode)288 }289 }290}291292// TestErrorStatus: what a server lacks is 501, a channel with no signal293// now 503.294func TestErrorStatus(t *testing.T) {295 for err, want := range map[error]int{296 service.ErrNoAntenna: http.StatusNotImplemented,297 fmt.Errorf("x: %w", service.ErrNoTuner): http.StatusNotImplemented,298 &service.NoSignalError{Channel: "20.1"}: http.StatusServiceUnavailable,299 fmt.Errorf("x: %w", &service.NoSignalError{}): http.StatusServiceUnavailable,300 errors.New("ffmpeg exited"): http.StatusInternalServerError,301 } {302 if got := errStatus(err); got != want {303 t.Errorf("%v: %d, want %d", err, got, want)304 }305 }306}307308// TestLogosNeedNoToken: players and guides fetch logos as images, with no309// way to send the API's token.310func TestLogosNeedNoToken(t *testing.T) {311 logos := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "logo "+r.URL.Path) })312 srv := httptest.NewServer((&Server{Backend: &fake{}, Token: "s3cret", Logos: logos}).Handler())313 defer srv.Close()314 resp, err := http.Get(srv.URL + "/channel-logos/1.4?v=x")315 if err != nil {316 t.Fatal(err)317 }318 defer resp.Body.Close()319 body, _ := io.ReadAll(resp.Body)320 if resp.StatusCode != 200 || string(body) != "logo /channel-logos/1.4" {321 t.Errorf("logo: %d %q", resp.StatusCode, body)322 }323}324325func TestNewClientDefaults(t *testing.T) {326 for in, want := range map[string]string{327 "nas": "http://nas:8089",328 "http://nas:8089/": "http://nas:8089",329 "https://tv.example.com": "https://tv.example.com",330 "100.64.0.7:9000": "http://100.64.0.7:9000",331 } {332 if got := NewClient(in, "").Base; got != want {333 t.Errorf("NewClient(%q).Base = %q, want %q", in, got, want)334 }335 }336}