Files
doormile_backend/controllers/cxCustomerApp_test.go

857 lines
32 KiB
Go

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