mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-29 07:54:44 +00:00
fix(reminders): lifecycle-aware jobs and dispatch skip guard
The reminder pipeline fired regardless of what happened to the booking after creation: cancelled customers got reminded about bookings that weren't happening, reschedules kept the old reminder time, and waitlisted bookings armed jobs too. Pending jobs are now cancelled on both cancel paths, deleted and re-armed on reschedule, and dispatch joins bookings.status to skip anything not confirmed. Reschedule also gained the same availability-window and blocked-day validation as booking creation, closing a 3am-reschedule hole via the manage URL. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
0dc26e0c16
commit
cdad7875e1
12 files changed
+272
-9
No files matched your search
@@ -518,3 +518,125 @@ func TestDashboardSummaryComputesKPIsWithTrends(t *testing.T) {
|
||||
t.Fatalf("utilization kpi = %+v", utilization)
|
||||
}
|
||||
}
|
||||
|
||||
func bookAppointmentSlot(t *testing.T, repo db.Repository, service *Service) domain.CreateBookingResponse {
|
||||
t.Helper()
|
||||
// Target a day ~72h out so the booking lands in the -24h reminder horizon.
|
||||
target := time.Now().UTC().AddDate(0, 0, 3).Format("2006-01-02")
|
||||
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{Date: target})
|
||||
if err != nil {
|
||||
t.Fatalf("availability: %v", err)
|
||||
}
|
||||
var slot domain.TimeSlot
|
||||
for _, s := range availability.Slots {
|
||||
if s.Mode == "appointment" {
|
||||
slot = s
|
||||
break
|
||||
}
|
||||
}
|
||||
if slot.StartsAt == "" {
|
||||
t.Fatal("expected appointment slot")
|
||||
}
|
||||
resp, err := service.Create(context.Background(), domain.CreateBookingRequest{
|
||||
TenantSlug: "studio-atelier",
|
||||
BookingMode: "appointment",
|
||||
ServiceID: slot.ServiceID,
|
||||
StaffID: slot.StaffID,
|
||||
LocationID: slot.LocationID,
|
||||
CustomerName: "Reminder Test",
|
||||
CustomerEmail: "[email protected]",
|
||||
StartsAt: slot.StartsAt,
|
||||
EndsAt: slot.EndsAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create booking: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func pendingJobsFor(t *testing.T, repo db.Repository, bookingID string) []db.ReminderJobRecord {
|
||||
t.Helper()
|
||||
jobs, err := repo.ListDueReminderJobs(context.Background(), time.Now().UTC().AddDate(1, 0, 0), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("list due jobs: %v", err)
|
||||
}
|
||||
var out []db.ReminderJobRecord
|
||||
for _, job := range jobs {
|
||||
if job.BookingID == bookingID {
|
||||
out = append(out, job)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestCancelBookingClearsPendingReminder(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo, nil, "http://localhost:3000")
|
||||
customerService := NewCustomerService(repo, nil, "http://localhost:3000")
|
||||
ctx := context.Background()
|
||||
|
||||
resp := bookAppointmentSlot(t, repo, service)
|
||||
if len(pendingJobsFor(t, repo, resp.BookingID)) != 1 {
|
||||
t.Fatal("expected one pending reminder job after booking")
|
||||
}
|
||||
|
||||
manageURL, _ := url.Parse(resp.ManageURL)
|
||||
token := manageURL.Query().Get("token")
|
||||
if err := customerService.CancelBooking(ctx, resp.Reference, token); err != nil {
|
||||
t.Fatalf("cancel: %v", err)
|
||||
}
|
||||
if got := pendingJobsFor(t, repo, resp.BookingID); len(got) != 0 {
|
||||
t.Fatalf("expected no pending jobs after cancel, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRescheduleRearmsReminder(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo, nil, "http://localhost:3000")
|
||||
customerService := NewCustomerService(repo, nil, "http://localhost:3000")
|
||||
ctx := context.Background()
|
||||
|
||||
resp := bookAppointmentSlot(t, repo, service)
|
||||
booking, _ := repo.GetBookingByID(ctx, resp.BookingID)
|
||||
|
||||
newStartsAt := booking.StartsAt.Add(48 * time.Hour)
|
||||
newEndsAt := booking.EndsAt.Add(48 * time.Hour)
|
||||
manageURL, _ := url.Parse(resp.ManageURL)
|
||||
token := manageURL.Query().Get("token")
|
||||
if err := customerService.RescheduleBooking(ctx, resp.Reference, domain.RescheduleBookingRequest{
|
||||
NewStartsAt: newStartsAt.UTC().Format(time.RFC3339),
|
||||
NewEndsAt: newEndsAt.UTC().Format(time.RFC3339),
|
||||
}, token); err != nil {
|
||||
t.Fatalf("reschedule: %v", err)
|
||||
}
|
||||
|
||||
jobs := pendingJobsFor(t, repo, resp.BookingID)
|
||||
if len(jobs) != 1 {
|
||||
t.Fatalf("expected one pending job after reschedule, got %d", len(jobs))
|
||||
}
|
||||
expected := newStartsAt.Add(-24 * time.Hour)
|
||||
if !jobs[0].ScheduledFor.Equal(expected) {
|
||||
t.Fatalf("expected reminder at %v, got %v", expected, jobs[0].ScheduledFor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRescheduleRejectsTimeOutsideAvailabilityWindow(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo, nil, "http://localhost:3000")
|
||||
customerService := NewCustomerService(repo, nil, "http://localhost:3000")
|
||||
ctx := context.Background()
|
||||
|
||||
resp := bookAppointmentSlot(t, repo, service)
|
||||
manageURL, _ := url.Parse(resp.ManageURL)
|
||||
token := manageURL.Query().Get("token")
|
||||
|
||||
// 03:00 UTC next week is outside every availability rule.
|
||||
night := time.Now().UTC().AddDate(0, 0, 7).Truncate(24 * time.Hour).Add(3 * time.Hour)
|
||||
err := customerService.RescheduleBooking(ctx, resp.Reference, domain.RescheduleBookingRequest{
|
||||
NewStartsAt: night.UTC().Format(time.RFC3339),
|
||||
NewEndsAt: night.Add(time.Hour).UTC().Format(time.RFC3339),
|
||||
}, token)
|
||||
if !errors.Is(err, ErrInvalidReschedule) {
|
||||
t.Fatalf("expected ErrInvalidReschedule, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user