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