package api import ( "testing" "time" ) func TestJourneyEventUsesAuthenticatedUser(t *testing.T) { now := time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC) payload := journeyEventPayload{UserID: "user-1", JourneyID: "journey-1", Sequence: 3, Category: "navigation", Action: "open", Screen: "home", Feature: "search", Target: "search"} event, ok := toJourneyEvent(payload, "user-1", now) if !ok { t.Fatal("valid event was rejected") } if event.UserID != "user-1" || event.Sequence != 3 { t.Fatalf("unexpected event: %+v", event) } if _, ok := toJourneyEvent(payload, "user-2", now); ok { t.Fatal("an event buffered under another profile was accepted") } } func TestJourneyEventRejectsFreeTextAndUnknownVocabulary(t *testing.T) { now := time.Now().UTC() base := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "content", Action: "open"} withTitle := base withTitle.Feature = "A Film Title" if _, ok := toJourneyEvent(withTitle, "u1", now); ok { t.Fatal("free text feature was accepted") } unknown := base unknown.Action = "typed_query" if _, ok := toJourneyEvent(unknown, "u1", now); ok { t.Fatal("unknown action was accepted") } } // The entry points the television states for a playback, and the two identifiers a playback // step carries. Every one of these words has to survive the vocabulary check, or the step is // dropped on arrival with nothing said and the journey reads as a viewer who reached the // player from nowhere — which is precisely what Continue Watching and Magic used to look // like, for want of the steps being recorded at all. func TestJourneyEventAcceptsPlaybackEntryPoints(t *testing.T) { now := time.Now().UTC() for _, source := range []string{ "continue_watching", "magic_movie", "next_episode", "home_hero", "search", "genre_browser", "calendar", "favourites", "recommendation", "detail_page", "screensaver", "unknown", } { payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Sequence: 1, Category: "playback", Action: "request", Screen: "home", Feature: "playback", Source: source, Target: "player", ItemID: "8821", ItemName: "Boy", ItemType: "Movie"} event, ok := toJourneyEvent(payload, "u1", now) if !ok { t.Fatalf("entry point %q was rejected", source) } if event.Source != source || event.ItemID != "8821" { t.Fatalf("entry point %q: unexpected event %+v", source, event) } } } func TestJourneyEventRetainsThePlaySession(t *testing.T) { now := time.Now().UTC() for _, outcome := range []string{"success", "failure", "completed", "abandoned"} { payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "playback", Action: "start", Screen: "player", Feature: "playback", Source: "magic_movie", Target: "player", ItemID: "42", ItemName: "Whale Rider", ItemType: "Movie", PlaySessionID: "b3d1f0a4-9c22-4f61-8a10-77d0e2c9aa51", Outcome: outcome} event, ok := toJourneyEvent(payload, "u1", now) if !ok { t.Fatalf("playback outcome %q was rejected", outcome) } if event.PlaySessionID != payload.PlaySessionID || event.Outcome != outcome { t.Fatalf("outcome %q: unexpected event %+v", outcome, event) } } } // A play session id is Emby's string rather than the gateway's vocabulary, so it is checked // like everything else: an unreadable one must not be written into the column. func TestJourneyEventRejectsAnUnreadablePlaySession(t *testing.T) { now := time.Now().UTC() payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "playback", Action: "start", PlaySessionID: "session for Boy (2010)"} if _, ok := toJourneyEvent(payload, "u1", now); ok { t.Fatal("a play session id containing free text was accepted") } } func TestJourneyEventRetainsTheItemName(t *testing.T) { now := time.Now().UTC() payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "content", Action: "open", ItemName: "Hunt for the Wilderpeople", ItemType: "Movie"} event, ok := toJourneyEvent(payload, "u1", now) if !ok || event.ItemName != payload.ItemName { t.Fatalf("item name was not retained: %+v", event) } }