714 lines
26 KiB
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)
|
|
}
|
|
}
|