package fa import ( "context" "fmt" "net/http" "net/http/httptest" "strings" "sync" "sync/atomic" "testing" ) // journalNotifRow renders one /msg/others/ journal-notification
  • . FA's // real markup carries no avatar image on these rows that is the whole // point of issue #14 so this helper deliberately omits one. func journalNotifRow(journalID int, title, authorName, authorDisplay string) string { return fmt.Sprintf(`
  • %s (G) posted by %s recently
  • `, journalID, journalID, title, authorName, authorName, authorDisplay) } // fakeMsgOthersPage wraps journal rows in the section markup parseNotifications // expects. func fakeMsgOthersPage(rows ...string) string { return `Notifications
    ` } // fakeUserAvatarPage renders the minimal /user/{name}/ markup fetchUserAvatar // reads just the header element. func fakeUserAvatarPage(name, avatarTS string) string { return `` + name + ` ` + name + ` ` } func TestNotifications_ResolvesAvatars(t *testing.T) { var userHits sync.Map // name -> *atomic.Int32 hit := func(name string) int32 { v, _ := userHits.LoadOrStore(name, &atomic.Int32{}) return v.(*atomic.Int32).Add(1) } mux := http.NewServeMux() mux.HandleFunc("/msg/others/", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(fakeMsgOthersPage( journalNotifRow(101, "First", "authora", "AuthorA"), journalNotifRow(102, "Second", "authora", "AuthorA"), // same author must dedup journalNotifRow(103, "Third", "authorb", "AuthorB"), ))) }) mux.HandleFunc("/user/authora/", func(w http.ResponseWriter, r *http.Request) { hit("authora") _, _ = w.Write([]byte(fakeUserAvatarPage("authora", "100"))) }) mux.HandleFunc("/user/authorb/", func(w http.ResponseWriter, r *http.Request) { hit("authorb") _, _ = w.Write([]byte(fakeUserAvatarPage("authorb", "200"))) }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) n, err := client.Notifications(context.Background(), NotificationsOptions{ResolveAvatars: true}) if err != nil { t.Fatalf("Notifications: %v", err) } if len(n.Journals) != 3 { t.Fatalf("Journals = %d; want 3", len(n.Journals)) } want := map[string]string{ "authora": "https://a.furaffinity.net/100/authora.gif", "authorb": "https://a.furaffinity.net/200/authorb.gif", } for i, j := range n.Journals { if got := j.Author.AvatarURL; got != want[j.Author.Name] { t.Errorf("Journals[%d] (%s): AvatarURL = %q; want %q", i, j.Author.Name, got, want[j.Author.Name]) } } // authora appears on two journals but must be fetched exactly once. if v, ok := userHits.Load("authora"); !ok || v.(*atomic.Int32).Load() != 1 { got := int32(0) if ok { got = v.(*atomic.Int32).Load() } t.Errorf("/user/authora/ fetched %d times; want 1 (dedup)", got) } if v, ok := userHits.Load("authorb"); !ok || v.(*atomic.Int32).Load() != 1 { t.Error("/user/authorb/ not fetched exactly once") } } func TestNotifications_ResolvedAvatarsRespectsLimit(t *testing.T) { var userPageHits atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/msg/others/", func(w http.ResponseWriter, r *http.Request) { // Four journals, three distinct authors. authora appears twice. _, _ = w.Write([]byte(fakeMsgOthersPage( journalNotifRow(101, "First", "authora", "AuthorA"), journalNotifRow(102, "Second", "authora", "AuthorA"), journalNotifRow(103, "Third", "authorb", "AuthorB"), journalNotifRow(104, "Fourth", "authorc", "AuthorC"), ))) }) for _, name := range []string{"authora", "authorb", "authorc"} { name := name mux.HandleFunc("/user/"+name+"/", func(w http.ResponseWriter, r *http.Request) { userPageHits.Add(1) _, _ = w.Write([]byte(fakeUserAvatarPage(name, "100"))) }) } srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) // Budget of 2 distinct authors. Journals resolve in document order, so // authora + authorb get fetched; authorc is past the budget. n, err := client.Notifications(context.Background(), NotificationsOptions{ResolveAvatars: true, AvatarLimit: 2}) if err != nil { t.Fatalf("Notifications: %v", err) } if len(n.Journals) != 4 { t.Fatalf("Journals = %d; want 4", len(n.Journals)) } // Exactly 2 profile fetches the limit caps *fetches*, not applications. if got := userPageHits.Load(); got != 2 { t.Errorf("/user/ fetched %d times; want 2 (limit)", got) } byName := map[string]string{} for _, j := range n.Journals { // Both authora rows must agree (cache hit applies past the limit). if prev, seen := byName[j.Author.Name]; seen && prev != j.Author.AvatarURL { t.Errorf("author %s: inconsistent AvatarURL %q vs %q", j.Author.Name, prev, j.Author.AvatarURL) } byName[j.Author.Name] = j.Author.AvatarURL } if byName["authora"] == "" || byName["authorb"] == "" { t.Errorf("authora/authorb should be resolved within budget; got %q / %q", byName["authora"], byName["authorb"]) } if byName["authorc"] != "" { t.Errorf("authorc is past the budget; AvatarURL = %q, want empty", byName["authorc"]) } } func TestNotifications_NoAvatarResolutionByDefault(t *testing.T) { var userPageHits atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/msg/others/", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(fakeMsgOthersPage( journalNotifRow(101, "First", "authora", "AuthorA"), ))) }) mux.HandleFunc("/user/", func(w http.ResponseWriter, r *http.Request) { userPageHits.Add(1) }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) n, err := client.Notifications(context.Background(), NotificationsOptions{}) if err != nil { t.Fatalf("Notifications: %v", err) } if len(n.Journals) != 1 { t.Fatalf("Journals = %d; want 1", len(n.Journals)) } // Without WithResolvedAvatars the SDK must not touch /user/ pages, and // the avatar stays empty (FA does not provide it on /msg/others/). if n.Journals[0].Author.AvatarURL != "" { t.Errorf("AvatarURL = %q; want empty without WithResolvedAvatars", n.Journals[0].Author.AvatarURL) } if userPageHits.Load() != 0 { t.Errorf("/user/ fetched %d times; want 0 (no resolution requested)", userPageHits.Load()) } }