package bookings import ( "context" "errors" "testing" "time" "bookra/apps/backend/internal/db" "bookra/apps/backend/internal/domain" ) func TestCreateAppointmentRejectsConflict(t *testing.T) { repo := db.NewMemoryRepository() service := NewService(repo, nil) availability, err := service.Availability(context.Background(), "studio-atelier") 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: "first@example.com", 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: "second@example.com", 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) availability, err := service.Availability(context.Background(), "studio-atelier") 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: "capacity@example.com", 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: "waitlist@example.com", 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) _, err := service.Create(context.Background(), domain.CreateBookingRequest{ TenantSlug: "studio-atelier", BookingMode: "appointment", CustomerName: "Missing Service", CustomerEmail: "missing@example.com", 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) missingSessionID := "11111111-1111-1111-1111-111111111111" _, err := service.Create(context.Background(), domain.CreateBookingRequest{ TenantSlug: "studio-atelier", BookingMode: "class", ClassSessionID: &missingSessionID, CustomerName: "Missing Session", CustomerEmail: "missing@example.com", 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) availability, err := service.Availability(context.Background(), "studio-atelier") 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) availability, err := service.Availability(context.Background(), "studio-atelier") 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: "reminder@example.com", StartsAt: time.Now().UTC().Add(30 * time.Hour).Format(time.RFC3339), EndsAt: time.Now().UTC().Add(31 * time.Hour).Format(time.RFC3339), }) 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") } }