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 } // defaultDiscoveryBaseURL is where docker-compose publishes discovery on the // host. const defaultDiscoveryBaseURL = "http://localhost:8080" // discoveryBaseURL is the Discovery service the scenarios read the catalogue // from. Override it the same way as CMS_BASE_URL. func discoveryBaseURL() string { if v := strings.TrimSpace(os.Getenv("DISCOVERY_BASE_URL")); v != "" { return strings.TrimRight(v, "/") } return defaultDiscoveryBaseURL } // response is one HTTP exchange, kept whole so a failing assertion can print // the body it actually got rather than just a status code. type response struct { status int contentType string body []byte } // json decodes the body into dest. It returns an error rather than failing the // step so callers can report the raw body alongside the decode failure. func (r response) json(dest any) error { if err := json.Unmarshal(r.body, dest); err != nil { return fmt.Errorf("%w (body: %s)", err, r.summary()) } return nil } func (r response) summary() string { const max = 512 body := strings.TrimSpace(string(r.body)) if len(body) > max { body = body[:max] + "…" } if body == "" { body = "" } return fmt.Sprintf("%d %s: %s", r.status, r.contentType, body) } // problem is the RFC 9457 body every error from the CMS carries. type problem struct { Type string `json:"type"` Title string `json:"title"` Status int `json:"status"` Detail string `json:"detail"` } type category struct { ID int16 `json:"id"` Name string `json:"name"` } type categoriesBody struct { Categories []category `json:"categories"` } type presignBody struct { UploadURL string `json:"uploadUrl"` Key string `json:"key"` } type videoBody struct { ID string `json:"id"` Title string `json:"title"` Description string `json:"description"` CategoryIDs []int16 `json:"categoryIds"` Tags string `json:"tags"` FileName string `json:"fileName"` StorageKey string `json:"storageKey"` MediaConvertJobID string `json:"mediaConvertJobId"` Status string `json:"status"` PlaybackURL string `json:"playbackUrl"` 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}, } } // newDiscoveryClient is newClient pointed at the read side. Same plumbing — // only the host differs, since both services speak the same JSON dialect. func newDiscoveryClient() *client { return &client{ base: discoveryBaseURL(), http: &http.Client{Timeout: 30 * time.Second}, } } // catalogueVideoBody is GET /api/videos/{id} on discovery: the catalogue's own // copy of a video, which is a different published shape from the CMS record of // the same name. type catalogueVideoBody struct { ID string `json:"id"` Title string `json:"title"` PlaybackURL string `json:"playbackUrl"` Categories []string `json:"categories"` } // 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 }