FEAT: Update Status Of Video Row On Media Convert Update

This commit is contained in:
FahdShalhoub
2026-08-27 20:04:38 +03:00
parent 5d35d4029e
commit 3f5301afb6
25 changed files with 1444 additions and 14 deletions
+119
View File
@@ -0,0 +1,119 @@
package tests
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
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"
)
// These scenarios stand in for EventBridge, not for a client: they publish the
// same "MediaConvert Job State Change" event AWS would put on the topic when a
// job changes state. That is the seam cms owns — the topic, the queue, the
// consumer and the record it updates. The EventBridge rule that feeds the
// topic in AWS is Pulumi configuration, and is not exercised here.
//
// This is the only place the suite reaches for the AWS SDK. Everything a real
// client does still goes over plain HTTP.
const (
defaultEndpointURL = "http://localhost.localstack.cloud:4566"
defaultTopicARN = "arn:aws:sns:us-east-1:000000000000:mediaconvert-job-events"
defaultRegion = "us-east-1"
)
func envOr(name, fallback string) string {
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
return v
}
return fallback
}
// jobStateChange is the EventBridge event MediaConvert emits. Only the members
// cms reads are modelled; AWS sends a good deal more.
type jobStateChange struct {
Version string `json:"version"`
ID string `json:"id"`
DetailType string `json:"detail-type"`
Source string `json:"source"`
Account string `json:"account"`
Time time.Time `json:"time"`
Region string `json:"region"`
Resources []string `json:"resources"`
Detail jobStateDetail `json:"detail"`
}
type jobStateDetail struct {
Timestamp int64 `json:"timestamp"`
AccountID string `json:"accountId"`
Queue string `json:"queue"`
JobID string `json:"jobId"`
Status string `json:"status"`
}
// eventPublisher puts job state changes on the topic cms's queue subscribes to.
type eventPublisher struct {
sns *sns.Client
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),
// 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"), "")),
)
if err != nil {
return nil, err
}
return &eventPublisher{
sns: sns.NewFromConfig(cfg, func(o *sns.Options) { o.BaseEndpoint = aws.String(endpoint) }),
topicARN: envOr("MEDIACONVERT_EVENTS_TOPIC_ARN", defaultTopicARN),
}, nil
}
func (p *eventPublisher) publishJobState(ctx context.Context, jobID, state string) error {
event := jobStateChange{
Version: "0",
ID: fmt.Sprintf("test-%d", time.Now().UnixNano()),
DetailType: "MediaConvert Job State Change",
Source: "aws.mediaconvert",
Account: "000000000000",
Time: time.Now().UTC(),
Region: envOr("AWS_REGION", defaultRegion),
Resources: []string{fmt.Sprintf("arn:aws:mediaconvert:%s:000000000000:jobs/%s", envOr("AWS_REGION", defaultRegion), jobID)},
Detail: jobStateDetail{
Timestamp: time.Now().UnixMilli(),
AccountID: "000000000000",
JobID: jobID,
Status: state,
},
}
body, err := json.Marshal(event)
if err != nil {
return err
}
_, err = p.sns.Publish(ctx, &sns.PublishInput{
TopicArn: aws.String(p.topicARN),
Message: aws.String(string(body)),
})
if err != nil {
return fmt.Errorf("publishing to %s: %w", p.topicARN, err)
}
return nil
}
+44
View File
@@ -0,0 +1,44 @@
Feature: Transcoding status
A registered video starts life as "processing" and is handed to MediaConvert.
MediaConvert reports every job state change to an SNS topic, which fans the
event out to a queue cms consumes, so the catalogue record catches up with
the transcoding job on its own nobody polls AWS and nothing is exposed for
AWS to call back into.
Background:
Given the CMS API is available
Scenario: A finished job marks the video ready
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"
Scenario Outline: A job that did not finish cleanly marks the video failed
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"
Examples:
| state |
| ERROR |
| CANCELED |
Scenario Outline: A state that is not an outcome leaves the video processing
Given I have registered a video that is being transcoded
When MediaConvert reports that the job reached "<state>"
Then the video keeps status "processing"
Examples:
| state |
| SUBMITTED |
| PROGRESSING |
| STATUS_UPDATE |
# A message naming a job nobody has must be consumed and dropped, not left to
# redeliver forever. The second event is the assertion that it was: it can
# only be handled if the first one did not wedge the consumer.
Scenario: An event for a job no video has does not stop the consumer
Given I have registered a video that is being transcoded
When MediaConvert reports that the job "1700000000000-nosuchjob" reached "COMPLETE"
And MediaConvert reports that the job reached "COMPLETE"
Then the video eventually has status "ready"
+52
View File
@@ -0,0 +1,52 @@
Feature: Video details
A registered video can be read back by its id. This is how a client learns
what became of an upload once it was handed to the transcoding pipeline
without it, the status a video carries is written but never visible.
Background:
Given the CMS API is available
Scenario: Reading back a video that was registered
Given I have requested an upload slot for "detail.mp4" of type "video/mp4"
And I have uploaded the file to the upload URL
And I have registered the uploaded video titled "Inside the Newsroom" under categories "documentary, news"
When I ask the CMS for that video
Then the request succeeds with status 200
And the video has an id
And the video is registered with status "processing"
And the video is stored under the key from the upload slot
And the video is filed under categories "documentary, news"
And the video keeps the metadata I sent
# A uuid has several accepted spellings, and Postgres takes only some of them
# verbatim — the urn form it rejects outright. The handler parses the id and
# queries with the canonical form, so every spelling names the same video
# instead of some of them faulting.
Scenario Outline: Reading a video by another accepted spelling of its id
Given I have registered a video
When I ask the CMS for that video with its id written <form>
Then the request succeeds with status 200
And the video has an id
Examples:
| form |
| braced |
| unhyphenated |
| as a urn |
Scenario: Asking for a video that does not exist
When I ask the CMS for the video with id "0199f3a1-7c2e-7b21-9f0d-1a2b3c4d5e6f"
Then the request is rejected with status 404
And the problem title is "Video Not Found"
Scenario Outline: Asking for a video with an id that is not a video id
When I ask the CMS for the video with id "<id>"
Then the request is rejected with status 404
And the problem title is "Video Not Found"
Examples:
| id |
| not-a-uuid |
| 12345 |
| 0199f3a1-7c2e-7b21-9f0d-1a2b3c4d5e6 |
| '; DROP TABLE videos; -- |
+15
View File
@@ -5,6 +5,21 @@ go 1.25.12
require 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/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
github.com/aws/smithy-go v1.28.1 // indirect
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
+30
View File
@@ -1,3 +1,33 @@
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/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/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=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.40 h1:gr3Fw1cxZXNCdeo/lQ7isHEHzvHVM7z75qb2zW9aMjw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.40/go.mod h1:8z/9CmfnQhiuXD7Ykbcg4a/whSWsniE0ODSx9uwVzfk=
github.com/aws/aws-sdk-go-v2/service/signin v1.6.0 h1:agcr0j8YeFEzdXNo17Rg9MbbjLRjrimabwNtji4e+lU=
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/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=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.39.0/go.mod h1:HjjZVhaBz0JBR/kbWKThmNDhFKS7y6EURuk493tJk9Y=
github.com/aws/aws-sdk-go-v2/service/sts v1.46.0 h1:IZ63JdogSNNjex/jsODNv7jGDcO/xJYd9FsgyfCsp1g=
github.com/aws/aws-sdk-go-v2/service/sts v1.46.0/go.mod h1:I+rwAf3spG5dITBaAo3xXRowk8kiOhtU1kYxfvCTC44=
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
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=
+180
View File
@@ -2,8 +2,11 @@ package tests
import (
"bytes"
"context"
"net/url"
"slices"
"strings"
"time"
"github.com/go-bdd/gobdd"
)
@@ -352,3 +355,180 @@ func theVideoKeepsTheMetadataISent(t gobdd.StepTest, ctx gobdd.Context) {
t.Errorf("expected the file name %q, got %q", w.sent.FileName, w.video.FileName)
}
}
func askForThatVideo(t gobdd.StepTest, ctx gobdd.Context) {
w := worldOf(t, ctx)
if w.video.ID == "" {
t.Fatalf("no video has been registered, so there is no id to ask for")
return
}
result, err := w.client.get("/api/videos/" + w.video.ID)
if err != nil {
t.Fatalf("could not ask for the video: %s", err)
return
}
w.last = result
w.video = videoBody{}
if result.status == 200 {
if err := result.json(&w.video); err != nil {
t.Fatalf("could not decode the video: %s", err)
}
}
}
func askForVideoWithID(t gobdd.StepTest, ctx gobdd.Context, id string) {
w := worldOf(t, ctx)
result, err := w.client.get("/api/videos/" + url.PathEscape(id))
if err != nil {
t.Fatalf("could not ask for the video: %s", err)
return
}
w.last = result
w.video = videoBody{}
}
// --- transcoding status -----------------------------------------------------
// statusSettleTimeout bounds how long a scenario waits for the consumer to
// pick an event off the queue and write the record. The consumer long-polls,
// so in practice this resolves in well under a second; the budget is for a
// loaded machine, not for a slow path.
const (
statusSettleTimeout = 20 * time.Second
statusPollInterval = 200 * time.Millisecond
statusHoldWindow = 3 * time.Second
)
func registerVideoBeingTranscoded(t gobdd.StepTest, ctx gobdd.Context) {
w := worldOf(t, ctx)
requestUploadSlot(t, ctx, "transcoding.mp4", "video/mp4")
uploadTheFile(t, ctx)
registerUploadedVideo(t, ctx, "A Video Being Transcoded", "other")
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)
if strings.TrimSpace(w.video.MediaConvertJobID) == "" {
t.Fatalf("no transcoding job has been started, so there is no job to report on")
return
}
publishJobState(t, w.video.MediaConvertJobID, state)
}
func mediaConvertReportsStateForJob(t gobdd.StepTest, ctx gobdd.Context, jobID, state string) {
worldOf(t, ctx) // keeps the step honest about needing a scenario world
publishJobState(t, jobID, state)
}
func publishJobState(t gobdd.StepTest, jobID, state string) {
background := context.Background()
publisher, err := newEventPublisher(background)
if err != nil {
t.Fatalf("could not reach the job events topic: %s", err)
return
}
if err := publisher.publishJobState(background, jobID, state); err != nil {
t.Fatalf("could not publish the job state change: %s", err)
}
}
func theVideoEventuallyHasStatus(t gobdd.StepTest, ctx gobdd.Context, want string) {
w := worldOf(t, ctx)
deadline := time.Now().Add(statusSettleTimeout)
last := ""
for time.Now().Before(deadline) {
last = currentStatus(t, w)
if last == want {
return
}
time.Sleep(statusPollInterval)
}
t.Errorf("the video never reached status %q within %s; it is still %q", want, statusSettleTimeout, last)
}
func theVideoKeepsStatus(t gobdd.StepTest, ctx gobdd.Context, want string) {
w := worldOf(t, ctx)
// Held rather than sampled once: the consumer is asynchronous, so a status
// that is still "processing" the instant after publishing proves nothing.
deadline := time.Now().Add(statusHoldWindow)
for time.Now().Before(deadline) {
if got := currentStatus(t, w); got != want {
t.Errorf("the video moved to status %q, but this event should have left it %q", got, want)
return
}
time.Sleep(statusPollInterval)
}
}
// currentStatus re-reads the video over the API. It deliberately does not
// touch w.last: these steps assert on the record, not on an exchange.
func currentStatus(t gobdd.StepTest, w *world) string {
result, err := w.client.get("/api/videos/" + w.video.ID)
if err != nil {
t.Fatalf("could not re-read the video: %s", err)
return ""
}
if result.status != 200 {
t.Fatalf("could not re-read the video: %s", result.summary())
return ""
}
var body videoBody
if err := result.json(&body); err != nil {
t.Fatalf("could not decode the video: %s", err)
return ""
}
return body.Status
}
// askForThatVideoWrittenAs re-reads the video by a different, still-valid
// spelling of the same uuid.
func askForThatVideoWrittenAs(t gobdd.StepTest, ctx gobdd.Context, form string) {
w := worldOf(t, ctx)
if w.video.ID == "" {
t.Fatalf("no video has been registered, so there is no id to rewrite")
return
}
var id string
switch form {
case "braced":
id = "{" + w.video.ID + "}"
case "unhyphenated":
id = strings.ReplaceAll(w.video.ID, "-", "")
case "as a urn":
id = "urn:uuid:" + w.video.ID
default:
t.Fatalf("no such spelling of a uuid: %q", form)
return
}
askForVideoWithID(t, ctx, id)
if w.last.status == 200 {
if err := w.last.json(&w.video); err != nil {
t.Fatalf("could not decode the video: %s", err)
}
}
}
+9
View File
@@ -139,6 +139,15 @@ func TestVideoUpload(t *testing.T) {
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 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$`, registerVideoBeingTranscoded)
suite.AddStep(`^I ask the CMS for that video with its id written (.*)$`, askForThatVideoWrittenAs)
suite.AddStep(`^MediaConvert reports that the job reached "(.*)"$`, mediaConvertReportsState)
suite.AddStep(`^MediaConvert reports that the job "(.*)" reached "(.*)"$`, mediaConvertReportsStateForJob)
suite.AddStep(`^the video eventually has status "(.*)"$`, theVideoEventuallyHasStatus)
suite.AddStep(`^the video keeps status "(.*)"$`, theVideoKeepsStatus)
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)