258 lines
8.3 KiB
Go
258 lines
8.3 KiB
Go
package services
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"nearle/models"
|
|
)
|
|
|
|
// A stand-in for the table, so these tests are about the RULE and not about
|
|
// GORM. Every test below fixes "now" explicitly, because a rule about clock
|
|
// time that is tested at whatever o'clock CI happens to run is not tested.
|
|
type fakeSlotRepo struct {
|
|
slots []models.DeliverySlots
|
|
}
|
|
|
|
func (f *fakeSlotRepo) ListForBranch(tenantID, locationID int) ([]models.DeliverySlots, error) {
|
|
return f.slots, nil
|
|
}
|
|
|
|
func (f *fakeSlotRepo) FindForBranch(tenantID, locationID, slotID int) (*models.DeliverySlots, error) {
|
|
for i := range f.slots {
|
|
if f.slots[i].Slotid == slotID {
|
|
return &f.slots[i], nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeSlotRepo) Save(slots []models.DeliverySlots) error {
|
|
f.slots = slots
|
|
return nil
|
|
}
|
|
|
|
func threeSlots() []models.DeliverySlots {
|
|
return []models.DeliverySlots{
|
|
{Slotid: 1, Slotkey: "morning", Name: "Morning", Starttime: "08:00", Endtime: "10:00", Status: "active"},
|
|
{Slotid: 2, Slotkey: "afternoon", Name: "Afternoon", Starttime: "12:00", Endtime: "15:00", Status: "active"},
|
|
{Slotid: 3, Slotkey: "evening", Name: "Evening", Starttime: "17:00", Endtime: "20:00", Status: "active"},
|
|
}
|
|
}
|
|
|
|
func serviceAt(clock string, slots []models.DeliverySlots) *deliverySlotService {
|
|
at, err := time.Parse("2006-01-02 15:04", clock)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return &deliverySlotService{
|
|
repo: &fakeSlotRepo{slots: slots},
|
|
now: func() time.Time { return at },
|
|
}
|
|
}
|
|
|
|
// Today's windows that have not ended, soonest first.
|
|
func todayKeys(t *testing.T, svc *deliverySlotService, date string) []string {
|
|
t.Helper()
|
|
available, err := svc.Available(1, 1)
|
|
if err != nil {
|
|
t.Fatalf("Available: %v", err)
|
|
}
|
|
keys := []string{}
|
|
for _, slot := range available {
|
|
if slot.Slotdate == date {
|
|
keys = append(keys, slot.Slotkey)
|
|
}
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func TestAvailableDropsWindowsThatHaveEnded(t *testing.T) {
|
|
// The rule the shop was given: a window takes orders right up to the moment
|
|
// it ends, and then stops being offered.
|
|
cases := []struct {
|
|
clock string
|
|
want string
|
|
}{
|
|
{"2026-10-06 07:00", "morning,afternoon,evening"}, // before trading
|
|
{"2026-10-06 09:59", "morning,afternoon,evening"}, // one minute left
|
|
{"2026-10-06 10:01", "afternoon,evening"}, // morning just closed
|
|
{"2026-10-06 15:30", "evening"}, // afternoon gone too
|
|
{"2026-10-06 20:01", ""}, // day over
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
svc := serviceAt(tc.clock, threeSlots())
|
|
got := strings.Join(todayKeys(t, svc, "2026-10-06"), ",")
|
|
if got != tc.want {
|
|
t.Errorf("at %s: today offers %q, want %q", tc.clock, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAvailableClosesOnTheEndMinuteNotTheStart(t *testing.T) {
|
|
// The boundary, stated on its own because it is the one thing a shopkeeper
|
|
// would notice being wrong. 10:00 exactly is PAST the end of an 08:00-10:00
|
|
// window: the window is over, and an order placed at 10:00 cannot be in it.
|
|
svc := serviceAt("2026-10-06 10:00", threeSlots())
|
|
for _, key := range todayKeys(t, svc, "2026-10-06") {
|
|
if key == "morning" {
|
|
t.Error("morning is still on offer at 10:00, its own end time")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAvailableAlwaysOffersTomorrow(t *testing.T) {
|
|
// A shopper at 9pm is ordering for tomorrow. Offering nothing would quietly
|
|
// drop the feature for every evening customer.
|
|
svc := serviceAt("2026-10-06 21:00", threeSlots())
|
|
available, err := svc.Available(1, 1)
|
|
if err != nil {
|
|
t.Fatalf("Available: %v", err)
|
|
}
|
|
|
|
if len(available) != 3 {
|
|
t.Fatalf("after the last window closed, got %d slots, want tomorrow's 3", len(available))
|
|
}
|
|
for _, slot := range available {
|
|
if slot.Slotdate != "2026-10-07" {
|
|
t.Errorf("%s is dated %s, want tomorrow", slot.Slotkey, slot.Slotdate)
|
|
}
|
|
if !slot.IsTomorrow {
|
|
t.Errorf("%s is tomorrow's but istomorrow is false", slot.Slotkey)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAvailableSkipsInactiveWindows(t *testing.T) {
|
|
slots := threeSlots()
|
|
slots[1].Status = "inactive" // the shop stopped doing afternoons
|
|
|
|
svc := serviceAt("2026-10-06 07:00", slots)
|
|
for _, key := range todayKeys(t, svc, "2026-10-06") {
|
|
if key == "afternoon" {
|
|
t.Error("an inactive window is being offered to shoppers")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAvailableIsEmptyAndNotAnErrorWhenNothingIsConfigured(t *testing.T) {
|
|
// The state EVERY shop is in the day this ships. An error here would turn
|
|
// every trading shop on the platform into a broken one.
|
|
svc := serviceAt("2026-10-06 09:00", nil)
|
|
|
|
available, err := svc.Available(1, 1)
|
|
if err != nil {
|
|
t.Fatalf("a branch with no windows must not be an error, got %v", err)
|
|
}
|
|
if available == nil {
|
|
t.Error("got nil, want an empty slice — `null` and `[]` read differently in the app")
|
|
}
|
|
if len(available) != 0 {
|
|
t.Errorf("got %d slots from a branch that configured none", len(available))
|
|
}
|
|
}
|
|
|
|
func TestValidateForOrderAcceptsNoWindow(t *testing.T) {
|
|
// Every order placed before this shipped, and every order from a branch
|
|
// that has set no windows.
|
|
svc := serviceAt("2026-10-06 09:00", threeSlots())
|
|
if err := svc.ValidateForOrder(1, 1, 0, ""); err != nil {
|
|
t.Errorf("an order with no window was rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateForOrderRejectsAWindowThatClosedWhileCheckingOut(t *testing.T) {
|
|
// The case this guard exists for: the shopper opened checkout at 09:58 and
|
|
// paid at 10:02.
|
|
svc := serviceAt("2026-10-06 10:02", threeSlots())
|
|
|
|
err := svc.ValidateForOrder(1, 1, 1, "2026-10-06")
|
|
if err == nil {
|
|
t.Fatal("an order was accepted into a window that had already closed")
|
|
}
|
|
if !strings.Contains(err.Error(), "morning") {
|
|
t.Errorf("the message does not name the window: %q", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateForOrderAcceptsTomorrowsWindowAfterTodaysClosed(t *testing.T) {
|
|
// Same window, next day. Closing today must not close it forever.
|
|
svc := serviceAt("2026-10-06 10:02", threeSlots())
|
|
if err := svc.ValidateForOrder(1, 1, 1, "2026-10-07"); err != nil {
|
|
t.Errorf("tomorrow's morning was rejected at 10:02 today: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateForOrderRejectsAWindowFromAnotherShop(t *testing.T) {
|
|
// The repository scopes its lookup by tenant and branch, so a slot id that
|
|
// belongs elsewhere comes back as nothing.
|
|
svc := serviceAt("2026-10-06 09:00", threeSlots())
|
|
|
|
if err := svc.ValidateForOrder(1, 1, 99, "2026-10-06"); err == nil {
|
|
t.Error("an order named a window this shop does not offer and was accepted")
|
|
}
|
|
}
|
|
|
|
func TestValidateForOrderRequiresADateWithAWindow(t *testing.T) {
|
|
// "evening" alone cannot say tonight or tomorrow night.
|
|
svc := serviceAt("2026-10-06 09:00", threeSlots())
|
|
|
|
if err := svc.ValidateForOrder(1, 1, 3, ""); err == nil {
|
|
t.Error("a window was accepted with no date")
|
|
}
|
|
}
|
|
|
|
func TestSaveRejectsAWindowThatEndsBeforeItStarts(t *testing.T) {
|
|
// Not pedantry: such a window never passes the "still running" test, so it
|
|
// would simply never appear, with nothing saying why.
|
|
svc := serviceAt("2026-10-06 09:00", nil)
|
|
|
|
err := svc.Save(1, 1, []models.DeliverySlots{
|
|
{Slotkey: "morning", Starttime: "10:00", Endtime: "08:00", Status: "active"},
|
|
})
|
|
if err == nil {
|
|
t.Error("a window ending before it starts was saved")
|
|
}
|
|
}
|
|
|
|
func TestSaveRejectsADuplicateWindow(t *testing.T) {
|
|
svc := serviceAt("2026-10-06 09:00", nil)
|
|
|
|
err := svc.Save(1, 1, []models.DeliverySlots{
|
|
{Slotkey: "morning", Starttime: "08:00", Endtime: "10:00", Status: "active"},
|
|
{Slotkey: "morning", Starttime: "09:00", Endtime: "11:00", Status: "active"},
|
|
})
|
|
if err == nil {
|
|
t.Error("a branch was given two mornings")
|
|
}
|
|
}
|
|
|
|
func TestSaveRejectsAKeyThatIsNotOneOfTheThree(t *testing.T) {
|
|
svc := serviceAt("2026-10-06 09:00", nil)
|
|
|
|
err := svc.Save(1, 1, []models.DeliverySlots{
|
|
{Slotkey: "midnight", Starttime: "00:00", Endtime: "02:00", Status: "active"},
|
|
})
|
|
if err == nil {
|
|
t.Error("a fourth window was accepted; the app has nowhere to show it")
|
|
}
|
|
}
|
|
|
|
func TestSaveAppliesNothingWhenOneRowIsWrong(t *testing.T) {
|
|
// A shop editing three and getting two is worse than getting none: the two
|
|
// that took are live, and nothing on screen says which.
|
|
repo := &fakeSlotRepo{}
|
|
svc := &deliverySlotService{repo: repo, now: time.Now}
|
|
|
|
_ = svc.Save(1, 1, []models.DeliverySlots{
|
|
{Slotkey: "morning", Starttime: "08:00", Endtime: "10:00", Status: "active"},
|
|
{Slotkey: "afternoon", Starttime: "15:00", Endtime: "12:00", Status: "active"}, // bad
|
|
})
|
|
|
|
if len(repo.slots) != 0 {
|
|
t.Errorf("%d windows were written despite one being invalid", len(repo.slots))
|
|
}
|
|
}
|