package fa import ( "context" "errors" "fmt" "net/http" "net/http/httptest" "net/url" "strings" "sync/atomic" "testing" "time" ) // rewritingRT routes every request to a fixed test server while preserving // the original path and query. Lets us exercise the SDK end-to-end against // canned responses without monkey-patching the urls package. type rewritingRT struct { target *url.URL base http.RoundTripper } func (r *rewritingRT) RoundTrip(req *http.Request) (*http.Response, error) { req2 := req.Clone(req.Context()) req2.URL = &url.URL{ Scheme: r.target.Scheme, Host: r.target.Host, Path: req.URL.Path, RawQuery: req.URL.RawQuery, } req2.Host = r.target.Host return r.base.RoundTrip(req2) } func newE2EClient(t *testing.T, srv *httptest.Server) *Client { t.Helper() target, err := url.Parse(srv.URL) if err != nil { t.Fatalf("parse server url: %v", err) } hc := &http.Client{ Transport: &rewritingRT{target: target, base: http.DefaultTransport}, Timeout: 5 * time.Second, } // Tight rate limit so tests don't sleep for seconds between pages. return New( WithHTTPClient(hc), WithRateLimit(time.Microsecond, 16), WithMaxRetries(0), ) } func TestE2E_GetSubmission(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/view/1234/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") _, _ = w.Write([]byte(syntheticSubmissionHTML)) }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) sub, err := client.GetSubmission(context.Background(), 1234) if err != nil { t.Fatalf("GetSubmission: %v", err) } if sub.Title != "My Test Submission" { t.Errorf("Title = %q", sub.Title) } if sub.Author.Name != "somefurry" { t.Errorf("Author.Name = %q", sub.Author.Name) } if sub.Rating != RatingGeneral { t.Errorf("Rating = %q", sub.Rating) } } func TestE2E_GalleryIterator_WalksPages(t *testing.T) { page1 := `
Next ` page2 := `
` var hits atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/gallery/me/", func(w http.ResponseWriter, r *http.Request) { hits.Add(1) w.Header().Set("Content-Type", "text/html") _, _ = w.Write([]byte(page1)) }) mux.HandleFunc("/gallery/me/2/", func(w http.ResponseWriter, r *http.Request) { hits.Add(1) w.Header().Set("Content-Type", "text/html") _, _ = w.Write([]byte(page2)) }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) var ids []SubmissionID for sub, err := range client.Gallery(context.Background(), "me", ListOptions{}) { if err != nil { t.Fatalf("iter: %v", err) } ids = append(ids, sub.ID) } wantIDs := []SubmissionID{1, 2, 3} if len(ids) != len(wantIDs) { t.Fatalf("got %d ids; want %d", len(ids), len(wantIDs)) } for i, id := range wantIDs { if ids[i] != id { t.Errorf("ids[%d] = %d; want %d", i, ids[i], id) } } if hits.Load() != 2 { t.Errorf("page fetches = %d; want 2", hits.Load()) } } func TestE2E_GalleryIterator_EarlyBreakStopsFetching(t *testing.T) { page1 := `
Next ` var hits atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/gallery/me/", func(w http.ResponseWriter, r *http.Request) { hits.Add(1) _, _ = w.Write([]byte(page1)) }) mux.HandleFunc("/gallery/me/2/", func(w http.ResponseWriter, r *http.Request) { hits.Add(1) t.Error("page 2 should not have been fetched after early break") }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) for _, err := range client.Gallery(context.Background(), "me", ListOptions{}) { if err != nil { t.Fatalf("iter: %v", err) } break // stop after first } if hits.Load() != 1 { t.Errorf("hits = %d; want 1", hits.Load()) } } func TestE2E_SystemMessage_NotFound(t *testing.T) { notFoundHTML := ` System Error

System Error

The submission you are trying to find is not in our database.
` mux := http.NewServeMux() mux.HandleFunc("/view/999/", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(notFoundHTML)) }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) _, err := client.GetSubmission(context.Background(), 999) if !errors.Is(err, ErrNotFound) { t.Fatalf("got %v; want ErrNotFound", err) } } func TestE2E_BrowseIterator_WalksPagesByFullness(t *testing.T) { // Build a full page (browsePerPage items) so the iterator keeps going, // then a half page (< browsePerPage) so it stops naturally. fullPage := buildFigurePage(browsePerPage, 1) tailPage := buildFigurePage(browsePerPage/2, 1+browsePerPage) var hits atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/browse/", func(w http.ResponseWriter, r *http.Request) { hits.Add(1) page := r.URL.Query().Get("page") w.Header().Set("Content-Type", "text/html") switch page { case "", "1": _, _ = w.Write([]byte(fullPage)) case "2": _, _ = w.Write([]byte(tailPage)) default: t.Errorf("unexpected page=%q requested", page) } }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) count := 0 for sub, err := range client.Browse(context.Background(), BrowseOptions{}) { if err != nil { t.Fatalf("iter: %v", err) } if sub.ID == 0 { t.Errorf("yielded submission with ID 0") } count++ } wantCount := browsePerPage + browsePerPage/2 if count != wantCount { t.Errorf("yielded %d items; want %d", count, wantCount) } if hits.Load() != 2 { t.Errorf("hit count = %d; want 2 (page=1 + page=2)", hits.Load()) } } func TestE2E_BrowseIterator_EarlyBreakStopsFetching(t *testing.T) { fullPage := buildFigurePage(browsePerPage, 1) var hits atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/browse/", func(w http.ResponseWriter, r *http.Request) { hits.Add(1) _, _ = w.Write([]byte(fullPage)) }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) for _, err := range client.Browse(context.Background(), BrowseOptions{}) { if err != nil { t.Fatalf("iter: %v", err) } break // stop after the very first item } if hits.Load() != 1 { t.Errorf("hit count = %d; want 1 (early break before page 2)", hits.Load()) } } func TestE2E_BrowseIterator_MaxPagesCap(t *testing.T) { full := buildFigurePage(browsePerPage, 1) var hits atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/browse/", func(w http.ResponseWriter, r *http.Request) { hits.Add(1) _, _ = w.Write([]byte(full)) }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) count := 0 for _, err := range client.Browse(context.Background(), BrowseOptions{MaxPages: 2}) { if err != nil { t.Fatalf("iter: %v", err) } count++ } if hits.Load() != 2 { t.Errorf("hits = %d; want 2 (BrowseOptions.MaxPages=2 should cap fetches)", hits.Load()) } if count != 2*browsePerPage { t.Errorf("count = %d; want %d", count, 2*browsePerPage) } } // buildFigurePage constructs a synthetic browse-style HTML page with n // figure[id^=sid-] items, starting from idStart. Each figure mirrors the // real FA structure closely enough to satisfy parseGalleryPage. func buildFigurePage(n int, idStart int) string { var b strings.Builder b.WriteString(``) for i := 0; i < n; i++ { id := idStart + i fmt.Fprintf(&b, `

Submission %d

by Artist%d

`, id, i, id, id, id, id, id, id, i, i, i) } b.WriteString(``) return b.String() } func TestE2E_Search_FollowsNextPagination(t *testing.T) { page1 := ` ` page2 := ` ` var hits atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/search/", func(w http.ResponseWriter, r *http.Request) { hits.Add(1) page := r.URL.Query().Get("page") if page == "2" { _, _ = w.Write([]byte(page2)) } else { _, _ = w.Write([]byte(page1)) } }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) var ids []SubmissionID for sub, err := range client.Search(context.Background(), "x", SearchOptions{}) { if err != nil { t.Fatalf("iter: %v", err) } ids = append(ids, sub.ID) } wantIDs := []SubmissionID{10, 11, 12} if len(ids) != len(wantIDs) { t.Fatalf("got %d ids; want %d (%v)", len(ids), len(wantIDs), ids) } for i, id := range wantIDs { if ids[i] != id { t.Errorf("ids[%d] = %d; want %d", i, ids[i], id) } } if hits.Load() != 2 { t.Errorf("hits = %d; want 2", hits.Load()) } } func TestE2E_Search_RespectsMaxPages(t *testing.T) { full := ` ` var hits atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/search/", func(w http.ResponseWriter, r *http.Request) { hits.Add(1) _, _ = w.Write([]byte(full)) }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) for _, err := range client.Search(context.Background(), "x", SearchOptions{MaxPages: 3}) { if err != nil { t.Fatalf("iter: %v", err) } } if hits.Load() != 3 { t.Errorf("hits = %d; want 3 (MaxPages cap)", hits.Load()) } } func TestE2E_SubmissionInbox_FollowsCursorPagination(t *testing.T) { // Build a fake inbox page with the date-divider wrapper and a cursor // link that points at a second page; the second page omits the cursor // so the iterator stops naturally. page1 := `

Today

Next 72
` page2 := `

Yesterday

` var hits atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/msg/submissions/", func(w http.ResponseWriter, r *http.Request) { hits.Add(1) _, _ = w.Write([]byte(page1)) }) mux.HandleFunc("/msg/submissions/new~2@72/", func(w http.ResponseWriter, r *http.Request) { hits.Add(1) _, _ = w.Write([]byte(page2)) }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) var ids []SubmissionID var posted []time.Time for sub, err := range client.SubmissionInbox(context.Background(), ListOptions{}) { if err != nil { t.Fatalf("iter: %v", err) } ids = append(ids, sub.ID) posted = append(posted, sub.PostedAt) } wantIDs := []SubmissionID{1, 2, 3} if len(ids) != len(wantIDs) { t.Fatalf("got %d ids; want %d", len(ids), len(wantIDs)) } for i, id := range wantIDs { if ids[i] != id { t.Errorf("ids[%d] = %d; want %d", i, ids[i], id) } } if posted[0].IsZero() || posted[2].IsZero() { t.Errorf("PostedAt not populated from data-date: %v / %v", posted[0], posted[2]) } if hits.Load() != 2 { t.Errorf("hits = %d; want 2", hits.Load()) } } // inboxPageHTML builds a /msg/submissions/ page wrapping figures for the // given submission IDs (newest first) in FA's date-divider markup. func inboxPageHTML(ids []int) string { var figs strings.Builder for _, id := range ids { fmt.Fprintf(&figs, `
`, id, id, id) } return `
` + `
` + `
` } // When FA serves a full inbox page (72 items) but omits the "Next 72" // cursor link, the iterator must keep crawling by synthesizing the cursor // from the oldest submission on the page otherwise a multi-thousand-item // inbox is truncated to its first page (SDK issue #23). func TestE2E_SubmissionInbox_SynthesizesCursorWhenLinkMissing(t *testing.T) { // Page 1: a full page (72 items, ids 200..129) with NO "Next 72" link. page1IDs := make([]int, 0, 72) for id := 200; id >= 129; id-- { page1IDs = append(page1IDs, id) } // Page 2: the synthesized cursor lands here; a short final page. page2IDs := make([]int, 0, 30) for id := 128; id >= 99; id-- { page2IDs = append(page2IDs, id) } mux := http.NewServeMux() mux.HandleFunc("/msg/submissions/", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(inboxPageHTML(page1IDs))) }) mux.HandleFunc("/msg/submissions/new~129@72/", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(inboxPageHTML(page2IDs))) }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) var got []int for sub, err := range client.SubmissionInbox(context.Background(), ListOptions{}) { if err != nil { t.Fatalf("iter: %v", err) } got = append(got, int(sub.ID)) } if len(got) != 102 { t.Fatalf("got %d items; want 102 (72 + 30)", len(got)) } if got[0] != 200 || got[101] != 99 { t.Errorf("boundary ids = %d..%d; want 200..99", got[0], got[101]) } } func TestE2E_ContextCancelInterruptsIterator(t *testing.T) { page := `
Next ` mux := http.NewServeMux() mux.HandleFunc("/gallery/me/", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(page)) }) mux.HandleFunc("/gallery/me/2/", func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() }) srv := httptest.NewServer(mux) defer srv.Close() client := newE2EClient(t, srv) ctx, cancel := context.WithCancel(context.Background()) gotErr := error(nil) count := 0 go func() { time.Sleep(30 * time.Millisecond) cancel() }() for _, err := range client.Gallery(ctx, "me", ListOptions{}) { if err != nil { gotErr = err break } count++ } if gotErr == nil { t.Fatal("expected cancellation error; iterator completed normally") } }