Files
thamanyah/tests/steps_test.go
T
FahdShalhoub 0cfd89f200
Build, Push and Deploy CMS / build-push-deploy (push) Successful in 2m28s
Deploy Infrastructure / pulumi-up (push) Successful in 2s
FEAT: Return Playback URL in Get Videos
2026-08-27 21:31:13 +03:00

584 lines
17 KiB
Go

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
}
}
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)
}
}
}