package storage import ( "strings" "testing" ) // seedStoryFull inserts a story with an explicit channel + source so ranking // tests can build a skewed affinity profile. func seedStoryFull(t *testing.T, guid, channel, source, headline, lede string) int64 { t.Helper() s := &Story{ GUID: guid, Headline: headline, Lede: lede, ArticleURL: "https://example.com/" + guid, Source: source, Channel: channel, Classified: true, SeenAt: nowUnix(), } if err := InsertStory(s); err != nil { t.Fatalf("insert story %s: %v", guid, err) } var id int64 if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, guid).Scan(&id); err != nil { t.Fatalf("lookup id %s: %v", guid, err) } return id } func TestForYou_LeansToAffinityAndExcludesRead(t *testing.T) { setupTestDB(t) const sub = "sub-1" // A skewed history: the user reads/bookmarks gaming stories. g1 := seedStoryFull(t, "g1", "gaming", "GameSite", "Game one", "") g2 := seedStoryFull(t, "g2", "gaming", "GameSite", "Game two", "") // Candidates to rank (all unread until we mark some). gc := seedStoryFull(t, "gc", "gaming", "GameSite", "Fresh gaming story", "") tc := seedStoryFull(t, "tc", "tech", "TechSite", "Fresh tech story", "") if err := SetRead(sub, g1, true); err != nil { t.Fatalf("read g1: %v", err) } if err := SetBookmark(sub, g2, true); err != nil { t.Fatalf("bookmark g2: %v", err) } got, err := ForYou(sub, 10) if err != nil { t.Fatalf("ForYou: %v", err) } if len(got) == 0 { t.Fatal("expected results") } // Affinity leans gaming, so a gaming story tops the list and the tech story // sinks to the bottom. (gc and the unread-but-bookmarked g2 both qualify and // tie on score, so we assert on channel rather than a specific id.) if got[0].Channel != "gaming" { t.Fatalf("expected a gaming story first, got channel %q (id %d)", got[0].Channel, got[0].ID) } if got[len(got)-1].ID != tc { t.Fatalf("expected tech candidate ranked last, got id %d", got[len(got)-1].ID) } // Read stories must never appear. for _, s := range got { if s.ID == g1 { t.Fatalf("read story g1 leaked into ForYou") } } // Sanity: the gaming candidate that was never touched should be present. var sawGC bool for _, s := range got { if s.ID == gc { sawGC = true } } if !sawGC { t.Fatalf("expected unread gaming candidate gc in results") } } func TestForYou_NoHistoryReturnsNil(t *testing.T) { setupTestDB(t) seedStoryFull(t, "a", "tech", "TechSite", "Something", "") got, err := ForYou("nobody", 10) if err != nil { t.Fatalf("ForYou: %v", err) } if got != nil { t.Fatalf("expected nil for a user with no history, got %d rows", len(got)) } } func TestRelatedStories_OnTopicExcludesSelf(t *testing.T) { setupTestDB(t) seed := seedStoryFull(t, "seed", "tech", "TechSite", "Apple unveils new iPhone camera", "The new camera sensor is larger.") rel := seedStoryFull(t, "rel", "tech", "OtherSite", "iPhone camera teardown reveals sensor", "A look at the new iPhone camera.") seedStoryFull(t, "off", "gaming", "GameSite", "Nintendo announces handheld", "A brand new portable console.") got, err := RelatedStories(seed, 5) if err != nil { t.Fatalf("RelatedStories: %v", err) } if len(got) == 0 { t.Fatal("expected at least one related story") } for _, s := range got { if s.ID == seed { t.Fatal("seed story returned as its own related") } } if got[0].ID != rel { t.Fatalf("expected the on-topic iPhone story first, got id %d", got[0].ID) } } func TestBuildRelatedFTSQuery(t *testing.T) { // Stopwords and 1-char tokens drop out; the rest become an OR of quoted terms. q := buildRelatedFTSQuery("The new iPhone camera is a big deal") if q == "" { t.Fatal("expected a non-empty query") } for _, bad := range []string{`"the"`, `"is"`, `"new"`} { if strings.Contains(q, bad) { t.Fatalf("stopword leaked into query: %s in %q", bad, q) } } for _, want := range []string{`"iphone"`, `"camera"`} { if !strings.Contains(q, want) { t.Fatalf("expected %s in query %q", want, q) } } if buildRelatedFTSQuery("the a of to") != "" { t.Fatal("expected empty query when only stopwords remain") } }