Files
doormile_backend/internal/assignment/release_pg_test.go

176 lines
7.3 KiB
Go

package assignment
import (
"testing"
"time"
"doormile/constants"
"doormile/models"
"gorm.io/gorm"
)
// Balanced assignment, Phase B, against a real Postgres. Skipped unless
// REGISTRY_TEST_DSN is set — see eligibility_pg_test.go.
// autoOrder is order() for an assignment made by auto-assignment (labelled),
// the only kind the release touches.
func autoOrder(t *testing.T, gdb *gorm.DB, rider int, bookingStatus, asgStatus string, at time.Time) int {
t.Helper()
id := order(t, gdb, rider, bookingStatus, asgStatus, at)
mustDo(t, gdb.Model(&models.BookingAssignment{}).Where("bookingid = ?", id).Update("remarks", autoAssignedRemark).Error)
return id
}
func bookingState(t *testing.T, gdb *gorm.DB, id int) (string, *int) {
t.Helper()
var b models.PickupBooking
mustDo(t, gdb.First(&b, id).Error)
return b.Status, b.Assignedmileruserid
}
func assignmentState(t *testing.T, gdb *gorm.DB, bookingID int) string {
t.Helper()
var a models.BookingAssignment
mustDo(t, gdb.Where("bookingid = ?", bookingID).Order("bookingassignmentid DESC").First(&a).Error)
return a.Assignmentstatus
}
// An order the rider never accepted goes back to the pool after the timeout;
// one they accepted, or one still within the timeout, stays with them.
func TestReleaseUnacceptedOrders(t *testing.T) {
gdb := balanceSetup(t)
t.Setenv("ASSIGNMENT_ACCEPT_TIMEOUT_MINUTES", "10")
onDuty(t, gdb, 1, time.Now().Add(-time.Hour))
mustDo(t, gdb.Model(&models.MilerProfile{}).Where("userid = 1").Update("availabilitystatus", constants.MilerAssigned).Error)
stale := autoOrder(t, gdb, 1, constants.BookingMilerAssigned, constants.AssignmentAssigned, time.Now().Add(-15*time.Minute))
fresh := autoOrder(t, gdb, 1, constants.BookingMilerAssigned, constants.AssignmentAssigned, time.Now().Add(-3*time.Minute))
accepted := autoOrder(t, gdb, 1, constants.BookingPickupScheduled, constants.AssignmentAccepted, time.Now().Add(-40*time.Minute))
if n := releaseUnaccepted(); n != 1 {
t.Fatalf("released %d, want 1", n)
}
if st, r := bookingState(t, gdb, stale); st != constants.BookingPendingPickup || r != nil {
t.Fatalf("stale order: status %s rider %v, want Pending_Pickup with no rider", st, r)
}
if s := assignmentState(t, gdb, stale); s != constants.AssignmentReassigned {
t.Fatalf("stale assignment = %s, want Reassigned", s)
}
if st, r := bookingState(t, gdb, fresh); st != constants.BookingMilerAssigned || r == nil {
t.Fatal("an order still inside the timeout must stay with the rider")
}
if st, r := bookingState(t, gdb, accepted); st != constants.BookingPickupScheduled || r == nil {
t.Fatal("an accepted order must never be released")
}
// The rider still holds two orders, so stays Assigned.
var p models.MilerProfile
mustDo(t, gdb.Where("userid = 1").First(&p).Error)
if p.Availabilitystatus != constants.MilerAssigned {
t.Fatalf("rider status %s, want Assigned (still holding orders)", p.Availabilitystatus)
}
// A rider hand-picked by staff (no auto label) is never taken away.
manual := order(t, gdb, 1, constants.BookingMilerAssigned, constants.AssignmentAssigned, time.Now().Add(-2*time.Hour))
if n := releaseUnaccepted(); n != 0 {
t.Fatal("a manually assigned order must never be released")
}
if st, r := bookingState(t, gdb, manual); st != constants.BookingMilerAssigned || r == nil {
t.Fatal("manual order changed")
}
t.Setenv("ASSIGNMENT_ACCEPT_TIMEOUT_MINUTES", "0")
autoOrder(t, gdb, 1, constants.BookingMilerAssigned, constants.AssignmentAssigned, time.Now().Add(-2*time.Hour))
if n := releaseUnaccepted(); n != 0 {
t.Fatal("timeout 0 must switch the release off")
}
}
// A rider freed of their only order goes back to Available.
func TestReleaseFreesARiderWithNothingElse(t *testing.T) {
gdb := balanceSetup(t)
onDuty(t, gdb, 1, time.Now().Add(-time.Hour))
mustDo(t, gdb.Model(&models.MilerProfile{}).Where("userid = 1").Update("availabilitystatus", constants.MilerAssigned).Error)
autoOrder(t, gdb, 1, constants.BookingMilerAssigned, constants.AssignmentAssigned, time.Now().Add(-30*time.Minute))
if n := releaseUnaccepted(); n != 1 {
t.Fatalf("released %d, want 1", n)
}
var p models.MilerProfile
mustDo(t, gdb.Where("userid = 1").First(&p).Error)
if p.Availabilitystatus != constants.MilerAvailable {
t.Fatalf("rider status %s, want Available", p.Availabilitystatus)
}
}
// A rider accepting at the same moment wins: nothing is released.
func TestReleaseLosesToAConcurrentAccept(t *testing.T) {
gdb := balanceSetup(t)
onDuty(t, gdb, 1, time.Now().Add(-time.Hour))
b := order(t, gdb, 1, constants.BookingMilerAssigned, constants.AssignmentAssigned, time.Now().Add(-30*time.Minute))
var a models.BookingAssignment
mustDo(t, gdb.Where("bookingid = ?", b).First(&a).Error)
// The rider accepts between the release listing and its write.
mustDo(t, gdb.Model(&models.PickupBooking{}).Where("bookingid = ?", b).Update("status", constants.BookingPickupScheduled).Error)
if releaseOne(a.Bookingassignmentid, b, 1, 10) {
t.Fatal("must not release an order the rider has just accepted")
}
if s := assignmentState(t, gdb, b); s != constants.AssignmentAssigned {
t.Fatalf("assignment changed to %s", s)
}
}
// A released (or rejected) order is never offered back to that rider: it goes
// to someone else even if the first rider is now the least loaded.
func TestReleasedOrderGoesToAnotherRider(t *testing.T) {
gdb := balanceSetup(t)
onDuty(t, gdb, 1, time.Now().Add(-time.Hour))
onDuty(t, gdb, 2, time.Now().Add(-time.Hour))
// Rider 2 is busier, so a plain balance would pick rider 1.
order(t, gdb, 2, constants.BookingMilerAssigned, constants.AssignmentAccepted, time.Now())
b := autoOrder(t, gdb, 1, constants.BookingMilerAssigned, constants.AssignmentAssigned, time.Now().Add(-30*time.Minute))
if n := releaseUnaccepted(); n != 1 {
t.Fatalf("released %d, want 1", n)
}
id, err := assignOne(t, gdb, b, near(1, 2))
if err != nil {
t.Fatal(err)
}
if id != 2 {
t.Fatalf("released order went to rider %d, want 2 (never back to rider 1)", id)
}
// Same rule for a rider who rejected it.
r := order(t, gdb, 0, constants.BookingPendingPickup, "", time.Time{})
mustDo(t, gdb.Create(&models.BookingAssignment{Bookingid: r, Mileruserid: 2, Assignmentstatus: constants.AssignmentRejected, Assignedat: time.Now()}).Error)
if id, _ := assignOne(t, gdb, r, near(1, 2)); id != 1 {
t.Fatalf("rejected order went to rider %d, want 1", id)
}
}
// When a rider starts duty, only the pending orders within reach are swept.
func TestPendingNearOnlyPicksNearbyOrders(t *testing.T) {
gdb := balanceSetup(t)
now := time.Now()
mk := func(no string, lat, lon float64) {
nextBookingID++
mustDo(t, gdb.Create(&models.PickupBooking{Bookingid: nextBookingID, Bookingno: no, Status: constants.BookingPendingPickup,
Bookingsource: constants.BookingSourceExpress, Pickuplatitude: lat, Pickuplongitude: lon,
Createdat: now.Add(-10 * time.Minute)}).Error)
}
mk("DM-NEAR1", 11.0090, 76.9500) // at the rider
mk("DM-NEAR2", 11.0500, 76.9800) // ~5.5 km
mk("DM-FAR", 11.3000, 77.2000) // ~40 km
mk("DM-CHENNAI", 13.0827, 80.2707) // another city
got, err := pendingNear(11.0090, 76.9500, now)
mustDo(t, err)
names := map[string]bool{}
for _, b := range got {
var full models.PickupBooking
mustDo(t, gdb.First(&full, b.Bookingid).Error)
names[full.Bookingno] = true
}
if len(got) != 2 || !names["DM-NEAR1"] || !names["DM-NEAR2"] {
t.Fatalf("swept %v, want only the two nearby orders", names)
}
}