package assignment import ( "errors" "fmt" "os" "sync" "testing" "time" "doormile/constants" "doormile/db" "doormile/internal/testpg" "doormile/models" "github.com/redis/go-redis/v9" "gorm.io/gorm" ) // Who auto-assignment may offer an order to, against a real Postgres. Skipped // unless REGISTRY_TEST_DSN is set; the DSN must be a THROWAWAY database — the // tables below are dropped and recreated in their own schema. See // internal/ai/registry/store_integration_test.go for how to start one. func eligibilityDB(t *testing.T) *gorm.DB { t.Helper() dsn := os.Getenv("REGISTRY_TEST_DSN") if dsn == "" { t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres assignment test") } gdb := testpg.Open(t, dsn, "assignment_eligibility_test") all := []any{&models.PickupBooking{}, &models.BookingAssignment{}, &models.MilerProfile{}, &models.BookingStageEvent{}, &models.BookingDestination{}, &models.MilerDutyLog{}} if err := gdb.Migrator().DropTable(all...); err != nil { t.Fatal(err) } if err := gdb.AutoMigrate(all...); err != nil { t.Fatal(err) } prev := db.DB db.DB = gdb // commitAssignment fires customer notifications in a goroutine that can // outlive the test. Putting back a nil db.DB would make it panic; leaving // the closed test handle makes it fail quietly instead. t.Cleanup(func() { if prev != nil { db.DB = prev } }) t.Setenv("MILER_MAX_ACTIVE_BOOKINGS", "") t.Setenv("ASSIGNMENT_MAX_GPS_AGE_MINUTES", "") return gdb } func mustDo(t *testing.T, err error) { t.Helper() if err != nil { t.Fatal(err) } } var nextBookingID = 1000 // order creates a booking and, when rider != 0, an assignment to that rider. func order(t *testing.T, gdb *gorm.DB, rider int, bookingStatus, asgStatus string, assignedAt time.Time) int { t.Helper() nextBookingID++ id := nextBookingID b := models.PickupBooking{Bookingid: id, Bookingno: fmt.Sprintf("DM-T%06d", id), Status: bookingStatus, Bookingsource: constants.BookingSourceExpress, Pickuplatitude: 11.0053, Pickuplongitude: 76.9511} if rider != 0 { r := rider b.Assignedmileruserid = &r } mustDo(t, gdb.Create(&b).Error) if rider != 0 { mustDo(t, gdb.Create(&models.BookingAssignment{Bookingid: id, Mileruserid: rider, Assignmentstatus: asgStatus, Assignedat: assignedAt}).Error) } return id } func rider(t *testing.T, gdb *gorm.DB, id int, status string, gpsAge time.Duration) { t.Helper() seen := time.Now().Add(-gpsAge) mustDo(t, gdb.Create(&models.MilerProfile{Userid: id, Displayname: fmt.Sprintf("rider %d", id), Phone: fmt.Sprintf("90000%05d", id), Availabilitystatus: status, Lastlocationupdatedat: &seen}).Error) } // The question that started this: Rajan has 10 orders from earlier days still // open, and a cancelled one from today. A new order must still be offered to // him — only today's real, open work counts. func TestOldAndCancelledOrdersDoNotBlockARider(t *testing.T) { gdb := eligibilityDB(t) const rajan = 38 rider(t, gdb, rajan, constants.MilerAssigned, time.Minute) for i := 0; i < 10; i++ { order(t, gdb, rajan, constants.BookingMilerAssigned, constants.AssignmentAccepted, time.Now().AddDate(0, 0, -(i+1))) } order(t, gdb, rajan, constants.BookingCancelled, constants.AssignmentAssigned, time.Now()) order(t, gdb, rajan, constants.BookingMilerAssigned, constants.AssignmentAssigned, time.Now()) if n := openStopsToday(rajan); n != 1 { t.Fatalf("open stops today = %d, want 1 (10 old + 1 cancelled must not count)", n) } got, _ := collectEligibleCandidates([]redis.GeoLocation{{Name: fmt.Sprint(rajan), Dist: 0.03}}) if len(got) != 1 { t.Fatal("Rajan must be offered the new order") } } // Today's real work still counts towards the cap. func TestTodaysOpenOrdersStillCountTowardsTheCap(t *testing.T) { gdb := eligibilityDB(t) rider(t, gdb, 6, constants.MilerAssigned, time.Minute) for i := 0; i < 3; i++ { order(t, gdb, 6, constants.BookingMilerAssigned, constants.AssignmentAccepted, time.Now()) } if got, _ := collectEligibleCandidates([]redis.GeoLocation{{Name: "6"}}); len(got) != 0 { t.Fatal("a rider with 3 open orders today is at the cap") } t.Setenv("MILER_MAX_ACTIVE_BOOKINGS", "5") if got, _ := collectEligibleCandidates([]redis.GeoLocation{{Name: "6"}}); len(got) != 1 { t.Fatal("raising the cap must let them take more") } } // A rider whose app stopped reporting is not offered orders, whatever their // status says — but only while the check is on. func TestStaleGPSRiderIsSkipped(t *testing.T) { gdb := eligibilityDB(t) rider(t, gdb, 23, constants.MilerOnDelivery, 6*24*time.Hour) // last GPS 6 days ago rider(t, gdb, 38, constants.MilerAssigned, 2*time.Minute) nearby := []redis.GeoLocation{{Name: "23", Dist: 0.5}, {Name: "38", Dist: 0.9}} got, _ := collectEligibleCandidates(nearby) if len(got) != 1 || got[0].profile.Userid != 38 { t.Fatalf("only the rider with live GPS may be offered the order, got %d candidates", len(got)) } t.Setenv("ASSIGNMENT_MAX_GPS_AGE_MINUTES", "0") if got, _ := collectEligibleCandidates(nearby); len(got) != 2 { t.Fatal("with the check off both riders are candidates") } } // An offline rider stays out, fresh GPS or not. func TestOfflineRiderIsSkipped(t *testing.T) { gdb := eligibilityDB(t) rider(t, gdb, 46, constants.MilerOffline, time.Minute) if got, _ := collectEligibleCandidates([]redis.GeoLocation{{Name: "46"}}); len(got) != 0 { t.Fatal("offline rider must not be offered orders") } } // Two attempts racing on one booking — queue retry, sweeper, hub button — // must end with exactly one rider. func TestConcurrentAttemptsAssignOnce(t *testing.T) { gdb := eligibilityDB(t) rider(t, gdb, 38, constants.MilerAvailable, time.Minute) rider(t, gdb, 21, constants.MilerAvailable, time.Minute) id := order(t, gdb, 0, constants.BookingPendingPickup, "", time.Time{}) var booking models.PickupBooking mustDo(t, gdb.First(&booking, id).Error) var wg sync.WaitGroup var mu sync.Mutex won, taken, other := 0, 0, 0 for i, r := range []int{38, 21, 38, 21, 38, 21} { wg.Add(1) go func(i, r int) { defer wg.Done() b := booking _, err := commitAssignment(&b, &milerCandidate{profile: models.MilerProfile{Userid: r}}, nil, nil) mu.Lock() defer mu.Unlock() switch { case err == nil: won++ case errors.Is(err, errBookingTaken): taken++ default: other++ t.Errorf("attempt %d: %v", i, err) } }(i, r) } wg.Wait() if won != 1 || taken != 5 || other != 0 { t.Fatalf("won=%d taken=%d other=%d, want exactly one winner", won, taken, other) } var n int64 gdb.Model(&models.BookingAssignment{}).Where("bookingid = ?", id).Count(&n) if n != 1 { t.Fatalf("assignment rows = %d, want 1", n) } } // A cancelled booking is never assigned, even by an attempt that read it // before the cancel. func TestCancelledBookingIsNotAssigned(t *testing.T) { gdb := eligibilityDB(t) rider(t, gdb, 38, constants.MilerAvailable, time.Minute) id := order(t, gdb, 0, constants.BookingPendingPickup, "", time.Time{}) var stale models.PickupBooking mustDo(t, gdb.First(&stale, id).Error) mustDo(t, gdb.Model(&models.PickupBooking{}).Where("bookingid = ?", id).Update("status", constants.BookingCancelled).Error) _, err := commitAssignment(&stale, &milerCandidate{profile: models.MilerProfile{Userid: 38}}, nil, nil) if !errors.Is(err, errBookingTaken) { t.Fatalf("want errBookingTaken, got %v", err) } var n int64 gdb.Model(&models.BookingAssignment{}).Where("bookingid = ?", id).Count(&n) if n != 0 { t.Fatal("a cancelled booking must get no assignment") } } // The sweeper retries every unassigned pending booking in its window — no // matter how many — and nothing else. func TestSweeperPicksEveryUnassignedPendingBooking(t *testing.T) { gdb := eligibilityDB(t) t.Setenv("EXPRESS_AGENT_ENABLED", "") t.Setenv("ASSIGNMENT_SWEEP_MAX_AGE_HOURS", "") now := time.Now() mk := func(no string, status string, rider *int, lat float64, age time.Duration, source string) { nextBookingID++ mustDo(t, gdb.Create(&models.PickupBooking{Bookingid: nextBookingID, Bookingno: no, Status: status, Assignedmileruserid: rider, Bookingsource: source, Pickuplatitude: lat, Pickuplongitude: 76.95, Createdat: now.Add(-age)}).Error) } r := 38 for i := 0; i < 25; i++ { // a pile of pending orders: all of them are retried mk(fmt.Sprintf("DM-P%02d", i), constants.BookingPendingPickup, nil, 11.0, time.Duration(10+i)*time.Minute, constants.BookingSourceExpress) } mk("DM-CX", constants.BookingPendingPickup, nil, 11.0, 30*time.Minute, constants.BookingSourceCustomerApp) mk("DM-NEW", constants.BookingPendingPickup, nil, 11.0, 30*time.Second, constants.BookingSourceExpress) // its own first attempt runs mk("DM-OLD", constants.BookingPendingPickup, nil, 11.0, 5*24*time.Hour, constants.BookingSourceExpress) // abandoned mk("DM-ASG", constants.BookingMilerAssigned, &r, 11.0, time.Hour, constants.BookingSourceExpress) // has a rider mk("DM-CAN", constants.BookingCancelled, nil, 11.0, time.Hour, constants.BookingSourceExpress) // cancelled mk("DM-NOLOC", constants.BookingPendingPickup, nil, 0, time.Hour, constants.BookingSourceExpress) // no pickup point got, err := pendingForSweep(now) mustDo(t, err) if len(got) != 26 { t.Fatalf("swept %d bookings, want 26 (25 console + 1 customer)", len(got)) } t.Setenv("EXPRESS_AGENT_ENABLED", "true") got, _ = pendingForSweep(now) if len(got) != 1 || got[0].Bookingsource != constants.BookingSourceCustomerApp { t.Fatalf("with the express agent on, only customer bookings are swept; got %d", len(got)) } }