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, email 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", CustomerEmail: email, 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; two first-time clients // (b@ returns, having booked 9 days ago). seed(1, 0, "confirmed", "a@example.com") seed(2, 0, "confirmed", "b@example.com") seed(3, 0, "cancelled", "c@example.com") // Last 7 days: 1 confirmed booking that already ended (returning client). seed(-2, 0, "confirmed", "d@example.com") // 7-14 days ago: 2 completed bookings; b@'s first visit plus a new client. seed(-8, 0, "confirmed", "b@example.com") seed(-9, 0, "confirmed", "e@example.com") 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) != 5 { t.Fatalf("expected 5 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) } // a@ and c@ have their first-ever booking next week (2); d@ was the // only first-timer last week (1) — b@ first booked 8 days ago. newClients := byCode["new_clients"] if newClients.Value != "2" || newClients.Change != "+1" || newClients.Trend != "up" { t.Fatalf("new clients kpi = %+v", newClients) } } 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: "reminder-test@example.com", 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) } } func TestCreateArmsSmsReminderWhenEnabled(t *testing.T) { repo := db.NewMemoryRepository() service := NewService(repo, nil, "http://localhost:3000") ctx := context.Background() membership, _ := repo.GetTenantMembershipBySubject(ctx, "demo-owner") if err := repo.UpsertTenantSMSSettings(ctx, db.TenantSMSSettingsRecord{ TenantID: membership.Tenant.ID, Enabled: true, }); err != nil { t.Fatalf("sms settings: %v", err) } // Booking with a phone: expect email + sms reminder jobs. availability, err := service.Availability(ctx, "studio-atelier", AvailabilityFilter{ Date: time.Now().UTC().AddDate(0, 0, 3).Format("2006-01-02"), }) if err != nil { t.Fatalf("availability: %v", err) } var slot domain.TimeSlot for _, s := range availability.Slots { if s.Mode == "appointment" { slot = s break } } resp, err := service.Create(ctx, domain.CreateBookingRequest{ TenantSlug: "studio-atelier", BookingMode: "appointment", ServiceID: slot.ServiceID, StaffID: slot.StaffID, LocationID: slot.LocationID, CustomerName: "SMS Test", CustomerEmail: "sms@test.cz", CustomerPhone: "+420777123456", StartsAt: slot.StartsAt, EndsAt: slot.EndsAt, }) if err != nil { t.Fatalf("create: %v", err) } jobs := pendingJobsFor(t, repo, resp.BookingID) if len(jobs) != 2 { t.Fatalf("expected 2 pending jobs, got %d", len(jobs)) } channels := map[string]bool{} for _, job := range jobs { channels[job.Channel] = true } if !channels["email"] || !channels["sms"] { t.Fatalf("expected email+sms channels, got %v", channels) } } func TestWaitlistPositionReflectsQueueOrder(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: "Queue Test", 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 seats []domain.CreateBookingResponse for i := 0; i < 4; i++ { seats = append(seats, book(fmt.Sprintf("qseat%d@example.com", i))) } first := book("qfirst@example.com") second := book("qsecond@example.com") positionOf := func(resp domain.CreateBookingResponse) int { manageURL, _ := url.Parse(resp.ManageURL) view, err := customerService.GetBookingByReference(ctx, resp.Reference, manageURL.Query().Get("token")) if err != nil { t.Fatalf("get booking view: %v", err) } return view.WaitlistPosition } if got := positionOf(first); got != 1 { t.Fatalf("expected first waiter at position 1, got %d", got) } if got := positionOf(second); got != 2 { t.Fatalf("expected second waiter at position 2, got %d", got) } // Free a seat: first waiter is promoted, second moves to the front. manageURL, _ := url.Parse(seats[0].ManageURL) if err := customerService.CancelBooking(ctx, seats[0].Reference, manageURL.Query().Get("token")); err != nil { t.Fatalf("cancel: %v", err) } if got := positionOf(second); got != 1 { t.Fatalf("expected second waiter at position 1 after promotion, got %d", got) } if got := positionOf(first); got != 0 { t.Fatalf("expected promoted booking to report no position, got %d", got) } } func TestStaffMetricsAggregatesPerStaff(t *testing.T) { repo := db.NewMemoryRepository() service := NewService(repo, nil, "http://localhost:3000") ctx := context.Background() tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148" staffID := "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0" // Demo Stylist serviceID := "d5d76a61-3d49-467c-8dd4-bf61ee754e39" // Signature treatment, 1200 Kč now := time.Now().UTC() seed := func(offsetDays int, status string, withService bool) { t.Helper() params := db.CreateBookingParams{ TenantID: tenantID, StaffID: &staffID, CustomerName: "Client", CustomerEmail: "client@example.com", StartsAt: now.AddDate(0, 0, offsetDays), EndsAt: now.AddDate(0, 0, offsetDays).Add(time.Hour), Status: status, Reference: "BK-SM", } if withService { params.ServiceID = &serviceID } if _, err := repo.CreateBooking(ctx, params); err != nil { t.Fatalf("seed booking: %v", err) } } seed(-5, "confirmed", true) seed(-3, "confirmed", true) // already ended → completed too seed(-2, "cancelled", false) // cancelled → no revenue seed(-40, "confirmed", true) // outside the 30-day window // Class booking (no staff) must not appear in per-staff metrics. sessionID := "4bf74c12-44dd-45ca-86bb-b104f16f2435" if _, err := repo.CreateBooking(ctx, db.CreateBookingParams{ TenantID: tenantID, ClassSessionID: &sessionID, CustomerName: "Class Client", CustomerEmail: "class@example.com", StartsAt: now.AddDate(0, 0, -1), EndsAt: now.AddDate(0, 0, -1).Add(time.Hour), Status: "confirmed", Reference: "BK-SM-CLASS", }); err != nil { t.Fatalf("seed class booking: %v", err) } response, err := service.StaffMetrics(ctx, domain.Principal{Subject: "demo-owner", Role: "owner"}, 30) if err != nil { t.Fatalf("staff metrics: %v", err) } if len(response.Staff) != 1 { t.Fatalf("expected 1 staff row, got %d", len(response.Staff)) } row := response.Staff[0] if row.StaffID != staffID || row.DisplayName != "Demo Stylist" { t.Fatalf("unexpected staff row identity: %+v", row) } if row.Bookings != 3 { t.Fatalf("expected 3 bookings in window, got %d", row.Bookings) } if row.Cancelled != 1 { t.Fatalf("expected 1 cancellation, got %d", row.Cancelled) } if row.UtilizationPercent != 66 { t.Fatalf("expected 66%% utilization, got %d", row.UtilizationPercent) } if row.RevenueCents != 240000 { t.Fatalf("expected 240000 revenue cents (2×1200 Kč), got %d", row.RevenueCents) } }