package transport import ( "bytes" "encoding/json" "image" "image/color" "image/png" "mime/multipart" "net/http" "net/http/httptest" "net/textproto" "strings" "testing" "go-places-api/internal/config" ) // The tests in this file cover the REST surface behind ENABLE_REST, which the // React client uses. The GraphQL surface has its own tests in transport_test.go. // // REST was added without the auth middleware at first, which left every place // and picture readable by an anonymous caller; the first test here pins that // closed. // restServer returns a server with the REST routes enabled. func restServer(t *testing.T) *Server { t.Helper() return newTestServer(t, func(c *config.Config) { c.EnableREST = true }) } // apiRequest performs an authenticated REST call and returns the status and the // decoded body. The body is decoded into any because the endpoints answer with // both objects and arrays. func apiRequest(t *testing.T, srv *Server, method, path string, body []byte, token string) (int, any) { t.Helper() req := httptest.NewRequest(method, path, bytes.NewReader(body)) if body != nil { req.Header.Set("Content-Type", "application/json") } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, req) var out any if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { // A non-JSON body (an error page, say) is still worth asserting on, so // the text is returned as a plain string rather than failing the decode. out = rec.Body.String() } return rec.Code, out } // object asserts that a decoded body is a JSON object and returns it. func object(t *testing.T, body any) map[string]any { t.Helper() m, ok := body.(map[string]any) if !ok { t.Fatalf("body is %T, want a JSON object: %v", body, body) } return m } // array asserts that a decoded body is a JSON array and returns it. func array(t *testing.T, body any) []any { t.Helper() a, ok := body.([]any) if !ok { t.Fatalf("body is %T, want a JSON array: %v", body, body) } return a } // restPlace creates a place over REST and returns its id. func restPlace(t *testing.T, srv *Server, title string, lat, lon float64) string { t.Helper() body, err := json.Marshal(map[string]any{ "title": title, "description": "notes", "latitude": lat, "longitude": lon, }) if err != nil { t.Fatalf("marshal: %v", err) } code, resp := apiRequest(t, srv, http.MethodPost, "/api/places", body, "valid_token") if code != http.StatusCreated { t.Fatalf("create place status = %d, want 201: %v", code, resp) } id, _ := object(t, resp)["id"].(string) if id == "" { t.Fatalf("create place returned no id: %v", resp) } return id } // restUpload posts a PNG to a place and returns the status and image object. func restUpload(t *testing.T, srv *Server, placeID string) (int, map[string]any) { t.Helper() img := image.NewRGBA(image.Rect(0, 0, 8, 4)) for y := 0; y < 4; y++ { for x := 0; x < 8; x++ { img.Set(x, y, color.RGBA{R: uint8(x * 30), G: uint8(y * 60), B: 200, A: 255}) } } var pngBuf bytes.Buffer if err := png.Encode(&pngBuf, img); err != nil { t.Fatalf("encode png: %v", err) } var body bytes.Buffer w := multipart.NewWriter(&body) h := make(textproto.MIMEHeader) h.Set("Content-Disposition", `form-data; name="image"; filename="pic.png"`) h.Set("Content-Type", "image/png") part, err := w.CreatePart(h) if err != nil { t.Fatalf("create part: %v", err) } if _, err := part.Write(pngBuf.Bytes()); err != nil { t.Fatalf("write part: %v", err) } if err := w.Close(); err != nil { t.Fatalf("close writer: %v", err) } req := httptest.NewRequest(http.MethodPost, "/places/"+placeID+"/images", bytes.NewReader(body.Bytes())) req.Header.Set("Content-Type", w.FormDataContentType()) req.Header.Set("Authorization", "Bearer valid_token") rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, req) var out map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("decode upload response %q: %v", rec.Body.String(), err) } inner, _ := out["image"].(map[string]any) return rec.Code, inner } func TestRESTRoutesRequireAuthentication(t *testing.T) { srv := restServer(t) placeID := restPlace(t, srv, "Guarded", 48.85, 2.29) // Every route that reads or writes user data has to reject an anonymous // caller. Without the token these endpoints returned a full listing. tests := []struct { name string method string path string }{ {"list places", http.MethodGet, "/api/places"}, {"get place", http.MethodGet, "/api/places/" + placeID}, {"list place images", http.MethodGet, "/api/places/" + placeID + "/images"}, {"create place", http.MethodPost, "/api/places"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(tc.method, tc.path, bytes.NewReader([]byte(`{"title":"x"}`))) if tc.method == http.MethodPost { req.Header.Set("Content-Type", "application/json") } rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Errorf("status = %d, want 401", rec.Code) } }) } // And a wrong token is rejected just as firmly. code, _ := apiRequest(t, srv, http.MethodGet, "/api/places", nil, "not-a-real-token") if code != http.StatusUnauthorized { t.Errorf("bad token status = %d, want 401", code) } } func TestRESTListsPlacesAndServesOne(t *testing.T) { srv := restServer(t) id := restPlace(t, srv, "Listed", 48.8584, 2.2945) code, list := apiRequest(t, srv, http.MethodGet, "/api/places", nil, "valid_token") if code != http.StatusOK { t.Fatalf("list status = %d: %v", code, list) } if got := array(t, list); len(got) != 1 { t.Errorf("list returned %d places, want 1", len(got)) } code, rawOne := apiRequest(t, srv, http.MethodGet, "/api/places/"+id, nil, "valid_token") if code != http.StatusOK { t.Fatalf("get status = %d: %v", code, rawOne) } one := object(t, rawOne) if one["title"] != "Listed" { t.Errorf("title = %v, want Listed", one["title"]) } if one["id"] != id { t.Errorf("id = %v, want %q", one["id"], id) } code, _ = apiRequest(t, srv, http.MethodGet, "/api/places/nope", nil, "valid_token") if code != http.StatusNotFound { t.Errorf("unknown place status = %d, want 404", code) } } func TestRESTPlaceCarriesImageURLs(t *testing.T) { // The stored record has no URL column, it is derived from the storage key. // When the REST handler skipped that step the client received pictures it // could not render. srv := restServer(t) id := restPlace(t, srv, "With Photo", 48.8584, 2.2945) code, img := restUpload(t, srv, id) if code != http.StatusCreated { t.Fatalf("upload status = %d, want 201", code) } wantURL, _ := img["url"].(string) if wantURL == "" { t.Fatalf("upload response has no url: %v", img) } code, rawPlace := apiRequest(t, srv, http.MethodGet, "/api/places/"+id, nil, "valid_token") if code != http.StatusOK { t.Fatalf("get status = %d: %v", code, rawPlace) } place := object(t, rawPlace) images, _ := place["images"].([]any) if len(images) != 1 { t.Fatalf("place carries %d images, want 1", len(images)) } if got, _ := images[0].(map[string]any)["url"].(string); got != wantURL { t.Errorf("picture url = %q, want %q", got, wantURL) } // The image listing has to carry the URL too. code, rawListed := apiRequest(t, srv, http.MethodGet, "/api/places/"+id+"/images", nil, "valid_token") if code != http.StatusOK { t.Fatalf("images status = %d: %v", code, rawListed) } listed := array(t, rawListed) if got, _ := listed[0].(map[string]any)["url"].(string); got != wantURL { t.Errorf("listed picture url = %q, want %q", got, wantURL) } } func TestRESTListsEmptyArraysRatherThanNull(t *testing.T) { // Go serialises a nil slice as JSON null. The client iterates these fields // directly, so an empty result has to be [] rather than null. srv := restServer(t) code, list := apiRequest(t, srv, http.MethodGet, "/api/places", nil, "valid_token") if code != http.StatusOK { t.Fatalf("list status = %d: %v", code, list) } if got := array(t, list); len(got) != 0 { t.Errorf("an empty list returned %d places", len(got)) } id := restPlace(t, srv, "Bare", 10, 10) code, rawPlace := apiRequest(t, srv, http.MethodGet, "/api/places/"+id, nil, "valid_token") if code != http.StatusOK { t.Fatalf("get status = %d: %v", code, rawPlace) } place := object(t, rawPlace) for _, field := range []string{"images", "comments", "relatedPlaces"} { if place[field] == nil { t.Errorf("%q is null on a place with nothing attached", field) } } } func TestRESTPlaceReportsRelatedPlaces(t *testing.T) { // The same spot catalogued twice is the case the feature exists for, so the // REST response has to surface the relationship. srv := restServer(t) first := restPlace(t, srv, "Eiffel (visit 1)", 48.8584, 2.2945) second := restPlace(t, srv, "Eiffel (visit 2)", 48.8585, 2.2946) if code, img := restUpload(t, srv, first); code != http.StatusCreated { t.Fatalf("first upload status = %d", code) } else if img["id"] == "" { t.Fatal("first upload returned no id") } if code, _ := restUpload(t, srv, second); code != http.StatusCreated { t.Fatalf("second upload status = %d", code) } code, rawPlace := apiRequest(t, srv, http.MethodGet, "/api/places/"+second, nil, "valid_token") if code != http.StatusOK { t.Fatalf("get status = %d: %v", code, rawPlace) } place := object(t, rawPlace) related, _ := place["relatedPlaces"].([]any) if len(related) != 1 { t.Fatalf("got %d related places, want 1", len(related)) } entry := related[0].(map[string]any) if got, _ := entry["place"].(map[string]any)["id"].(string); got != first { t.Errorf("related place id = %q, want %q", got, first) } if _, ok := entry["distanceMeters"].(float64); !ok { t.Errorf("distanceMeters missing: %v", entry) } // The relationship must not nest into itself. nested, _ := entry["place"].(map[string]any)["relatedPlaces"].([]any) if len(nested) != 0 { t.Errorf("the nested place carries %d related places, want 0", len(nested)) } } func TestRESTPictureReportsLinkedImages(t *testing.T) { // Pictures in different place records at the same coordinates are linked, and // the link has to be visible from the picture listing. srv := restServer(t) first := restPlace(t, srv, "Spot A", 48.8584, 2.2945) second := restPlace(t, srv, "Spot B", 48.8585, 2.2946) restUpload(t, srv, first) code, secondImg := restUpload(t, srv, second) if code != http.StatusCreated { t.Fatalf("upload status = %d", code) } linked, _ := secondImg["linkedImages"].([]any) if len(linked) != 1 { t.Fatalf("got %d linked images, want 1: %v", len(linked), secondImg) } code, rawListed := apiRequest(t, srv, http.MethodGet, "/api/places/"+second+"/images", nil, "valid_token") if code != http.StatusOK { t.Fatalf("images status = %d: %v", code, rawListed) } listed := array(t, rawListed) listedLinks, _ := listed[0].(map[string]any)["linkedImages"].([]any) if len(listedLinks) != 1 { t.Errorf("the listing reports %d linked images, want 1", len(listedLinks)) } } func TestRESTRejectsInvalidInputWithJSON(t *testing.T) { // The error shape has to match the success shape, otherwise a client has to // parse two formats. srv := restServer(t) code, resp := apiRequest(t, srv, http.MethodPost, "/api/places", []byte(`{"title":""}`), "valid_token") if code != http.StatusBadRequest { t.Errorf("empty title status = %d, want 400: %v", code, resp) } if msg, _ := object(t, resp)["error"].(string); msg == "" { t.Errorf("error body has no message: %v", resp) } code, resp = apiRequest(t, srv, http.MethodPost, "/api/places", []byte(`not json`), "valid_token") if code != http.StatusBadRequest { t.Errorf("malformed body status = %d, want 400: %v", code, resp) } } func TestRESTUnknownSubresourceIsNotAPlaceLookup(t *testing.T) { // GET /api/places/ is a prefix match, so a deeper path arrives at the same // handler. IDs are UUIDs, so a path with a slash in it is an unknown route // rather than a place that happens not to exist. srv := restServer(t) id := restPlace(t, srv, "Spot", 48.8584, 2.2945) code, body := apiRequest(t, srv, http.MethodGet, "/api/places/"+id+"/comments", nil, "valid_token") if code != http.StatusNotFound { t.Errorf("status = %d, want 404 for an unknown subresource: %v", code, body) } if msg, _ := object(t, body)["error"].(string); !strings.Contains(msg, "resource") { t.Errorf("error = %q, want it to name the missing resource", msg) } } func TestRESTDisabledByDefault(t *testing.T) { // ENABLE_REST defaults to off, so a deployment that did not opt in must not // expose the routes at all. srv := newTestServer(t) code, _ := apiRequest(t, srv, http.MethodGet, "/api/places", nil, "valid_token") if code != http.StatusNotFound { t.Errorf("status = %d, want 404 when REST is disabled", code) } }