initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:14:50 +02:00
commit 1253955070
75 changed files with 13972 additions and 0 deletions

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