1220 lines
36 KiB
Go
1220 lines
36 KiB
Go
package tests
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"slices"
|
|
"strconv"
|
|
"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)
|
|
}
|
|
|
|
// --- Catalogue search ------------------------------------------------------
|
|
|
|
// searchLimit is the page size the search scenarios ask for unless they are
|
|
// about paging itself.
|
|
const searchLimit = 20
|
|
|
|
// uniqueTitle appends a nonce to the title a scenario names.
|
|
//
|
|
// Scenarios write real rows and nothing cleans up after them, so a fixed title
|
|
// accumulates a copy per run and "the search returns exactly this video" stops
|
|
// meaning anything. The nonce keeps every run's video findable on its own
|
|
// while the feature file still reads in plain words.
|
|
func uniqueTitle(title string) string {
|
|
return fmt.Sprintf("%s %s", title, strings.ToUpper(strconv.FormatInt(time.Now().UnixNano(), 36)))
|
|
}
|
|
|
|
// theCatalogueHoldsAVideoTitled puts one video into the catalogue under a
|
|
// title the scenario chose: register it, report its transcode finished, and
|
|
// wait for the announcement to land on the read side.
|
|
func theCatalogueHoldsAVideoTitled(t gobdd.StepTest, ctx gobdd.Context, title string) {
|
|
catalogueVideoTitledUnder(t, ctx, title, "other")
|
|
}
|
|
|
|
// catalogueVideoTitledUnder is the whole write-to-read round trip for one
|
|
// video, which is what "the catalogue holds …" costs: cms issues the id, the
|
|
// job completes, cms announces it, discovery ingests it.
|
|
func catalogueVideoTitledUnder(t gobdd.StepTest, ctx gobdd.Context, title, categories string) {
|
|
w := worldOf(t, ctx)
|
|
|
|
full := uniqueTitle(title)
|
|
|
|
requestUploadSlot(t, ctx, "searchable.mp4", "video/mp4")
|
|
uploadTheFile(t, ctx)
|
|
registerUploadedVideo(t, ctx, full, categories)
|
|
|
|
if w.last.status != 201 {
|
|
t.Fatalf("could not register a video to search for: %s", w.last.summary())
|
|
return
|
|
}
|
|
|
|
w.searchTitle = full
|
|
|
|
mediaConvertReportsState(t, ctx, "COMPLETE")
|
|
theCatalogueEventuallyHoldsThatVideo(t, ctx)
|
|
}
|
|
|
|
// searchTheCatalogueForThatTitle searches for the exact title the scenario's
|
|
// video was catalogued under, nonce included — the scenario says "that video's
|
|
// title" precisely so it does not have to know about the nonce.
|
|
func searchTheCatalogueForThatTitle(t gobdd.StepTest, ctx gobdd.Context) {
|
|
w := worldOf(t, ctx)
|
|
|
|
if strings.TrimSpace(w.searchTitle) == "" {
|
|
t.Fatalf("no video has been catalogued, so there is no title to search for")
|
|
return
|
|
}
|
|
|
|
searchTheCatalogue(t, w, searchRequest{Title: w.searchTitle, Limit: searchLimit})
|
|
}
|
|
|
|
// searchTheCatalogue performs the query and decodes the page, leaving both the
|
|
// raw exchange and the decoded results on the world so a "Then" step can
|
|
// assert on either.
|
|
func searchTheCatalogue(t gobdd.StepTest, w *world, request searchRequest) {
|
|
if w.discovery == nil {
|
|
w.discovery = newDiscoveryClient()
|
|
}
|
|
|
|
result, err := w.discovery.search("/api/videos", request)
|
|
if err != nil {
|
|
t.Fatalf("could not search the catalogue: %s", err)
|
|
return
|
|
}
|
|
|
|
w.last = result
|
|
w.results = searchResultsBody{}
|
|
|
|
// A rejected search has no page to decode; the scenario asserting the
|
|
// rejection reads w.last instead.
|
|
if result.status == 200 {
|
|
if err := result.json(&w.results); err != nil {
|
|
t.Fatalf("could not decode the search results: %s", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func theSearchReturnsThatVideo(t gobdd.StepTest, ctx gobdd.Context) {
|
|
w := worldOf(t, ctx)
|
|
|
|
if w.last.status != 200 {
|
|
t.Fatalf("the search did not succeed: %s", w.last.summary())
|
|
return
|
|
}
|
|
if !w.results.holds(w.video.ID) {
|
|
t.Errorf("the search for %q did not return video %q; it returned %v",
|
|
w.searchTitle, w.video.ID, w.results.ids())
|
|
}
|
|
}
|
|
|
|
// searchTheCatalogueFor searches for a term the scenario spells out, rather
|
|
// than for the title of the video it catalogued.
|
|
func searchTheCatalogueFor(t gobdd.StepTest, ctx gobdd.Context, term string) {
|
|
searchTheCatalogue(t, worldOf(t, ctx), searchRequest{Title: term, Limit: searchLimit})
|
|
}
|
|
|
|
// theCatalogueHoldsAVideoTitledUnder is theCatalogueHoldsAVideoTitled for a
|
|
// scenario that cares which categories the video is filed under.
|
|
func theCatalogueHoldsAVideoTitledUnder(t gobdd.StepTest, ctx gobdd.Context, title, categories string) {
|
|
catalogueVideoTitledUnder(t, ctx, title, categories)
|
|
}
|
|
|
|
func searchTheCatalogueForThatTitleInCategory(t gobdd.StepTest, ctx gobdd.Context, category string) {
|
|
w := worldOf(t, ctx)
|
|
|
|
if strings.TrimSpace(w.searchTitle) == "" {
|
|
t.Fatalf("no video has been catalogued, so there is no title to search for")
|
|
return
|
|
}
|
|
|
|
searchTheCatalogue(t, w, searchRequest{
|
|
Title: w.searchTitle,
|
|
Categories: []string{category},
|
|
Limit: searchLimit,
|
|
})
|
|
}
|
|
|
|
func theSearchDoesNotReturnThatVideo(t gobdd.StepTest, ctx gobdd.Context) {
|
|
w := worldOf(t, ctx)
|
|
|
|
if w.last.status != 200 {
|
|
t.Fatalf("the search did not succeed: %s", w.last.summary())
|
|
return
|
|
}
|
|
if w.results.holds(w.video.ID) {
|
|
t.Errorf("the search returned video %q, which it should have left out; it returned %v",
|
|
w.video.ID, w.results.ids())
|
|
}
|
|
}
|
|
|
|
// searchTheCatalogueForThatTitleInCategories is the several-categories form,
|
|
// for pinning that they mean "any of" rather than "all of".
|
|
func searchTheCatalogueForThatTitleInCategories(t gobdd.StepTest, ctx gobdd.Context, categories string) {
|
|
w := worldOf(t, ctx)
|
|
|
|
if strings.TrimSpace(w.searchTitle) == "" {
|
|
t.Fatalf("no video has been catalogued, so there is no title to search for")
|
|
return
|
|
}
|
|
|
|
names := []string{}
|
|
for _, name := range strings.Split(categories, ",") {
|
|
if name = strings.TrimSpace(name); name != "" {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
|
|
searchTheCatalogue(t, w, searchRequest{
|
|
Title: w.searchTitle,
|
|
Categories: names,
|
|
Limit: searchLimit,
|
|
})
|
|
}
|
|
|
|
// theCatalogueHoldsVideosTitled catalogues several videos under one shared
|
|
// title, which is what a paging scenario needs: enough matches to fill more
|
|
// than one page.
|
|
//
|
|
// The nonce is generated once and shared, rather than per video, so that
|
|
// searching for it matches this scenario's videos and no others. Runs leave
|
|
// their videos behind, so a term that also matched a previous run's would make
|
|
// "the search returns 2 videos" mean nothing.
|
|
func theCatalogueHoldsVideosTitled(t gobdd.StepTest, ctx gobdd.Context, count int, title string) {
|
|
w := worldOf(t, ctx)
|
|
|
|
w.searchTitle = uniqueTitle(title)
|
|
w.cohort = nil
|
|
w.pageSeen = nil
|
|
|
|
for i := 0; i < count; i++ {
|
|
requestUploadSlot(t, ctx, "searchable.mp4", "video/mp4")
|
|
uploadTheFile(t, ctx)
|
|
registerUploadedVideo(t, ctx, w.searchTitle, "other")
|
|
|
|
if w.last.status != 201 {
|
|
t.Fatalf("could not register video %d of %d to page through: %s", i+1, count, w.last.summary())
|
|
return
|
|
}
|
|
|
|
mediaConvertReportsState(t, ctx, "COMPLETE")
|
|
theCatalogueEventuallyHoldsThatVideo(t, ctx)
|
|
|
|
w.cohort = append(w.cohort, w.video.ID)
|
|
}
|
|
}
|
|
|
|
func searchTheCatalogueForThatTitleAPageAtATime(t gobdd.StepTest, ctx gobdd.Context, size int) {
|
|
w := worldOf(t, ctx)
|
|
|
|
w.pageSeen = nil
|
|
searchTheCatalogue(t, w, searchRequest{Title: w.searchTitle, Limit: size})
|
|
recordPage(w)
|
|
}
|
|
|
|
// followTheCursor asks for the page after the one just returned, with the same
|
|
// term and page size — a cursor says where to carry on from, not what to look
|
|
// for.
|
|
func followTheCursor(t gobdd.StepTest, ctx gobdd.Context) {
|
|
w := worldOf(t, ctx)
|
|
|
|
if w.results.NextCursor == "" {
|
|
t.Fatalf("the last page offered no cursor, so there is no next page to follow")
|
|
return
|
|
}
|
|
|
|
searchTheCatalogue(t, w, searchRequest{
|
|
Title: w.searchTitle,
|
|
Limit: len(w.results.Videos),
|
|
Cursor: w.results.NextCursor,
|
|
})
|
|
recordPage(w)
|
|
}
|
|
|
|
// recordPage remembers what a page held, so a later step can check the pages
|
|
// tile the results.
|
|
func recordPage(w *world) {
|
|
w.pageSeen = append(w.pageSeen, w.results.ids()...)
|
|
}
|
|
|
|
func theSearchReturnsNVideos(t gobdd.StepTest, ctx gobdd.Context, count int) {
|
|
w := worldOf(t, ctx)
|
|
|
|
if w.last.status != 200 {
|
|
t.Fatalf("the search did not succeed: %s", w.last.summary())
|
|
return
|
|
}
|
|
if len(w.results.Videos) != count {
|
|
t.Errorf("expected %d videos in the page, got %d: %v", count, len(w.results.Videos), w.results.ids())
|
|
}
|
|
}
|
|
|
|
func theSearchOffersACursor(t gobdd.StepTest, ctx gobdd.Context) {
|
|
w := worldOf(t, ctx)
|
|
|
|
if strings.TrimSpace(w.results.NextCursor) == "" {
|
|
t.Errorf("the page offered no cursor, so there is no way to ask for the next one")
|
|
}
|
|
}
|
|
|
|
func theSearchOffersNoFurtherCursor(t gobdd.StepTest, ctx gobdd.Context) {
|
|
w := worldOf(t, ctx)
|
|
|
|
if w.results.NextCursor != "" {
|
|
t.Errorf("the last page still offered cursor %q, so a reader cannot tell they have reached the end",
|
|
w.results.NextCursor)
|
|
}
|
|
}
|
|
|
|
// thePagesTileTheCohort checks the pages between them held each of the
|
|
// scenario's videos exactly once — the property a cursor exists to give, and
|
|
// the one an offset loses as soon as the catalogue is written to.
|
|
func thePagesTileTheCohort(t gobdd.StepTest, ctx gobdd.Context) {
|
|
w := worldOf(t, ctx)
|
|
|
|
seen := map[string]int{}
|
|
for _, id := range w.pageSeen {
|
|
seen[id]++
|
|
}
|
|
|
|
for _, id := range w.cohort {
|
|
switch seen[id] {
|
|
case 1:
|
|
case 0:
|
|
t.Errorf("video %q was catalogued but appeared in none of the pages; the pages held %v",
|
|
id, w.pageSeen)
|
|
default:
|
|
t.Errorf("video %q appeared in %d pages; a video should be on exactly one", id, seen[id])
|
|
}
|
|
}
|
|
|
|
if len(w.pageSeen) != len(w.cohort) {
|
|
t.Errorf("the pages held %d videos between them, but %d were catalogued: %v",
|
|
len(w.pageSeen), len(w.cohort), w.pageSeen)
|
|
}
|
|
}
|
|
|
|
func searchTheCatalogueFromCursor(t gobdd.StepTest, ctx gobdd.Context, cursor string) {
|
|
w := worldOf(t, ctx)
|
|
searchTheCatalogue(t, w, searchRequest{
|
|
Title: w.searchTitle,
|
|
Limit: searchLimit,
|
|
Cursor: cursor,
|
|
})
|
|
}
|
|
|
|
// searchTheWholeCatalogue searches with no term and no categories, which is
|
|
// how a reader browses rather than searches.
|
|
func searchTheWholeCatalogue(t gobdd.StepTest, ctx gobdd.Context, size int) {
|
|
searchTheCatalogue(t, worldOf(t, ctx), searchRequest{Limit: size})
|
|
}
|
|
|
|
func theSearchReturnsAtMostNVideos(t gobdd.StepTest, ctx gobdd.Context, most int) {
|
|
w := worldOf(t, ctx)
|
|
|
|
if w.last.status != 200 {
|
|
t.Fatalf("the search did not succeed: %s", w.last.summary())
|
|
return
|
|
}
|
|
if len(w.results.Videos) > most {
|
|
t.Errorf("the search returned %d videos, more than the %d it should cap at",
|
|
len(w.results.Videos), most)
|
|
}
|
|
}
|