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 / sd / sd_test.go
sd_test.go 19.1K
1package sd23import (4 "context"5 "encoding/json"6 "fmt"7 "net/http"8 "net/http/httptest"9 "strings"10 "sync"11 "testing"12 "time"13)1415// fakeSD is just enough of Schedules Direct: one OTA lineup of two16// stations, a day of airings each, and the programs they air.17type fakeSD struct {18 mu sync.Mutex19 password string // the SHA-1 it accepts20 calls map[string]int21 asked []string // schedule station-days requested22 programs []string // program IDs requested23 added bool24 expire bool // the next request gets 4006 once25 md5 string26 images []string // pictures downloaded27 capped bool // pictures answer with the daily cap28 stale bool // the next request gets 4003 once, as for an old token29 s3 string // where pictures redirect to, when set30}3132func (f *fakeSD) ServeHTTP(w http.ResponseWriter, r *http.Request) {33 f.mu.Lock()34 defer f.mu.Unlock()35 path := strings.TrimPrefix(r.URL.Path, "/")36 f.calls[r.Method+" "+path]++37 reply := func(v any) { _ = json.NewEncoder(w).Encode(v) }38 fail := func(status, code int, msg string) {39 w.WriteHeader(status)40 reply(map[string]any{"code": code, "response": msg, "message": msg})41 }42 if !strings.HasPrefix(r.UserAgent(), "airwaves/") {43 fail(400, 1003, "NO_USER_AGENT")44 return45 }46 if path == "token" {47 var in struct{ Username, Password string }48 _ = json.NewDecoder(r.Body).Decode(&in)49 if in.Password != f.password {50 fail(400, 4003, "INVALID_USER")51 return52 }53 now := time.Now().Unix()54 reply(map[string]any{"code": 0, "token": "tok", "tokenExpires": now + 86400, "serverTime": now})55 return56 }57 if r.Header.Get("Token") != "tok" {58 fail(403, 4006, "TOKEN_EXPIRED")59 return60 }61 if f.expire {62 f.expire = false63 fail(403, 4006, "TOKEN_EXPIRED")64 return65 }66 if f.stale {67 f.stale = false68 fail(400, 4003, "INVALID_USER")69 return70 }71 today := time.Now().UTC().Format(time.DateOnly)72 if uri, ok := strings.CutPrefix(path, "image/"); ok {73 if f.capped {74 reply(map[string]any{"code": 5002, "response": "MAX_IMAGE_DOWNLOADS", "message": "cap"})75 return76 }77 f.images = append(f.images, uri)78 if f.s3 != "" {79 http.Redirect(w, r, f.s3+"/"+uri, http.StatusSeeOther)80 return81 }82 w.Header().Set("Content-Type", "image/jpeg")83 fmt.Fprint(w, "JPEG "+uri)84 return85 }86 switch path {87 case "metadata/programs/":88 var ids []string89 _ = json.NewDecoder(r.Body).Decode(&ids)90 pics := map[string][]any{91 "SH013657560000": {92 map[string]any{"uri": "banner43.jpg", "width": "720", "height": "540", "aspect": "4x3", "category": "Banner-L1"},93 map[string]any{"uri": "iconic169small.jpg", "width": 240, "height": 135, "aspect": "16x9", "category": "Iconic"},94 map[string]any{"uri": "iconic169.jpg", "width": 960, "height": 540, "aspect": "16x9", "category": "Iconic"},95 map[string]any{"uri": "poster.jpg", "width": 720, "height": 1080, "aspect": "2x3", "category": "Iconic"},96 },97 "MV000087710000": {map[string]any{"uri": "movie.jpg", "width": 720, "height": 405, "aspect": "16x9", "category": "Iconic"}},98 }99 var out []any100 for _, id := range ids {101 out = append(out, map[string]any{"programID": id, "data": pics[id]})102 }103 reply(out)104 return105 case "status":106 lineups := []map[string]any{}107 if f.added {108 lineups = append(lineups, map[string]any{"lineup": "USA-OTA-80302", "modified": "2026-10-01T00:00:00Z"})109 }110 reply(map[string]any{"code": 0, "account": map[string]any{"expires": "2026-10-19T00:00:00Z", "maxLineups": 4},111 "lineups": lineups, "systemStatus": []any{map[string]any{"status": "Online"}}})112 case "lineups/USA-OTA-80302":113 if r.Method == http.MethodPut {114 f.added = true115 reply(map[string]any{"code": 0, "response": "OK", "changesRemaining": 5})116 return117 }118 reply(map[string]any{119 "map": []any{120 map[string]any{"stationID": "20370", "uhfVhf": 36, "atscMajor": 2, "atscMinor": 1},121 map[string]any{"stationID": "21252", "uhfVhf": 35, "atscMajor": 4, "atscMinor": 1},122 },123 "stations": []any{124 map[string]any{"stationID": "20370", "callsign": "KWGNDT", "name": "KWGNDT (KWGN-DT)", "affiliate": "CW",125 "stationLogo": []any{map[string]any{"URL": "https://example.com/kwgn-dark.png", "category": "dark"}, map[string]any{"URL": "https://example.com/kwgn-light.png", "category": "light"}}},126 map[string]any{"stationID": "21252", "callsign": "KCNCDT", "affiliate": "CBS"},127 },128 })129 case "schedules/md5":130 out := map[string]any{}131 for _, id := range []string{"20370", "21252"} {132 out[id] = map[string]any{today: map[string]any{"code": 0, "md5": f.md5}}133 }134 out["99999"] = []any{} // a station with nothing135 reply(out)136 case "schedules":137 var in []stationDates138 _ = json.NewDecoder(r.Body).Decode(&in)139 var out []any140 start := time.Now().UTC().Truncate(time.Hour)141 for _, s := range in {142 for _, d := range s.Date {143 f.asked = append(f.asked, s.StationID+"@"+d)144 progs := []any{145 map[string]any{"programID": "EP013657560045", "airDateTime": start.Format(time.RFC3339), "duration": 3600, "md5": "p1",146 "new": true, "audioProperties": []string{"cc", "stereo", "dvs"}, "videoProperties": []string{"hdtv"},147 "ratings": []any{map[string]any{"body": "USA Parental Rating", "code": "TV14"}}},148 map[string]any{"programID": "MV000087710000", "airDateTime": start.Add(time.Hour).Format(time.RFC3339), "duration": 7200, "md5": "p2"},149 }150 if s.StationID == "21252" {151 progs = []any{map[string]any{"programID": "SP003190180000", "airDateTime": start.Format(time.RFC3339), "duration": 10800, "md5": "p3", "liveTapeDelay": "Live"}}152 }153 out = append(out, map[string]any{"stationID": s.StationID, "programs": progs, "metadata": map[string]any{"md5": f.md5, "startDate": d}})154 }155 }156 reply(out)157 case "programs":158 var ids []string159 _ = json.NewDecoder(r.Body).Decode(&ids)160 f.programs = append(f.programs, ids...)161 all := map[string]any{162 "EP013657560045": map[string]any{"programID": "EP013657560045", "md5": "p1", "titles": []any{map[string]any{"title120": "The Voice"}},163 "episodeTitle150": "The Knockouts", "originalAirDate": "2026-10-07", "genres": []string{"Reality", "Music"},164 "descriptions": map[string]any{"description100": []any{map[string]any{"descriptionLanguage": "en", "description": "Short."}},165 "description1000": []any{map[string]any{"descriptionLanguage": "en", "description": "The knockouts continue, longer."}}},166 "cast": []any{map[string]any{"billingOrder": "02", "name": "Second"}, map[string]any{"billingOrder": "01", "name": "First", "characterName": "Coach"}},167 "metadata": []any{map[string]any{"Gracenote": map[string]any{"season": 28, "episode": 12}}}, "entityType": "Episode", "hasSeriesArtwork": true},168 "MV000087710000": map[string]any{"programID": "MV000087710000", "md5": "p2", "titles": []any{map[string]any{"title120": "The Wizard of Oz"}},169 "entityType": "Movie", "hasMovieArtwork": true, "contentRating": []any{map[string]any{"body": "Motion Picture Association", "code": "PG"}},170 "movie": map[string]any{"year": "1939", "duration": 6120, "qualityRating": []any{map[string]any{"ratingsBody": "Gracenote", "rating": "4"}}}},171 "SP003190180000": map[string]any{"programID": "SP003190180000", "md5": "p3", "titles": []any{map[string]any{"title120": "NFL Football"}},172 "entityType": "Sports", "genres": []string{"Sports event", "Football"},173 "eventDetails": map[string]any{"teams": []any{map[string]any{"name": "Kansas City Chiefs"}, map[string]any{"name": "Denver Broncos", "isHome": true}}}},174 }175 var out []any176 for _, id := range ids {177 if p, ok := all[id]; ok {178 out = append(out, p)179 } else {180 out = append(out, map[string]any{"programID": id, "code": 6000})181 }182 }183 reply(out)184 default:185 fail(404, 1008, "INCORRECT_REQUEST")186 }187}188189func newSource(t *testing.T) (*Source, *fakeSD) {190 t.Helper()191 f := &fakeSD{password: HashPassword("hunter2"), calls: map[string]int{}, md5: "day1"}192 srv := httptest.NewServer(f)193 t.Cleanup(srv.Close)194 s := &Source{Dir: t.TempDir(), ZIP: "80302", HTTP: srv.Client(), UserAgent: "airwaves/test", BaseURL: srv.URL + "/"}195 return s, f196}197198func (f *fakeSD) count(call string) int {199 f.mu.Lock()200 defer f.mu.Unlock()201 return f.calls[call]202}203204// TestUpdate: a first update logs in, adds the antenna lineup and fetches205// everything; a second asks what changed and fetches nothing more; the206// listings come out as the guide has them.207func TestUpdate(t *testing.T) {208 s, f := newSource(t)209 if err := s.SetAccount("viewer", "hunter2"); err != nil {210 t.Fatal(err)211 }212 ctx := context.Background()213 a, _ := LoadAccount(s.accountPath())214 s.update(ctx, a)215 st := s.Status()216 if !st.OK || st.Error != "" || st.Lineup != "USA-OTA-80302" || st.Stations != 2 || st.Programs == 0 || st.Expires.IsZero() {217 t.Fatalf("status = %+v", st)218 }219 if f.count("POST token") != 1 || f.count("PUT lineups/USA-OTA-80302") != 1 || len(f.programs) != 3 {220 t.Fatalf("calls %v, programs %v", f.calls, f.programs)221 }222223 // Nothing changed: no new login, no schedules, no programs.224 asked := len(f.asked)225 s.update(ctx, a)226 if f.count("POST token") != 1 || len(f.asked) != asked || len(f.programs) != 3 || f.count("PUT lineups/USA-OTA-80302") != 1 {227 t.Fatalf("second update: calls %v, asked %d more", f.calls, len(f.asked)-asked)228 }229 // A changed day is fetched again, its unchanged programs not.230 f.mu.Lock()231 f.md5 = "day2"232 f.mu.Unlock()233 s.update(ctx, a)234 if len(f.asked) == asked || len(f.programs) != 3 {235 t.Fatalf("changed day: asked %d more, programs %v", len(f.asked)-asked, f.programs)236 }237238 g := s.Guide()239 if g == nil || len(g.Channels) != 2 || g.Channels[0].Number != "2.1" || g.Channels[0].CallSign != "KWGNDT" || g.Channels[0].Logo != "https://example.com/kwgn-light.png" || g.Channels[0].LogoDark != "https://example.com/kwgn-dark.png" {240 t.Fatalf("guide channels = %+v", g)241 }242 ps := g.Programs["20370"]243 if len(ps) != 2 {244 t.Fatalf("2.1 programs = %+v", ps)245 }246 ep := ps[0]247 if ep.Title != "The Voice" || ep.EpisodeTitle != "The Knockouts" || ep.Description != "The knockouts continue, longer." ||248 ep.Season != "28" || ep.Episode != "12" || ep.SeriesID != "SH01365756" || ep.Rating != "TV-14" ||249 strings.Join(ep.Flags, ",") != "New" || strings.Join(ep.Tags, ",") != "CC,Stereo,DVS,HD" ||250 strings.Join(ep.Cast, ",") != "First (Coach),Second" || ep.OriginalAirDate != "2026-10-07" || strings.Join(ep.Genres, ",") != "music,Reality" {251 t.Errorf("episode = %+v", ep)252 }253 if mv := ps[1]; mv.Year != "1939" || mv.Runtime != 102 || mv.Stars != 4 || mv.Rating != "PG" || mv.SeriesID != "" || mv.Genres[0] != "movie" {254 t.Errorf("movie = %+v", mv)255 }256 game := g.Programs["21252"][0]257 if game.HomeTeam != "Denver Broncos" || game.AwayTeam != "Kansas City Chiefs" || game.Flags[0] != "Live" || game.Genres[0] != "sports" {258 t.Errorf("game = %+v", game)259 }260261 // Kept across a restart.262 again := &Source{Dir: s.Dir, ZIP: "80302", HTTP: s.HTTP, UserAgent: "airwaves/test", BaseURL: s.BaseURL}263 if g := again.Guide(); g == nil || len(g.Programs["20370"]) != 2 || again.Status().Lineup != "USA-OTA-80302" {264 t.Fatalf("after a restart: %+v", again.Status())265 }266}267268// TestWrongPassword: a refused login stops updating, with no more logins,269// until the account is saved again.270func TestWrongPassword(t *testing.T) {271 s, f := newSource(t)272 if err := s.SetAccount("viewer", "wrong"); err != nil {273 t.Fatal(err)274 }275 ctx, cancel := context.WithCancel(context.Background())276 defer cancel()277 done := make(chan struct{})278 go func() { s.Run(ctx); close(done) }()279 for deadline := time.Now().Add(5 * time.Second); s.Status().Stopped == ""; time.Sleep(10 * time.Millisecond) {280 if time.Now().After(deadline) {281 t.Fatal("not stopped")282 }283 }284 if st := s.Status(); !strings.Contains(st.Stopped, "user name and password") || st.OK {285 t.Fatalf("status = %+v", st)286 }287 s.Kick()288 time.Sleep(100 * time.Millisecond)289 if n := f.count("POST token"); n != 1 {290 t.Fatalf("%d logins after a refusal", n)291 }292 // Saving the account again tries again, once.293 if err := s.SetAccount("viewer", "hunter2"); err != nil {294 t.Fatal(err)295 }296 for deadline := time.Now().Add(5 * time.Second); !s.Status().OK; time.Sleep(10 * time.Millisecond) {297 if time.Now().After(deadline) {298 t.Fatalf("not updated after fixing the password: %+v", s.Status())299 }300 }301 if n := f.count("POST token"); n != 2 {302 t.Fatalf("%d logins", n)303 }304 cancel()305 <-done306}307308// TestTokenExpired: a token refused mid-update is renewed once, and the309// request retried.310func TestTokenExpired(t *testing.T) {311 s, f := newSource(t)312 _ = s.SetAccount("viewer", "hunter2")313 a, _ := LoadAccount(s.accountPath())314 s.update(context.Background(), a)315 f.mu.Lock()316 f.expire = true317 f.md5 = "day3"318 f.mu.Unlock()319 s.update(context.Background(), a)320 if st := s.Status(); !st.OK || f.count("POST token") != 2 {321 t.Fatalf("status %+v, %d logins", st, f.count("POST token"))322 }323}324325// TestNotJSON: a page that isn't JSON (a load balancer's block) stops326// updating rather than knocking again.327func TestNotJSON(t *testing.T) {328 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {329 w.WriteHeader(http.StatusForbidden)330 fmt.Fprint(w, "<html>blocked</html>")331 }))332 defer srv.Close()333 s := &Source{Dir: t.TempDir(), ZIP: "80302", HTTP: srv.Client(), UserAgent: "airwaves/test", BaseURL: srv.URL + "/"}334 _ = s.SetAccount("viewer", "hunter2")335 a, _ := LoadAccount(s.accountPath())336 s.update(context.Background(), a)337 if st := s.Status(); !strings.Contains(st.Stopped, "other than JSON") {338 t.Fatalf("status = %+v", st)339 }340}341342func TestPasswordHash(t *testing.T) {343 if got := HashPassword("password"); got != "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8" {344 t.Fatalf("SHA-1 = %s", got)345 }346 s, _ := newSource(t)347 _ = s.SetAccount("viewer", "hunter2")348 raw, _ := LoadAccount(s.accountPath())349 if raw.PasswordSHA1 != HashPassword("hunter2") || strings.Contains(fmt.Sprint(raw), "hunter2") {350 t.Fatalf("kept %+v", raw)351 }352 // An empty password keeps the saved one for the same user.353 if err := s.SetAccount("viewer", ""); err != nil {354 t.Fatal(err)355 }356 if a, _ := LoadAccount(s.accountPath()); a.PasswordSHA1 != HashPassword("hunter2") {357 t.Fatal("the saved password was dropped")358 }359 if err := s.SetAccount("someone", ""); err == nil {360 t.Fatal("a new user without a password was taken")361 }362}363364// TestRunOncePerDay: after an update, Run waits for the next day, however365// short the listings are (here, a day of them).366func TestRunOncePerDay(t *testing.T) {367 s, f := newSource(t)368 _ = s.SetAccount("viewer", "hunter2")369 ctx, cancel := context.WithCancel(context.Background())370 done := make(chan struct{})371 go func() { s.Run(ctx); close(done) }()372 for deadline := time.Now().Add(5 * time.Second); !s.Status().OK; time.Sleep(10 * time.Millisecond) {373 if time.Now().After(deadline) {374 t.Fatal("no update")375 }376 }377 time.Sleep(300 * time.Millisecond)378 cancel()379 <-done380 if n := f.count("POST schedules/md5"); n != 1 {381 t.Fatalf("%d updates, want one", n)382 }383 if next := s.Status().Next; time.Until(next) < 11*time.Hour || time.Until(next) > 37*time.Hour {384 t.Fatalf("next update %s", next)385 }386}387388// TestOldLogos: stations kept before their logos' variants were have the389// lineup read again at the next start, without the schedules.390func TestOldLogos(t *testing.T) {391 s, f := newSource(t)392 _ = s.SetAccount("viewer", "hunter2")393 a, _ := LoadAccount(s.accountPath())394 s.update(context.Background(), a)395 st, _ := loadState(s.statePath())396 for i := range st.Stations {397 st.Stations[i].Logos, st.Stations[i].Logo = nil, "https://example.com/white.png"398 }399 if err := st.save(s.statePath()); err != nil {400 t.Fatal(err)401 }402 again := &Source{Dir: s.Dir, ZIP: "80302", HTTP: s.HTTP, UserAgent: "airwaves/test", BaseURL: s.BaseURL}403 if g := again.Guide(); g == nil || g.Channels[0].Logo != "https://example.com/white.png" {404 t.Fatalf("before: %+v", g)405 }406 asked := len(f.asked)407 again.update(context.Background(), a)408 if g := again.Guide(); g.Channels[0].Logo != "https://example.com/kwgn-light.png" || len(f.asked) != asked || f.count("GET lineups/USA-OTA-80302") != 2 {409 t.Fatalf("after: logo %q, %d schedules asked, calls %v", g.Channels[0].Logo, len(f.asked)-asked, f.calls)410 }411}412413// TestArtwork: pictures are picked (landscape, without the title on it,414// near 720 wide), downloaded once, served, and paused by the daily cap.415func TestArtwork(t *testing.T) {416 s, f := newSource(t)417 _ = s.SetAccount("viewer", "hunter2")418 a, _ := LoadAccount(s.accountPath())419 s.update(context.Background(), a)420 ps := s.Guide().Programs["20370"]421 if ps[0].Image != ArtPath+"iconic169.jpg" || ps[1].Image != ArtPath+"movie.jpg" {422 t.Fatalf("images %q, %q", ps[0].Image, ps[1].Image)423 }424 if len(f.images) != 2 {425 t.Fatalf("downloaded %v", f.images)426 }427 rec := httptest.NewRecorder()428 s.ArtHandler().ServeHTTP(rec, httptest.NewRequest("GET", ArtPath+"iconic169.jpg", nil))429 if rec.Code != 200 || rec.Body.String() != "JPEG iconic169.jpg" {430 t.Fatalf("served %d %q", rec.Code, rec.Body.String())431 }432 for _, bad := range []string{ArtPath + "../account.json", ArtPath + ".hidden", ArtPath} {433 rec := httptest.NewRecorder()434 s.ArtHandler().ServeHTTP(rec, httptest.NewRequest("GET", bad, nil))435 if rec.Code != http.StatusNotFound {436 t.Errorf("%s: %d", bad, rec.Code)437 }438 }439440 // Kept: nothing is downloaded again, nor looked up.441 f.mu.Lock()442 f.md5 = "day9"443 f.mu.Unlock()444 s.update(context.Background(), a)445 if len(f.images) != 2 || f.count("POST metadata/programs/") != 1 {446 t.Fatalf("again: downloaded %v, %d lookups", f.images, f.count("POST metadata/programs/"))447 }448}449450// TestArtworkCap: the daily cap pauses downloads until midnight UTC; the451// listings update anyway.452func TestArtworkCap(t *testing.T) {453 s, f := newSource(t)454 f.capped = true455 _ = s.SetAccount("viewer", "hunter2")456 a, _ := LoadAccount(s.accountPath())457 s.update(context.Background(), a)458 if st := s.Status(); !st.OK {459 t.Fatalf("status %+v", st)460 }461 s.mu.Lock()462 paused := s.st.ImagesPaused463 s.mu.Unlock()464 if !paused.After(time.Now()) || paused.Hour() != 0 || paused.Sub(time.Now()) > 24*time.Hour {465 t.Fatalf("paused until %s", paused)466 }467 if s.Guide().Programs["20370"][0].Image != "" {468 t.Fatal("a picture without a download")469 }470}471472// TestStaleToken: a 4003 outside a login means an old token: one new473// login, and the update goes on.474func TestStaleToken(t *testing.T) {475 s, f := newSource(t)476 _ = s.SetAccount("viewer", "hunter2")477 a, _ := LoadAccount(s.accountPath())478 s.update(context.Background(), a)479 f.mu.Lock()480 f.stale, f.md5 = true, "day5"481 f.mu.Unlock()482 s.update(context.Background(), a)483 if st := s.Status(); !st.OK || st.Stopped != "" || f.count("POST token") != 2 {484 t.Fatalf("status %+v, %d logins", st, f.count("POST token"))485 }486}487488// TestPictureRedirect: pictures are fetched from where Schedules Direct489// redirects, without the token.490func TestPictureRedirect(t *testing.T) {491 var mu sync.Mutex492 var tokens []string493 s3 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {494 mu.Lock()495 tokens = append(tokens, r.Header.Get("Token"))496 mu.Unlock()497 w.Header().Set("Content-Type", "image/jpeg")498 fmt.Fprint(w, "JPEG from S3")499 }))500 defer s3.Close()501 s, f := newSource(t)502 f.s3 = strings.Replace(s3.URL, "127.0.0.1", "localhost", 1) // another host503 _ = s.SetAccount("viewer", "hunter2")504 a, _ := LoadAccount(s.accountPath())505 s.update(context.Background(), a)506 mu.Lock()507 defer mu.Unlock()508 if len(tokens) != 2 || tokens[0] != "" || tokens[1] != "" {509 t.Fatalf("S3 saw tokens %q", tokens)510 }511 if s.Guide().Programs["20370"][0].Image == "" {512 t.Fatal("no picture")513 }514}