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
+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)
}