Files
foundry-core/internal/research/http_test.go

146 lines
5.3 KiB
Go

package research
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
type fixtureRepository struct {
filters Filters
id int64
err error
wait bool
calls int
}
func (f *fixtureRepository) List(ctx context.Context, filters Filters) (Page, error) {
f.calls++
f.filters = filters
if f.wait {
<-ctx.Done()
return Page{}, ctx.Err()
}
return Page{Items: []Paper{}, Topics: []string{}, Page: filters.Page, PageSize: filters.PageSize}, f.err
}
func (f *fixtureRepository) Get(ctx context.Context, id int64) (Paper, error) {
f.calls++
f.id = id
return Paper{ID: "42", Title: "Original title", Authors: []string{}, Topics: []string{}, Sources: []Source{}}, f.err
}
func request(handler http.Handler, method, path string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
handler.ServeHTTP(w, httptest.NewRequest(method, path, nil))
return w
}
func TestAnonymousReadRoutes(t *testing.T) {
f := &fixtureRepository{}
mux := http.NewServeMux()
Register(mux, f, map[string]string{"thesisly": "http://localhost:17322/auth/callback"})
w := request(mux, "GET", "/projects/thesisly/research/papers?q=graph&searchIn=author&sort=title&topic=cs.LG&page=2&pageSize=10")
if w.Code != 200 || f.filters != (Filters{Query: "graph", SearchIn: "author", Topic: "cs.LG", Sort: "title", Page: 2, PageSize: 10}) {
t.Fatalf("list: %d %+v", w.Code, f.filters)
}
var page Page
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil || page.Items == nil || page.Topics == nil {
t.Fatalf("invalid page: %s", w.Body.String())
}
if w.Header().Get("Cache-Control") != "no-store" || w.Header().Get("Content-Type") != "application/json" {
t.Fatal("missing response headers")
}
w = request(mux, "GET", "/projects/thesisly/research/papers/42")
if w.Code != 200 || f.id != 42 || !strings.Contains(w.Body.String(), `"abstract":null`) {
t.Fatalf("detail: %d %s", w.Code, w.Body.String())
}
if got := request(mux, "POST", "/projects/thesisly/research/papers").Code; got != 405 {
t.Fatalf("mutation status %d", got)
}
if got := request(mux, "GET", "/projects/other/research/papers").Code; got != 404 {
t.Fatalf("unknown project status %d", got)
}
}
func TestInvalidQueriesNeverReachRepository(t *testing.T) {
queries := []string{"unknown=1", "q=x&q=y", "q=%ff", "q=%00", "q=%zz", "page=0", "page=1001", "page=-1", "page=1.5", "page=01", "pageSize=51", "pageSize=0", "pageSize=9999999999999999999999", "searchIn=sql", "sort=published", "topic=arxiv:cs.LG", "q=" + url.QueryEscape(strings.Repeat("字", 201)), "q=" + url.QueryEscape(strings.Repeat("word ", 21))}
for _, query := range queries {
t.Run(query, func(t *testing.T) {
f := &fixtureRepository{}
mux := http.NewServeMux()
Register(mux, f, map[string]string{"thesisly": "unused"})
w := request(mux, "GET", "/projects/thesisly/research/papers?"+query)
if w.Code != 400 || f.calls != 0 {
t.Fatalf("status %d, calls %d", w.Code, f.calls)
}
})
}
for _, path := range []string{"0", "-1", "1x", "9223372036854775808", "1?extra=1"} {
mux := http.NewServeMux()
f := &fixtureRepository{}
Register(mux, f, map[string]string{"thesisly": "unused"})
if w := request(mux, "GET", "/projects/thesisly/research/papers/"+path); w.Code != 400 || f.calls != 0 {
t.Fatalf("invalid id %q: %d", path, w.Code)
}
}
}
func TestQueryDefaultsAndUnicode(t *testing.T) {
f, err := parseFilters("q=" + url.QueryEscape(" 论文 Graph "))
if err != nil || f.Query != "论文 Graph" || f.Page != 1 || f.PageSize != 8 || f.SearchIn != "text" || f.Sort != "recent" {
t.Fatalf("%+v %v", f, err)
}
}
type databaseFailure string
func (failure databaseFailure) Error() string { return "private database error" }
func (failure databaseFailure) SQLState() string { return string(failure) }
func TestStorageErrorsAreRedacted(t *testing.T) {
for _, test := range []struct {
err error
status int
code string
}{
{sql.ErrNoRows, 404, "not_found"},
{context.DeadlineExceeded, 504, "research_timeout"},
{errors.Join(databaseFailure("57014"), errors.New("private statement")), 504, "research_timeout"},
{databaseFailure("42501"), 503, "research_unavailable"},
{errors.New("postgres://private-user:private-password@db/corpus"), 503, "research_unavailable"},
} {
mux := http.NewServeMux()
Register(mux, &fixtureRepository{err: test.err}, map[string]string{"thesisly": "unused"})
w := request(mux, "GET", "/projects/thesisly/research/papers/42")
if w.Code != test.status || !strings.Contains(w.Body.String(), test.code) || strings.Contains(w.Body.String(), "private") {
t.Fatalf("%d %s", w.Code, w.Body.String())
}
}
}
func TestConcurrencyAndTimeoutReleaseSlots(t *testing.T) {
s := &service{repository: &fixtureRepository{wait: true}, projects: map[string]string{"thesisly": "unused"}, slots: make(chan struct{}, 2), timeout: time.Millisecond}
mux := http.NewServeMux()
mux.HandleFunc("GET /projects/{project}/research/papers", s.serve)
s.slots <- struct{}{}
s.slots <- struct{}{}
w := request(mux, "GET", "/projects/thesisly/research/papers")
if w.Code != 429 || w.Header().Get("Retry-After") != "1" {
t.Fatalf("busy %d", w.Code)
}
<-s.slots
<-s.slots
w = request(mux, "GET", "/projects/thesisly/research/papers")
if w.Code != 504 || len(s.slots) != 0 {
t.Fatalf("timeout %d, slots %d", w.Code, len(s.slots))
}
}