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) } } 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.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)) } }