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: "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, "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: "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, "http://localhost:3000") _, 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, "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: "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, "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: "reminder@example.com", 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: "night@example.com", 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: "fake@example.com", 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%d@example.com", i))) } waiter := book("waiter@example.com") 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) } }