Compare commits

...

2 Commits

Author SHA1 Message Date
FahdShalhoub b68086eea4 FIX: Uploading File With Unkown Catagory Id
Build, Push and Deploy CMS / build-push-deploy (push) Successful in 2m22s
2026-08-27 18:43:04 +03:00
FahdShalhoub ae129c8f61 FEAT: Init BDD Tests 2026-08-27 18:42:35 +03:00
13 changed files with 1002 additions and 10 deletions
+24 -1
View File
@@ -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
+7 -1
View File
@@ -78,8 +78,14 @@ const docTemplate = `{
"$ref": "#/definitions/api.ProblemDetails"
}
},
"404": {
"description": "categoryIds referenced a category that does not exist",
"schema": {
"$ref": "#/definitions/api.ProblemDetails"
}
},
"422": {
"description": "title was empty, key was missing or not a key issued by this API, or categoryIds was empty or contained an id that does not exist",
"description": "title was empty, key was missing or not a key issued by this API, or categoryIds was empty",
"schema": {
"$ref": "#/definitions/api.ProblemDetails"
}
+7 -1
View File
@@ -71,8 +71,14 @@
"$ref": "#/definitions/api.ProblemDetails"
}
},
"404": {
"description": "categoryIds referenced a category that does not exist",
"schema": {
"$ref": "#/definitions/api.ProblemDetails"
}
},
"422": {
"description": "title was empty, key was missing or not a key issued by this API, or categoryIds was empty or contained an id that does not exist",
"description": "title was empty, key was missing or not a key issued by this API, or categoryIds was empty",
"schema": {
"$ref": "#/definitions/api.ProblemDetails"
}
+5 -1
View File
@@ -186,9 +186,13 @@ paths:
description: Request body was not valid JSON
schema:
$ref: '#/definitions/api.ProblemDetails'
"404":
description: categoryIds referenced a category that does not exist
schema:
$ref: '#/definitions/api.ProblemDetails'
"422":
description: title was empty, key was missing or not a key issued by this
API, or categoryIds was empty or contained an id that does not exist
API, or categoryIds was empty
schema:
$ref: '#/definitions/api.ProblemDetails'
"500":
+16
View File
@@ -0,0 +1,16 @@
package handlers
import "errors"
// Sentinel errors the handlers branch on with errors.Is to decide which
// problem response a failure deserves. They exist to separate a client's
// mistake from a server fault where the code that detects both is the same
// code — the repositories return plain errors, so without these every failure
// out of a lookup would have to be answered as a 500.
// errUnknownCatagoryID reports that a submitted category id is not in the
// category table. It is kept distinct from the error the lookup itself
// returns because the two are not the same kind of failure: an id nobody has
// is the client's mistake and answers 404, while a lookup that could not run
// is the server's and answers 500.
var errUnknownCatagoryID = errors.New("unknown catagory id")
+16 -6
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
@@ -111,7 +112,8 @@ func PresignVideoUpload(w http.ResponseWriter, r *http.Request) {
// @Param request body api.CompleteRequest true "Video metadata plus the storage key from the presign step. title, key and categoryIds are required; categoryIds must hold at least one id from GET /api/categories."
// @Success 201 {object} api.CompleteResponse
// @Failure 400 {object} api.ProblemDetails "Request body was not valid JSON"
// @Failure 422 {object} api.ProblemDetails "title was empty, key was missing or not a key issued by this API, or categoryIds was empty or contained an id that does not exist"
// @Failure 404 {object} api.ProblemDetails "categoryIds referenced a category that does not exist"
// @Failure 422 {object} api.ProblemDetails "title was empty, key was missing or not a key issued by this API, or categoryIds was empty"
// @Failure 500 {object} api.ProblemDetails "Categories could not be read, the transcoding job could not be queued, or the video record could not be saved"
// @Router /api/videos [post]
func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) {
@@ -151,7 +153,12 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) {
return
}
err := validateCatagoryIDS(r.Context(), req.CategoryIDs)
unknownCatagoryID, err := validateCatagoryIDS(r.Context(), req.CategoryIDs)
if errors.Is(err, errUnknownCatagoryID) {
writeProblem(w, http.StatusNotFound, "Catagory Does Not Exist",
fmt.Sprintf("The 'categoryIds' field referenced the id %d, which no category has. Send only ids taken from the 'id' field of GET /api/categories. No video record was created and no transcoding job was queued; the uploaded file is still in storage, so retry this request with the same 'key'.", unknownCatagoryID))
return
}
if err != nil {
log.Printf("Something Went Wrong Loading Categories: %s", err)
writeProblem(w, http.StatusInternalServerError, "Something Went Wrong Loading Categories",
@@ -201,19 +208,22 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) {
})
}
func validateCatagoryIDS(ctx context.Context, submitted []int16) (err error) {
// validateCatagoryIDS checks every submitted id against the category table. On
// the first id that has no category it returns that id alongside
// errUnknownCatagoryID, so the handler can name it in the response.
func validateCatagoryIDS(ctx context.Context, submitted []int16) (int16, error) {
categoryIDS, err := repositories.CatagoriesRepo.ListCategoriesIDs(ctx)
if err != nil {
return err
return 0, err
}
for _, submittedID := range submitted {
if !slices.Contains(categoryIDS, submittedID) {
return fmt.Errorf("Incorrect Category IDs")
return submittedID, errUnknownCatagoryID
}
}
return nil
return 0, nil
}
func randomFilename(original string) (string, error) {
+99
View File
@@ -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.
+164
View File
@@ -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 = "<empty>"
}
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
}
+119
View File
@@ -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 "<file>" of type "<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 "<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"
+11
View File
@@ -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
)
+18
View File
@@ -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=
+354
View File
@@ -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)
}
}
+162
View File
@@ -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()
}