FEAT: Discovery Video Service Subscription
Build, Push and Deploy CMS / build-push-deploy (push) Successful in 2m35s
Build, Push and Deploy Discovery / build-push-deploy (push) Successful in 1m55s
Deploy Infrastructure / pulumi-up (push) Successful in 2s

This commit is contained in:
FahdShalhoub
2026-08-29 16:50:58 +03:00
parent 98950aba20
commit 6f9e04ee97
34 changed files with 1754 additions and 65 deletions
+21 -1
View File
@@ -12,7 +12,7 @@ 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
features/*.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
@@ -68,6 +68,26 @@ Both steps of the upload flow, end to end:
the category lookup itself failing produces
- answering 409 for a key that was already registered, rather than queueing a
second transcoding job for the same file
- announcing a video on the catalogue topic once its job comes back COMPLETE,
carrying its id, title, playback URL and the **names** of its categories —
and announcing nothing for a job that ended in an error
- the read side building its catalogue from those announcements: the video
turns up in discovery under the id cms issued, a video still transcoding is
not there at all, and a video announced a second time is updated rather than
colliding with its own row
The publication scenarios read `tests-catalogue-events`, the suite's **own**
queue on the catalogue topic — not discovery's. A consumer destroys what it
reads, so sharing discovery's queue would have the suite and the running
service race for every announcement and each see about half. Delivery is raw,
so a message body is the announcement with no SNS envelope.
The ingestion scenarios need `discovery` up as well as `cms`; they read it over
HTTP at `DISCOVERY_BASE_URL` (default `http://localhost:8080`).
Like the job-state publisher they stand in for AWS, so they are the second
place the suite reaches for the AWS SDK; everything a real client does still
goes over plain HTTP.
## Adding a scenario
+32
View File
@@ -23,6 +23,19 @@ func baseURL() string {
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 {
@@ -107,6 +120,25 @@ func newClient() *client {
}
}
// 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)
+103 -6
View File
@@ -13,6 +13,7 @@ import (
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/sns"
"github.com/aws/aws-sdk-go-v2/service/sqs"
)
// These scenarios stand in for EventBridge, not for a client: they publish the
@@ -30,6 +31,15 @@ const (
defaultRegion = "us-east-1"
defaultOutputBucket = "encoded-bucket"
defaultPlaybackBaseURL = "http://localhost.localstack.cloud:4566/encoded-bucket"
// The suite's own queue on the catalogue topic cms announces ready videos
// to. The suite reads a queue rather than the topic because that is what a
// subscriber sees — SNS has nothing to poll.
//
// Deliberately not discovery's queue: a consumer destroys what it reads, so
// sharing one would have the suite and the running service race for every
// announcement and each see about half. Pinned by the localstack branch in
// infrastructure/main.go; LocalStack always uses account 000000000000.
defaultCatalogueQueueURL = "http://localhost.localstack.cloud:4566/000000000000/tests-catalogue-events"
)
// playbackBaseURL is the public host the encoded output is served from — the
@@ -97,21 +107,27 @@ type eventPublisher struct {
topicARN string
}
func newEventPublisher(ctx context.Context) (*eventPublisher, error) {
endpoint := envOr("AWS_ENDPOINT_URL", defaultEndpointURL)
region := envOr("AWS_REGION", defaultRegion)
cfg, err := awsconfig.LoadDefaultConfig(ctx,
awsconfig.WithRegion(region),
// awsConfig is the SDK configuration both ends of the event seam use: the
// publisher that stands in for EventBridge, and the reader that stands in for
// whoever consumes the catalogue queue.
func awsConfig(ctx context.Context) (aws.Config, error) {
return awsconfig.LoadDefaultConfig(ctx,
awsconfig.WithRegion(envOr("AWS_REGION", defaultRegion)),
// LocalStack accepts any credentials; these keep the SDK from hunting
// for a profile that a developer's machine may not have.
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
envOr("AWS_ACCESS_KEY_ID", "test"), envOr("AWS_SECRET_ACCESS_KEY", "test"), "")),
)
}
func newEventPublisher(ctx context.Context) (*eventPublisher, error) {
cfg, err := awsConfig(ctx)
if err != nil {
return nil, err
}
endpoint := envOr("AWS_ENDPOINT_URL", defaultEndpointURL)
return &eventPublisher{
sns: sns.NewFromConfig(cfg, func(o *sns.Options) { o.BaseEndpoint = aws.String(endpoint) }),
topicARN: envOr("MEDIACONVERT_EVENTS_TOPIC_ARN", defaultTopicARN),
@@ -160,3 +176,84 @@ func (p *eventPublisher) publishJobState(ctx context.Context, jobID, state, play
}
return nil
}
// --- the catalogue queue ----------------------------------------------------
// catalogueAnnouncement is what cms puts on the catalogue queue when a video
// becomes ready. This is the published contract, so the suite spells it out
// rather than importing it: a change here is a change other services see.
type catalogueAnnouncement struct {
VideoID string `json:"videoId"`
Title string `json:"title"`
PlaybackURL string `json:"playbackUrl"`
Categories []string `json:"categories"`
}
// catalogueReader stands in for the read side, draining its own queue on the
// catalogue topic. It consumes what it reads, exactly as a real subscriber
// would. The subscription delivers raw, so a message body is the announcement
// itself with no SNS envelope to unwrap.
type catalogueReader struct {
sqs *sqs.Client
queueURL string
}
func newCatalogueReader(ctx context.Context) (*catalogueReader, error) {
cfg, err := awsConfig(ctx)
if err != nil {
return nil, err
}
endpoint := envOr("AWS_ENDPOINT_URL", defaultEndpointURL)
return &catalogueReader{
sqs: sqs.NewFromConfig(cfg, func(o *sqs.Options) { o.BaseEndpoint = aws.String(endpoint) }),
queueURL: envOr("CATALOGUE_QUEUE_URL", defaultCatalogueQueueURL),
}, nil
}
// await waits for the announcement naming videoID and returns it. Messages for
// other videos are consumed and discarded on the way: scenarios run one at a
// time, so anything else on the queue is a leftover, and leaving it would only
// make the next scenario wade through it. It returns nil if nothing names that
// video before the deadline.
func (r *catalogueReader) await(ctx context.Context, videoID string, within time.Duration) (*catalogueAnnouncement, error) {
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
output, err := r.sqs.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
QueueUrl: aws.String(r.queueURL),
MaxNumberOfMessages: 10,
WaitTimeSeconds: 2,
})
if err != nil {
return nil, fmt.Errorf("receiving from %s: %w", r.queueURL, err)
}
var found *catalogueAnnouncement
for _, message := range output.Messages {
if message.Body == nil || message.ReceiptHandle == nil {
continue
}
var announcement catalogueAnnouncement
if err := json.Unmarshal([]byte(*message.Body), &announcement); err == nil &&
announcement.VideoID == videoID {
found = &announcement
}
if _, err := r.sqs.DeleteMessage(ctx, &sqs.DeleteMessageInput{
QueueUrl: aws.String(r.queueURL),
ReceiptHandle: message.ReceiptHandle,
}); err != nil {
return nil, fmt.Errorf("acknowledging a message on %s: %w", r.queueURL, err)
}
}
if found != nil {
return found, nil
}
}
return nil, nil
}
@@ -0,0 +1,40 @@
Feature: Building the catalogue from announcements
Discovery is the read side. It owns no ingestion of its own: it learns what
exists by subscribing to the catalogue topic cms announces ready videos on,
and keeps its own copy in its own database. Nothing polls cms and neither
service reads the other's tables.
A video therefore reaches the catalogue only once its transcode has finished
which is the point, since a catalogue entry nobody can play is no use.
Background:
Given the CMS API is available
And the Discovery API is available
Scenario: A video the CMS announces appears in the catalogue
Given I have registered a video that is being transcoded
When MediaConvert reports that the job reached "COMPLETE"
Then the catalogue eventually holds that video
And the catalogue's copy carries its title, playback URL and categories
# The catalogue is built from announcements, and cms announces only what came
# out ready. A video part-way through its transcode is therefore genuinely
# absent rather than merely late, and saying so is the honest answer.
Scenario: A video that is still being transcoded is not in the catalogue
Given I have registered a video that is being transcoded
Then the catalogue does not hold that video
And the request is rejected with status 404
And the problem title is "Video Not In The Catalogue"
# The catalogue topic delivers at-least-once, and a job's outcome can be
# reported more than once, so the same video can be announced again. The
# later announcement is the truth: it lands on top of what is already there
# rather than colliding with it. Announcing it again with no playlist is what
# makes that visible — a second announcement carrying identical content is
# indistinguishable from never having arrived.
Scenario: A video announced again is updated rather than colliding
Given I have registered a video that is being transcoded
And MediaConvert reports that the job reached "COMPLETE"
And the catalogue eventually holds that video
When MediaConvert reports that the job reached "COMPLETE" without a playlist
Then the catalogue eventually holds that video with no playback URL
@@ -0,0 +1,43 @@
Feature: Announcing ready videos to the catalogue
A video is only worth showing once its transcode has finished. When
MediaConvert reports a job complete, cms announces that video on a queue of
its own the seam the read side reads to build the public catalogue, so
nothing downstream has to poll cms or reach into its database.
The announcement is the published contract: the video's id, its title, the
URL a player can open, and the categories it is filed under, named rather
than numbered so a reader needs nothing from cms to make sense of it.
Background:
Given the CMS API is available
Scenario: A finished job announces the video to the catalogue
Given I have registered a video that is being transcoded
When MediaConvert reports that the job reached "COMPLETE"
Then the video eventually has status "ready"
And the catalogue is told about the video
And the announcement carries the video's title, playback URL and categories
# The catalogue is a list of things worth showing. A job that ended in an
# error produced nothing to play, so it is recorded against the video and
# goes no further.
Scenario Outline: A job that did not finish is not announced
Given I have registered a video that is being transcoded
When MediaConvert reports that the job reached "<state>"
Then the video eventually has status "failed"
And the catalogue is told nothing about the video
Examples:
| state |
| ERROR |
| CANCELED |
# A video can be filed under several categories, so the announcement carries
# all of them. Names, not the ids the register call took: what cms numbers
# them is its own business.
Scenario: The announcement lists every category the video is filed under
Given I have registered a video being transcoded under categories "documentary, news, podcast"
When MediaConvert reports that the job reached "COMPLETE"
Then the video eventually has status "ready"
And the catalogue is told about the video
And the announcement files it under "documentary, news, podcast"
+10 -7
View File
@@ -2,20 +2,23 @@ module thamanyah/tests
go 1.25.12
require github.com/go-bdd/gobdd v1.1.4
require (
github.com/aws/aws-sdk-go-v2 v1.45.1
github.com/aws/aws-sdk-go-v2/config v1.32.40
github.com/aws/aws-sdk-go-v2/credentials v1.19.39
github.com/aws/aws-sdk-go-v2/service/sns v1.43.0
github.com/aws/aws-sdk-go-v2/service/sqs v1.48.1
github.com/go-bdd/gobdd v1.1.4
)
require (
github.com/aws/aws-sdk-go-v2 v1.44.0 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.40 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.39 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.40 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.41 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.40 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.6.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sns v1.43.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.34.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.39.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.46.0 // indirect
+8 -6
View File
@@ -1,15 +1,15 @@
github.com/aws/aws-sdk-go-v2 v1.44.0 h1:4IbaHhtzy+4h37z4JQyO9a2QsiCml3CNYHtq5hIHigo=
github.com/aws/aws-sdk-go-v2 v1.44.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks=
github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/config v1.32.40 h1:lAVC9gMmKusmqDRe32dPtgKl/BWvJmMJoWELKHCAObw=
github.com/aws/aws-sdk-go-v2/config v1.32.40/go.mod h1:8xOJLbe/hOj1g4PVsfJYV7O2byq+UGET1onDdUgbwqc=
github.com/aws/aws-sdk-go-v2/credentials v1.19.39 h1:XOg8LC3Kgnsa3WiPQjc7Bi8k5IBN92cPYfIV9XMFss0=
github.com/aws/aws-sdk-go-v2/credentials v1.19.39/go.mod h1:GonTDBQ+mTpCVNwaHjj0PagspfrYYMEqOx7FehoEP/I=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.40 h1:r5aGipEVgI9aT/tAGjdrPbDQvIAKdTrS3rUPQtG4Rmo=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.40/go.mod h1:vOD3CnPxAdkL6MWZeROkZsTlskklMFfgVFkHzx/oZpY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40 h1:UIXlbijuB2XK1Kr57fo8iIxCuaSHJzwZ1uo+2tbEYIk=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40/go.mod h1:wcEsL6jscjZjVUinb0Q5qD/GXOG1yT3GNfmT9HuDwzU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40 h1:xLQVRDs2NddDmK9BEyh5KSlJ1Gpy5/GIJXrV6WcVGAE=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40/go.mod h1:XRXnpFVFGLaEVK+olDdFIM1vNa04ETW452oFGEPUxAo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 h1:pc138gM1CW+XPc60rEwUlwwuwWFQK16CI1T7v1F9Oec=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1/go.mod h1:1+koxpPIbfBdfzP6vojm5/zTpTQ/micYwlxIiNB3TxI=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 h1:K0JsbZQj+1h208Ro1zHeA4l7bMp0NvRffHQ91q8Ol1s=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1/go.mod h1:W3/vL6EtCIatICGy9ab29QhMuae+cOKPWcMxv02CO+Q=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.41 h1:nv/ILuCY0yXACzMQwvtt/HbqDDjemZiI0AeDbxGQlnU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.41/go.mod h1:dzvOSpxaPqQ3j0xS6Lc1vyVuWW0RBj7s/QqYpzu3Q/0=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
@@ -20,6 +20,8 @@ github.com/aws/aws-sdk-go-v2/service/signin v1.6.0 h1:agcr0j8YeFEzdXNo17Rg9MbbjL
github.com/aws/aws-sdk-go-v2/service/signin v1.6.0/go.mod h1:qU5PxgQ4JiUOOMotzfO3+5oUda5W+8JDVKyLQqlrJik=
github.com/aws/aws-sdk-go-v2/service/sns v1.43.0 h1:VPYjwn0BoX34hb44OT8T+Ikgn4NzsN7fHetaHaevsDc=
github.com/aws/aws-sdk-go-v2/service/sns v1.43.0/go.mod h1:I1vnLPvvi9KBqxddu8nJ4vktoPJvaIG05UmjBD9sqm8=
github.com/aws/aws-sdk-go-v2/service/sqs v1.48.1 h1:jXP3BdVenFa8RfLVH+D2gswrWZHJcgtygKCf22APFqo=
github.com/aws/aws-sdk-go-v2/service/sqs v1.48.1/go.mod h1:d4DToDhLnEofHKvFu4yCF0Be65pZW267COfKOztsZOQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.34.0 h1:FxaN8/sn61DTXNI6Gt678tFJUY8iUsCchm6Y/F/RjaA=
github.com/aws/aws-sdk-go-v2/service/sso v1.34.0/go.mod h1:vu4OY6s8LJtT8BtYG2LD6BGSZMptkYn3o5hvCPB22jc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.39.0 h1:crWKPeGYTBTuBxQ3p73kjfJvt4brUIsr+Fuypko8FxY=
+321
View File
@@ -422,6 +422,25 @@ func registerVideoBeingTranscoded(t gobdd.StepTest, ctx gobdd.Context) {
}
}
// registerVideoBeingTranscodedUnder is registerVideoBeingTranscoded for a
// scenario that cares which categories the video is filed under.
func registerVideoBeingTranscodedUnder(t gobdd.StepTest, ctx gobdd.Context, categories string) {
w := worldOf(t, ctx)
requestUploadSlot(t, ctx, "transcoding.mp4", "video/mp4")
uploadTheFile(t, ctx)
registerUploadedVideo(t, ctx, "A Video In Several Categories", categories)
if w.last.status != 201 {
t.Fatalf("could not register a video to transcode: %s", w.last.summary())
return
}
if strings.TrimSpace(w.video.MediaConvertJobID) == "" {
t.Fatalf("the registered video carries no transcoding job id, so no event could name it")
return
}
}
func mediaConvertReportsState(t gobdd.StepTest, ctx gobdd.Context, state string) {
w := worldOf(t, ctx)
@@ -581,3 +600,305 @@ func askForThatVideoWrittenAs(t gobdd.StepTest, ctx gobdd.Context, form string)
}
}
}
// --- the catalogue queue ----------------------------------------------------
// announcementTimeout bounds how long a scenario waits for the announcement.
// It is the status timeout's sibling: the publish happens in the same consumer
// pass as the status write, so a video that is ready and still unannounced
// after this is not slow, it is unannounced.
const announcementTimeout = 20 * time.Second
func theCatalogueIsToldAboutTheVideo(t gobdd.StepTest, ctx gobdd.Context) {
w := worldOf(t, ctx)
if strings.TrimSpace(w.video.ID) == "" {
t.Fatalf("no video has been registered, so nothing could be announced")
return
}
background := context.Background()
reader, err := newCatalogueReader(background)
if err != nil {
t.Fatalf("could not reach the catalogue queue: %s", err)
return
}
announcement, err := reader.await(background, w.video.ID, announcementTimeout)
if err != nil {
t.Fatalf("could not read the catalogue queue: %s", err)
return
}
if announcement == nil {
t.Errorf("the catalogue was never told about video %q within %s", w.video.ID, announcementTimeout)
return
}
w.announcement = announcement
}
// announcementHoldWindow is how long "told nothing" watches for before it is
// satisfied. The announcement rides in the same consumer pass as the status
// write, and the status is already settled by the time this step runs, so a
// message that is not here by now is not coming.
const announcementHoldWindow = 5 * time.Second
func theCatalogueIsToldNothingAboutTheVideo(t gobdd.StepTest, ctx gobdd.Context) {
w := worldOf(t, ctx)
if strings.TrimSpace(w.video.ID) == "" {
t.Fatalf("no video has been registered, so there is nothing to look for")
return
}
background := context.Background()
reader, err := newCatalogueReader(background)
if err != nil {
t.Fatalf("could not reach the catalogue queue: %s", err)
return
}
announcement, err := reader.await(background, w.video.ID, announcementHoldWindow)
if err != nil {
t.Fatalf("could not read the catalogue queue: %s", err)
return
}
if announcement != nil {
t.Errorf("the catalogue was told about video %q, which has nothing to play", w.video.ID)
}
}
func theAnnouncementCarriesTheVideosDetails(t gobdd.StepTest, ctx gobdd.Context) {
w := worldOf(t, ctx)
if w.announcement == nil {
t.Fatalf("no announcement has been received, so there is nothing to inspect")
return
}
if w.announcement.Title != w.sent.Title {
t.Errorf("the announcement calls the video %q, but it was registered as %q",
w.announcement.Title, w.sent.Title)
}
if want := playbackURLFor(w.slot.Key); w.announcement.PlaybackURL != want {
t.Errorf("the announcement points a player at %q, but the output landed at %q",
w.announcement.PlaybackURL, want)
}
// Named, not numbered: a reader of the queue has no access to the category
// ids cms issues, so the names are what make the message self-contained.
want := make([]string, 0, len(w.sent.CategoryIDs))
for _, id := range w.sent.CategoryIDs {
want = append(want, w.categoryName(t, id))
}
got := slices.Clone(w.announcement.Categories)
slices.Sort(got)
slices.Sort(want)
if !slices.Equal(got, want) {
t.Errorf("the announcement files the video under %v, but it was registered under %v",
w.announcement.Categories, want)
}
}
func theAnnouncementFilesItUnder(t gobdd.StepTest, ctx gobdd.Context, names string) {
w := worldOf(t, ctx)
if w.announcement == nil {
t.Fatalf("no announcement has been received, so there is nothing to inspect")
return
}
want := []string{}
for _, name := range strings.Split(names, ",") {
if name = strings.TrimSpace(name); name != "" {
want = append(want, name)
}
}
got := slices.Clone(w.announcement.Categories)
slices.Sort(got)
slices.Sort(want)
if !slices.Equal(got, want) {
t.Errorf("the announcement files the video under %v, but it was registered under %v",
w.announcement.Categories, want)
}
}
// --- the catalogue, as discovery serves it ----------------------------------
// catalogueSettleTimeout bounds how long a scenario waits for the read side to
// catch up. It is longer than the CMS's own status timeout because two hops
// have to happen — cms consuming the job event and announcing, then discovery
// consuming that announcement — each with its own long poll.
const catalogueSettleTimeout = 30 * time.Second
func theDiscoveryAPIIsAvailable(t gobdd.StepTest, ctx gobdd.Context) {
w := worldOf(t, ctx)
w.discovery = newDiscoveryClient()
result, err := w.discovery.get("/health")
if err != nil {
t.Fatalf("no Discovery at %s (%s) — start one with `docker compose up` from the repo root, "+
"or set DISCOVERY_BASE_URL to point at a running instance", w.discovery.base, err)
return
}
if result.status != 200 {
t.Fatalf("Discovery at %s is not healthy: %s", w.discovery.base, result.summary())
}
}
func theCatalogueEventuallyHoldsThatVideo(t gobdd.StepTest, ctx gobdd.Context) {
w := worldOf(t, ctx)
if strings.TrimSpace(w.video.ID) == "" {
t.Fatalf("no video has been registered, so the catalogue could hold nothing")
return
}
deadline := time.Now().Add(catalogueSettleTimeout)
last := response{}
for time.Now().Before(deadline) {
result, err := w.discovery.get("/api/videos/" + w.video.ID)
if err != nil {
t.Fatalf("could not ask the catalogue for the video: %s", err)
return
}
last = result
if result.status == 200 {
var body catalogueVideoBody
if err := result.json(&body); err != nil {
t.Fatalf("could not decode the catalogue's copy: %s", err)
return
}
w.catalogued = &body
return
}
time.Sleep(statusPollInterval)
}
t.Errorf("the catalogue never held video %q within %s; asking for it still gives %s",
w.video.ID, catalogueSettleTimeout, last.summary())
}
func theCataloguesCopyCarriesTheDetails(t gobdd.StepTest, ctx gobdd.Context) {
w := worldOf(t, ctx)
if w.catalogued == nil {
t.Fatalf("the catalogue holds no copy of the video, so there is nothing to inspect")
return
}
if w.catalogued.ID != w.video.ID {
t.Errorf("the catalogue filed the video as %q, but the CMS issued id %q",
w.catalogued.ID, w.video.ID)
}
if w.catalogued.Title != w.sent.Title {
t.Errorf("the catalogue calls the video %q, but it was registered as %q",
w.catalogued.Title, w.sent.Title)
}
if want := playbackURLFor(w.slot.Key); w.catalogued.PlaybackURL != want {
t.Errorf("the catalogue points a player at %q, but the output landed at %q",
w.catalogued.PlaybackURL, want)
}
want := make([]string, 0, len(w.sent.CategoryIDs))
for _, id := range w.sent.CategoryIDs {
want = append(want, w.categoryName(t, id))
}
got := slices.Clone(w.catalogued.Categories)
slices.Sort(got)
slices.Sort(want)
if !slices.Equal(got, want) {
t.Errorf("the catalogue files the video under %v, but it was registered under %v",
w.catalogued.Categories, want)
}
}
// catalogueAbsenceWindow is how long "does not hold" watches before it is
// satisfied. Held rather than sampled once: the read side is asynchronous, so
// a video that is absent the instant after registration proves nothing — it
// might simply not have been announced yet.
const catalogueAbsenceWindow = 5 * time.Second
func theCatalogueDoesNotHoldThatVideo(t gobdd.StepTest, ctx gobdd.Context) {
w := worldOf(t, ctx)
if strings.TrimSpace(w.video.ID) == "" {
t.Fatalf("no video has been registered, so there is nothing to look for")
return
}
deadline := time.Now().Add(catalogueAbsenceWindow)
for {
result, err := w.discovery.get("/api/videos/" + w.video.ID)
if err != nil {
t.Fatalf("could not ask the catalogue for the video: %s", err)
return
}
// Kept as the scenario's last exchange so the assertions that follow
// can read the status and the problem body the usual way.
w.last = result
if result.status != 404 {
t.Errorf("the catalogue holds video %q, which the CMS never announced: %s",
w.video.ID, result.summary())
return
}
if time.Now().After(deadline) {
return
}
time.Sleep(statusPollInterval)
}
}
func theCatalogueEventuallyHoldsThatVideoWithNoPlaybackURL(t gobdd.StepTest, ctx gobdd.Context) {
w := worldOf(t, ctx)
if strings.TrimSpace(w.video.ID) == "" {
t.Fatalf("no video has been registered, so the catalogue could hold nothing")
return
}
deadline := time.Now().Add(catalogueSettleTimeout)
last := ""
for time.Now().Before(deadline) {
result, err := w.discovery.get("/api/videos/" + w.video.ID)
if err != nil {
t.Fatalf("could not ask the catalogue for the video: %s", err)
return
}
if result.status == 200 {
var body catalogueVideoBody
if err := result.json(&body); err != nil {
t.Fatalf("could not decode the catalogue's copy: %s", err)
return
}
if body.PlaybackURL == "" {
w.catalogued = &body
return
}
last = body.PlaybackURL
}
time.Sleep(statusPollInterval)
}
t.Errorf("the catalogue still points a player at %q for video %q after %s; the later "+
"announcement carried no playlist and should have replaced it", last, w.video.ID, catalogueSettleTimeout)
}
+35
View File
@@ -16,6 +16,10 @@ import (
type world struct {
client *client
// discovery is the read side, set by the Background step that checks it is
// up. Nil in scenarios that never mention the catalogue.
discovery *client
// last is the most recent exchange with the CMS. Every "Then the request
// …" step reads it.
last response
@@ -36,6 +40,14 @@ type world struct {
// category instead of hard-coding the id the seed migration happened to
// give it.
categoriesByName map[string]int16
// announcement is the message the catalogue topic carried for this
// scenario's video, once a step has waited for it.
announcement *catalogueAnnouncement
// catalogued is the read side's own copy of the video, once a step has
// waited for it to arrive.
catalogued *catalogueVideoBody
}
type worldKey struct{}
@@ -91,6 +103,19 @@ func (w *world) categoryID(t gobdd.StepTest, name string) int16 {
return id
}
// categoryName resolves a category id back to its name, for asserting on a
// message that names categories rather than numbering them.
func (w *world) categoryName(t gobdd.StepTest, id int16) string {
w.categoryID(t, "other") // ensures the list is loaded
for name, known := range w.categoriesByName {
if known == id {
return name
}
}
t.Fatalf("the CMS knows no category with id %d; it offers %v", id, w.categoriesByName)
return ""
}
// 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.
@@ -130,6 +155,7 @@ func TestVideoUpload(t *testing.T) {
// 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(`^the Discovery API is available$`, theDiscoveryAPIIsAvailable)
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)
@@ -142,6 +168,7 @@ func TestVideoUpload(t *testing.T) {
suite.AddStep(`^I ask the CMS for that video$`, askForThatVideo)
suite.AddStep(`^I ask the CMS for the video with id "(.*)"$`, askForVideoWithID)
suite.AddStep(`^I have registered a video that is being transcoded$`, registerVideoBeingTranscoded)
suite.AddStep(`^I have registered a video being transcoded under categories "(.*)"$`, registerVideoBeingTranscodedUnder)
suite.AddStep(`^I have registered a video$`, registerVideoBeingTranscoded)
suite.AddStep(`^I ask the CMS for that video with its id written (.*)$`, askForThatVideoWrittenAs)
suite.AddStep(`^MediaConvert reports that the job reached "(.*)"$`, mediaConvertReportsState)
@@ -151,6 +178,14 @@ func TestVideoUpload(t *testing.T) {
suite.AddStep(`^the video keeps status "(.*)"$`, theVideoKeepsStatus)
suite.AddStep(`^the video has a playback URL for its HLS playlist$`, theVideoHasAPlaybackURL)
suite.AddStep(`^the video has no playback URL$`, theVideoHasNoPlaybackURL)
suite.AddStep(`^the catalogue is told about the video$`, theCatalogueIsToldAboutTheVideo)
suite.AddStep(`^the catalogue is told nothing about the video$`, theCatalogueIsToldNothingAboutTheVideo)
suite.AddStep(`^the announcement carries the video's title, playback URL and categories$`, theAnnouncementCarriesTheVideosDetails)
suite.AddStep(`^the announcement files it under "(.*)"$`, theAnnouncementFilesItUnder)
suite.AddStep(`^the catalogue eventually holds that video$`, theCatalogueEventuallyHoldsThatVideo)
suite.AddStep(`^the catalogue does not hold that video$`, theCatalogueDoesNotHoldThatVideo)
suite.AddStep(`^the catalogue eventually holds that video with no playback URL$`, theCatalogueEventuallyHoldsThatVideoWithNoPlaybackURL)
suite.AddStep(`^the catalogue's copy carries its title, playback URL and categories$`, theCataloguesCopyCarriesTheDetails)
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)