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 } 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(`^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() }