Files
Bookra/apps/backend/internal/bookings/service_test.go
T
Tomas DvorakandDevin cdad7875e1 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>
2026-09-18 12:47:07 +02:00

643 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package bookings
import (
"context"
"errors"
"fmt"
"net/url"
"testing"
"time"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
func TestCreateAppointmentRejectsConflict(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{})
if err != nil {
t.Fatalf("availability: %v", err)
}
var appointment domain.TimeSlot
for _, slot := range availability.Slots {
if slot.Mode == "appointment" {
appointment = slot
break
}
}
if appointment.StartsAt == "" {
t.Fatal("expected appointment slot")
}
first, err := service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "appointment",
ServiceID: appointment.ServiceID,
StaffID: appointment.StaffID,
LocationID: appointment.LocationID,
CustomerName: "First",
CustomerEmail: "[email protected]",
StartsAt: appointment.StartsAt,
EndsAt: appointment.EndsAt,
})
if err != nil {
t.Fatalf("first create: %v", err)
}
if first.Status != "confirmed" {
t.Fatalf("expected confirmed, got %s", first.Status)
}
_, err = service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "appointment",
ServiceID: appointment.ServiceID,
StaffID: appointment.StaffID,
LocationID: appointment.LocationID,
CustomerName: "Second",
CustomerEmail: "[email protected]",
StartsAt: appointment.StartsAt,
EndsAt: appointment.EndsAt,
})
if err == nil {
t.Fatal("expected conflict error")
}
if err != ErrBookingConflict {
t.Fatalf("expected ErrBookingConflict, got %v", err)
}
}
func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{})
if err != nil {
t.Fatalf("availability: %v", err)
}
var classSlot domain.TimeSlot
for _, slot := range availability.Slots {
if slot.Mode == "class" {
classSlot = slot
break
}
}
if classSlot.ClassSessionID == nil {
t.Fatal("expected class slot")
}
for i := 0; i < 4; i++ {
response, err := service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "class",
ClassSessionID: classSlot.ClassSessionID,
LocationID: classSlot.LocationID,
CustomerName: "Capacity",
CustomerEmail: "[email protected]",
StartsAt: classSlot.StartsAt,
EndsAt: classSlot.EndsAt,
})
if err != nil {
t.Fatalf("create within capacity: %v", err)
}
if response.Status != "confirmed" {
t.Fatalf("expected confirmed within capacity, got %s", response.Status)
}
}
response, err := service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "class",
ClassSessionID: classSlot.ClassSessionID,
LocationID: classSlot.LocationID,
CustomerName: "Waitlist",
CustomerEmail: "[email protected]",
StartsAt: classSlot.StartsAt,
EndsAt: classSlot.EndsAt,
})
if err != nil {
t.Fatalf("create waitlist: %v", err)
}
if response.Status != "waitlisted" {
t.Fatalf("expected waitlisted, got %s", response.Status)
}
}
func TestCreateAppointmentRequiresTenantService(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
_, err := service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "appointment",
CustomerName: "Missing Service",
CustomerEmail: "[email protected]",
StartsAt: time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339),
EndsAt: time.Now().UTC().Add(25 * time.Hour).Format(time.RFC3339),
})
if !errors.Is(err, ErrInvalidBooking) {
t.Fatalf("expected ErrInvalidBooking, got %v", err)
}
}
func TestCreateClassRequiresExistingSession(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
missingSessionID := "11111111-1111-1111-1111-111111111111"
_, err := service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "class",
ClassSessionID: &missingSessionID,
CustomerName: "Missing Session",
CustomerEmail: "[email protected]",
StartsAt: time.Now().UTC().Add(48 * time.Hour).Format(time.RFC3339),
EndsAt: time.Now().UTC().Add(49 * time.Hour).Format(time.RFC3339),
})
if !errors.Is(err, ErrInvalidBooking) {
t.Fatalf("expected ErrInvalidBooking, got %v", err)
}
}
func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{})
if err != nil {
t.Fatalf("availability: %v", err)
}
if len(availability.Slots) == 0 {
t.Fatal("expected slots")
}
for _, slot := range availability.Slots {
startsAt, err := time.Parse(time.RFC3339, slot.StartsAt)
if err != nil {
t.Fatalf("parse startsAt: %v", err)
}
if startsAt.Before(time.Now().UTC().Add(90 * time.Minute)) {
t.Fatalf("expected upcoming slot, got %s", slot.StartsAt)
}
}
}
func TestCreateSchedulesReminderJobForUpcomingAppointment(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{Date: nextRuleDay(t, 2)})
if err != nil {
t.Fatalf("availability: %v", err)
}
var appointment domain.TimeSlot
for _, slot := range availability.Slots {
if slot.Mode == "appointment" {
appointment = slot
break
}
}
if appointment.StartsAt == "" {
t.Fatal("expected appointment slot")
}
_, err = service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "appointment",
ServiceID: appointment.ServiceID,
StaffID: appointment.StaffID,
LocationID: appointment.LocationID,
CustomerName: "Reminder",
CustomerEmail: "[email protected]",
StartsAt: appointment.StartsAt,
EndsAt: appointment.EndsAt,
})
if err != nil {
t.Fatalf("create: %v", err)
}
reminders, err := repo.ListDueReminderJobs(context.Background(), time.Now().UTC().Add(365*24*time.Hour), 10)
if err != nil {
t.Fatalf("list reminder jobs: %v", err)
}
if len(reminders) == 0 {
t.Fatal("expected reminder job to be scheduled")
}
}
// nextRuleDay returns the first day at least minOffset days out whose weekday
// has a seeded availability rule (Mon–Fri), formatted YYYY-MM-DD.
func nextRuleDay(t *testing.T, minOffset int) string {
t.Helper()
for offset := minOffset; offset < minOffset+7; offset++ {
day := time.Now().AddDate(0, 0, offset)
if day.Weekday() >= time.Monday && day.Weekday() <= time.Friday {
return day.Format("2006-01-02")
}
}
t.Fatal("no rule day found")
return ""
}
func TestAvailabilityDateFilterReturnsFullDaySlots(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
day := nextRuleDay(t, 2)
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{Date: day})
if err != nil {
t.Fatalf("availability: %v", err)
}
if len(availability.Slots) == 0 {
t.Fatal("expected day slots")
}
tenantLocation, err := time.LoadLocation(availability.Timezone)
if err != nil {
t.Fatalf("load timezone: %v", err)
}
for _, slot := range availability.Slots {
if slot.Mode != "appointment" {
t.Fatalf("expected only appointment slots, got %s", slot.Mode)
}
startsAt, err := time.Parse(time.RFC3339, slot.StartsAt)
if err != nil {
t.Fatalf("parse startsAt: %v", err)
}
if startsAt.In(tenantLocation).Format("2006-01-02") != day {
t.Fatalf("slot %s outside requested day %s", slot.StartsAt, day)
}
if slot.StaffName == nil || *slot.StaffName == "" {
t.Fatal("expected staffName on slot")
}
}
}
func TestAvailabilityHonorsTenantWideBlockedDay(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
day := nextRuleDay(t, 2)
dayStart, err := time.Parse("2006-01-02", day)
if err != nil {
t.Fatalf("parse day: %v", err)
}
if _, err := repo.CreateBlockedDay(context.Background(), db.CreateBlockedDayParams{
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
StartsAt: dayStart.UTC(),
EndsAt: dayStart.Add(26 * time.Hour).UTC(),
Kind: "blocked",
Reason: "holiday",
}); err != nil {
t.Fatalf("create blocked day: %v", err)
}
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{Date: day})
if err != nil {
t.Fatalf("availability: %v", err)
}
if len(availability.Slots) != 0 {
t.Fatalf("expected no slots on blocked day, got %d", len(availability.Slots))
}
}
func TestAvailabilityStaffFilterValidatesTenantStaff(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
foreignStaff := "22222222-2222-2222-2222-222222222222"
_, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{StaffID: foreignStaff})
if !errors.Is(err, ErrInvalidBooking) {
t.Fatalf("expected ErrInvalidBooking, got %v", err)
}
}
func TestCatalogReturnsServicesAndActiveStaff(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
catalog, err := service.Catalog(context.Background(), "studio-atelier")
if err != nil {
t.Fatalf("catalog: %v", err)
}
if len(catalog.Services) == 0 {
t.Fatal("expected services")
}
if len(catalog.Staff) == 0 {
t.Fatal("expected staff")
}
for _, staff := range catalog.Staff {
if staff.DisplayName == "" {
t.Fatal("expected display name")
}
}
}
func TestCreateAppointmentRejectsTimeOutsideAvailabilityWindow(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
catalog, err := service.Catalog(context.Background(), "studio-atelier")
if err != nil {
t.Fatalf("catalog: %v", err)
}
serviceID := catalog.Services[0].ID
staffID := catalog.Staff[0].ID
// 03:00 on a rule day is outside the seeded 09:00–17:00 window.
day := nextRuleDay(t, 2)
startsAt, err := time.Parse(time.RFC3339, day+"T03:00:00Z")
if err != nil {
t.Fatalf("parse startsAt: %v", err)
}
_, err = service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "appointment",
ServiceID: &serviceID,
StaffID: &staffID,
CustomerName: "Night Owl",
CustomerEmail: "[email protected]",
StartsAt: startsAt.Format(time.RFC3339),
EndsAt: startsAt.Add(time.Hour).Format(time.RFC3339),
})
if !errors.Is(err, ErrInvalidBooking) {
t.Fatalf("expected ErrInvalidBooking, got %v", err)
}
}
func TestCreateAppointmentRejectsForeignStaff(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{})
if err != nil {
t.Fatalf("availability: %v", err)
}
var appointment domain.TimeSlot
for _, slot := range availability.Slots {
if slot.Mode == "appointment" {
appointment = slot
break
}
}
if appointment.StartsAt == "" {
t.Fatal("expected appointment slot")
}
foreignStaff := "22222222-2222-2222-2222-222222222222"
_, err = service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "appointment",
ServiceID: appointment.ServiceID,
StaffID: &foreignStaff,
CustomerName: "Impersonator",
CustomerEmail: "[email protected]",
StartsAt: appointment.StartsAt,
EndsAt: appointment.EndsAt,
})
if !errors.Is(err, ErrInvalidBooking) {
t.Fatalf("expected ErrInvalidBooking, got %v", err)
}
}
func TestCustomerCancelPromotesWaitlist(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
customerService := NewCustomerService(repo, nil, "http://localhost:3000")
ctx := context.Background()
session, err := repo.GetClassSessionByID(ctx, "4bf74c12-44dd-45ca-86bb-b104f16f2435")
if err != nil {
t.Fatalf("get session: %v", err)
}
sessionID := session.ID
book := func(email string) domain.CreateBookingResponse {
resp, err := service.Create(ctx, domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "class",
ClassSessionID: &sessionID,
CustomerName: "Test User",
CustomerEmail: email,
StartsAt: session.StartsAt.UTC().Format(time.RFC3339),
EndsAt: session.EndsAt.UTC().Format(time.RFC3339),
})
if err != nil {
t.Fatalf("book %s: %v", email, err)
}
return resp
}
var confirmed []domain.CreateBookingResponse
for i := 0; i < 4; i++ {
confirmed = append(confirmed, book(fmt.Sprintf("seat%[email protected]", i)))
}
waiter := book("[email protected]")
if waiter.Status != "waitlisted" {
t.Fatalf("expected waitlisted, got %s", waiter.Status)
}
// Customer cancels a confirmed seat via the management token.
manageURL, _ := url.Parse(confirmed[0].ManageURL)
token := manageURL.Query().Get("token")
if err := customerService.CancelBooking(ctx, confirmed[0].Reference, token); err != nil {
t.Fatalf("customer cancel: %v", err)
}
booking, _ := repo.GetBookingByID(ctx, waiter.BookingID)
if booking.Status != "confirmed" {
t.Fatalf("expected waiter promoted to confirmed, got %q", booking.Status)
}
}
func TestDashboardSummaryComputesKPIsWithTrends(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
ctx := context.Background()
membership, err := repo.GetTenantMembershipBySubject(ctx, "demo-owner")
if err != nil {
t.Fatalf("membership: %v", err)
}
tenantID := membership.Tenant.ID
now := time.Now().UTC()
seed := func(offsetDays int, hours int, status string) {
t.Helper()
starts := now.AddDate(0, 0, offsetDays).Add(time.Duration(hours) * time.Hour)
_, err := repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: tenantID,
BookingMode: "appointment",
StartsAt: starts,
EndsAt: starts.Add(time.Hour),
Status: status,
Reference: "BK-T",
})
if err != nil {
t.Fatalf("seed booking: %v", err)
}
}
// Next 7 days: 3 bookings, one cancelled.
seed(1, 0, "confirmed")
seed(2, 0, "confirmed")
seed(3, 0, "cancelled")
// Last 7 days: 1 confirmed booking that already ended.
seed(-2, 0, "confirmed")
// 7-14 days ago: 2 completed bookings.
seed(-8, 0, "confirmed")
seed(-9, 0, "confirmed")
summary, err := service.DashboardSummary(ctx, domain.Principal{Subject: "demo-owner", Role: "owner"})
if err != nil {
t.Fatalf("dashboard summary: %v", err)
}
if len(summary.KPIs) != 4 {
t.Fatalf("expected 4 kpis, got %d", len(summary.KPIs))
}
byCode := map[string]domain.DashboardKPI{}
for _, kpi := range summary.KPIs {
byCode[kpi.Code] = kpi
}
bookings := byCode["bookings_this_week"]
if bookings.Value != "3" || bookings.Change != "+2" || bookings.Trend != "up" {
t.Fatalf("bookings kpi = %+v", bookings)
}
completed := byCode["completed"]
if completed.Value != "1" || completed.Change != "-1" || completed.Trend != "down" {
t.Fatalf("completed kpi = %+v", completed)
}
cancellations := byCode["cancellations"]
if cancellations.Value != "1" || cancellations.Change != "+1" || cancellations.Trend != "up" {
t.Fatalf("cancellations kpi = %+v", cancellations)
}
utilization := byCode["utilization"]
if utilization.Value != "66%" || utilization.Trend != "down" {
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)
}
}