857 lines
32 KiB
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)
|
|
}
|
|
}
|