270 lines
7.6 KiB
Go
270 lines
7.6 KiB
Go
package tests
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"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 = "<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"`
|
|
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"`
|
|
}
|
|
|
|
// searchRequest is a search of GET /api/videos on discovery: what a reader is
|
|
// looking for, plus where in the results to carry on from.
|
|
//
|
|
// Every field is optional. A search with none of them set is the whole
|
|
// catalogue, newest first.
|
|
type searchRequest struct {
|
|
Title string
|
|
Categories []string
|
|
Limit int
|
|
Cursor string
|
|
}
|
|
|
|
// query renders the search as the query string discovery reads it from.
|
|
// Categories are repeated rather than joined, since that is how the endpoint
|
|
// takes several of them, and an unset field is left out entirely so that the
|
|
// service applies its own default.
|
|
func (r searchRequest) query() url.Values {
|
|
values := url.Values{}
|
|
if r.Title != "" {
|
|
values.Set("title", r.Title)
|
|
}
|
|
for _, category := range r.Categories {
|
|
values.Add("categories", category)
|
|
}
|
|
if r.Limit != 0 {
|
|
values.Set("limit", strconv.Itoa(r.Limit))
|
|
}
|
|
if r.Cursor != "" {
|
|
values.Set("cursor", r.Cursor)
|
|
}
|
|
return values
|
|
}
|
|
|
|
// searchResultsBody is what that query answers with: the page of videos, and
|
|
// the cursor that reaches the page after it. NextCursor is empty on the last
|
|
// page, which is how a client knows to stop.
|
|
type searchResultsBody struct {
|
|
Videos []catalogueVideoBody `json:"videos"`
|
|
NextCursor string `json:"nextCursor"`
|
|
}
|
|
|
|
// ids lists the video ids in the page, for asserting on which videos came back
|
|
// without caring about the rest of their fields.
|
|
func (b searchResultsBody) ids() []string {
|
|
found := make([]string, 0, len(b.Videos))
|
|
for _, v := range b.Videos {
|
|
found = append(found, v.ID)
|
|
}
|
|
return found
|
|
}
|
|
|
|
func (b searchResultsBody) holds(id string) bool {
|
|
for _, v := range b.Videos {
|
|
if v.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// search sends the catalogue search: a plain GET with the search spelled out
|
|
// in the query string.
|
|
func (c *client) search(path string, request searchRequest) (response, error) {
|
|
query := request.query().Encode()
|
|
if query != "" {
|
|
path += "?" + query
|
|
}
|
|
return c.get(path)
|
|
}
|
|
|
|
// 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
|
|
}
|