Files
doormile_backend/internal/assignment/eligibility_pg_test.go

247 lines
9.1 KiB
Go

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