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