updates on the sweeper and eligibility things in the order
This commit is contained in:
246
internal/assignment/eligibility_pg_test.go
Normal file
246
internal/assignment/eligibility_pg_test.go
Normal file
@@ -0,0 +1,246 @@
|
||||
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{}}
|
||||
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
|
||||
t.Cleanup(func() { 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)
|
||||
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)
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user