package foryou import ( "bytes" "encoding/json" "testing" "time" "github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/store" "github.com/ponzischeme89/memby/server/internal/tracearr" ) func TestRankPreparedForTimeMovesTypicalPosterAheadWithinRelevantPool(t *testing.T) { location := time.UTC contextProfile := recommend.NewContextAffinityProfile() comedyHistory := recommend.Item{Genres: []string{"Comedy"}} for i := 0; i < 5; i++ { contextProfile.Add( comedyHistory, time.Date(2026, 6, 29+i*7, 20, 0, 0, 0, location), 1, i, location, ) } rawContext, err := json.Marshal(contextProfile) if err != nil { t.Fatal(err) } items := []store.PreparedForYouItem{ { ItemID: "drama", BaseRank: 1, BaseScore: 5, Payload: json.RawMessage(`{"Id":"drama","Genres":["Drama"]}`), ContextAffinity: rawContext, }, { ItemID: "comedy", BaseRank: 2, BaseScore: 4.8, Payload: json.RawMessage(`{"Id":"comedy","Genres":["Comedy"]}`), RecommendationReason: "Matches your viewing", ContextAffinity: rawContext, }, } ranked := rankPreparedForTime( items, time.Date(2026, 7, 27, 20, 0, 0, 0, location), location, ) if ranked[0].ItemID != "comedy" { t.Fatalf("first contextual poster = %q, want comedy", ranked[0].ItemID) } if ranked[0].RecommendationReason != "Matches your viewing · fits what you watch around this time" { t.Fatalf("context reason = %q", ranked[0].RecommendationReason) } } func TestImportedSessionUsesStableKeyAndRecommendationFingerprint(t *testing.T) { session := tracearr.Session{ ID: "session-1", MediaType: "movie", MediaTitle: "Arrival", ProgressMs: 500, TotalDurationMs: 1000, } session.User.ID = "user-1" session.User.Username = "Matt" first, ok := importedSession(session, "configured-server") if !ok { t.Fatal("session was rejected") } if first.ServerID != "configured-server" || first.SessionID != "session-1" { t.Fatalf("key = %q/%q", first.ServerID, first.SessionID) } second, _ := importedSession(session, "configured-server") if !bytes.Equal(first.SourceFingerprint, second.SourceFingerprint) { t.Fatal("unchanged session did not produce a stable fingerprint") } session.ProgressMs = 750 updated, _ := importedSession(session, "configured-server") if bytes.Equal(first.SourceFingerprint, updated.SourceFingerprint) { t.Fatal("updated progress was not detected by the fingerprint") } } func TestTracearrSessionTerminalRequiresCompletedState(t *testing.T) { active := store.TracearrSession{State: "playing"} if tracearrSessionTerminal(active) { t.Fatal("active session was terminal") } stopped := active stopped.State = "stopped" if !tracearrSessionTerminal(stopped) { t.Fatal("stopped session was not terminal") } watched := active watched.Watched = true if !tracearrSessionTerminal(watched) { t.Fatal("watched session was not terminal") } } func TestImportDueMeasuresElapsedTimeRatherThanProcessUptime(t *testing.T) { now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) at := func(d time.Duration) *time.Time { value := now.Add(-d) return &value } const incremental = 5 * time.Minute const full = 24 * time.Hour for _, testCase := range []struct { name string state store.TracearrImportState wantFull bool wantDue bool wantSkip bool }{ { name: "never imported runs a full pass", state: store.TracearrImportState{}, wantFull: true, wantDue: true, }, { // The restart case: without persisted stamps every boot re-imported. name: "recent import is not repeated after a restart", state: store.TracearrImportState{ LastIncrementalAt: at(30 * time.Second), LastFullAt: at(2 * time.Hour), }, wantSkip: true, }, { name: "elapsed incremental interval is due", state: store.TracearrImportState{ LastIncrementalAt: at(6 * time.Minute), LastFullAt: at(2 * time.Hour), }, wantDue: true, }, { // A gateway restarted daily never reconciled: the full ticker restarted too. name: "elapsed full interval wins over the incremental one", state: store.TracearrImportState{ LastIncrementalAt: at(6 * time.Minute), LastFullAt: at(25 * time.Hour), }, wantFull: true, wantDue: true, }, { name: "a stamp in the future does not strand the importer", state: store.TracearrImportState{ LastIncrementalAt: at(-time.Hour), LastFullAt: at(-time.Hour), }, wantFull: true, wantDue: true, }, } { t.Run(testCase.name, func(t *testing.T) { gotFull, gotDue := importDue(testCase.state, now, incremental, full) if testCase.wantSkip { if gotDue { t.Fatal("import ran when none was owed") } return } if gotDue != testCase.wantDue || gotFull != testCase.wantFull { t.Fatalf("full=%v due=%v, want full=%v due=%v", gotFull, gotDue, testCase.wantFull, testCase.wantDue) } }) } } func TestImportDueRespectsDisabledIntervals(t *testing.T) { now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) if _, due := importDue(store.TracearrImportState{}, now, 0, 0); due { t.Fatal("import was due with scheduling turned off") } stale := now.Add(-48 * time.Hour) state := store.TracearrImportState{LastIncrementalAt: &stale, LastFullAt: &stale} full, due := importDue(state, now, 5*time.Minute, 0) if !due || full { t.Fatalf("full=%v due=%v, want an incremental pass with full reconciliation off", full, due) } } func TestNextDailyRebuildUsesConfiguredLocalHour(t *testing.T) { location := time.FixedZone("NZST", 12*60*60) now := time.Date(2026, 7, 31, 5, 30, 0, 0, location) next := nextDailyRebuild(now, location, 4) want := time.Date(2026, 8, 1, 4, 0, 0, 0, location) if !next.Equal(want) { t.Fatalf("next rebuild = %v, want %v", next, want) } } func TestStoredResultCapsCandidatePoolAndPersistsAlgorithmVersion(t *testing.T) { result := recommend.PreparedResult{ Candidates: make([]recommend.PreparedCandidate, maxPreparedCandidates+50), } for i := range result.Candidates { result.Candidates[i].ItemID = itoaForTest(i + 1) } profile, candidates, err := storedResult("user", time.Now(), result) if err != nil { t.Fatal(err) } if len(candidates) != maxPreparedCandidates { t.Fatalf("candidate count = %d, want %d", len(candidates), maxPreparedCandidates) } if profile.AlgorithmVersion != preparedAlgorithmVersion { t.Fatalf("algorithm version = %q", profile.AlgorithmVersion) } } func TestBuildPreparedRowsUsesMultipleSourcesAndDeduplicatesTitles(t *testing.T) { items := make([]store.PreparedForYouItem, 0, 120) for i := 0; i < 120; i++ { sourceID, sourceTitle := "six-feet-under", "Six Feet Under" genre := "Drama" if i%2 == 1 { sourceID, sourceTitle = "arrival", "Arrival" genre = "Science Fiction" } id := "item-" + itoaForTest(i) items = append(items, store.PreparedForYouItem{ ItemID: id, BaseRank: i + 1, Payload: json.RawMessage(`{"Id":"` + id + `"}`), CompatibilityScore: 0.8, CompatibilityLabel: "Direct plays well on this TV", RecommendationReason: "Because you finished " + sourceTitle, ReasonGenre: genre, ReasonSourceItemID: sourceID, ReasonSourceTitle: sourceTitle, }) } rows := buildPreparedRows(items, 0, 4) if len(rows) < 4 { t.Fatalf("expected several prepared rows, got %d", len(rows)) } titles := map[string]bool{} itemIDs := map[string]bool{} for _, row := range rows { titles[row.Title] = true for _, raw := range row.Items { var item struct { ID string `json:"Id"` } if err := json.Unmarshal(raw, &item); err != nil { t.Fatal(err) } if itemIDs[item.ID] { t.Fatalf("item %q appeared in more than one row", item.ID) } itemIDs[item.ID] = true } } if !titles["Because you finished Six Feet Under"] || !titles["Because you finished Arrival"] { t.Fatalf("completed-title rows = %+v", titles) } } func TestBuildPreparedRowsKeepsASingleGenuinePickup(t *testing.T) { rows := buildPreparedRows([]store.PreparedForYouItem{ { ItemID: "show-1", BaseRank: 1, Payload: json.RawMessage(`{"Id":"show-1"}`), ReasonKind: "pick-up", RecommendationReason: "You left this in season 1 · pick it up again", }, { ItemID: "show-2", BaseRank: 2, Payload: json.RawMessage(`{"Id":"show-2"}`), ReasonKind: "pick-up", RecommendationReason: "You made it through season 2 · season 3 is waiting", }, }, 0, 4) if len(rows) != 1 || rows[0].ID != "for-you:pick-up" || rows[0].Title != "Pick this show up again" || len(rows[0].Items) != 1 { t.Fatalf("pickup rows = %+v", rows) } } func itoaForTest(value int) string { if value == 0 { return "0" } var digits [20]byte position := len(digits) for value > 0 { position-- digits[position] = byte('0' + value%10) value /= 10 } return string(digits[position:]) }