From ae129c8f616ac11f8b4f7d85ebb7dab83529b95b Mon Sep 17 00:00:00 2001 From: FahdShalhoub Date: Thu, 27 Aug 2026 18:42:35 +0300 Subject: [PATCH] FEAT: Init BDD Tests --- CLAUDE.md | 25 +- tests/README.md | 99 ++++++++ tests/client_test.go | 164 +++++++++++++ tests/features/video_upload.feature | 119 ++++++++++ tests/go.mod | 11 + tests/go.sum | 18 ++ tests/steps_test.go | 354 ++++++++++++++++++++++++++++ tests/suite_test.go | 162 +++++++++++++ 8 files changed, 951 insertions(+), 1 deletion(-) create mode 100644 tests/README.md create mode 100644 tests/client_test.go create mode 100644 tests/features/video_upload.feature create mode 100644 tests/go.mod create mode 100644 tests/go.sum create mode 100644 tests/steps_test.go create mode 100644 tests/suite_test.go diff --git a/CLAUDE.md b/CLAUDE.md index fee33f5..5818b32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ├── cms/ Go service: video ingestion/CMS (implemented) ├── discovery/ Go service: empty placeholder, no code yet ├── infrastructure/ Pulumi (Go) program provisioning all AWS resources +├── tests/ Go module: Gherkin/gobdd scenarios run against a live cms └── .gitea/workflows/ Gitea Actions CI/CD pipelines ``` @@ -28,7 +29,8 @@ go build ./... go vet ./... ``` -There are no test files in this repo (`cms`, `discovery`, or `infrastructure`) — don't assume a test suite exists. +`cms`, `discovery` and `infrastructure` hold no test files of their own. The +only tests in the repo are the black-box BDD scenarios in `tests/` — see below. `cms` is a JSON API only — it serves no HTML and has no static assets. The OpenAPI spec is generated from swaggo annotations on the handlers into @@ -48,6 +50,27 @@ emit, and the build breaks outright if the two drift. (The CLI's `--version` misreports itself as v1.16.4; `go version -m $(go env GOPATH)/bin/swag` gives the real one.) +### tests (Go, module `thamanyah/tests`) + +```bash +cd tests +go test ./... # runs features/*.feature against CMS_BASE_URL (default http://localhost:8081) +go test -v ./... # -v prints the Gherkin: gobdd nests a subtest per feature/scenario/step +CMS_BASE_URL=… go test ./... +``` + +Black-box BDD covering the video upload feature: its own Go module importing +nothing from `cms/`, talking to a running service over HTTP only, so the same +scenarios run against compose and against a deployed environment. Skips (does +not fail) when nothing is serving. Uploading is a plain `PUT` to the presigned +URL, no AWS SDK. Scenarios tagged `@known-gap` deliberately pin current +behaviour that differs from the documented contract — see `tests/README.md`. + +Note compose starts `cms` in server mode only: the migrate container exists in +the ECS task definition, not in `docker-compose.yml`, so a freshly created +local database needs `docker exec cms ./cms migrate` once or every scenario +fails on `relation "categories" does not exist`. + ### infrastructure (Go, Pulumi, module `thamanyah`) ```bash diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..e7c229b --- /dev/null +++ b/tests/README.md @@ -0,0 +1,99 @@ +# tests + +BDD scenarios for the video upload feature, written in Gherkin and run by +[gobdd](https://github.com/go-bdd/gobdd). + +The suite is **black box**: it speaks nothing but HTTP to a running `cms`, so +the same scenarios run against the local docker-compose stack and against a +deployed environment. It is its own Go module (`thamanyah/tests`) and imports +nothing from `cms/` — the API is the contract under test, not the code. + +The upload to storage is a plain `PUT` to the presigned URL the API hands out, +with no AWS SDK involved, because that is exactly what a real client does. + +``` +features/video_upload.feature the scenarios, in Gherkin +suite_test.go the gobdd suite: step registration + per-scenario world +steps_test.go what each step does +client_test.go HTTP plumbing and the wire types +``` + +## Running + +The suite needs a live `cms` with its database migrated and its buckets +provisioned. From the repo root: + +```bash +docker compose up -d # localstack + infra + cms +docker exec cms ./cms migrate # see "Migrations" below +cd tests && go test ./... +``` + +If nothing is serving, the suite **skips** rather than fails, so `go test ./...` +in a fresh checkout stays green. + +Point it at another environment with `CMS_BASE_URL` (default +`http://localhost:8081`): + +```bash +CMS_BASE_URL=https://cms.example.com go test -v ./... +``` + +Use `-v` to see the Gherkin: gobdd nests a Go subtest per feature, scenario and +step, so the step text shows up in the test output. + +### Migrations + +`docker-compose.yml` starts `cms` in server mode only. The migrate container +that runs `./cms migrate` to completion before the service starts exists in the +ECS task definition (`runMigrations` in `infrastructure/main.go`), not in +compose — so a freshly created local database has no schema and every scenario +fails with `relation "categories" does not exist`. Run `docker exec cms ./cms +migrate` once after the stack first comes up. + +## What the scenarios cover + +Both steps of the upload flow, end to end: + +- listing the categories a video can be filed under, and the shape of that list +- issuing an upload slot for MP4 and QuickTime, including that the storage key + is generated rather than taken from the client's file name +- refusing a slot for every other media type, and for a body that is not JSON +- uploading the file to the presigned URL and registering it: the record comes + back `processing`, under the key from the slot, with a transcoding job id and + the categories and metadata that were sent +- refusing to register: a file that was never uploaded, a missing title, no + category, and a storage key this API never issued +- answering 404 for a category id no category has, kept distinct from the 500 + the category lookup itself failing produces + +## `@known-gap` + +One scenario is tagged `@known-gap`. It passes — it pins down what the service +does **today**, where that differs from what it is documented to do: + +- **registering the same upload twice answers 500.** `videos.storage_key` is + `UNIQUE`, so the retry the API's own error message invites ("retry this + request with the same 'key'") fails the insert — after a second MediaConvert + job has already been queued for the same file. + +Fixing it means changing the scenario alongside the handler. It is tagged so it +is easy to find, and so it can be excluded with +`gobdd.WithIgnoredTags("@known-gap")` if that is ever wanted. + +## Adding a scenario + +Write it in `features/`. If it needs a step that does not exist yet, add the +function to `steps_test.go` and register it in `TestVideoUpload`. Two things to +know about gobdd: + +- Step patterns are matched **unanchored**, and the first registered pattern + that matches wins. Anchor every pattern with `^…$`, as the existing ones do, + or a new step can silently shadow an old one. +- State moves between steps through the `*world` in the gobdd context, not + package variables — gobdd clones the context between the `Background` and the + scenario, so the world is held by pointer. + +Scenarios write real rows and real objects, and nothing cleans up after them. +That is fine against LocalStack, which is disposable; think twice before +pointing the suite at anything that is not. diff --git a/tests/client_test.go b/tests/client_test.go new file mode 100644 index 0000000..f0f427c --- /dev/null +++ b/tests/client_test.go @@ -0,0 +1,164 @@ +package tests + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// defaultBaseURL is where docker-compose publishes cms on the host. +const defaultBaseURL = "http://localhost:8081" + +// baseURL is the CMS the scenarios are run against. Override it to point the +// suite at a deployed environment instead of the local compose stack. +func baseURL() string { + if v := strings.TrimSpace(os.Getenv("CMS_BASE_URL")); v != "" { + return strings.TrimRight(v, "/") + } + return defaultBaseURL +} + +// response is one HTTP exchange, kept whole so a failing assertion can print +// the body it actually got rather than just a status code. +type response struct { + status int + contentType string + body []byte +} + +// json decodes the body into dest. It returns an error rather than failing the +// step so callers can report the raw body alongside the decode failure. +func (r response) json(dest any) error { + if err := json.Unmarshal(r.body, dest); err != nil { + return fmt.Errorf("%w (body: %s)", err, r.summary()) + } + return nil +} + +func (r response) summary() string { + const max = 512 + body := strings.TrimSpace(string(r.body)) + if len(body) > max { + body = body[:max] + "…" + } + if body == "" { + body = "" + } + return fmt.Sprintf("%d %s: %s", r.status, r.contentType, body) +} + +// problem is the RFC 9457 body every error from the CMS carries. +type problem struct { + Type string `json:"type"` + Title string `json:"title"` + Status int `json:"status"` + Detail string `json:"detail"` +} + +type category struct { + ID int16 `json:"id"` + Name string `json:"name"` +} + +type categoriesBody struct { + Categories []category `json:"categories"` +} + +type presignBody struct { + UploadURL string `json:"uploadUrl"` + Key string `json:"key"` +} + +type videoBody struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + CategoryIDs []int16 `json:"categoryIds"` + Tags string `json:"tags"` + FileName string `json:"fileName"` + StorageKey string `json:"storageKey"` + MediaConvertJobID string `json:"mediaConvertJobId"` + Status string `json:"status"` + SizeBytes int64 `json:"sizeBytes"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// client talks to the CMS over plain HTTP. It deliberately does not use the +// AWS SDK: the presigned URL is handed out by the API and is a complete +// request on its own, so uploading is a plain PUT — exactly what a browser +// client does, and what these scenarios are meant to exercise. +type client struct { + base string + http *http.Client +} + +func newClient() *client { + return &client{ + base: baseURL(), + // Generous: the registration call queues a MediaConvert job inline. + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +// postJSON marshals payload and posts it to a path on the CMS. +func (c *client) postJSON(path string, payload any) (response, error) { + encoded, err := json.Marshal(payload) + if err != nil { + return response{}, err + } + return c.send(http.MethodPost, c.base+path, "application/json", encoded) +} + +// postRaw posts a body the caller has already framed, so a scenario can send +// something that is not valid JSON. +func (c *client) postRaw(path, contentType string, body []byte) (response, error) { + return c.send(http.MethodPost, c.base+path, contentType, body) +} + +func (c *client) get(path string) (response, error) { + return c.send(http.MethodGet, c.base+path, "", nil) +} + +// put uploads bytes to an absolute URL — the presigned one, which points at +// storage rather than at the CMS. +func (c *client) put(url, contentType string, body []byte) (response, error) { + return c.send(http.MethodPut, url, contentType, body) +} + +func (c *client) send(method, url, contentType string, body []byte) (response, error) { + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + + request, err := http.NewRequest(method, url, reader) + if err != nil { + return response{}, err + } + if contentType != "" { + request.Header.Set("Content-Type", contentType) + } + + result, err := c.http.Do(request) + if err != nil { + return response{}, fmt.Errorf("%s %s: %w", method, url, err) + } + defer result.Body.Close() + + responseBody, err := io.ReadAll(result.Body) + if err != nil { + return response{}, fmt.Errorf("%s %s: reading body: %w", method, url, err) + } + + return response{ + status: result.StatusCode, + contentType: result.Header.Get("Content-Type"), + body: responseBody, + }, nil +} diff --git a/tests/features/video_upload.feature b/tests/features/video_upload.feature new file mode 100644 index 0000000..50cda6c --- /dev/null +++ b/tests/features/video_upload.feature @@ -0,0 +1,119 @@ +Feature: Video upload + Getting a video into the catalogue is a two-step exchange. The client asks + the CMS for an upload slot, PUTs the file straight to storage with the + short-lived URL it is handed, and then registers the video — which queues + the transcoding job and writes the catalogue record. The file itself never + passes through the API. + + Background: + Given the CMS API is available + + Scenario: Discovering the categories a video can be filed under + When I ask the CMS for the list of categories + Then the request succeeds with status 200 + And the category list is not empty + And every category has an id and a name + + Scenario: Asking for an upload slot for an MP4 + When I request an upload slot for "interview-cut.mp4" of type "video/mp4" + Then the request succeeds with status 200 + And I am given an upload URL + And the storage key is under "videos/" and ends with ".mp4" + + Scenario: Asking for an upload slot for a QuickTime movie + When I request an upload slot for "interview-cut.mov" of type "video/quicktime" + Then the request succeeds with status 200 + And I am given an upload URL + And the storage key is under "videos/" and ends with ".mov" + + Scenario Outline: Refusing to hand out a slot for anything that is not an MP4 or MOV + When I request an upload slot for "" of type "" + Then the request is rejected with status 422 + And the problem title is "Unsupported file type. Please upload an MP4 or MOV video." + + Examples: + | file | type | + | clip.avi | video/avi | + | reel.mkv | video/x-matroska | + | poster.png | image/png | + | notes.txt | text/plain | + | blob.bin | application/octet-stream | + | clip.mp4 | | + + Scenario: Refusing a request body that is not JSON + When I send malformed JSON to the upload slot endpoint + Then the request is rejected with status 400 + And the problem title is "Invalid request." + + Scenario: Registering a video that has been uploaded + Given I have requested an upload slot for "interview-cut.mp4" of type "video/mp4" + And I have uploaded the file to the upload URL + When I register the uploaded video titled "Inside the Newsroom" under categories "documentary, news" + Then the request succeeds with status 201 + And the video is registered with status "processing" + And the video has an id + And the video is stored under the key from the upload slot + And the video has a transcoding job id + And the video is filed under categories "documentary, news" + And the video keeps the metadata I sent + + Scenario: Registering a video under a single category + Given I have requested an upload slot for "short.mov" of type "video/quicktime" + And I have uploaded the file to the upload URL + When I register the uploaded video titled "A Short Film" under categories "documentary" + Then the request succeeds with status 201 + And the video is filed under categories "documentary" + + Scenario: Refusing to register a video whose file was never uploaded + Given I have requested an upload slot for "ghost.mp4" of type "video/mp4" + When I register the uploaded video titled "The One That Got Away" under categories "other" + Then the request is rejected with status 422 + And the problem title is "Failed to find video in storage" + + Scenario: Refusing to register a video without a title + Given I have requested an upload slot for "untitled.mp4" of type "video/mp4" + And I have uploaded the file to the upload URL + When I register the uploaded video titled " " under categories "other" + Then the request is rejected with status 422 + And the problem title is "Title is required." + + Scenario: Refusing to register a video with no category + Given I have requested an upload slot for "uncategorised.mp4" of type "video/mp4" + And I have uploaded the file to the upload URL + When I register the uploaded video titled "Uncategorised" under categories "" + Then the request is rejected with status 422 + And the problem title is "At least one category is required." + + Scenario Outline: Refusing to register a storage key this API never issued + When I register a video titled "Smuggled In" with the key "" under categories "other" + Then the request is rejected with status 422 + And the problem title is "A video file is required." + + Examples: + | key | + | | + | not-mine.mp4 | + | uploads/not-mine.mp4 | + | ../videos/escape.mp4 | + | videos-of-someone-else/clip.mp4 | + + # An id no category has is the client's mistake, and is answered separately + # from the category lookup failing, which is the server's and stays a 500. + Scenario: Registering a video under a category id that does not exist + Given I have requested an upload slot for "mystery.mp4" of type "video/mp4" + And I have uploaded the file to the upload URL + When I register the uploaded video titled "Mystery Genre" under category id 32767 + Then the request is rejected with status 404 + And the problem title is "Catagory Does Not Exist" + + # Registration is not idempotent: videos.storage_key is UNIQUE, so a retry + # with a key that was already registered fails the insert — after a second + # MediaConvert job has already been queued for the same file. + @known-gap + Scenario: Registering the same upload twice + Given I have requested an upload slot for "twice.mp4" of type "video/mp4" + And I have uploaded the file to the upload URL + And I have registered the uploaded video titled "Filed Once" under categories "other" + When I register the uploaded video titled "Filed Twice" under categories "other" + Then the request is rejected with status 500 + And the problem title is "Something Went Wrong Saving The Video Record" diff --git a/tests/go.mod b/tests/go.mod new file mode 100644 index 0000000..59b49d8 --- /dev/null +++ b/tests/go.mod @@ -0,0 +1,11 @@ +module thamanyah/tests + +go 1.25.12 + +require github.com/go-bdd/gobdd v1.1.4 + +require ( + github.com/cucumber/gherkin/go/v33 v33.0.0 // indirect + github.com/cucumber/messages/go/v28 v28.0.0 // indirect + github.com/google/uuid v1.6.0 // indirect +) diff --git a/tests/go.sum b/tests/go.sum new file mode 100644 index 0000000..8d2aef6 --- /dev/null +++ b/tests/go.sum @@ -0,0 +1,18 @@ +github.com/cucumber/gherkin/go/v33 v33.0.0 h1:PqQ81cHjD732/GZ7c/6k/sjlYG4LzLp51nggL6172Sg= +github.com/cucumber/gherkin/go/v33 v33.0.0/go.mod h1:mnP4fdkoc+LmjSLi9Kq3M5D84GphRWLdgeKAlvUYp2c= +github.com/cucumber/messages/go/v28 v28.0.0 h1:BOJmy8LKSbdKxM6Ba1v9ZmpZk7j5cyH+LTAaGxMeflc= +github.com/cucumber/messages/go/v28 v28.0.0/go.mod h1:2njGQBk+mu0aqzQgN8xcXvnrfWNcP7UjEyi1YauQ5Lc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-bdd/assert v0.0.0-20200713105154-236f01430281 h1:jfonqldVSNVekjmLvHqelFzBba7SwylNIZ3fbM/lKVs= +github.com/go-bdd/assert v0.0.0-20200713105154-236f01430281/go.mod h1:dOoqt7g2I/fpR7/Pyz0P19J3xjDj5lsHn3v9EaFLRjM= +github.com/go-bdd/gobdd v1.1.4 h1:HCECNRNcqEBqbOVCCT6CP/K0iBkqIxfUwX6e47rI1Lk= +github.com/go-bdd/gobdd v1.1.4/go.mod h1:CRd+r+YXUzK44n8GxagSEFsIYhd8fl0reKg7AoArUMw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tests/steps_test.go b/tests/steps_test.go new file mode 100644 index 0000000..0133097 --- /dev/null +++ b/tests/steps_test.go @@ -0,0 +1,354 @@ +package tests + +import ( + "bytes" + "slices" + "strings" + + "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) + } +} diff --git a/tests/suite_test.go b/tests/suite_test.go new file mode 100644 index 0000000..f38928b --- /dev/null +++ b/tests/suite_test.go @@ -0,0 +1,162 @@ +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 + + // 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 +} + +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 +} + +// 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(`^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 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() +}