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

557 lines
17 KiB
Go

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 := `<html><body>
<figure id="sid-1"><a href="/view/1/" title="One"><img src="/t1.png"/></a></figure>
<figure id="sid-2"><a href="/view/2/" title="Two"><img src="/t2.png"/></a></figure>
<a class="button standard" href="/gallery/me/2/">Next</a>
</body></html>`
page2 := `<html><body>
<figure id="sid-3"><a href="/view/3/" title="Three"><img src="/t3.png"/></a></figure>
</body></html>`
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 := `<html><body>
<figure id="sid-1"><a href="/view/1/" title="One"><img src="/t1.png"/></a></figure>
<figure id="sid-2"><a href="/view/2/" title="Two"><img src="/t2.png"/></a></figure>
<a class="button standard" href="/gallery/me/2/">Next</a>
</body></html>`
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 := `<html>
<head><title>System Error</title></head>
<body>
<section>
<div class="section-header"><h2>System Error</h2></div>
<div class="section-body">The submission you are trying to find is not in our database.</div>
</section>
</body></html>`
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(`<html><body>`)
for i := 0; i < n; i++ {
id := idStart + i
fmt.Fprintf(&b, `<figure id="sid-%d" class="r-general t-image u-artist%d">
<a href="/view/%d/" title="Submission %d"><img src="/t%d.png"/></a>
<figcaption>
<p><a href="/view/%d/" title="Submission %d">Submission %d</a></p>
<p><i>by</i> <a href="/user/artist%d/" title="Artist%d">Artist%d</a></p>
</figcaption>
</figure>
`, id, i, id, id, id, id, id, id, i, i, i)
}
b.WriteString(`</body></html>`)
return b.String()
}
func TestE2E_Search_FollowsNextPagination(t *testing.T) {
page1 := `<html><body>
<section id="gallery-search-results" class="gallery">
<figure id="sid-10"><a href="/view/10/" title="Ten"><img src="/t10.png"/></a>
<figcaption><p><a href="/view/10/" title="Ten">Ten</a></p><p>by <a href="/user/alice/">Alice</a></p></figcaption>
</figure>
<figure id="sid-11"><a href="/view/11/" title="Eleven"><img src="/t11.png"/></a>
<figcaption><p><a href="/view/11/" title="Eleven">Eleven</a></p><p>by <a href="/user/bob/">Bob</a></p></figcaption>
</figure>
</section>
<div class="pagination">
<button class="button" disabled>Back</button>
<a class="button" href="/search/?q=x&amp;page=2">Next</a>
</div>
</body></html>`
page2 := `<html><body>
<section id="gallery-search-results" class="gallery">
<figure id="sid-12"><a href="/view/12/" title="Twelve"><img src="/t12.png"/></a>
<figcaption><p><a href="/view/12/" title="Twelve">Twelve</a></p><p>by <a href="/user/carol/">Carol</a></p></figcaption>
</figure>
</section>
<div class="pagination">
<a class="button" href="/search/?q=x&amp;page=1">Back</a>
</div>
</body></html>`
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 := `<html><body>
<section id="gallery-search-results">
<figure id="sid-1"><a href="/view/1/" title="A"><img src="/t.png"/></a>
<figcaption><p><a href="/view/1/" title="A">A</a></p><p>by <a href="/user/x/">X</a></p></figcaption>
</figure>
</section>
<div class="pagination"><a class="button" href="/search/?page=99">Next</a></div>
</body></html>`
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 := `<html><body>
<div id="messagecenter-submissions">
<div class="notifications-by-date" data-date="1779177165">
<h4>Today</h4>
<section class="gallery">
<figure id="sid-1"><a href="/view/1/" title="One"><img src="/t1.png"/></a>
<figcaption><p><a href="/view/1/" title="One">One</a></p><p>by <a href="/user/alice/">Alice</a></p></figcaption>
</figure>
<figure id="sid-2"><a href="/view/2/" title="Two"><img src="/t2.png"/></a>
<figcaption><p><a href="/view/2/" title="Two">Two</a></p><p>by <a href="/user/bob/">Bob</a></p></figcaption>
</figure>
</section>
</div>
</div>
<div class="messagecenter-navigation">
<a class="button standard more" href="/msg/submissions/new~2@72/">Next 72</a>
</div>
</body></html>`
page2 := `<html><body>
<div id="messagecenter-submissions">
<div class="notifications-by-date" data-date="1779000000">
<h4>Yesterday</h4>
<section class="gallery">
<figure id="sid-3"><a href="/view/3/" title="Three"><img src="/t3.png"/></a>
<figcaption><p><a href="/view/3/" title="Three">Three</a></p><p>by <a href="/user/carol/">Carol</a></p></figcaption>
</figure>
</section>
</div>
</div>
</body></html>`
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,
`<figure id="sid-%d"><a href="/view/%d/" title="S%d"><img src="/t.png"/></a></figure>`,
id, id, id)
}
return `<html><body><div id="messagecenter-submissions">` +
`<div class="notifications-by-date" data-date="1779177165">` +
`<section class="gallery">` + figs.String() + `</section></div></div></body></html>`
}
// 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 := `<html><body>
<figure id="sid-1"><a href="/view/1/" title="One"><img src="/t1.png"/></a></figure>
<a class="button standard" href="/gallery/me/2/">Next</a>
</body></html>`
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")
}
}