package tests import ( "strings" "testing" "github.com/go-bdd/gobdd" ) // world is the state one scenario accumulates as its steps run: the exchange // the last step performed, and the upload slot it is working with. // // It is stored in the gobdd context behind worldKey and handed out as a // pointer, because gobdd clones the context between the Background steps and // the scenario steps — a value would leave the Background's work behind. type world struct { client *client // discovery is the read side, set by the Background step that checks it is // up. Nil in scenarios that never mention the catalogue. discovery *client // last is the most recent exchange with the CMS. Every "Then the request // …" step reads it. last response // The upload slot handed out by POST /api/videos/presign, and whether the // file has been PUT to it. slot presignBody slotType string slotFile string uploaded bool // sent is the last registration body posted to POST /api/videos, and video // is the record that came back from it. sent registerRequest video videoBody // categoriesByName caches GET /api/categories so scenarios can name a // category instead of hard-coding the id the seed migration happened to // give it. categoriesByName map[string]int16 // announcement is the message the catalogue topic carried for this // scenario's video, once a step has waited for it. announcement *catalogueAnnouncement // catalogued is the read side's own copy of the video, once a step has // waited for it to arrive. catalogued *catalogueVideoBody // searchTitle is the exact title the scenario's video was catalogued // under. Scenarios name a plain title like "Desert Falcons"; the step // registering it appends a nonce, because nothing cleans up between runs // and a title reused across runs would make a result count meaningless. searchTitle string // results is the last page the catalogue search answered with. results searchResultsBody // pageSeen accumulates the ids from every page a paging scenario has // walked, so it can assert the pages tile the results rather than // repeating or dropping one. pageSeen []string // cohort is the ids of the videos a scenario catalogued together under one // shared title, in the order they were created. cohort []string } type worldKey struct{} func worldOf(t gobdd.StepTest, ctx gobdd.Context) *world { value, err := ctx.Get(worldKey{}) if err != nil { t.Fatalf("no world in the scenario context: %s", err) return nil } w, ok := value.(*world) if !ok { t.Fatalf("the scenario context holds a %T, not a *world", value) return nil } return w } // categoryID resolves a category name to the id the API issues for it, so the // feature file can say "documentary" instead of "1". func (w *world) categoryID(t gobdd.StepTest, name string) int16 { if w.categoriesByName == nil { result, err := w.client.get("/api/categories") if err != nil { t.Fatalf("could not load the category list: %s", err) return 0 } if result.status != 200 { t.Fatalf("could not load the category list: %s", result.summary()) return 0 } var body categoriesBody if err := result.json(&body); err != nil { t.Fatalf("could not decode the category list: %s", err) return 0 } w.categoriesByName = make(map[string]int16, len(body.Categories)) for _, c := range body.Categories { w.categoriesByName[c.Name] = c.ID } } id, ok := w.categoriesByName[name] if !ok { t.Fatalf("the CMS knows no category named %q; it offers %v", name, w.categoriesByName) return 0 } return id } // categoryName resolves a category id back to its name, for asserting on a // message that names categories rather than numbering them. func (w *world) categoryName(t gobdd.StepTest, id int16) string { w.categoryID(t, "other") // ensures the list is loaded for name, known := range w.categoriesByName { if known == id { return name } } t.Fatalf("the CMS knows no category with id %d; it offers %v", id, w.categoriesByName) return "" } // categoryIDs turns a comma-separated list of category names into the ids the // register endpoint expects. An empty list stays empty — that is a scenario in // its own right. func (w *world) categoryIDs(t gobdd.StepTest, names string) []int16 { ids := []int16{} for _, name := range strings.Split(names, ",") { name = strings.TrimSpace(name) if name == "" { continue } ids = append(ids, w.categoryID(t, name)) } return ids } // TestVideoUpload runs features/*.feature against a live CMS. // // The suite is black-box on purpose: it speaks only HTTP, so the same // scenarios run against the docker-compose stack and against a deployed // environment. Point it somewhere else with CMS_BASE_URL. func TestVideoUpload(t *testing.T) { c := newClient() // Skip rather than fail when nothing is serving: a bare `go test ./...` // in a fresh checkout should not go red because the stack is down. if _, err := c.get("/health"); err != nil { t.Skipf("no CMS at %s (%s) — start one with `docker compose up` from the repo root, "+ "or set CMS_BASE_URL to point at a running instance", c.base, err) } suite := gobdd.NewSuite(t, gobdd.WithFeaturesPath("features/*.feature"), gobdd.WithBeforeScenario(func(ctx gobdd.Context) { ctx.Set(worldKey{}, &world{client: newClient()}) }), ) // Given — the stack is up, and steps that arrange state a later When acts on. suite.AddStep(`^the CMS API is available$`, theAPIIsAvailable) suite.AddStep(`^the Discovery API is available$`, theDiscoveryAPIIsAvailable) suite.AddStep(`^I have requested an upload slot for "(.*)" of type "(.*)"$`, requestUploadSlot) suite.AddStep(`^I have uploaded the file to the upload URL$`, uploadTheFile) suite.AddStep(`^I have registered the uploaded video titled "(.*)" under categories "(.*)"$`, registerUploadedVideo) // When — the action under test. suite.AddStep(`^I ask the CMS for the list of categories$`, askForCategories) suite.AddStep(`^I request an upload slot for "(.*)" of type "(.*)"$`, requestUploadSlot) suite.AddStep(`^I upload the file to the upload URL$`, uploadTheFile) suite.AddStep(`^I send malformed JSON to the upload slot endpoint$`, sendMalformedJSON) suite.AddStep(`^I ask the CMS for that video$`, askForThatVideo) suite.AddStep(`^I ask the CMS for the video with id "(.*)"$`, askForVideoWithID) suite.AddStep(`^I have registered a video that is being transcoded$`, registerVideoBeingTranscoded) suite.AddStep(`^I have registered a video being transcoded under categories "(.*)"$`, registerVideoBeingTranscodedUnder) suite.AddStep(`^I have registered a video$`, registerVideoBeingTranscoded) suite.AddStep(`^I ask the CMS for that video with its id written (.*)$`, askForThatVideoWrittenAs) suite.AddStep(`^MediaConvert reports that the job reached "(.*)"$`, mediaConvertReportsState) suite.AddStep(`^MediaConvert reports that the job reached "(.*)" without a playlist$`, mediaConvertReportsStateWithoutPlaylist) suite.AddStep(`^MediaConvert reports that the job "(.*)" reached "(.*)"$`, mediaConvertReportsStateForJob) suite.AddStep(`^the video eventually has status "(.*)"$`, theVideoEventuallyHasStatus) suite.AddStep(`^the video keeps status "(.*)"$`, theVideoKeepsStatus) suite.AddStep(`^the video has a playback URL for its HLS playlist$`, theVideoHasAPlaybackURL) suite.AddStep(`^the video has no playback URL$`, theVideoHasNoPlaybackURL) suite.AddStep(`^the catalogue is told about the video$`, theCatalogueIsToldAboutTheVideo) suite.AddStep(`^the catalogue is told nothing about the video$`, theCatalogueIsToldNothingAboutTheVideo) suite.AddStep(`^the announcement carries the video's title, playback URL and categories$`, theAnnouncementCarriesTheVideosDetails) suite.AddStep(`^the announcement files it under "(.*)"$`, theAnnouncementFilesItUnder) suite.AddStep(`^the catalogue eventually holds that video$`, theCatalogueEventuallyHoldsThatVideo) suite.AddStep(`^the catalogue does not hold that video$`, theCatalogueDoesNotHoldThatVideo) suite.AddStep(`^the catalogue eventually holds that video with no playback URL$`, theCatalogueEventuallyHoldsThatVideoWithNoPlaybackURL) suite.AddStep(`^the catalogue's copy carries its title, playback URL and categories$`, theCataloguesCopyCarriesTheDetails) suite.AddStep(`^the catalogue holds a video titled "([^"]*)"$`, theCatalogueHoldsAVideoTitled) suite.AddStep(`^I search the catalogue for that video's title$`, searchTheCatalogueForThatTitle) suite.AddStep(`^I search the catalogue for "([^"]*)"$`, searchTheCatalogueFor) suite.AddStep(`^the catalogue holds a video titled "([^"]*)" filed under "([^"]*)"$`, theCatalogueHoldsAVideoTitledUnder) suite.AddStep(`^I search the catalogue for that video's title in category "([^"]*)"$`, searchTheCatalogueForThatTitleInCategory) suite.AddStep(`^I search the catalogue for that video's title in categories "([^"]*)"$`, searchTheCatalogueForThatTitleInCategories) suite.AddStep(`^the catalogue holds (\d+) videos titled "([^"]*)"$`, theCatalogueHoldsVideosTitled) suite.AddStep(`^I search the catalogue for that title (\d+) at a time$`, searchTheCatalogueForThatTitleAPageAtATime) suite.AddStep(`^I search the whole catalogue (\d+) at a time$`, searchTheWholeCatalogue) suite.AddStep(`^I follow the cursor$`, followTheCursor) suite.AddStep(`^I search the catalogue for that video's title from the cursor "([^"]*)"$`, searchTheCatalogueFromCursor) suite.AddStep(`^the search returns (\d+) videos?$`, theSearchReturnsNVideos) suite.AddStep(`^the search returns at most (\d+) videos$`, theSearchReturnsAtMostNVideos) suite.AddStep(`^the search offers a cursor to the next page$`, theSearchOffersACursor) suite.AddStep(`^the search offers no further cursor$`, theSearchOffersNoFurtherCursor) suite.AddStep(`^the pages together hold every one of those videos exactly once$`, thePagesTileTheCohort) suite.AddStep(`^the search does not return that video$`, theSearchDoesNotReturnThatVideo) suite.AddStep(`^the search returns that video$`, theSearchReturnsThatVideo) suite.AddStep(`^I register the uploaded video titled "(.*)" under categories "(.*)"$`, registerUploadedVideo) suite.AddStep(`^I register the uploaded video titled "(.*)" under category id (\d+)$`, registerUnderCategoryID) suite.AddStep(`^I register a video titled "(.*)" with the key "(.*)" under categories "(.*)"$`, registerWithKey) // Then — assertions on the last exchange. suite.AddStep(`^the request succeeds with status (\d+)$`, theRequestSucceedsWith) suite.AddStep(`^the request is rejected with status (\d+)$`, theRequestIsRejectedWith) suite.AddStep(`^the problem title is "(.*)"$`, theProblemTitleIs) suite.AddStep(`^the category list is not empty$`, theCategoryListIsNotEmpty) suite.AddStep(`^every category has an id and a name$`, everyCategoryHasAnIDAndAName) suite.AddStep(`^I am given an upload URL$`, iAmGivenAnUploadURL) suite.AddStep(`^the storage key is under "(.*)" and ends with "(.*)"$`, theStorageKeyIs) suite.AddStep(`^the video is registered with status "(.*)"$`, theVideoIsRegisteredWithStatus) suite.AddStep(`^the video has an id$`, theVideoHasAnID) suite.AddStep(`^the video is stored under the key from the upload slot$`, theVideoIsStoredUnderTheSlotKey) suite.AddStep(`^the video has a transcoding job id$`, theVideoHasATranscodingJobID) suite.AddStep(`^the video is filed under categories "(.*)"$`, theVideoIsFiledUnder) suite.AddStep(`^the video keeps the metadata I sent$`, theVideoKeepsTheMetadataISent) suite.Run() }