package fa import ( "bytes" "context" "errors" "io" "net/http" "net/http/httptest" "net/url" "strconv" "strings" "sync/atomic" "testing" "github.com/PuerkitoBio/goquery" ) // fakeSubmissionPage builds a minimal /view/{id}/ response that has either // a Fav or Unfav anchor (controlled by faved). Sufficient to satisfy the // classifySystemMessage check (title is non-System-Error) and the // findFavLinks selector. func fakeSubmissionPage(id int, faved bool) string { prefix := "/fav/" label := "+Fav" if faved { prefix = "/unfav/" label = "-Fav" } href := prefix + strconv.Itoa(id) + "/?key=k1" return `Test submission

T

x
` + label + ` ` } func fakeUserPage(name string, watching bool) string { href := "/watch/" + name + "/?key=k2" text := "Watch" if watching { href = "/unwatch/" + name + "/?key=k2" text = "Unwatch" } return `Userpage

` + name + `

` + text + ` ` } func TestFav_FetchesAndFollowsFavLink(t *testing.T) { var hits atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/view/123/", func(w http.ResponseWriter, r *http.Request) { hits.Add(1) _, _ = w.Write([]byte(fakeSubmissionPage(123, false))) }) mux.HandleFunc("/fav/123/", func(w http.ResponseWriter, r *http.Request) { hits.Add(1) if r.URL.Query().Get("key") != "k1" { t.Errorf("missing key on /fav/: %q", r.URL.RawQuery) } w.WriteHeader(http.StatusOK) }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) if err := client.Fav(context.Background(), 123); err != nil { t.Fatalf("Fav: %v", err) } if hits.Load() != 2 { t.Errorf("hits = %d; want 2 (fetch + follow)", hits.Load()) } } func TestFav_AlreadyFavedIsNoOp(t *testing.T) { var follows atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/view/77/", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(fakeSubmissionPage(77, true))) }) mux.HandleFunc("/fav/77/", func(w http.ResponseWriter, r *http.Request) { follows.Add(1) }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) if err := client.Fav(context.Background(), 77); err != nil { t.Fatalf("Fav: %v", err) } if follows.Load() != 0 { t.Errorf("/fav/ was hit %d times; want 0 (already faved)", follows.Load()) } } func TestUnfav_FollowsUnfavLink(t *testing.T) { var hits atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/view/55/", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(fakeSubmissionPage(55, true))) }) mux.HandleFunc("/unfav/55/", func(w http.ResponseWriter, r *http.Request) { hits.Add(1) if r.URL.Query().Get("key") != "k1" { t.Errorf("missing key on /unfav/") } }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) if err := client.Unfav(context.Background(), 55); err != nil { t.Fatalf("Unfav: %v", err) } if hits.Load() != 1 { t.Errorf("/unfav/ hits = %d; want 1", hits.Load()) } } func TestWatch_FollowsWatchLink(t *testing.T) { var hits atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/user/alice/", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(fakeUserPage("alice", false))) }) mux.HandleFunc("/watch/alice/", func(w http.ResponseWriter, r *http.Request) { hits.Add(1) if r.URL.Query().Get("key") != "k2" { t.Errorf("missing key on /watch/") } }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) if err := client.Watch(context.Background(), "alice"); err != nil { t.Fatalf("Watch: %v", err) } if hits.Load() != 1 { t.Errorf("/watch/ hits = %d", hits.Load()) } } func TestUnwatch_AlreadyNotWatching_NoOp(t *testing.T) { var follows atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/user/bob/", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(fakeUserPage("bob", false))) }) mux.HandleFunc("/unwatch/bob/", func(w http.ResponseWriter, r *http.Request) { follows.Add(1) }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) if err := client.Unwatch(context.Background(), "bob"); err != nil { t.Fatalf("Unwatch: %v", err) } if follows.Load() != 0 { t.Errorf("/unwatch/ was hit; want no-op when not watching") } } func TestPostSubmissionComment_POSTsCorrectForm(t *testing.T) { var got url.Values mux := http.NewServeMux() mux.HandleFunc("/view/200/", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "want POST", http.StatusBadRequest) return } body, _ := io.ReadAll(r.Body) got, _ = url.ParseQuery(string(body)) _, _ = w.Write([]byte("
posted
")) }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) err := client.PostSubmissionComment(context.Background(), 200, "Nice work!", PostCommentOptions{}) if err != nil { t.Fatalf("PostSubmissionComment: %v", err) } if got.Get("action") != "reply" { t.Errorf("action = %q", got.Get("action")) } if got.Get("reply") != "Nice work!" { t.Errorf("reply = %q", got.Get("reply")) } if got.Get("replyto") != "" { t.Errorf("replyto should be empty for top-level; got %q", got.Get("replyto")) } } func TestPostSubmissionComment_ReplytoSetForThreadedReply(t *testing.T) { var got url.Values mux := http.NewServeMux() mux.HandleFunc("/view/200/", func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) got, _ = url.ParseQuery(string(body)) _, _ = w.Write([]byte("ok")) }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) err := client.PostSubmissionComment(context.Background(), 200, "reply text", PostCommentOptions{ParentID: 999}) if err != nil { t.Fatalf("PostSubmissionComment: %v", err) } if got.Get("replyto") != "999" { t.Errorf("replyto = %q; want 999", got.Get("replyto")) } } func TestPostJournalComment_TargetsJournalURL(t *testing.T) { var hit string mux := http.NewServeMux() mux.HandleFunc("/journal/400/", func(w http.ResponseWriter, r *http.Request) { hit = r.URL.Path _, _ = w.Write([]byte("ok")) }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) err := client.PostJournalComment(context.Background(), 400, "hi", PostCommentOptions{}) if err != nil { t.Fatalf("PostJournalComment: %v", err) } if hit != "/journal/400/" { t.Errorf("hit = %q", hit) } } func TestSendNote_ScrapesKeyAndPosts(t *testing.T) { inboxHTML := `Inbox
` var sendBody url.Values mux := http.NewServeMux() mux.HandleFunc("/msg/pms/", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(inboxHTML)) }) mux.HandleFunc("/msg/send/", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("want POST, got %s", r.Method) } body, _ := io.ReadAll(r.Body) sendBody, _ = url.ParseQuery(string(body)) _, _ = w.Write([]byte("sent")) }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) err := client.SendNote(context.Background(), "vampexx", "Re: hi", "body text") if err != nil { t.Fatalf("SendNote: %v", err) } if sendBody.Get("key") != "SCRAPED_KEY" { t.Errorf("key = %q; want SCRAPED_KEY", sendBody.Get("key")) } if sendBody.Get("to") != "vampexx" { t.Errorf("to = %q", sendBody.Get("to")) } if sendBody.Get("subject") != "Re: hi" { t.Errorf("subject = %q", sendBody.Get("subject")) } if sendBody.Get("message") != "body text" { t.Errorf("message = %q", sendBody.Get("message")) } } func TestSendNote_NoKeyOnInboxPage_Unauthorized(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/msg/pms/", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`Inboxno form here`)) }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) err := client.SendNote(context.Background(), "x", "s", "b") if !errors.Is(err, ErrUnauthorized) { t.Fatalf("got %v; want ErrUnauthorized", err) } } func TestBuildBrowseURL_NoFiltersUsesSimpleForm(t *testing.T) { u := buildBrowseURL(1, BrowseOptions{}) if u != "https://www.furaffinity.net/browse/" { t.Errorf("page=1 default = %q", u) } u = buildBrowseURL(2, BrowseOptions{}) if u != "https://www.furaffinity.net/browse/?page=2" { t.Errorf("page=2 default = %q", u) } } func TestBuildBrowseURL_RatingsAndCategoryFilters(t *testing.T) { got := buildBrowseURL(1, BrowseOptions{ Ratings: []Rating{RatingGeneral, RatingMature}, Category: 2, PerPage: 48, }) q := mustQuery(t, got) if q.Get("rating_general") != "1" || q.Get("rating_mature") != "1" { t.Errorf("ratings not encoded: %v", q) } if q.Get("rating_adult") != "" { t.Errorf("rating_adult should not be set when not requested") } if q.Get("cat") != "2" { t.Errorf("cat = %q", q.Get("cat")) } if q.Get("perpage") != "48" { t.Errorf("perpage = %q", q.Get("perpage")) } } func TestE2E_Browse_RatingsFilterFlowsThroughIterator(t *testing.T) { var seenRatings []string mux := http.NewServeMux() mux.HandleFunc("/browse/", func(w http.ResponseWriter, r *http.Request) { seenRatings = []string{ r.URL.Query().Get("rating_general"), r.URL.Query().Get("rating_mature"), r.URL.Query().Get("rating_adult"), } w.Header().Set("Content-Type", "text/html") _, _ = w.Write([]byte(``)) }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) for _, err := range client.Browse(context.Background(), BrowseOptions{ Ratings: []Rating{RatingGeneral}, }) { if err != nil { t.Fatalf("iter: %v", err) } } if seenRatings[0] != "1" || seenRatings[1] != "" || seenRatings[2] != "" { t.Errorf("seenRatings = %v; want [1, '', '']", seenRatings) } } // TestFindFavLinks_RealFixture confirms findFavLinks scrapes the +Fav anchor // out of a real captured /view/ page. Guards against FA changing the fav-link // markup (issue #5: favourite action doing nothing). func TestFindFavLinks_RealFixture(t *testing.T) { raw := loadFixture(t, "submission.html") doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw)) if err != nil { t.Fatalf("read doc: %v", err) } // submission.html was captured for submission 12345678. FA renders either // a +Fav or a -Fav anchor depending on the capturing account's current // state never both, never neither (when authenticated). The test stays // direction-agnostic so it doesn't break when the capturing account // favourites/unfavourites this submission. favURL, unfavURL := findFavLinks(doc, 12345678) switch { case favURL == "" && unfavURL == "": t.Error("findFavLinks: both URLs empty fav/unfav anchor not found in real markup") case favURL != "" && unfavURL != "": t.Errorf("findFavLinks: both URLs set (fav=%q unfav=%q); expected exactly one", favURL, unfavURL) case favURL != "": if !strings.Contains(favURL, "/fav/12345678/") || !strings.Contains(favURL, "key=") { t.Errorf("findFavLinks: favURL = %q; want a /fav/12345678/?key=... URL", favURL) } case unfavURL != "": if !strings.Contains(unfavURL, "/unfav/12345678/") || !strings.Contains(unfavURL, "key=") { t.Errorf("findFavLinks: unfavURL = %q; want a /unfav/12345678/?key=... URL", unfavURL) } } } // TestFindWatchLinks_RealFixture confirms findWatchLinks scrapes the // watch-button anchor out of a real captured userpage-style page. Guards // against FA changing the watch-button markup (issue #10). func TestFindWatchLinks_RealFixture(t *testing.T) { raw := loadFixture(t, "gallery_page1.html") doc, err := goquery.NewDocumentFromReader(bytes.NewReader(raw)) if err != nil { t.Fatalf("read doc: %v", err) } // gallery_page1.html was captured for kazucreations, whom the capturing // account watches so the header shows an Unwatch button. watchURL, unwatchURL := findWatchLinks(doc, "kazucreations") if unwatchURL == "" { t.Error("findWatchLinks: unwatchURL empty watch-button anchor not found in real markup") } if unwatchURL != "" && (!strings.Contains(unwatchURL, "/unwatch/kazucreations/") || !strings.Contains(unwatchURL, "key=")) { t.Errorf("findWatchLinks: unwatchURL = %q; want a /unwatch/kazucreations/?key=... URL", unwatchURL) } if watchURL != "" { t.Errorf("findWatchLinks: watchURL = %q; want empty when already watching", watchURL) } }