Compare commits
2 Commits
334d9c1afc
...
b68086eea4
| Author | SHA1 | Date | |
|---|---|---|---|
| b68086eea4 | |||
| ae129c8f61 |
@@ -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)
|
├── cms/ Go service: video ingestion/CMS (implemented)
|
||||||
├── discovery/ Go service: empty placeholder, no code yet
|
├── discovery/ Go service: empty placeholder, no code yet
|
||||||
├── infrastructure/ Pulumi (Go) program provisioning all AWS resources
|
├── 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
|
└── .gitea/workflows/ Gitea Actions CI/CD pipelines
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -28,7 +29,8 @@ go build ./...
|
|||||||
go vet ./...
|
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
|
`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
|
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
|
misreports itself as v1.16.4; `go version -m $(go env GOPATH)/bin/swag` gives
|
||||||
the real one.)
|
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`)
|
### infrastructure (Go, Pulumi, module `thamanyah`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+7
-1
@@ -78,8 +78,14 @@ const docTemplate = `{
|
|||||||
"$ref": "#/definitions/api.ProblemDetails"
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "categoryIds referenced a category that does not exist",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
|
}
|
||||||
|
},
|
||||||
"422": {
|
"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": {
|
"schema": {
|
||||||
"$ref": "#/definitions/api.ProblemDetails"
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,8 +71,14 @@
|
|||||||
"$ref": "#/definitions/api.ProblemDetails"
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "categoryIds referenced a category that does not exist",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
|
}
|
||||||
|
},
|
||||||
"422": {
|
"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": {
|
"schema": {
|
||||||
"$ref": "#/definitions/api.ProblemDetails"
|
"$ref": "#/definitions/api.ProblemDetails"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,9 +186,13 @@ paths:
|
|||||||
description: Request body was not valid JSON
|
description: Request body was not valid JSON
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/api.ProblemDetails'
|
$ref: '#/definitions/api.ProblemDetails'
|
||||||
|
"404":
|
||||||
|
description: categoryIds referenced a category that does not exist
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/api.ProblemDetails'
|
||||||
"422":
|
"422":
|
||||||
description: title was empty, key was missing or not a key issued by this
|
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:
|
schema:
|
||||||
$ref: '#/definitions/api.ProblemDetails'
|
$ref: '#/definitions/api.ProblemDetails'
|
||||||
"500":
|
"500":
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"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."
|
// @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
|
// @Success 201 {object} api.CompleteResponse
|
||||||
// @Failure 400 {object} api.ProblemDetails "Request body was not valid JSON"
|
// @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"
|
// @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]
|
// @Router /api/videos [post]
|
||||||
func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) {
|
func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -151,7 +153,12 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
log.Printf("Something Went Wrong Loading Categories: %s", err)
|
log.Printf("Something Went Wrong Loading Categories: %s", err)
|
||||||
writeProblem(w, http.StatusInternalServerError, "Something Went Wrong Loading Categories",
|
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)
|
categoryIDS, err := repositories.CatagoriesRepo.ListCategoriesIDs(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, submittedID := range submitted {
|
for _, submittedID := range submitted {
|
||||||
if !slices.Contains(categoryIDS, submittedID) {
|
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) {
|
func randomFilename(original string) (string, error) {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user