package web import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "io/ioutil" "net/http" "net/url" "os" "path/filepath" "testing" "time" "github.com/kellegous/go/backend" "github.com/kellegous/go/backend/leveldb" "github.com/kellegous/go/internal" ) type urlReq struct { URL string `json:"url"` } type env struct { mux *http.ServeMux dir string backend backend.Backend } func (e *env) destroy() { os.RemoveAll(e.dir) } func (e *env) get(path string) (*mockResponse, error) { return e.call("GET", path, nil) } func (e *env) post(path string, body interface{}) (*mockResponse, error) { return e.callWithJSON("POST", path, body) } func (e *env) callWithJSON(method, path string, body interface{}) (*mockResponse, error) { var r io.Reader if body != nil { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(body); err != nil { return nil, err } r = &buf } return e.call(method, path, r) } func (e *env) call(method, path string, body io.Reader) (*mockResponse, error) { req, err := http.NewRequest(method, path, body) if err != nil { return nil, err } res := &mockResponse{ header: map[string][]string{}, } e.mux.ServeHTTP(res, req) return res, nil } func newEnv(host string) (*env, error) { dir, err := ioutil.TempDir("", "") if err != nil { return nil, err } backend, err := leveldb.New(filepath.Join(dir, "data")) if err != nil { os.RemoveAll(dir) return nil, err } mux := http.NewServeMux() Setup(mux, backend, host) return &env{ mux: mux, dir: dir, backend: backend, }, nil } func needEnv(t *testing.T, host string) *env { e, err := newEnv(host) if err != nil { t.Fatal(err) } return e } type mockResponse struct { header http.Header bytes.Buffer status int } func (r *mockResponse) Header() http.Header { return r.header } func (r *mockResponse) WriteHeader(status int) { r.status = status } func mustBeSameNamedRoute(t *testing.T, a, b *routeWithName) { if a.Name != b.Name || a.URL != b.URL || a.Time.UnixNano() != b.Time.UnixNano() { t.Fatalf("routes are not same: %v vs %v", a, b) } } func mustBeRouteOf(t *testing.T, rt *internal.Route, url string) { if rt == nil { t.Fatal("route is nil") } if rt.URL != url { t.Fatalf("expected url of %s, got %s", url, rt.URL) } if rt.Time.IsZero() { t.Fatal("route time is empty") } } func mustBeNamedRouteOf(t *testing.T, rt *routeWithName, name, url string, host string) { mustBeRouteOf(t, rt.Route, url) if rt.Name != name { t.Fatalf("expected name of %s, got %s", name, rt.Name) } if host != "" && rt.SourceHost != host { t.Fatalf("expected source of %s, got %s", host, rt.SourceHost) } } func mustBeOk(t *testing.T, ok bool) { if !ok { t.Fatal("response is not ok") } } func mustBeErr(t *testing.T, m *msgErr) { if m.Ok { t.Fatal("response is ok, should be err") } if m.Error == "" { t.Fatal("expected an Error, but it is empty") } } func mustHaveStatus(t *testing.T, res *mockResponse, status int) { if res.status != status { t.Fatalf("expected response status %d, got %d", status, res.status) } } func TestAPIGetNotFound(t *testing.T) { e := needEnv(t, "") defer e.destroy() names := map[string]int{ "": http.StatusBadRequest, "nothing": http.StatusNotFound, "nothing/there": http.StatusNotFound, } for name, status := range names { res, err := e.get(fmt.Sprintf("/api/url/%s", name)) if err != nil { t.Fatal(err) } mustHaveStatus(t, res, status) var m msgErr if err := json.NewDecoder(res).Decode(&m); err != nil { t.Fatal(err) } mustBeErr(t, &m) } } func TestAPIPutThenGet(t *testing.T) { e := needEnv(t, "") defer e.destroy() res, err := e.post("/api/url/xxx", &urlReq{ URL: "http://ex.com/", }) if err != nil { t.Fatal(err) } mustHaveStatus(t, res, http.StatusOK) var pm msgRoute if err := json.NewDecoder(res).Decode(&pm); err != nil { t.Fatal(err) } mustBeOk(t, pm.Ok) mustBeNamedRouteOf(t, pm.Route, "xxx", "http://ex.com/", "") res, err = e.get("/api/url/xxx") if err != nil { t.Fatal(err) } mustHaveStatus(t, res, http.StatusOK) var gm msgRoute if err := json.NewDecoder(res).Decode(&gm); err != nil { t.Fatal(err) } mustBeOk(t, gm.Ok) mustBeNamedRouteOf(t, pm.Route, "xxx", "http://ex.com/", "") } func TestAPIPutThenGetWithHost(t *testing.T) { host := "http://test.com" e := needEnv(t, host) defer e.destroy() res, err := e.post("/api/url/xxx", &urlReq{ URL: "http://ex.com/", }) if err != nil { t.Fatal(err) } mustHaveStatus(t, res, http.StatusOK) var pm msgRoute if err := json.NewDecoder(res).Decode(&pm); err != nil { t.Fatal(err) } mustBeOk(t, pm.Ok) mustBeNamedRouteOf(t, pm.Route, "xxx", "http://ex.com/", host) res, err = e.get("/api/url/xxx") if err != nil { t.Fatal(err) } mustHaveStatus(t, res, http.StatusOK) var gm msgRoute if err := json.NewDecoder(res).Decode(&gm); err != nil { t.Fatal(err) } mustBeOk(t, gm.Ok) mustBeNamedRouteOf(t, pm.Route, "xxx", "http://ex.com/", host) } func TestBadPuts(t *testing.T) { e := needEnv(t, "") defer e.destroy() var m msgErr res, err := e.call("POST", "/api/url/yyy", bytes.NewBufferString("not json")) if err != nil { t.Fatal(err) } mustHaveStatus(t, res, http.StatusBadRequest) if err := json.NewDecoder(res).Decode(&m); err != nil { t.Fatal(err) } mustBeErr(t, &m) res, err = e.post("/api/url/yyy", &urlReq{}) if err != nil { t.Fatal(err) } mustHaveStatus(t, res, http.StatusBadRequest) if err := json.NewDecoder(res).Decode(&m); err != nil { t.Fatal(err) } mustBeErr(t, &m) res, err = e.post("/api/url/yyy", &urlReq{"not a URL"}) if err != nil { t.Fatal(err) } mustHaveStatus(t, res, http.StatusBadRequest) if err := json.NewDecoder(res).Decode(&m); err != nil { t.Fatal(err) } mustBeErr(t, &m) } func TestAPIDel(t *testing.T) { e := needEnv(t, "") defer e.destroy() ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() if err := e.backend.Put(ctx, "xxx", &internal.Route{ URL: "http://ex.com/", Time: time.Now(), }); err != nil { t.Fatal(err) } res, err := e.call("DELETE", "/api/url/xxx", nil) if err != nil { t.Fatal(err) } mustHaveStatus(t, res, http.StatusOK) var m msg if err := json.NewDecoder(res).Decode(&m); err != nil { t.Fatal(err) } mustBeOk(t, m.Ok) ctx, cancel = context.WithTimeout(context.Background(), time.Minute) defer cancel() if _, err := e.backend.Get(ctx, "xxx"); !errors.Is(err, internal.ErrRouteNotFound) { t.Fatal("expected xxx to be deleted") } } func TestAPIPutThenGetAuto(t *testing.T) { e := needEnv(t, "") defer e.destroy() res, err := e.post("/api/url/", &urlReq{URL: "http://b.com/"}) if err != nil { t.Fatal(err) } mustHaveStatus(t, res, http.StatusOK) var am msgRoute if err := json.NewDecoder(res).Decode(&am); err != nil { t.Fatal(err) } mustBeOk(t, am.Ok) mustBeRouteOf(t, am.Route.Route, "http://b.com/") res, err = e.get(fmt.Sprintf("/api/url/%s", am.Route.Name)) if err != nil { t.Fatal(err) } mustHaveStatus(t, res, http.StatusOK) var bm msgRoute if err := json.NewDecoder(res).Decode(&bm); err != nil { t.Fatal(err) } mustBeOk(t, bm.Ok) mustBeNamedRouteOf(t, bm.Route, am.Route.Name, "http://b.com/", "") } func getInPages(e *env, params url.Values) ([][]*routeWithName, error) { var pages [][]*routeWithName for { res, err := e.get("/api/urls/?" + params.Encode()) if err != nil { return nil, err } if res.status != http.StatusOK { return nil, fmt.Errorf("HTTP status: %d", res.status) } var m msgRoutes if err := json.NewDecoder(res).Decode(&m); err != nil { return nil, err } if !m.Ok { return nil, errors.New("response is not ok") } pages = append(pages, m.Routes) if m.Next == "" { return pages, nil } params.Set("cursor", m.Next) } } type listTest struct { Params url.Values Pages [][]*routeWithName } func TestAPIList(t *testing.T) { e := needEnv(t, "") defer e.destroy() rts := []*routeWithName{ &routeWithName{ Name: "0", Route: &internal.Route{ URL: "http://0.com/", Time: time.Now(), }, }, &routeWithName{ Name: "1", Route: &internal.Route{ URL: "http://1.com/", Time: time.Now(), }, }, &routeWithName{ Name: ":a", Route: &internal.Route{ URL: "http://ga.com/", Time: time.Now(), }, }, &routeWithName{ Name: ":b", Route: &internal.Route{ URL: "http://gb.com/", Time: time.Now(), }, }, &routeWithName{ Name: "a", Route: &internal.Route{ URL: "http://a.com/", Time: time.Now(), }, }, &routeWithName{ Name: "b", Route: &internal.Route{ URL: "http://b.com/", Time: time.Now(), }, }, } ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() for _, rt := range rts { if err := e.backend.Put(ctx, rt.Name, rt.Route); err != nil { t.Fatal(err) } } tests := []*listTest{ &listTest{ Params: url.Values(map[string][]string{}), Pages: [][]*routeWithName{ []*routeWithName{rts[0], rts[1], rts[4], rts[5]}, }, }, &listTest{ Params: url.Values(map[string][]string{ "include-generated-names": {"true"}, }), Pages: [][]*routeWithName{rts}, }, &listTest{ Params: url.Values(map[string][]string{ "include-generated-names": {"false"}, }), Pages: [][]*routeWithName{ []*routeWithName{rts[0], rts[1], rts[4], rts[5]}, }, }, &listTest{ Params: url.Values(map[string][]string{ "limit": {"2"}, }), Pages: [][]*routeWithName{ []*routeWithName{rts[0], rts[1]}, []*routeWithName{rts[4], rts[5]}, }, }, &listTest{ Params: url.Values(map[string][]string{ "limit": {"2"}, "include-generated-names": {"true"}, }), Pages: [][]*routeWithName{ []*routeWithName{rts[0], rts[1]}, []*routeWithName{rts[2], rts[3]}, []*routeWithName{rts[4], rts[5]}, }, }, &listTest{ Params: url.Values(map[string][]string{ "limit": {"2"}, "cursor": {base64.URLEncoding.EncodeToString([]byte{':'})}, }), Pages: [][]*routeWithName{ []*routeWithName{rts[4], rts[5]}, }, }, &listTest{ Params: url.Values(map[string][]string{ "limit": {"3"}, "include-generated-names": {"true"}, "cursor": {base64.URLEncoding.EncodeToString([]byte{':'})}, }), Pages: [][]*routeWithName{ []*routeWithName{rts[2], rts[3], rts[4]}, []*routeWithName{rts[5]}, }, }, &listTest{ Params: url.Values(map[string][]string{ "limit": {"1"}, }), Pages: [][]*routeWithName{ []*routeWithName{rts[0]}, []*routeWithName{rts[1]}, []*routeWithName{rts[4]}, []*routeWithName{rts[5]}, }, }, &listTest{ Params: url.Values(map[string][]string{ "cursor": {base64.URLEncoding.EncodeToString([]byte{'z'})}, }), Pages: [][]*routeWithName{nil}, }, } for _, test := range tests { t.Logf("running tests for ?%s", test.Params.Encode()) pages, err := getInPages(e, test.Params) if err != nil { t.Fatal(err) } if len(pages) != len(test.Pages) { t.Fatalf("number of pages mismatch %d vs %d", len(pages), len(test.Pages)) } for i, n := 0, len(pages); i < n; i++ { page := pages[i] expected := test.Pages[i] if len(page) != len(expected) { t.Fatalf("page %d, length mismatch expected %d got %d", i, len(expected), len(page)) } for j, m := 0, len(page); j < m; j++ { mustBeSameNamedRoute(t, page[j], expected[j]) } } } } func TestBadList(t *testing.T) { e := needEnv(t, "") defer e.destroy() tests := map[string]int{ url.Values{ "cursor": {"not a cursor"}, }.Encode(): http.StatusBadRequest, url.Values{ "limit": {"0"}, }.Encode(): http.StatusBadRequest, url.Values{ "limit": {"not a limit"}, }.Encode(): http.StatusBadRequest, url.Values{ "limit": {"100000"}, }.Encode(): http.StatusBadRequest, url.Values{ "include-generated-names": {"butter"}, }.Encode(): http.StatusBadRequest, } for params, status := range tests { res, err := e.get("/api/urls/?" + params) if err != nil { t.Fatal(err) } mustHaveStatus(t, res, status) var m msgErr if err := json.NewDecoder(res).Decode(&m); err != nil { t.Fatal(err) } mustBeErr(t, &m) } }