initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
388
api/internal/transport/rest_test.go
Normal file
388
api/internal/transport/rest_test.go
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue