package api import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "milkway/internal/config" "milkway/internal/models" ) // decodeEnvelope decodes the standard error envelope {"error":{...}} from a // recorded response body, failing the test if it does not match the contract. func decodeEnvelope(t *testing.T, body []byte) apiError { t.Helper() var env struct { Error apiError `json:"error"` } if err := json.Unmarshal(body, &env); err != nil { t.Fatalf("decode error envelope: %v (body=%s)", err, body) } return env.Error } // TestWriteErrorCodeFromStatus verifies writeError emits the {"error":{...}} // envelope and derives the code purely from the HTTP status. func TestWriteErrorCodeFromStatus(t *testing.T) { tests := []struct { status int wantCode string }{ {http.StatusBadRequest, CodeValidation}, {http.StatusUnauthorized, CodeUnauthenticated}, {http.StatusForbidden, CodeForbidden}, {http.StatusNotFound, CodeNotFound}, {http.StatusConflict, CodeConflict}, {http.StatusInternalServerError, CodeInternal}, {http.StatusTeapot, CodeInternal}, // any unmapped status falls back to INTERNAL } for _, tc := range tests { rr := httptest.NewRecorder() writeError(rr, tc.status, "boom") if rr.Code != tc.status { t.Errorf("status %d: recorder code = %d", tc.status, rr.Code) } got := decodeEnvelope(t, rr.Body.Bytes()) if got.Code != tc.wantCode { t.Errorf("status %d: code = %q, want %q", tc.status, got.Code, tc.wantCode) } if got.Message != "boom" { t.Errorf("status %d: message = %q, want %q", tc.status, got.Message, "boom") } if got.Ref != "" { t.Errorf("status %d: ref = %q, want empty (only INTERNAL via internalError sets ref)", tc.status, got.Ref) } } } // testServer builds a Server with a nil store; the routes exercised here // (static handler / unmatched API path) never touch the database. func testServer() *Server { return NewServer(&config.Config{Profile: "test"}, nil) } // TestUnmatchedAPIPathReturns404Envelope asserts that an unknown /api/* path is // served as a JSON 404 NOT_FOUND envelope (never the SPA HTML), while a non-API // path falls through to the HTML placeholder. func TestUnmatchedAPIPathReturns404Envelope(t *testing.T) { // A real (empty) static dir with no index.html: the path-traversal guard // passes and the handler falls through to the built-in placeholder HTML. h := testServer().Routes(t.TempDir()) t.Run("unmatched /api path is a NOT_FOUND envelope", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/does-not-exist", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", rr.Code) } ct := rr.Header().Get("Content-Type") if !strings.HasPrefix(ct, "application/json") { t.Errorf("content-type = %q, want application/json", ct) } got := decodeEnvelope(t, rr.Body.Bytes()) if got.Code != CodeNotFound { t.Errorf("code = %q, want %q", got.Code, CodeNotFound) } if got.Message == "" { t.Error("message is empty") } }) t.Run("non-api path serves HTML", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/some/spa/route", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } ct := rr.Header().Get("Content-Type") if !strings.HasPrefix(ct, "text/html") { t.Errorf("content-type = %q, want text/html", ct) } if !strings.Contains(rr.Body.String(), " envelope mapping: a // missing required field and a bad oneof both yield a 400 VALIDATION naming the // offending json field. func TestValidateStructFieldErrors(t *testing.T) { t.Run("missing required name -> 400 VALIDATION naming name", func(t *testing.T) { rr := httptest.NewRecorder() // Customer with no Name (required) — Unit has no constraint on Customer. ok := validateStruct(rr, models.Customer{}) if ok { t.Fatal("validateStruct returned true for an invalid struct") } if rr.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rr.Code) } got := decodeEnvelope(t, rr.Body.Bytes()) if got.Code != CodeValidation { t.Errorf("code = %q, want %q", got.Code, CodeValidation) } if got.Field != "name" { t.Errorf("field = %q, want %q", got.Field, "name") } if !strings.Contains(got.Message, "name") { t.Errorf("message = %q, want it to mention the field", got.Message) } }) t.Run("bad oneof unit -> 400 VALIDATION naming unit", func(t *testing.T) { rr := httptest.NewRecorder() // Product with a valid name but an invalid unit (oneof=litre kg piece). ok := validateStruct(rr, models.Product{Name: "Milk", Unit: "gallon"}) if ok { t.Fatal("validateStruct returned true for an invalid unit") } if rr.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rr.Code) } got := decodeEnvelope(t, rr.Body.Bytes()) if got.Code != CodeValidation { t.Errorf("code = %q, want %q", got.Code, CodeValidation) } if got.Field != "unit" { t.Errorf("field = %q, want %q", got.Field, "unit") } }) t.Run("valid struct passes", func(t *testing.T) { rr := httptest.NewRecorder() if !validateStruct(rr, models.Customer{Name: "Ravi"}) { t.Fatalf("validateStruct returned false for a valid customer (body=%s)", rr.Body.String()) } }) }