Files
go-fa-api/actions_test.go
2026-05-25 22:27:18 +02:00

402 lines
12 KiB
Go

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 `<html><head><title>Test submission</title></head><body>
<div class="submission-description-artist">
<a href="/user/x/"><img class="avatar" src="/a.png"/></a>
<div>
<div class="submission-title"><h2>T</h2></div>
<div><span class="c-usernameBlockSimple__displayName" title="x">x</span></div>
</div>
</div>
<a class="button" href="` + href + `">` + label + `</a>
</body></html>`
}
func fakeUserPage(name string, watching bool) string {
href := "/watch/" + name + "/?key=k2"
text := "Watch"
if watching {
href = "/unwatch/" + name + "/?key=k2"
text = "Unwatch"
}
return `<html><head><title>Userpage</title></head><body>
<div class="username"><h2><span>` + name + `</span></h2></div>
<a id="watch-button" class="button" href="` + href + `">` + text + `</a>
</body></html>`
}
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("<html><body><div class='comment-container'>posted</div></body></html>"))
})
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("<html><body>ok</body></html>"))
})
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("<html><body>ok</body></html>"))
})
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 := `<html><head><title>Inbox</title></head><body>
<form action="/msg/send/"><input type="hidden" name="key" value="SCRAPED_KEY"/></form>
</body></html>`
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("<html><body>sent</body></html>"))
})
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(`<html><head><title>Inbox</title></head><body>no form here</body></html>`))
})
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(`<html><body></body></html>`))
})
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 65052636 (a "+Fav" page).
favURL, unfavURL := findFavLinks(doc, 65052636)
if favURL == "" {
t.Error("findFavLinks: favURL empty +Fav anchor not found in real markup")
}
if !strings.Contains(favURL, "/fav/65052636/") || !strings.Contains(favURL, "key=") {
t.Errorf("findFavLinks: favURL = %q; want a /fav/65052636/?key=... URL", favURL)
}
if unfavURL != "" {
t.Errorf("findFavLinks: unfavURL = %q; want empty on a not-yet-faved page", 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)
}
}