backend requirements onthe xustomer app
This commit is contained in:
856
controllers/cxCustomerApp_test.go
Normal file
856
controllers/cxCustomerApp_test.go
Normal file
@@ -0,0 +1,856 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
)
|
||||
|
||||
// The app currently sends "+91 98765 43210" with spaces and is being tightened
|
||||
// to send E.164. Both shapes must land on the SAME stored value: if they do
|
||||
// not, a customer signing in from the newer build gets a second account and
|
||||
// loses every booking they have made.
|
||||
func TestNormalizePhoneCollapsesEveryShapeToOne(t *testing.T) {
|
||||
want := "+919876543210"
|
||||
|
||||
inputs := []string{
|
||||
"+91 98765 43210", // what the app sends today
|
||||
"+919876543210", // what it is being tightened to send
|
||||
"9876543210", // bare keypad entry
|
||||
"09876543210", // with the national trunk prefix
|
||||
"919876543210", // country code, no plus
|
||||
"+91-98765-43210", // typed with dashes by support
|
||||
" 9876543210 ", // pasted with whitespace
|
||||
}
|
||||
|
||||
for _, in := range inputs {
|
||||
got, kind, ok := normalizePhone(in)
|
||||
if !ok {
|
||||
t.Errorf("normalizePhone(%q) rejected a valid number", in)
|
||||
continue
|
||||
}
|
||||
if kind != "phone" {
|
||||
t.Errorf("normalizePhone(%q) kind = %q, want \"phone\"", in, kind)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("normalizePhone(%q) = %q, want %q — two spellings of one number must not make two accounts",
|
||||
in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePhoneRejectsNonsense(t *testing.T) {
|
||||
for _, in := range []string{"", " ", "12345", "abcdef", "+1"} {
|
||||
if got, _, ok := normalizePhone(in); ok {
|
||||
t.Errorf("normalizePhone(%q) = %q, ok — want rejected", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeIdentifierSplitsPhoneFromEmail(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
wantKind string
|
||||
wantOK bool
|
||||
}{
|
||||
{"Joe@Example.COM", "joe@example.com", "email", true},
|
||||
{"+91 98765 43210", "+919876543210", "phone", true},
|
||||
{"joe@example", "", "", false}, // no dot in the domain
|
||||
{"@example.com", "", "", false}, // no local part
|
||||
{"joe@", "", "", false}, // no domain
|
||||
{"", "", "", false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got, kind, ok := normalizeIdentifier(tc.in)
|
||||
if ok != tc.wantOK || got != tc.want || kind != tc.wantKind {
|
||||
t.Errorf("normalizeIdentifier(%q) = (%q, %q, %v), want (%q, %q, %v)",
|
||||
tc.in, got, kind, ok, tc.want, tc.wantKind, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitNameKeepsOneWordNames(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
first, last string
|
||||
}{
|
||||
{"Joe Oommen", "Joe", "Oommen"},
|
||||
{"Meera", "Meera", ""}, // plenty of people have one name
|
||||
{" Arun Kumar ", "Arun", "Kumar"}, // collapses whitespace
|
||||
{"Vijay Raghav Menon", "Vijay Raghav", "Menon"}, // last token is the surname
|
||||
{"J", "", ""}, // under two characters is invalid_name
|
||||
{"", "", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
first, last := splitName(tc.in)
|
||||
if first != tc.first || last != tc.last {
|
||||
t.Errorf("splitName(%q) = (%q, %q), want (%q, %q)", tc.in, first, last, tc.first, tc.last)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The slot id is opaque to the client but has to round-trip: it encodes the
|
||||
// date so a slot id from yesterday's cached list resolves to yesterday and gets
|
||||
// rejected, rather than silently booking today's window.
|
||||
func TestSlotIDCarriesItsDate(t *testing.T) {
|
||||
from := time.Date(2026, 9, 5, 14, 0, 0, 0, time.UTC)
|
||||
if got, want := cxSlotID(from, "t1"), "slot_20260905_t1"; got != want {
|
||||
t.Errorf("cxSlotID = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// Two days must never produce the same id for the same template.
|
||||
other := time.Date(2026, 9, 6, 14, 0, 0, 0, time.UTC)
|
||||
if cxSlotID(from, "t1") == cxSlotID(other, "t1") {
|
||||
t.Error("cxSlotID collides across days — a stale slot would book today's window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeParcelsMatchesTheReceiptCopy(t *testing.T) {
|
||||
cases := []struct {
|
||||
packages int
|
||||
want string
|
||||
}{
|
||||
{0, "Standard box (up to 3 kg)"},
|
||||
{1, "Standard box (up to 3 kg)"},
|
||||
{3, "3 boxes (up to 3 kg each)"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := describeParcels(tc.packages); got != tc.want {
|
||||
t.Errorf("describeParcels(%d) = %q, want %q", tc.packages, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pickup.title and pickup.sub are typed non-nullable by the client and will
|
||||
// throw in its parser on a null. A console-created booking has only one flat
|
||||
// address string, so both have to be derivable from it.
|
||||
func TestPickupTitleAndSubAreNeverEmpty(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
booking models.PickupBooking
|
||||
}{
|
||||
{"customer-app booking", models.PickupBooking{
|
||||
Pickuptitle: "12 Nehru Street", Pickupsub: "Gandhipuram, Coimbatore 641012"}},
|
||||
{"console booking with a flat address", models.PickupBooking{
|
||||
Pickupaddress: "12 Nehru Street, Gandhipuram, Coimbatore", Pickuppincode: "641012"}},
|
||||
{"address with no comma", models.PickupBooking{
|
||||
Pickupaddress: "Brookefields", Pickuppincode: "641001"}},
|
||||
{"nothing recorded at all", models.PickupBooking{}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
b := tc.booking
|
||||
if title := cxPickupTitle(&b); title == "" {
|
||||
t.Error("cxPickupTitle returned empty — the client types it non-nullable")
|
||||
}
|
||||
if sub := cxPickupSub(&b); sub == "" {
|
||||
t.Error("cxPickupSub returned empty — the client types it non-nullable")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickupTitleIsCappedForTheDesign(t *testing.T) {
|
||||
b := models.PickupBooking{
|
||||
Pickupaddress: "A very long single-line address with no commas at all in it anywhere",
|
||||
}
|
||||
if got := cxPickupTitle(&b); len([]rune(got)) > 32 {
|
||||
t.Errorf("cxPickupTitle = %q (%d runes), want at most 32", got, len([]rune(got)))
|
||||
}
|
||||
}
|
||||
|
||||
// deliveredAt is the moment the LAST parcel landed. Reporting the first would
|
||||
// tell a customer their whole pickup completed while parcels were still moving.
|
||||
func TestAllDeliveredAtWaitsForTheLastParcel(t *testing.T) {
|
||||
early := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC)
|
||||
late := time.Date(2026, 9, 6, 16, 0, 0, 0, time.UTC)
|
||||
|
||||
partly := []models.BookingDestination{
|
||||
{Deliveredat: &early},
|
||||
{Deliveredat: nil},
|
||||
}
|
||||
if got := cxAllDeliveredAt(partly); got != nil {
|
||||
t.Errorf("cxAllDeliveredAt with one parcel still moving = %v, want nil", got)
|
||||
}
|
||||
|
||||
all := []models.BookingDestination{
|
||||
{Deliveredat: &early},
|
||||
{Deliveredat: &late},
|
||||
}
|
||||
got := cxAllDeliveredAt(all)
|
||||
if got == nil || !got.Equal(late) {
|
||||
t.Errorf("cxAllDeliveredAt = %v, want the last delivery %v", got, late)
|
||||
}
|
||||
|
||||
if got := cxAllDeliveredAt(nil); got != nil {
|
||||
t.Errorf("cxAllDeliveredAt(no destinations) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A booking written before this surface existed carries no stored stage, and a
|
||||
// console-created one never will. Both still have to render, so the stage is
|
||||
// derived from the operational status rather than left blank.
|
||||
func TestStageDerivationForBookingsWithNoStoredStage(t *testing.T) {
|
||||
arrived := time.Date(2026, 9, 5, 14, 0, 0, 0, time.UTC)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
booking models.PickupBooking
|
||||
want string
|
||||
}{
|
||||
{"pending pickup", models.PickupBooking{Status: constants.BookingPendingPickup}, constants.CxStageBooked},
|
||||
{"assigned, not yet arrived", models.PickupBooking{Status: constants.BookingMilerAssigned}, constants.CxStageAssigned},
|
||||
{"scheduled and arrived", models.PickupBooking{Status: constants.BookingPickupScheduled, Arrivedat: &arrived}, constants.CxStageArrived},
|
||||
{"picked up", models.PickupBooking{Status: constants.BookingPickedUp}, constants.CxStagePickedUp},
|
||||
{"converted", models.PickupBooking{Status: constants.BookingConvertedConsignment}, constants.CxStageOrderCreated},
|
||||
{"cancelled", models.PickupBooking{Status: constants.BookingCancelled}, constants.CxStageBooked},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
b := tc.booking
|
||||
if got := deriveStageFromStatus(&b); got != tc.want {
|
||||
t.Errorf("deriveStageFromStatus = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The timeline is built from the event log. Cancellation and release rows carry
|
||||
// a stage key only because the table needs one — putting them on the timeline
|
||||
// would show the customer "Pickup booked" a second time when a rider handed
|
||||
// their pickup back.
|
||||
func TestHistoryExcludesAuditRowsAndDeduplicates(t *testing.T) {
|
||||
at := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
events := []models.BookingStageEvent{
|
||||
{Stage: constants.CxStageBooked, Occurredat: at},
|
||||
{Stage: constants.CxStageAssigned, Occurredat: at.Add(time.Minute)},
|
||||
{Stage: constants.CxStageBooked, Remarks: "released: rider unavailable", Occurredat: at.Add(2 * time.Minute)},
|
||||
{Stage: constants.CxStageAssigned, Occurredat: at.Add(3 * time.Minute)}, // second rider, same stage
|
||||
{Stage: constants.CxStageBooked, Remarks: "cancelled: Package not ready", Occurredat: at.Add(4 * time.Minute)},
|
||||
}
|
||||
|
||||
history := renderCxHistory(events)
|
||||
if len(history) != 2 {
|
||||
t.Fatalf("renderCxHistory returned %d entries, want 2 (booked, assigned)", len(history))
|
||||
}
|
||||
if history[0]["stage"] != constants.CxStageBooked || history[1]["stage"] != constants.CxStageAssigned {
|
||||
t.Errorf("renderCxHistory = %v, want booked then assigned in order", history)
|
||||
}
|
||||
}
|
||||
|
||||
// A multi-destination pickup emits each per-order stage once per destination.
|
||||
// The booking reaches that stage when its SLOWEST order does, so the timeline
|
||||
// must carry the LAST of them — otherwise "Delivered" is timestamped at the
|
||||
// moment the earliest parcel landed while deliveredAt reports the last one, and
|
||||
// the same screen contradicts itself.
|
||||
func TestHistoryTimestampsPerOrderStagesAtTheSlowestOrder(t *testing.T) {
|
||||
base := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC)
|
||||
firstParcel := base.Add(2 * time.Hour)
|
||||
lastParcel := base.Add(30 * time.Hour)
|
||||
|
||||
events := []models.BookingStageEvent{
|
||||
{Stage: constants.CxStageBooked, Occurredat: base},
|
||||
{Stage: constants.CxStageDelivered, Occurredat: firstParcel},
|
||||
{Stage: constants.CxStageDelivered, Occurredat: lastParcel},
|
||||
}
|
||||
|
||||
history := renderCxHistory(events)
|
||||
if len(history) != 2 {
|
||||
t.Fatalf("renderCxHistory returned %d entries, want 2", len(history))
|
||||
}
|
||||
if got, want := history[1]["at"], utils.EpochMillis(lastParcel); got != want {
|
||||
t.Errorf("delivered timestamped at %v, want the last parcel %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Booking-level stages happen once for the whole pickup, so a duplicate is a
|
||||
// re-assertion (Record dedupes, but a release can bring one back) and the
|
||||
// original moment is the true one.
|
||||
func TestHistoryKeepsTheFirstBookingLevelTimestamp(t *testing.T) {
|
||||
first := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC)
|
||||
later := first.Add(3 * time.Hour)
|
||||
|
||||
events := []models.BookingStageEvent{
|
||||
{Stage: constants.CxStageAssigned, Occurredat: first},
|
||||
{Stage: constants.CxStageAssigned, Occurredat: later},
|
||||
}
|
||||
|
||||
history := renderCxHistory(events)
|
||||
if len(history) != 1 {
|
||||
t.Fatalf("renderCxHistory returned %d entries, want 1", len(history))
|
||||
}
|
||||
if got, want := history[0]["at"], utils.EpochMillis(first); got != want {
|
||||
t.Errorf("assigned timestamped at %v, want the original %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Every stage the timeline can carry has to be one the client parses. An
|
||||
// unknown key is silently rendered as `booked`, so a mapping that invents one
|
||||
// makes a moving parcel look un-started.
|
||||
func TestConsignmentStatusMapsOnlyToRealStages(t *testing.T) {
|
||||
mapped := map[string]string{
|
||||
constants.ConsignmentInwardedAtHub: constants.CxStageInTransit,
|
||||
constants.ConsignmentTripsheetLoaded: constants.CxStageInTransit,
|
||||
constants.ConsignmentInTransit: constants.CxStageInTransit,
|
||||
constants.ConsignmentOutForDelivery: constants.CxStageOutForDelivery,
|
||||
constants.ConsignmentDelivered: constants.CxStageDelivered,
|
||||
}
|
||||
for status, want := range mapped {
|
||||
got, ok := cxStageForConsignmentStatus(status)
|
||||
if !ok || got != want {
|
||||
t.Errorf("cxStageForConsignmentStatus(%q) = (%q, %v), want (%q, true)", status, got, ok, want)
|
||||
}
|
||||
if constants.CxStageRank(got) < 0 {
|
||||
t.Errorf("cxStageForConsignmentStatus(%q) produced %q, which is not one of the nine stage keys", status, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Statuses that mean "the order exists and is in the rider's hands" are
|
||||
// already covered by order_created and must not emit a second stage.
|
||||
for _, status := range []string{
|
||||
constants.ConsignmentCreated,
|
||||
constants.ConsignmentCollectedByMiler,
|
||||
constants.ConsignmentMissing,
|
||||
"Cancelled",
|
||||
} {
|
||||
if got, ok := cxStageForConsignmentStatus(status); ok {
|
||||
t.Errorf("cxStageForConsignmentStatus(%q) = %q, want no stage", status, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cxLegWeights is what the price settles on. A leg whose packages were never
|
||||
// weighed must still produce a chargeable consignment, or an unweighed pickup
|
||||
// bills at zero.
|
||||
func TestLegWeightsFallBackWhenNothingWasWeighed(t *testing.T) {
|
||||
empty := cxPickupLeg{}
|
||||
dead, chargeable, _, _, _ := cxLegWeights(empty)
|
||||
if dead != 0.5 || chargeable != 0.5 {
|
||||
t.Errorf("cxLegWeights(no parcels) = (%v, %v), want the 0.5 kg placeholder", dead, chargeable)
|
||||
}
|
||||
|
||||
// Volumetric weight wins when the box is bulky and light — that is the
|
||||
// whole reason the column exists.
|
||||
bulky := cxPickupLeg{Parcels: []models.BookingParcel{
|
||||
{Weight: 1.0, Length: 40, Width: 40, Height: 40}, // volumetric = 64000/5000 = 12.8
|
||||
}}
|
||||
_, chargeable, maxL, _, _ := cxLegWeights(bulky)
|
||||
if chargeable != 12.8 {
|
||||
t.Errorf("cxLegWeights chargeable = %v, want the volumetric 12.8", chargeable)
|
||||
}
|
||||
if maxL != 40 {
|
||||
t.Errorf("cxLegWeights maxL = %v, want 40", maxL)
|
||||
}
|
||||
}
|
||||
|
||||
// How many stops a booking is worth to the rider decides whether a parcel is
|
||||
// deliverable at all. The queue used to emit one row per booking keyed on
|
||||
// pickupbookings.consignmentid — a column that names only the FIRST order — so
|
||||
// on a three-destination pickup two parcels sat in the rider's bag with no
|
||||
// stop, no deliver button and no way to close them.
|
||||
func TestMilerStopsBeforeCollectionAreOneVisit(t *testing.T) {
|
||||
booking := models.PickupBooking{
|
||||
Bookingid: 7,
|
||||
Deliveryaddress: "12th Main, Chennai",
|
||||
Deliverylatitude: 13.08,
|
||||
Deliverylongitude: 80.27,
|
||||
}
|
||||
// Booked, not yet collected: no destination carries a consignment.
|
||||
destinations := []models.BookingDestination{
|
||||
{Bookingdestinationid: 1, Bookingid: 7, Seq: 0, Districtname: "Chennai"},
|
||||
{Bookingdestinationid: 2, Bookingid: 7, Seq: 1, Districtname: "Ernakulam"},
|
||||
{Bookingdestinationid: 3, Bookingid: 7, Seq: 2, Districtname: "Bengaluru Urban"},
|
||||
}
|
||||
|
||||
stops := milerStopsForBooking(&booking, destinations)
|
||||
if len(stops) != 1 {
|
||||
t.Fatalf("got %d stops before collection, want 1 — the rider makes ONE visit to the door", len(stops))
|
||||
}
|
||||
if stops[0].deliveryLat != 13.08 {
|
||||
t.Errorf("pre-pickup stop lost the booking's mirrored coordinates: %v", stops[0].deliveryLat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMilerStopsAfterCollectionAreOnePerOrder(t *testing.T) {
|
||||
c1, c2, c3 := 101, 102, 103
|
||||
pinLat, pinLng := 9.98, 76.29
|
||||
|
||||
booking := models.PickupBooking{
|
||||
Bookingid: 7,
|
||||
Consignmentid: &c1,
|
||||
Deliverylatitude: 13.08,
|
||||
Deliverylongitude: 80.27,
|
||||
Parcels: []models.BookingParcel{
|
||||
{Bookingparcelid: 1, Bookingdestinationid: intPtr(1)},
|
||||
{Bookingparcelid: 2, Bookingdestinationid: intPtr(1)},
|
||||
{Bookingparcelid: 3, Bookingdestinationid: intPtr(2)},
|
||||
{Bookingparcelid: 4, Bookingdestinationid: intPtr(3)},
|
||||
},
|
||||
}
|
||||
destinations := []models.BookingDestination{
|
||||
{Bookingdestinationid: 1, Bookingid: 7, Seq: 0, Districtname: "Chennai",
|
||||
Statename: "Tamil Nadu", Consignmentid: &c1, Trackingno: "DMX10000001"},
|
||||
{Bookingdestinationid: 2, Bookingid: 7, Seq: 1, Districtname: "Ernakulam",
|
||||
Statename: "Kerala", Consignmentid: &c2, Trackingno: "DMX10000002",
|
||||
Pinlatitude: &pinLat, Pinlongitude: &pinLng},
|
||||
{Bookingdestinationid: 3, Bookingid: 7, Seq: 2, Districtname: "Bengaluru Urban",
|
||||
Statename: "Karnataka", Consignmentid: &c3, Trackingno: "DMX10000003"},
|
||||
}
|
||||
|
||||
stops := milerStopsForBooking(&booking, destinations)
|
||||
if len(stops) != 3 {
|
||||
t.Fatalf("got %d stops after collection, want 3 — one per order, or the other parcels are undeliverable", len(stops))
|
||||
}
|
||||
|
||||
// Each stop names its own order. Sharing one consignment id would have the
|
||||
// rider close the same parcel three times.
|
||||
seen := map[int]bool{}
|
||||
for i, s := range stops {
|
||||
if s.consignmentID == nil {
|
||||
t.Fatalf("stop %d has no consignment id", i)
|
||||
}
|
||||
if seen[*s.consignmentID] {
|
||||
t.Errorf("stop %d repeats consignment %d", i, *s.consignmentID)
|
||||
}
|
||||
seen[*s.consignmentID] = true
|
||||
if s.trackingNo == "" {
|
||||
t.Errorf("stop %d has no tracking number", i)
|
||||
}
|
||||
if s.deliveryAddress == "" {
|
||||
t.Errorf("stop %d has no delivery address — the rider has nowhere to go", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Parcels follow their own destination, so each order settles and is handed
|
||||
// over with the packages that actually belong to it.
|
||||
if len(stops[0].parcels) != 2 || len(stops[1].parcels) != 1 || len(stops[2].parcels) != 1 {
|
||||
t.Errorf("parcels split as %d/%d/%d, want 2/1/1",
|
||||
len(stops[0].parcels), len(stops[1].parcels), len(stops[2].parcels))
|
||||
}
|
||||
|
||||
// A destination with its own pin uses it; destination 0 falls back to the
|
||||
// coordinates mirrored onto the booking, which the rider may have corrected.
|
||||
if stops[1].deliveryLat != pinLat {
|
||||
t.Errorf("stop 1 lat = %v, want the customer's pin %v", stops[1].deliveryLat, pinLat)
|
||||
}
|
||||
if stops[0].deliveryLat != 13.08 {
|
||||
t.Errorf("stop 0 lat = %v, want the booking's mirrored 13.08", stops[0].deliveryLat)
|
||||
}
|
||||
}
|
||||
|
||||
// A console-created express booking has no destination rows at all and must
|
||||
// keep producing exactly the one stop it always did.
|
||||
func TestMilerStopsForConsoleBookingAreUnchanged(t *testing.T) {
|
||||
cid := 55
|
||||
booking := models.PickupBooking{
|
||||
Bookingid: 9,
|
||||
Consignmentid: &cid,
|
||||
Deliveryaddress: "Kitchen 4, Nagercoil",
|
||||
Deliverylatitude: 8.17,
|
||||
Deliverylongitude: 77.43,
|
||||
Parcels: []models.BookingParcel{{Bookingparcelid: 1}},
|
||||
}
|
||||
|
||||
stops := milerStopsForBooking(&booking, nil)
|
||||
if len(stops) != 1 {
|
||||
t.Fatalf("got %d stops, want 1", len(stops))
|
||||
}
|
||||
if stops[0].consignmentID == nil || *stops[0].consignmentID != cid {
|
||||
t.Errorf("console stop lost its consignment id: %v", stops[0].consignmentID)
|
||||
}
|
||||
if stops[0].deliveryAddress != "Kitchen 4, Nagercoil" || len(stops[0].parcels) != 1 {
|
||||
t.Errorf("console stop changed shape: %+v", stops[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Three console paths cancel a booking by writing pickupbookings.status
|
||||
// directly and none of them knows the customer projection exists. The customer
|
||||
// must never keep seeing a cancelled pickup as active and cancellable, so the
|
||||
// operational status is the authority here regardless of what customerstatus
|
||||
// holds.
|
||||
func TestOpsCancellationAlwaysReachesTheCustomer(t *testing.T) {
|
||||
bundle := loadCxBundleForTest()
|
||||
|
||||
booking := models.PickupBooking{
|
||||
Bookingid: 1,
|
||||
Bookingno: "DM-482913",
|
||||
// Written as active at booking time — this is the value that used to
|
||||
// win and keep a cancelled pickup looking live.
|
||||
Customerstatus: constants.CxStatusActive,
|
||||
Customerstage: constants.CxStageAssigned,
|
||||
Status: constants.BookingCancelled,
|
||||
Createdat: time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
out := renderCxBooking(&booking, bundle)
|
||||
|
||||
if out["status"] != constants.CxStatusCancelled {
|
||||
t.Errorf("status = %v, want %q — an ops cancel must reach the customer",
|
||||
out["status"], constants.CxStatusCancelled)
|
||||
}
|
||||
if out["cancellable"] != false {
|
||||
t.Errorf("cancellable = %v on a cancelled pickup, want false", out["cancellable"])
|
||||
}
|
||||
}
|
||||
|
||||
// A booking that is genuinely still live must not be swept up by that rule.
|
||||
func TestActiveBookingStaysActiveAndCancellable(t *testing.T) {
|
||||
bundle := loadCxBundleForTest()
|
||||
|
||||
booking := models.PickupBooking{
|
||||
Bookingid: 2,
|
||||
Bookingno: "DM-482914",
|
||||
Customerstatus: constants.CxStatusActive,
|
||||
Customerstage: constants.CxStageArrived,
|
||||
Status: constants.BookingPickupScheduled,
|
||||
Createdat: time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
out := renderCxBooking(&booking, bundle)
|
||||
|
||||
if out["status"] != constants.CxStatusActive {
|
||||
t.Errorf("status = %v, want %q", out["status"], constants.CxStatusActive)
|
||||
}
|
||||
// arrived is the last cancellable stage.
|
||||
if out["cancellable"] != true {
|
||||
t.Errorf("cancellable = %v at arrived, want true", out["cancellable"])
|
||||
}
|
||||
}
|
||||
|
||||
// loadCxBundleForTest builds an empty bundle, so the projection can be
|
||||
// exercised without a database.
|
||||
func loadCxBundleForTest() *cxBookingBundle {
|
||||
return &cxBookingBundle{
|
||||
destinations: map[int][]models.BookingDestination{},
|
||||
events: map[int][]models.BookingStageEvent{},
|
||||
photos: map[int][]models.BookingParcelPhoto{},
|
||||
milers: map[int]cxAgent{},
|
||||
assignedTo: map[int]int{},
|
||||
deliveryAgent: map[int]int{},
|
||||
payments: map[int]float64{},
|
||||
districts: map[string]models.ServiceableDistrict{},
|
||||
hubNames: map[int]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fan-out row-level proof ─────────────────────────────────────────────────
|
||||
//
|
||||
// One pickup, three genuinely different destinations. Every assertion below
|
||||
// exists because the failure it guards against is silent: the rows would still
|
||||
// render, the rider would still see stops, and the parcels would go to the
|
||||
// wrong doors. Booking-level destination-0 data leaking into rows 2 and 3 is
|
||||
// the specific defect this locks down.
|
||||
|
||||
// cxThreeDestinationFixture builds a collected pickup bound for Chennai,
|
||||
// Ernakulam and Bengaluru — different addresses, different coordinates,
|
||||
// different recipients, different tracking numbers, different COD.
|
||||
func cxThreeDestinationFixture() (models.PickupBooking, []models.BookingDestination) {
|
||||
c1, c2, c3 := 901, 902, 903
|
||||
chennaiLat, chennaiLng := 13.082680, 80.270718
|
||||
kochiLat, kochiLng := 9.981636, 76.299881
|
||||
blrLat, blrLng := 12.971599, 77.594566
|
||||
|
||||
booking := models.PickupBooking{
|
||||
Bookingid: 70,
|
||||
Bookingno: "DM-482913",
|
||||
Consignmentid: &c1,
|
||||
// Booking-level delivery data mirrors destination 0 ONLY. If any of it
|
||||
// leaks onto stops 1 or 2, the assertions below catch it.
|
||||
Deliveryaddress: "3B, 12th Main, Chennai, Tamil Nadu",
|
||||
Deliverylatitude: chennaiLat,
|
||||
Deliverylongitude: chennaiLng,
|
||||
Parcels: []models.BookingParcel{
|
||||
{Bookingparcelid: 1, Bookingdestinationid: intPtr(11)},
|
||||
{Bookingparcelid: 2, Bookingdestinationid: intPtr(11)},
|
||||
{Bookingparcelid: 3, Bookingdestinationid: intPtr(12)},
|
||||
{Bookingparcelid: 4, Bookingdestinationid: intPtr(13)},
|
||||
},
|
||||
}
|
||||
|
||||
destinations := []models.BookingDestination{
|
||||
{
|
||||
Bookingdestinationid: 11, Bookingid: 70, Seq: 0,
|
||||
Statecode: "TN", Statename: "Tamil Nadu",
|
||||
Districtcode: "TN-MAA", Districtname: "Chennai",
|
||||
Building: "3B", Street: "12th Main",
|
||||
Recipientname: "Meera S", Recipientphone: "+919884412210",
|
||||
Packagecount: 2, Codamount: 1200,
|
||||
Consignmentid: &c1, Trackingno: "DMX10482913",
|
||||
Pinlatitude: &chennaiLat, Pinlongitude: &chennaiLng,
|
||||
},
|
||||
{
|
||||
Bookingdestinationid: 12, Bookingid: 70, Seq: 1,
|
||||
Statecode: "KL", Statename: "Kerala",
|
||||
Districtcode: "KL-EKM", Districtname: "Ernakulam",
|
||||
Street: "Marine Drive", Landmark: "Near the ferry",
|
||||
Recipientname: "Joe Oommen", Recipientphone: "+919847011223",
|
||||
Packagecount: 1, Codamount: 0,
|
||||
Consignmentid: &c2, Trackingno: "DMX10559120",
|
||||
Pinlatitude: &kochiLat, Pinlongitude: &kochiLng,
|
||||
},
|
||||
{
|
||||
Bookingdestinationid: 13, Bookingid: 70, Seq: 2,
|
||||
Statecode: "KA", Statename: "Karnataka",
|
||||
Districtcode: "KA-BLR", Districtname: "Bengaluru Urban",
|
||||
Street: "Brigade Road",
|
||||
Recipientname: "Arun Kumar", Recipientphone: "+919000011223",
|
||||
Packagecount: 1, Codamount: 450,
|
||||
Consignmentid: &c3, Trackingno: "DMX10662004",
|
||||
Pinlatitude: &blrLat, Pinlongitude: &blrLng,
|
||||
},
|
||||
}
|
||||
return booking, destinations
|
||||
}
|
||||
|
||||
// Each generated stop carries a DIFFERENT, correct address — none of them
|
||||
// inherits the booking-level destination-0 string.
|
||||
func TestFanoutStopsHaveDistinctAddresses(t *testing.T) {
|
||||
booking, destinations := cxThreeDestinationFixture()
|
||||
stops := milerStopsForBooking(&booking, destinations)
|
||||
|
||||
if len(stops) != 3 {
|
||||
t.Fatalf("got %d stops, want 3", len(stops))
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for i, s := range stops {
|
||||
if s.deliveryAddress == "" {
|
||||
t.Fatalf("stop %d has no delivery address — the rider has nowhere to go", i)
|
||||
}
|
||||
if seen[s.deliveryAddress] {
|
||||
t.Errorf("stop %d repeats an address already used: %q", i, s.deliveryAddress)
|
||||
}
|
||||
seen[s.deliveryAddress] = true
|
||||
}
|
||||
|
||||
wantDistrict := []string{"Chennai", "Ernakulam", "Bengaluru Urban"}
|
||||
for i, want := range wantDistrict {
|
||||
if !strings.Contains(stops[i].deliveryAddress, want) {
|
||||
t.Errorf("stop %d address %q does not name %q", i, stops[i].deliveryAddress, want)
|
||||
}
|
||||
}
|
||||
// The specific leak this guards: destination 0's address on a later stop.
|
||||
for i := 1; i < 3; i++ {
|
||||
if strings.Contains(stops[i].deliveryAddress, "Chennai") {
|
||||
t.Errorf("stop %d inherited destination 0's address: %q", i, stops[i].deliveryAddress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Coordinates are distinct and correct per stop. A shared coordinate sends
|
||||
// every parcel to one map pin, which is how a rider drives to the wrong city.
|
||||
func TestFanoutStopsHaveDistinctCoordinates(t *testing.T) {
|
||||
booking, destinations := cxThreeDestinationFixture()
|
||||
stops := milerStopsForBooking(&booking, destinations)
|
||||
|
||||
type point struct{ lat, lng float64 }
|
||||
want := []point{{13.082680, 80.270718}, {9.981636, 76.299881}, {12.971599, 77.594566}}
|
||||
|
||||
seen := map[point]bool{}
|
||||
for i, s := range stops {
|
||||
got := point{s.deliveryLat, s.deliveryLng}
|
||||
if got.lat == 0 && got.lng == 0 {
|
||||
t.Errorf("stop %d sits at 0,0 — route sequencing skips those", i)
|
||||
}
|
||||
if seen[got] {
|
||||
t.Errorf("stop %d repeats coordinates %v", i, got)
|
||||
}
|
||||
seen[got] = true
|
||||
if got != want[i] {
|
||||
t.Errorf("stop %d coordinates = %v, want %v", i, got, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recipient details stay attached to their own stop. Delivering Meera's parcel
|
||||
// while showing Joe's phone number is a handover to the wrong person.
|
||||
func TestFanoutRecipientsStayWithTheirStop(t *testing.T) {
|
||||
booking, destinations := cxThreeDestinationFixture()
|
||||
stops := milerStopsForBooking(&booking, destinations)
|
||||
|
||||
want := []struct{ name, phone string }{
|
||||
{"Meera S", "+919884412210"},
|
||||
{"Joe Oommen", "+919847011223"},
|
||||
{"Arun Kumar", "+919000011223"},
|
||||
}
|
||||
for i, w := range want {
|
||||
if stops[i].recipientName != w.name {
|
||||
t.Errorf("stop %d recipientName = %q, want %q", i, stops[i].recipientName, w.name)
|
||||
}
|
||||
if stops[i].recipientPhone != w.phone {
|
||||
t.Errorf("stop %d recipientPhone = %q, want %q", i, stops[i].recipientPhone, w.phone)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tracking and consignment ids stay attached to their own stop. A crossed id
|
||||
// means the rider closes the wrong order and the customer is told the wrong
|
||||
// parcel arrived.
|
||||
func TestFanoutTrackingAndConsignmentIdsStayWithTheirStop(t *testing.T) {
|
||||
booking, destinations := cxThreeDestinationFixture()
|
||||
stops := milerStopsForBooking(&booking, destinations)
|
||||
|
||||
wantTracking := []string{"DMX10482913", "DMX10559120", "DMX10662004"}
|
||||
wantConsignment := []int{901, 902, 903}
|
||||
|
||||
seenTracking := map[string]bool{}
|
||||
seenConsignment := map[int]bool{}
|
||||
|
||||
for i := range stops {
|
||||
if stops[i].trackingNo != wantTracking[i] {
|
||||
t.Errorf("stop %d trackingNo = %q, want %q", i, stops[i].trackingNo, wantTracking[i])
|
||||
}
|
||||
if stops[i].consignmentID == nil {
|
||||
t.Fatalf("stop %d has no consignment id", i)
|
||||
}
|
||||
if *stops[i].consignmentID != wantConsignment[i] {
|
||||
t.Errorf("stop %d consignmentID = %d, want %d", i, *stops[i].consignmentID, wantConsignment[i])
|
||||
}
|
||||
if seenTracking[stops[i].trackingNo] || seenConsignment[*stops[i].consignmentID] {
|
||||
t.Errorf("stop %d repeats an identifier already used by another stop", i)
|
||||
}
|
||||
seenTracking[stops[i].trackingNo] = true
|
||||
seenConsignment[*stops[i].consignmentID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// COD is never copied across destinations. Money is the one field where a leak
|
||||
// is not a display bug: a rider shown 1200 at a door that owes nothing collects
|
||||
// it, and a door owing 450 shown 0 goes uncollected.
|
||||
func TestFanoutCodIsNotCopiedAcrossDestinations(t *testing.T) {
|
||||
booking, destinations := cxThreeDestinationFixture()
|
||||
stops := milerStopsForBooking(&booking, destinations)
|
||||
|
||||
want := []float64{1200, 0, 450}
|
||||
for i, w := range want {
|
||||
if stops[i].codAmount != w {
|
||||
t.Errorf("stop %d codAmount = %v, want %v", i, stops[i].codAmount, w)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 1; i < len(stops); i++ {
|
||||
if stops[i].codAmount == 1200 {
|
||||
t.Errorf("stop %d carries destination 0's COD of 1200", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parcels follow their own destination, so each order settles and is handed
|
||||
// over with the packages that actually belong to it.
|
||||
func TestFanoutParcelsFollowTheirDestination(t *testing.T) {
|
||||
booking, destinations := cxThreeDestinationFixture()
|
||||
stops := milerStopsForBooking(&booking, destinations)
|
||||
|
||||
want := []int{2, 1, 1}
|
||||
for i, w := range want {
|
||||
if len(stops[i].parcels) != w {
|
||||
t.Errorf("stop %d has %d parcels, want %d", i, len(stops[i].parcels), w)
|
||||
}
|
||||
}
|
||||
seen := map[int]int{}
|
||||
for i, s := range stops {
|
||||
for _, p := range s.parcels {
|
||||
if prev, dup := seen[p.Bookingparcelid]; dup {
|
||||
t.Errorf("parcel %d appears on stops %d and %d", p.Bookingparcelid, prev, i)
|
||||
}
|
||||
seen[p.Bookingparcelid] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Route order follows destinationseq. The stops are emitted in seq order so an
|
||||
// app that renders them as received shows "Stop 1, 2, 3" without sorting.
|
||||
func TestFanoutRouteOrderFollowsDestinationSeq(t *testing.T) {
|
||||
booking, destinations := cxThreeDestinationFixture()
|
||||
|
||||
// Deliberately handed over out of order — the ordering must come from seq,
|
||||
// not from however the rows happened to arrive.
|
||||
shuffled := []models.BookingDestination{destinations[2], destinations[0], destinations[1]}
|
||||
stops := milerStopsForBooking(&booking, shuffled)
|
||||
|
||||
if len(stops) != 3 {
|
||||
t.Fatalf("got %d stops, want 3", len(stops))
|
||||
}
|
||||
for i := 1; i < len(stops); i++ {
|
||||
if stops[i].seq <= stops[i-1].seq {
|
||||
t.Errorf("stop %d has seq %d, which does not follow stop %d's seq %d",
|
||||
i, stops[i].seq, i-1, stops[i-1].seq)
|
||||
}
|
||||
}
|
||||
// And seq must still name the right destination after ordering.
|
||||
if stops[0].trackingNo != "DMX10482913" || stops[2].trackingNo != "DMX10662004" {
|
||||
t.Errorf("ordering by seq detached a stop from its order: %q ... %q",
|
||||
stops[0].trackingNo, stops[2].trackingNo)
|
||||
}
|
||||
}
|
||||
|
||||
// Every one of the ten row-level fields the rider app reads is present and
|
||||
// destination-specific. This is the field-by-field audit, asserted rather than
|
||||
// described: consignmentid, trackingno, deliveryaddress, deliverylatitude,
|
||||
// deliverylongitude, recipientname, recipientphone, collectionamt (codAmount),
|
||||
// destinationseq (seq) and destinationcount (len).
|
||||
func TestFanoutEveryRowLevelFieldIsDestinationSpecific(t *testing.T) {
|
||||
booking, destinations := cxThreeDestinationFixture()
|
||||
stops := milerStopsForBooking(&booking, destinations)
|
||||
|
||||
if len(stops) != 3 {
|
||||
t.Fatalf("got %d stops, want 3", len(stops))
|
||||
}
|
||||
|
||||
for i, s := range stops {
|
||||
if s.consignmentID == nil {
|
||||
t.Errorf("stop %d: consignmentid missing", i)
|
||||
}
|
||||
if s.trackingNo == "" {
|
||||
t.Errorf("stop %d: trackingno missing", i)
|
||||
}
|
||||
if s.deliveryAddress == "" {
|
||||
t.Errorf("stop %d: deliveryaddress missing", i)
|
||||
}
|
||||
if s.deliveryLat == 0 && s.deliveryLng == 0 {
|
||||
t.Errorf("stop %d: delivery coordinates missing", i)
|
||||
}
|
||||
if s.recipientName == "" {
|
||||
t.Errorf("stop %d: recipientname missing", i)
|
||||
}
|
||||
if s.recipientPhone == "" {
|
||||
t.Errorf("stop %d: recipientphone missing", i)
|
||||
}
|
||||
if s.seq != i {
|
||||
t.Errorf("stop %d: destinationseq = %d, want %d", i, s.seq, i)
|
||||
}
|
||||
}
|
||||
|
||||
// codAmount is exempt from the non-empty check — 0 is a legitimate value
|
||||
// (destination 1 owes nothing) and is asserted exactly in the COD test.
|
||||
}
|
||||
|
||||
// maxDestinations is enforced HERE, not in the app. The Flutter limit is UI
|
||||
// guidance that a modified client, a stale build, or a failed
|
||||
// /config/booking-limits fetch can all bypass; this is the authority.
|
||||
//
|
||||
// The default matters as much as the check: with no configuration row at all
|
||||
// the cap must resolve to the SAFEST value, not the most permissive. A gate
|
||||
// that opens when its config is missing is not a gate — a migration that ran
|
||||
// without the seed would silently admit multi-destination bookings the rider
|
||||
// app cannot complete.
|
||||
func TestMaxDestinationsDefaultsToTheSafestValue(t *testing.T) {
|
||||
if cxDefaultMaxDestinations != 1 {
|
||||
t.Errorf("cxDefaultMaxDestinations = %d, want 1 — a missing config row must not open the gate",
|
||||
cxDefaultMaxDestinations)
|
||||
}
|
||||
if cxDefaultMaxPackages <= 0 {
|
||||
t.Errorf("cxDefaultMaxPackages = %d — a zero cap rejects every booking on the platform",
|
||||
cxDefaultMaxPackages)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user