package controllers import ( "bytes" "encoding/json" "io" "net/http" "net/http/httptest" "os" "strings" "testing" "doormile/config" "doormile/utils" "github.com/gofiber/fiber/v2" ) // HTTP-level contract tests for the customer surface. // // These exercise the real handlers through a real Fiber router and assert the // STATUS CODE and the ENVELOPE the client will actually receive. They cover // every path that can be reached without a database — which is every validation // and gate in the surface, and is precisely where a wrong status code would // reach production unnoticed. // // What they deliberately do NOT cover: the happy paths, which need Postgres, // Redis and NATS. A 200 from CreateCxBooking cannot be asserted here, and // pretending otherwise with a mock would test the mock. Those need the // integration pass against staging (see docs/customer-app-api.md §7). // // The rule every test below enforces: a validation failure must be a 4xx with a // machine-readable error.code and customer-safe English. It must NEVER be a 500 // ("Something went wrong" on a request the server understood perfectly well) // and never a bare 404 from the router (which would mean the route is missing). // cxFutureSlotID is a slot id whose date cannot go stale. Used by the cases // where the SLOT is not what is under test — a dated id like slot_20260905_t1 // silently becomes an expired-slot test the day after it was written, and // would then assert the wrong failure. const cxFutureSlotID = "slot_20991231_t1" // cxTestApp builds a router with the customer routes mounted and a stub auth // middleware, so handler behaviour is tested rather than JWT parsing. func cxTestApp(t *testing.T, authenticated bool) *fiber.App { t.Helper() app := fiber.New(fiber.Config{ // Without this a panic becomes a dropped connection instead of a 500, // and a test would report a confusing transport error rather than the // real fault. DisableStartupMessage: true, }) app.Use(func(c *fiber.Ctx) error { if authenticated { c.Locals("userid", 4242) c.Locals("roleid", 9) c.Locals("tenantid", 0) } return c.Next() }) cfg := &config.Config{JWTSecret: "test-secret"} customer := app.Group("/customer") customer.Post("/auth/otp/request", CxRequestOtp(cfg)) customer.Post("/auth/signup", CxSignup(cfg)) customer.Post("/auth/otp/verify", CxVerifyOtp(cfg)) customer.Post("/auth/refresh", CxRefresh(cfg)) customer.Post("/fare/estimate", EstimateCxFare) customer.Post("/bookings", CreateCxBooking) customer.Patch("/bookings/:reference/destinations/:index", PatchCxDestination) customer.Get("/orders/:trackingId", GetCxOrder) customer.Post("/devices", RegisterCxDevice) customer.Get("/places/reverse-geocode", ReverseGeocodeCx(cfg)) customer.Post("/ops/bookings/:reference/stage", ForceCxStage) return app } type cxResponse struct { status int body map[string]interface{} raw string } func cxDo(t *testing.T, app *fiber.App, method, path string, body interface{}) cxResponse { t.Helper() var reader io.Reader if body != nil { encoded, err := json.Marshal(body) if err != nil { t.Fatalf("could not encode request body: %v", err) } reader = bytes.NewReader(encoded) } req := httptest.NewRequest(method, path, reader) if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := app.Test(req, 5000) if err != nil { t.Fatalf("%s %s: transport error: %v", method, path, err) } defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) out := cxResponse{status: resp.StatusCode, raw: string(raw)} _ = json.Unmarshal(raw, &out.body) return out } // assertCxError checks the full error contract in one place: the status code, // the envelope shape, the machine code, and that the message is fit to show a // customer. func assertCxError(t *testing.T, got cxResponse, wantStatus int, wantCode string) { t.Helper() if got.status != wantStatus { t.Fatalf("status = %d, want %d (body: %s)", got.status, wantStatus, got.raw) } if success, _ := got.body["success"].(bool); success { t.Errorf("success = true on an error response (body: %s)", got.raw) } errObj, ok := got.body["error"].(map[string]interface{}) if !ok { t.Fatalf("no error object in the envelope — the client reads error.code (body: %s)", got.raw) } if code, _ := errObj["code"].(string); code != wantCode { t.Errorf("error.code = %q, want %q", errObj["code"], wantCode) } message, _ := got.body["message"].(string) if message == "" { t.Error("message is empty — the app renders it verbatim in its one error state") } assertCustomerSafe(t, message) } // assertCustomerSafe rejects anything that reads like an internal artefact // rather than something a customer should be shown. The contract is explicit // that `message` is displayed verbatim and must never be an enum key, a stack // trace or a driver error. func assertCustomerSafe(t *testing.T, message string) { t.Helper() leaks := []string{ "gorm", "sql:", "pq:", "panic", "nil pointer", "goroutine", "doormile/", ".go:", "SELECT ", "INSERT ", "record not found", } for _, leak := range leaks { if bytes.Contains([]byte(message), []byte(leak)) { t.Errorf("message %q leaks an internal detail (%q) to the customer", message, leak) } } // An enum key rather than a sentence — "INVALID_INPUT", "not_found". if message == "" { return } upperOnly := true for _, r := range message { if r >= 'a' && r <= 'z' { upperOnly = false break } } if upperOnly { t.Errorf("message %q looks like an enum key, not customer-safe English", message) } } // ── Auth (§4) ──────────────────────────────────────────────────────────────── func TestCxAuthValidationStatusCodes(t *testing.T) { app := cxTestApp(t, false) cases := []struct { name string method string path string body interface{} wantStatus int wantCode string }{ { name: "otp request with no identifier", method: http.MethodPost, path: "/customer/auth/otp/request", body: map[string]interface{}{"identifier": ""}, wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, }, { name: "otp request with a malformed phone", method: http.MethodPost, path: "/customer/auth/otp/request", body: map[string]interface{}{"identifier": "12345"}, wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, }, { name: "otp request with a malformed email", method: http.MethodPost, path: "/customer/auth/otp/request", body: map[string]interface{}{"identifier": "joe@example"}, wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, }, { // The contract pins this to its own code so the app can highlight // the name field rather than showing a generic error. name: "signup with a one-character name", method: http.MethodPost, path: "/customer/auth/signup", body: map[string]interface{}{"name": "J", "phone": "+919876543210"}, wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalidName, }, { name: "signup with no name at all", method: http.MethodPost, path: "/customer/auth/signup", body: map[string]interface{}{"phone": "+919876543210"}, wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalidName, }, { name: "signup with a valid name but an unusable phone", method: http.MethodPost, path: "/customer/auth/signup", body: map[string]interface{}{"name": "Joe Oommen", "phone": "123"}, wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, }, { name: "verify with no code", method: http.MethodPost, path: "/customer/auth/otp/verify", body: map[string]interface{}{"identifier": "+919876543210", "code": ""}, wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, }, { name: "verify with an unusable identifier", method: http.MethodPost, path: "/customer/auth/otp/verify", body: map[string]interface{}{"identifier": "nope", "code": "4821"}, wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, }, { // 401 rather than 400: the client's recovery is "sign in again", // which it branches on the status for. name: "refresh with no token", method: http.MethodPost, path: "/customer/auth/refresh", body: map[string]interface{}{"refreshToken": ""}, wantStatus: fiber.StatusUnauthorized, wantCode: utils.CxErrUnauthorized, }, { name: "refresh with a whitespace token", method: http.MethodPost, path: "/customer/auth/refresh", body: map[string]interface{}{"refreshToken": " "}, wantStatus: fiber.StatusUnauthorized, wantCode: utils.CxErrUnauthorized, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := cxDo(t, app, tc.method, tc.path, tc.body) // Logged so `go test -v` shows the exact bytes the app receives, // not just a pass mark. These tests are about the wire contract, so // the wire response is the evidence. t.Logf("%s %s -> %d %s", tc.method, tc.path, got.status, got.raw) assertCxError(t, got, tc.wantStatus, tc.wantCode) }) } } // ── Bookings, estimate, devices, places ────────────────────────────────────── func TestCxRequestValidationStatusCodes(t *testing.T) { app := cxTestApp(t, true) cases := []struct { name string method string path string body interface{} wantStatus int wantCode string }{ { name: "booking with no destinations", method: http.MethodPost, path: "/customer/bookings", body: map[string]interface{}{"slotId": cxFutureSlotID}, wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, }, { name: "booking with an empty destinations array", method: http.MethodPost, path: "/customer/bookings", body: map[string]interface{}{ "slotId": cxFutureSlotID, "destinations": []interface{}{}, }, wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, }, { name: "booking with destinations but no slot", method: http.MethodPost, path: "/customer/bookings", body: map[string]interface{}{ "destinations": []map[string]interface{}{ {"stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 1}, }, }, wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, }, { name: "estimate with no destinations", method: http.MethodPost, path: "/customer/fare/estimate", body: map[string]interface{}{"pickup": map[string]float64{"lat": 11.0, "lng": 76.9}}, wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, }, { name: "device registration with no token", method: http.MethodPost, path: "/customer/devices", body: map[string]interface{}{"platform": "android"}, wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, }, { name: "device registration with a whitespace token", method: http.MethodPost, path: "/customer/devices", body: map[string]interface{}{"token": " ", "platform": "ios"}, wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, }, { name: "destination patch with a non-numeric index", method: http.MethodPatch, path: "/customer/bookings/DM-482913/destinations/abc", body: map[string]interface{}{"street": "12th Main"}, wantStatus: fiber.StatusNotFound, wantCode: utils.CxErrNotFound, }, { name: "destination patch with a negative index", method: http.MethodPatch, path: "/customer/bookings/DM-482913/destinations/-1", body: map[string]interface{}{"street": "12th Main"}, wantStatus: fiber.StatusNotFound, wantCode: utils.CxErrNotFound, }, { name: "reverse geocode with no coordinates", method: http.MethodGet, path: "/customer/places/reverse-geocode", wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, }, { name: "reverse geocode with unparseable coordinates", method: http.MethodGet, path: "/customer/places/reverse-geocode?lat=abc&lng=def", wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, }, { name: "reverse geocode at the null island", method: http.MethodGet, path: "/customer/places/reverse-geocode?lat=0&lng=0", wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := cxDo(t, app, tc.method, tc.path, tc.body) t.Logf("%s %s -> %d %s", tc.method, tc.path, got.status, got.raw) assertCxError(t, got, tc.wantStatus, tc.wantCode) }) } } // A body the parser cannot read is the customer's problem to fix, not a server // fault. Answering 500 here would put a "Something went wrong" retry loop in // front of a request that will never succeed. func TestCxMalformedJsonIsFourHundredNotFiveHundred(t *testing.T) { app := cxTestApp(t, true) for _, path := range []string{ "/customer/bookings", "/customer/fare/estimate", "/customer/devices", } { t.Run(path, func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader([]byte("{not json"))) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req, 5000) if err != nil { t.Fatalf("transport error: %v", err) } defer resp.Body.Close() if resp.StatusCode >= 500 { raw, _ := io.ReadAll(resp.Body) t.Fatalf("status = %d on malformed JSON, want 4xx (body: %s)", resp.StatusCode, raw) } if resp.StatusCode != fiber.StatusBadRequest { t.Errorf("status = %d, want 400", resp.StatusCode) } }) } } // ── The QA stage override (§11) ────────────────────────────────────────────── // The override is double-gated. Both switches off must be indistinguishable // from the route not existing — advertising a disabled admin capability tells // an attacker exactly what to go looking for. func TestForceStageIsInvisibleUnlessBothGatesAreOpen(t *testing.T) { app := cxTestApp(t, true) restore := func(key, value string) func() { previous, had := os.LookupEnv(key) _ = os.Setenv(key, value) return func() { if had { _ = os.Setenv(key, previous) } else { _ = os.Unsetenv(key) } } } cases := []struct { name string env string override string }{ {"both gates closed", "development", ""}, {"override off in development", "development", "false"}, {"override on but production", "production", "true"}, {"override on but PRODUCTION in caps", "PRODUCTION", "true"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { defer restore("ENV", tc.env)() defer restore("CX_ALLOW_STAGE_OVERRIDE", tc.override)() got := cxDo(t, app, http.MethodPost, "/customer/ops/bookings/DM-482913/stage", map[string]interface{}{"stage": "delivered"}) if got.status != fiber.StatusNotFound { t.Fatalf("status = %d, want 404 — a disabled override must not announce itself (body: %s)", got.status, got.raw) } }) } } // With both gates open the route is reachable, and an unknown stage is a // validation failure rather than a server fault. func TestForceStageRejectsAnUnknownStage(t *testing.T) { app := cxTestApp(t, true) previousEnv, hadEnv := os.LookupEnv("ENV") previousOverride, hadOverride := os.LookupEnv("CX_ALLOW_STAGE_OVERRIDE") _ = os.Setenv("ENV", "development") _ = os.Setenv("CX_ALLOW_STAGE_OVERRIDE", "true") defer func() { if hadEnv { _ = os.Setenv("ENV", previousEnv) } else { _ = os.Unsetenv("ENV") } if hadOverride { _ = os.Setenv("CX_ALLOW_STAGE_OVERRIDE", previousOverride) } else { _ = os.Unsetenv("CX_ALLOW_STAGE_OVERRIDE") } }() got := cxDo(t, app, http.MethodPost, "/customer/ops/bookings/DM-482913/stage", map[string]interface{}{"stage": "teleported"}) assertCxError(t, got, fiber.StatusBadRequest, utils.CxErrInvalid) } // ── Envelope shape (§3.1, §3.4) ────────────────────────────────────────────── // Every success response carries `message` as a present-but-empty string. The // contract states it explicitly, and a client that reads message.length on a // missing key throws. func TestSuccessEnvelopeAlwaysCarriesAnEmptyMessage(t *testing.T) { app := fiber.New(fiber.Config{DisableStartupMessage: true}) app.Get("/ok", func(c *fiber.Ctx) error { return utils.CxOK(c, fiber.Map{"value": 1}) }) app.Get("/created", func(c *fiber.Ctx) error { return utils.CxCreated(c, fiber.Map{"value": 1}) }) app.Get("/list", func(c *fiber.Ctx) error { return utils.CxList(c, []int{1, 2}, 2, nil) }) cases := []struct { path string wantStatus int }{ {"/ok", fiber.StatusOK}, {"/created", fiber.StatusCreated}, {"/list", fiber.StatusOK}, } for _, tc := range cases { t.Run(tc.path, func(t *testing.T) { got := cxDo(t, app, http.MethodGet, tc.path, nil) if got.status != tc.wantStatus { t.Fatalf("status = %d, want %d", got.status, tc.wantStatus) } if success, _ := got.body["success"].(bool); !success { t.Error("success != true on a success response") } if _, present := got.body["message"]; !present { t.Error("message key missing — the contract says always present, empty on success") } if message, _ := got.body["message"].(string); message != "" { t.Errorf("message = %q on a success response, want empty", message) } if _, present := got.body["data"]; !present { t.Error("data key missing — every payload lives in data, auth included") } }) } } // A list envelope always carries an ARRAY and an explicit nextCursor, even when // empty. The client types data as a list and nextCursor as nullable; a missing // key or a null data throws in its parser. func TestListEnvelopeIsAlwaysAnArrayWithACursorKey(t *testing.T) { app := fiber.New(fiber.Config{DisableStartupMessage: true}) app.Get("/empty", func(c *fiber.Ctx) error { return utils.CxList(c, []string{}, 0, nil) }) cursor := "1042" app.Get("/paged", func(c *fiber.Ctx) error { return utils.CxList(c, []string{"a"}, 9, &cursor) }) empty := cxDo(t, app, http.MethodGet, "/empty", nil) if empty.status != fiber.StatusOK { t.Fatalf("status = %d, want 200", empty.status) } if _, ok := empty.body["data"].([]interface{}); !ok { t.Errorf("data is not an array on an empty list (body: %s)", empty.raw) } if _, present := empty.body["nextCursor"]; !present { t.Error("nextCursor key missing — it must be present and null on the last page") } if empty.body["nextCursor"] != nil { t.Errorf("nextCursor = %v on the last page, want null", empty.body["nextCursor"]) } if total, _ := empty.body["total"].(float64); total != 0 { t.Errorf("total = %v, want 0", empty.body["total"]) } paged := cxDo(t, app, http.MethodGet, "/paged", nil) if got, _ := paged.body["nextCursor"].(string); got != cursor { t.Errorf("nextCursor = %v, want %q", paged.body["nextCursor"], cursor) } if total, _ := paged.body["total"].(float64); total != 9 { t.Errorf("total = %v, want 9 (the size of the filtered set, not the page)", paged.body["total"]) } } // Every code in the contract maps to the status the client branches on, and // none of them produce a 5xx. func TestErrorEnvelopeStatusCodeMapping(t *testing.T) { app := fiber.New(fiber.Config{DisableStartupMessage: true}) cases := []struct { name string status int code string message string }{ {"invalid", fiber.StatusBadRequest, utils.CxErrInvalid, "Every destination needs a serviceable state and district"}, {"invalid_name", fiber.StatusBadRequest, utils.CxErrInvalidName, "Enter your full name"}, {"invalid_otp", fiber.StatusUnauthorized, utils.CxErrInvalidOtp, "That code did not match"}, {"unauthorized", fiber.StatusUnauthorized, utils.CxErrUnauthorized, "Please sign in again"}, {"forbidden", fiber.StatusForbidden, utils.CxErrForbidden, "You do not have access to this"}, {"not_found", fiber.StatusNotFound, utils.CxErrNotFound, "We could not find that pickup"}, {"conflict", fiber.StatusConflict, utils.CxErrConflict, "This pickup can no longer be cancelled"}, {"unserviceable", fiber.StatusUnprocessableEntity, utils.CxErrUnserviceable, "That district is no longer available"}, {"rate_limited", fiber.StatusTooManyRequests, utils.CxErrRateLimited, "Too many attempts. Try again in a minute"}, } for _, tc := range cases { tc := tc app.Get("/"+tc.name, func(c *fiber.Ctx) error { return utils.CxFail(c, tc.status, tc.code, tc.message) }) } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := cxDo(t, app, http.MethodGet, "/"+tc.name, nil) assertCxError(t, got, tc.status, tc.code) if got.status >= 500 { t.Errorf("a documented client error answered %d", got.status) } }) } } // CxInternal is the only 5xx the surface produces, and it must never carry the // underlying error outward. func TestInternalErrorNeverLeaksTheCause(t *testing.T) { app := fiber.New(fiber.Config{DisableStartupMessage: true}) app.Get("/boom", func(c *fiber.Ctx) error { return utils.CxInternal(c) }) got := cxDo(t, app, http.MethodGet, "/boom", nil) if got.status != fiber.StatusInternalServerError { t.Fatalf("status = %d, want 500", got.status) } if message, _ := got.body["message"].(string); message != "Something went wrong" { t.Errorf("message = %q, want the fixed customer-safe string", message) } assertCustomerSafe(t, got.body["message"].(string)) errObj, ok := got.body["error"].(map[string]interface{}) if !ok || errObj["code"] != utils.CxErrServer { t.Errorf("error.code = %v, want %q", got.body["error"], utils.CxErrServer) } } // An expired slot and a full slot are different failures. A client that cached // the slot list and was left open across midnight sends yesterday's window in // good faith; telling that customer the window "just filled up" is untrue and // points them at the wrong recovery. func TestExpiredSlotIsNotReportedAsFull(t *testing.T) { app := cxTestApp(t, true) // A slot id from a date that has certainly passed. It is rejected before // any database access, because the id carries its own date. got := cxDo(t, app, http.MethodPost, "/customer/bookings", map[string]interface{}{ "pickup": map[string]interface{}{"title": "a", "sub": "b", "lat": 11.0168, "lng": 76.9558}, "slotId": "slot_20200101_t1", "destinations": []map[string]interface{}{ {"stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 1}, }, }) if got.status == fiber.StatusConflict { t.Fatalf("an expired slot answered 409 — that is the capacity race, not a stale id (body: %s)", got.raw) } if got.status >= 500 { t.Fatalf("status = %d, want 4xx (body: %s)", got.status, got.raw) } if message, _ := got.body["message"].(string); strings.Contains(strings.ToLower(message), "filled up") { t.Errorf("message = %q — an expired slot is not a full one", message) } } // The server refuses an over-cap booking itself. The client's own limit is UI // guidance — a modified build, or one whose /config/booking-limits fetch // failed, still cannot create a booking the fleet cannot service. func TestMaxDestinationsIsEnforcedServerSide(t *testing.T) { app := cxTestApp(t, true) // Above the absolute ceiling, so the refusal lands before any database // work and is assertable here. The configured per-city cap is the real // policy and is exercised separately in cxCustomerApp_test.go. destinations := make([]map[string]interface{}, 0, cxAbsoluteMaxDestinations+1) for i := 0; i <= cxAbsoluteMaxDestinations; i++ { destinations = append(destinations, map[string]interface{}{ "stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 1, }) } got := cxDo(t, app, http.MethodPost, "/customer/bookings", map[string]interface{}{ "pickup": map[string]interface{}{"title": "a", "sub": "b", "lat": 11.0168, "lng": 76.9558}, "slotId": "slot_20991231_t1", "destinations": destinations, }) if got.status >= 500 { t.Fatalf("status = %d, want a 4xx refusal (body: %s)", got.status, got.raw) } if got.status < 400 { t.Fatalf("status = %d — an over-cap booking was accepted (body: %s)", got.status, got.raw) } } // "No destinations at all" and "a destination is missing its state or district" // are different problems with different fixes. They shared one message, which // told a customer who had added nothing to go and correct the state on // destinations they did not have. The messages must stay distinct AND the empty // case must match what the estimate endpoint says for the same mistake. func TestEmptyDestinationsSaysAddOneNotFixTheirDetails(t *testing.T) { app := cxTestApp(t, true) booking := cxDo(t, app, http.MethodPost, "/customer/bookings", map[string]interface{}{ "pickup": map[string]interface{}{"title": "a", "sub": "b", "lat": 11.0168, "lng": 76.9558}, "slotId": cxFutureSlotID, "destinations": []interface{}{}, }) estimate := cxDo(t, app, http.MethodPost, "/customer/fare/estimate", map[string]interface{}{ "pickup": map[string]interface{}{"lat": 11.0168, "lng": 76.9558}, "destinations": []interface{}{}, }) t.Logf("booking -> %d %s", booking.status, booking.raw) t.Logf("estimate -> %d %s", estimate.status, estimate.raw) bookingMsg, _ := booking.body["message"].(string) estimateMsg, _ := estimate.body["message"].(string) if strings.Contains(bookingMsg, "serviceable state and district") { t.Errorf("empty destinations answered %q — that describes a problem the customer does not have; "+ "they added nothing, so they need to add one", bookingMsg) } if bookingMsg != estimateMsg { t.Errorf("the same mistake is described two ways: booking says %q, estimate says %q", bookingMsg, estimateMsg) } if !strings.Contains(strings.ToLower(bookingMsg), "at least one destination") { t.Errorf("message = %q, want it to name the actual fix (add a destination)", bookingMsg) } }