Files
doormile_backend/controllers/cxHttp_test.go

714 lines
26 KiB
Go

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