FEAT: Discovery Video Service Subscription
This commit is contained in:
+21
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user