package tests import ( "bytes" "context" "net/url" "slices" "strings" "time" "github.com/go-bdd/gobdd" ) // uploadPayload stands in for the video file. Nothing in the upload flow reads // it — the CMS only checks that an object exists under the key, and // MediaConvert does not open the input until the job runs — so a fixed blob // keeps the scenarios fast and deterministic. var uploadPayload = bytes.Repeat([]byte("thamanyah-test-video-"), 64) type presignRequest struct { FileName string `json:"fileName"` ContentType string `json:"contentType"` } type registerRequest struct { Title string `json:"title"` Description string `json:"description"` CategoryIDs []int16 `json:"categoryIds"` Tags string `json:"tags"` FileName string `json:"fileName"` Key string `json:"key"` } const ( sentDescription = "A behind-the-scenes look at the evening bulletin." sentTags = "media, press, riyadh" ) // --- Given ----------------------------------------------------------------- func theAPIIsAvailable(t gobdd.StepTest, ctx gobdd.Context) { w := worldOf(t, ctx) result, err := w.client.get("/health") if err != nil { t.Fatalf("the CMS at %s is not answering: %s", w.client.base, err) return } if result.status != 200 { t.Fatalf("the CMS at %s is not healthy: %s", w.client.base, result.summary()) } } // --- When ------------------------------------------------------------------ func askForCategories(t gobdd.StepTest, ctx gobdd.Context) { w := worldOf(t, ctx) result, err := w.client.get("/api/categories") if err != nil { t.Fatalf("could not ask for the categories: %s", err) return } w.last = result } func requestUploadSlot(t gobdd.StepTest, ctx gobdd.Context, fileName, contentType string) { w := worldOf(t, ctx) result, err := w.client.postJSON("/api/videos/presign", presignRequest{ FileName: fileName, ContentType: contentType, }) if err != nil { t.Fatalf("could not ask for an upload slot: %s", err) return } w.last = result w.slot = presignBody{} w.slotType = contentType w.slotFile = fileName w.uploaded = false // A rejected request has no slot to remember; the scenario asserting the // rejection does not need one. if result.status == 200 { if err := result.json(&w.slot); err != nil { t.Fatalf("could not decode the upload slot: %s", err) } } } func uploadTheFile(t gobdd.StepTest, ctx gobdd.Context) { w := worldOf(t, ctx) if w.slot.UploadURL == "" { t.Fatalf("there is no upload URL to PUT to — the upload slot step did not succeed") return } // The content type is signed into the URL, so it has to be sent back // verbatim or storage rejects the PUT as a signature mismatch. result, err := w.client.put(w.slot.UploadURL, w.slotType, uploadPayload) if err != nil { t.Fatalf("could not upload to the presigned URL: %s", err) return } if result.status < 200 || result.status > 299 { t.Fatalf("storage refused the upload: %s", result.summary()) return } w.uploaded = true } func sendMalformedJSON(t gobdd.StepTest, ctx gobdd.Context) { w := worldOf(t, ctx) result, err := w.client.postRaw("/api/videos/presign", "application/json", []byte(`{"fileName": `)) if err != nil { t.Fatalf("could not send the malformed body: %s", err) return } w.last = result } func registerUploadedVideo(t gobdd.StepTest, ctx gobdd.Context, title, categories string) { w := worldOf(t, ctx) register(t, w, registerRequest{ Title: title, Description: sentDescription, CategoryIDs: w.categoryIDs(t, categories), Tags: sentTags, FileName: w.slotFile, Key: w.slot.Key, }) } func registerUnderCategoryID(t gobdd.StepTest, ctx gobdd.Context, title string, categoryID int) { w := worldOf(t, ctx) register(t, w, registerRequest{ Title: title, Description: sentDescription, CategoryIDs: []int16{int16(categoryID)}, Tags: sentTags, FileName: w.slotFile, Key: w.slot.Key, }) } func registerWithKey(t gobdd.StepTest, ctx gobdd.Context, title, key, categories string) { w := worldOf(t, ctx) register(t, w, registerRequest{ Title: title, Description: sentDescription, CategoryIDs: w.categoryIDs(t, categories), Tags: sentTags, FileName: "smuggled.mp4", Key: key, }) } func register(t gobdd.StepTest, w *world, body registerRequest) { result, err := w.client.postJSON("/api/videos", body) if err != nil { t.Fatalf("could not register the video: %s", err) return } w.last = result w.sent = body w.video = videoBody{} if result.status == 201 { if err := result.json(&w.video); err != nil { t.Fatalf("could not decode the registered video: %s", err) } } } // --- Then ------------------------------------------------------------------ func theRequestSucceedsWith(t gobdd.StepTest, ctx gobdd.Context, status int) { w := worldOf(t, ctx) if w.last.status != status { t.Fatalf("expected the request to succeed with %d, got %s", status, w.last.summary()) return } if !strings.HasPrefix(w.last.contentType, "application/json") { t.Errorf("expected a application/json response, got %q", w.last.contentType) } } func theRequestIsRejectedWith(t gobdd.StepTest, ctx gobdd.Context, status int) { w := worldOf(t, ctx) if w.last.status != status { t.Fatalf("expected the request to be rejected with %d, got %s", status, w.last.summary()) return } // Errors are RFC 9457 Problem Details, and the media type is part of that // contract — clients branch on it to know the body is a problem. if !strings.HasPrefix(w.last.contentType, "application/problem+json") { t.Errorf("expected a application/problem+json response, got %q", w.last.contentType) } } func theProblemTitleIs(t gobdd.StepTest, ctx gobdd.Context, title string) { w := worldOf(t, ctx) var p problem if err := w.last.json(&p); err != nil { t.Fatalf("could not decode the problem details: %s", err) return } if p.Title != title { t.Errorf("expected the problem title %q, got %q (detail: %s)", title, p.Title, p.Detail) } if p.Status != w.last.status { t.Errorf("the problem body says status %d but the response was %d", p.Status, w.last.status) } if p.Type != "about:blank" { t.Errorf("expected the problem type %q, got %q", "about:blank", p.Type) } } func theCategoryListIsNotEmpty(t gobdd.StepTest, ctx gobdd.Context) { if len(decodeCategories(t, ctx)) == 0 { t.Errorf("the CMS offered no categories, so no video could ever be filed") } } func everyCategoryHasAnIDAndAName(t gobdd.StepTest, ctx gobdd.Context) { for _, c := range decodeCategories(t, ctx) { if c.ID <= 0 { t.Errorf("the category %q has id %d, which is not a usable id", c.Name, c.ID) } if strings.TrimSpace(c.Name) == "" { t.Errorf("the category with id %d has no name", c.ID) } } } func decodeCategories(t gobdd.StepTest, ctx gobdd.Context) []category { w := worldOf(t, ctx) var body categoriesBody if err := w.last.json(&body); err != nil { t.Fatalf("could not decode the category list: %s", err) return nil } return body.Categories } func iAmGivenAnUploadURL(t gobdd.StepTest, ctx gobdd.Context) { w := worldOf(t, ctx) if w.slot.UploadURL == "" { t.Fatalf("no upload URL was issued: %s", w.last.summary()) return } if !strings.HasPrefix(w.slot.UploadURL, "http://") && !strings.HasPrefix(w.slot.UploadURL, "https://") { t.Errorf("the upload URL %q is not an absolute HTTP URL", w.slot.UploadURL) } // It is presigned, not a bare object URL — the signature is what lets an // unauthenticated client PUT to a private bucket. if !strings.Contains(w.slot.UploadURL, "X-Amz-Signature=") { t.Errorf("the upload URL carries no X-Amz-Signature, so it is not presigned: %s", w.slot.UploadURL) } } func theStorageKeyIs(t gobdd.StepTest, ctx gobdd.Context, prefix, suffix string) { w := worldOf(t, ctx) if !strings.HasPrefix(w.slot.Key, prefix) { t.Errorf("expected the storage key to start with %q, got %q", prefix, w.slot.Key) } if !strings.HasSuffix(w.slot.Key, suffix) { t.Errorf("expected the storage key to end with %q, got %q", suffix, w.slot.Key) } // The key is generated, never the client's file name — two people // uploading "interview.mp4" must not collide. if strings.Contains(w.slot.Key, w.slotFile) { t.Errorf("the storage key %q echoes the submitted file name %q", w.slot.Key, w.slotFile) } } func theVideoIsRegisteredWithStatus(t gobdd.StepTest, ctx gobdd.Context, status string) { w := worldOf(t, ctx) if w.video.Status != status { t.Errorf("expected the video to be registered with status %q, got %q", status, w.video.Status) } } func theVideoHasAnID(t gobdd.StepTest, ctx gobdd.Context) { w := worldOf(t, ctx) if strings.TrimSpace(w.video.ID) == "" { t.Errorf("the registered video came back without an id: %s", w.last.summary()) } } func theVideoIsStoredUnderTheSlotKey(t gobdd.StepTest, ctx gobdd.Context) { w := worldOf(t, ctx) if !w.uploaded { t.Fatalf("nothing was uploaded, so there is no stored file to check against") return } if w.video.StorageKey != w.slot.Key { t.Errorf("the video was filed under %q but the file was uploaded to %q", w.video.StorageKey, w.slot.Key) } } func theVideoHasATranscodingJobID(t gobdd.StepTest, ctx gobdd.Context) { w := worldOf(t, ctx) if strings.TrimSpace(w.video.MediaConvertJobID) == "" { t.Errorf("the video has no transcoding job id, so nothing was queued for it: %s", w.last.summary()) } } func theVideoIsFiledUnder(t gobdd.StepTest, ctx gobdd.Context, categories string) { w := worldOf(t, ctx) want := w.categoryIDs(t, categories) got := slices.Clone(w.video.CategoryIDs) slices.Sort(want) slices.Sort(got) if !slices.Equal(want, got) { t.Errorf("expected the video to be filed under %s (ids %v), got ids %v", categories, want, got) } } func theVideoKeepsTheMetadataISent(t gobdd.StepTest, ctx gobdd.Context) { w := worldOf(t, ctx) if w.video.Title != strings.TrimSpace(w.sent.Title) { t.Errorf("expected the title %q, got %q", strings.TrimSpace(w.sent.Title), w.video.Title) } if w.video.Description != w.sent.Description { t.Errorf("expected the description %q, got %q", w.sent.Description, w.video.Description) } if w.video.Tags != w.sent.Tags { t.Errorf("expected the tags %q, got %q", w.sent.Tags, w.video.Tags) } if w.video.FileName != w.sent.FileName { t.Errorf("expected the file name %q, got %q", w.sent.FileName, w.video.FileName) } } func askForThatVideo(t gobdd.StepTest, ctx gobdd.Context) { w := worldOf(t, ctx) if w.video.ID == "" { t.Fatalf("no video has been registered, so there is no id to ask for") return } result, err := w.client.get("/api/videos/" + w.video.ID) if err != nil { t.Fatalf("could not ask for the video: %s", err) return } w.last = result w.video = videoBody{} if result.status == 200 { if err := result.json(&w.video); err != nil { t.Fatalf("could not decode the video: %s", err) } } } func askForVideoWithID(t gobdd.StepTest, ctx gobdd.Context, id string) { w := worldOf(t, ctx) result, err := w.client.get("/api/videos/" + url.PathEscape(id)) if err != nil { t.Fatalf("could not ask for the video: %s", err) return } w.last = result w.video = videoBody{} } // --- transcoding status ----------------------------------------------------- // statusSettleTimeout bounds how long a scenario waits for the consumer to // pick an event off the queue and write the record. The consumer long-polls, // so in practice this resolves in well under a second; the budget is for a // loaded machine, not for a slow path. const ( statusSettleTimeout = 20 * time.Second statusPollInterval = 200 * time.Millisecond statusHoldWindow = 3 * time.Second ) func registerVideoBeingTranscoded(t gobdd.StepTest, ctx gobdd.Context) { w := worldOf(t, ctx) requestUploadSlot(t, ctx, "transcoding.mp4", "video/mp4") uploadTheFile(t, ctx) registerUploadedVideo(t, ctx, "A Video Being Transcoded", "other") if w.last.status != 201 { t.Fatalf("could not register a video to transcode: %s", w.last.summary()) return } if strings.TrimSpace(w.video.MediaConvertJobID) == "" { t.Fatalf("the registered video carries no transcoding job id, so no event could name it") return } } // registerVideoBeingTranscodedUnder is registerVideoBeingTranscoded for a // scenario that cares which categories the video is filed under. func registerVideoBeingTranscodedUnder(t gobdd.StepTest, ctx gobdd.Context, categories string) { w := worldOf(t, ctx) requestUploadSlot(t, ctx, "transcoding.mp4", "video/mp4") uploadTheFile(t, ctx) registerUploadedVideo(t, ctx, "A Video In Several Categories", categories) if w.last.status != 201 { t.Fatalf("could not register a video to transcode: %s", w.last.summary()) return } if strings.TrimSpace(w.video.MediaConvertJobID) == "" { t.Fatalf("the registered video carries no transcoding job id, so no event could name it") return } } func mediaConvertReportsState(t gobdd.StepTest, ctx gobdd.Context, state string) { w := worldOf(t, ctx) if strings.TrimSpace(w.video.MediaConvertJobID) == "" { t.Fatalf("no transcoding job has been started, so there is no job to report on") return } // A real COMPLETE from an HLS output group always names the manifests it // wrote; the other states carry no output at all. playlist := "" if state == "COMPLETE" { playlist = playlistPathFor(w.video.StorageKey) } publishJobState(t, w.video.MediaConvertJobID, state, playlist) } func mediaConvertReportsStateWithoutPlaylist(t gobdd.StepTest, ctx gobdd.Context, state string) { w := worldOf(t, ctx) if strings.TrimSpace(w.video.MediaConvertJobID) == "" { t.Fatalf("no transcoding job has been started, so there is no job to report on") return } publishJobState(t, w.video.MediaConvertJobID, state, "") } func mediaConvertReportsStateForJob(t gobdd.StepTest, ctx gobdd.Context, jobID, state string) { worldOf(t, ctx) // keeps the step honest about needing a scenario world publishJobState(t, jobID, state, "") } func publishJobState(t gobdd.StepTest, jobID, state, playlistPath string) { background := context.Background() publisher, err := newEventPublisher(background) if err != nil { t.Fatalf("could not reach the job events topic: %s", err) return } if err := publisher.publishJobState(background, jobID, state, playlistPath); err != nil { t.Fatalf("could not publish the job state change: %s", err) } } func theVideoEventuallyHasStatus(t gobdd.StepTest, ctx gobdd.Context, want string) { w := worldOf(t, ctx) deadline := time.Now().Add(statusSettleTimeout) last := "" for time.Now().Before(deadline) { last = currentStatus(t, w) if last == want { return } time.Sleep(statusPollInterval) } t.Errorf("the video never reached status %q within %s; it is still %q", want, statusSettleTimeout, last) } func theVideoKeepsStatus(t gobdd.StepTest, ctx gobdd.Context, want string) { w := worldOf(t, ctx) // Held rather than sampled once: the consumer is asynchronous, so a status // that is still "processing" the instant after publishing proves nothing. deadline := time.Now().Add(statusHoldWindow) for time.Now().Before(deadline) { if got := currentStatus(t, w); got != want { t.Errorf("the video moved to status %q, but this event should have left it %q", got, want) return } time.Sleep(statusPollInterval) } } // currentVideo re-reads the video over the API. It deliberately does not touch // w.last: these steps assert on the record, not on an exchange. func currentVideo(t gobdd.StepTest, w *world) videoBody { result, err := w.client.get("/api/videos/" + w.video.ID) if err != nil { t.Fatalf("could not re-read the video: %s", err) return videoBody{} } if result.status != 200 { t.Fatalf("could not re-read the video: %s", result.summary()) return videoBody{} } var body videoBody if err := result.json(&body); err != nil { t.Fatalf("could not decode the video: %s", err) return videoBody{} } return body } func currentStatus(t gobdd.StepTest, w *world) string { return currentVideo(t, w).Status } func theVideoHasAPlaybackURL(t gobdd.StepTest, ctx gobdd.Context) { w := worldOf(t, ctx) video := currentVideo(t, w) want := playbackURLFor(video.StorageKey) if video.PlaybackURL != want { t.Errorf("expected the playback URL %q, got %q", want, video.PlaybackURL) return } // The point of the rewrite: what is stored has to be something a player // can fetch, not the s3:// path MediaConvert reported. if !strings.HasSuffix(video.PlaybackURL, ".m3u8") { t.Errorf("the playback URL %q is not an HLS playlist", video.PlaybackURL) } if strings.HasPrefix(video.PlaybackURL, "s3://") { t.Errorf("the playback URL %q is still an S3 URI, which no player can fetch", video.PlaybackURL) } } func theVideoHasNoPlaybackURL(t gobdd.StepTest, ctx gobdd.Context) { w := worldOf(t, ctx) if got := currentVideo(t, w).PlaybackURL; got != "" { t.Errorf("expected no playback URL, got %q", got) } } // askForThatVideoWrittenAs re-reads the video by a different, still-valid // spelling of the same uuid. func askForThatVideoWrittenAs(t gobdd.StepTest, ctx gobdd.Context, form string) { w := worldOf(t, ctx) if w.video.ID == "" { t.Fatalf("no video has been registered, so there is no id to rewrite") return } var id string switch form { case "braced": id = "{" + w.video.ID + "}" case "unhyphenated": id = strings.ReplaceAll(w.video.ID, "-", "") case "as a urn": id = "urn:uuid:" + w.video.ID default: t.Fatalf("no such spelling of a uuid: %q", form) return } askForVideoWithID(t, ctx, id) if w.last.status == 200 { if err := w.last.json(&w.video); err != nil { t.Fatalf("could not decode the video: %s", err) } } } // --- the catalogue queue ---------------------------------------------------- // announcementTimeout bounds how long a scenario waits for the announcement. // It is the status timeout's sibling: the publish happens in the same consumer // pass as the status write, so a video that is ready and still unannounced // after this is not slow, it is unannounced. const announcementTimeout = 20 * time.Second func theCatalogueIsToldAboutTheVideo(t gobdd.StepTest, ctx gobdd.Context) { w := worldOf(t, ctx) if strings.TrimSpace(w.video.ID) == "" { t.Fatalf("no video has been registered, so nothing could be announced") return } background := context.Background() reader, err := newCatalogueReader(background) if err != nil { t.Fatalf("could not reach the catalogue queue: %s", err) return } announcement, err := reader.await(background, w.video.ID, announcementTimeout) if err != nil { t.Fatalf("could not read the catalogue queue: %s", err) return } if announcement == nil { t.Errorf("the catalogue was never told about video %q within %s", w.video.ID, announcementTimeout) return } w.announcement = announcement } // announcementHoldWindow is how long "told nothing" watches for before it is // satisfied. The announcement rides in the same consumer pass as the status // write, and the status is already settled by the time this step runs, so a // message that is not here by now is not coming. const announcementHoldWindow = 5 * time.Second func theCatalogueIsToldNothingAboutTheVideo(t gobdd.StepTest, ctx gobdd.Context) { w := worldOf(t, ctx) if strings.TrimSpace(w.video.ID) == "" { t.Fatalf("no video has been registered, so there is nothing to look for") return } background := context.Background() reader, err := newCatalogueReader(background) if err != nil { t.Fatalf("could not reach the catalogue queue: %s", err) return } announcement, err := reader.await(background, w.video.ID, announcementHoldWindow) if err != nil { t.Fatalf("could not read the catalogue queue: %s", err) return } if announcement != nil { t.Errorf("the catalogue was told about video %q, which has nothing to play", w.video.ID) } } func theAnnouncementCarriesTheVideosDetails(t gobdd.StepTest, ctx gobdd.Context) { w := worldOf(t, ctx) if w.announcement == nil { t.Fatalf("no announcement has been received, so there is nothing to inspect") return } if w.announcement.Title != w.sent.Title { t.Errorf("the announcement calls the video %q, but it was registered as %q", w.announcement.Title, w.sent.Title) } if want := playbackURLFor(w.slot.Key); w.announcement.PlaybackURL != want { t.Errorf("the announcement points a player at %q, but the output landed at %q", w.announcement.PlaybackURL, want) } // Named, not numbered: a reader of the queue has no access to the category // ids cms issues, so the names are what make the message self-contained. want := make([]string, 0, len(w.sent.CategoryIDs)) for _, id := range w.sent.CategoryIDs { want = append(want, w.categoryName(t, id)) } got := slices.Clone(w.announcement.Categories) slices.Sort(got) slices.Sort(want) if !slices.Equal(got, want) { t.Errorf("the announcement files the video under %v, but it was registered under %v", w.announcement.Categories, want) } } func theAnnouncementFilesItUnder(t gobdd.StepTest, ctx gobdd.Context, names string) { w := worldOf(t, ctx) if w.announcement == nil { t.Fatalf("no announcement has been received, so there is nothing to inspect") return } want := []string{} for _, name := range strings.Split(names, ",") { if name = strings.TrimSpace(name); name != "" { want = append(want, name) } } got := slices.Clone(w.announcement.Categories) slices.Sort(got) slices.Sort(want) if !slices.Equal(got, want) { t.Errorf("the announcement files the video under %v, but it was registered under %v", w.announcement.Categories, want) } } // --- the catalogue, as discovery serves it ---------------------------------- // catalogueSettleTimeout bounds how long a scenario waits for the read side to // catch up. It is longer than the CMS's own status timeout because two hops // have to happen — cms consuming the job event and announcing, then discovery // consuming that announcement — each with its own long poll. const catalogueSettleTimeout = 30 * time.Second func theDiscoveryAPIIsAvailable(t gobdd.StepTest, ctx gobdd.Context) { w := worldOf(t, ctx) w.discovery = newDiscoveryClient() result, err := w.discovery.get("/health") if err != nil { t.Fatalf("no Discovery at %s (%s) — start one with `docker compose up` from the repo root, "+ "or set DISCOVERY_BASE_URL to point at a running instance", w.discovery.base, err) return } if result.status != 200 { t.Fatalf("Discovery at %s is not healthy: %s", w.discovery.base, result.summary()) } } func theCatalogueEventuallyHoldsThatVideo(t gobdd.StepTest, ctx gobdd.Context) { w := worldOf(t, ctx) if strings.TrimSpace(w.video.ID) == "" { t.Fatalf("no video has been registered, so the catalogue could hold nothing") return } deadline := time.Now().Add(catalogueSettleTimeout) last := response{} for time.Now().Before(deadline) { result, err := w.discovery.get("/api/videos/" + w.video.ID) if err != nil { t.Fatalf("could not ask the catalogue for the video: %s", err) return } last = result if result.status == 200 { var body catalogueVideoBody if err := result.json(&body); err != nil { t.Fatalf("could not decode the catalogue's copy: %s", err) return } w.catalogued = &body return } time.Sleep(statusPollInterval) } t.Errorf("the catalogue never held video %q within %s; asking for it still gives %s", w.video.ID, catalogueSettleTimeout, last.summary()) } func theCataloguesCopyCarriesTheDetails(t gobdd.StepTest, ctx gobdd.Context) { w := worldOf(t, ctx) if w.catalogued == nil { t.Fatalf("the catalogue holds no copy of the video, so there is nothing to inspect") return } if w.catalogued.ID != w.video.ID { t.Errorf("the catalogue filed the video as %q, but the CMS issued id %q", w.catalogued.ID, w.video.ID) } if w.catalogued.Title != w.sent.Title { t.Errorf("the catalogue calls the video %q, but it was registered as %q", w.catalogued.Title, w.sent.Title) } if want := playbackURLFor(w.slot.Key); w.catalogued.PlaybackURL != want { t.Errorf("the catalogue points a player at %q, but the output landed at %q", w.catalogued.PlaybackURL, want) } want := make([]string, 0, len(w.sent.CategoryIDs)) for _, id := range w.sent.CategoryIDs { want = append(want, w.categoryName(t, id)) } got := slices.Clone(w.catalogued.Categories) slices.Sort(got) slices.Sort(want) if !slices.Equal(got, want) { t.Errorf("the catalogue files the video under %v, but it was registered under %v", w.catalogued.Categories, want) } } // catalogueAbsenceWindow is how long "does not hold" watches before it is // satisfied. Held rather than sampled once: the read side is asynchronous, so // a video that is absent the instant after registration proves nothing — it // might simply not have been announced yet. const catalogueAbsenceWindow = 5 * time.Second func theCatalogueDoesNotHoldThatVideo(t gobdd.StepTest, ctx gobdd.Context) { w := worldOf(t, ctx) if strings.TrimSpace(w.video.ID) == "" { t.Fatalf("no video has been registered, so there is nothing to look for") return } deadline := time.Now().Add(catalogueAbsenceWindow) for { result, err := w.discovery.get("/api/videos/" + w.video.ID) if err != nil { t.Fatalf("could not ask the catalogue for the video: %s", err) return } // Kept as the scenario's last exchange so the assertions that follow // can read the status and the problem body the usual way. w.last = result if result.status != 404 { t.Errorf("the catalogue holds video %q, which the CMS never announced: %s", w.video.ID, result.summary()) return } if time.Now().After(deadline) { return } time.Sleep(statusPollInterval) } } func theCatalogueEventuallyHoldsThatVideoWithNoPlaybackURL(t gobdd.StepTest, ctx gobdd.Context) { w := worldOf(t, ctx) if strings.TrimSpace(w.video.ID) == "" { t.Fatalf("no video has been registered, so the catalogue could hold nothing") return } deadline := time.Now().Add(catalogueSettleTimeout) last := "" for time.Now().Before(deadline) { result, err := w.discovery.get("/api/videos/" + w.video.ID) if err != nil { t.Fatalf("could not ask the catalogue for the video: %s", err) return } if result.status == 200 { var body catalogueVideoBody if err := result.json(&body); err != nil { t.Fatalf("could not decode the catalogue's copy: %s", err) return } if body.PlaybackURL == "" { w.catalogued = &body return } last = body.PlaybackURL } time.Sleep(statusPollInterval) } t.Errorf("the catalogue still points a player at %q for video %q after %s; the later "+ "announcement carried no playlist and should have replaced it", last, w.video.ID, catalogueSettleTimeout) }