Files
Bookra/apps/backend/internal/bookings/service_test.go
T
Tomas DvorakandDevin 5c860a1264 feat: data surfacing — intake labels, service metrics, honest capacity
- booking views gain intakeLabels (declared key→label join); dashboard
  modal + manage view render real question labels with slug fallback
- GET /v1/metrics/services aggregates bookings/completed/cancelled/
  revenue per service + classBookings; service-report.csv delegates to
  the same ServiceMetrics aggregation and gains a completed column
- services page gains a Service performance card (per-service stat grid,
  locale CZK revenue, class-bookings footer); demo rows match the
  fabricated service list
- staff capacity utilization subtracts blocked days — tenant-wide and
  staff-scoped blocks remove that day's rule minutes via blockedAt
- tests: blocked-day capacity (tenant + staff scope), service aggregation

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 20:25:32 +02:00

1025 lines
33 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, 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", "[email protected]")
seed(2, 0, "confirmed", "[email protected]")
seed(3, 0, "cancelled", "[email protected]")
// Last 7 days: 1 confirmed booking that already ended (returning client).
seed(-2, 0, "confirmed", "[email protected]")
// 7-14 days ago: 2 completed bookings; b@'s first visit plus a new client.
seed(-8, 0, "confirmed", "[email protected]")
seed(-9, 0, "confirmed", "[email protected]")
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: "[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)
// Reschedule into a real availability slot on a later day, mirroring the
// manage-page slot picker — a blind +48h shift can land on a weekend or
// blocked day and fail validation for the wrong reason.
var newStartsAt, newEndsAt time.Time
horizon := time.Now().UTC().Add(25 * time.Hour)
for offset := 1; offset <= 14 && newStartsAt.IsZero(); offset++ {
date := time.Now().UTC().AddDate(0, 0, offset).Format("2006-01-02")
availability, err := service.Availability(ctx, "studio-atelier", AvailabilityFilter{Date: date})
if err != nil {
t.Fatalf("availability for %s: %v", date, err)
}
for _, slot := range availability.Slots {
if slot.Mode != "appointment" {
continue
}
starts, err := time.Parse(time.RFC3339, slot.StartsAt)
if err != nil {
t.Fatalf("parse slot start: %v", err)
}
ends, err := time.Parse(time.RFC3339, slot.EndsAt)
if err != nil {
t.Fatalf("parse slot end: %v", err)
}
// Stay above the 25h tier so the reminder re-arms at -24h.
if starts.Equal(booking.StartsAt) || !starts.After(horizon) {
continue
}
newStartsAt, newEndsAt = starts, ends
break
}
}
if newStartsAt.IsZero() {
t.Fatal("no alternative appointment slot found within 14 days")
}
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: "[email protected]",
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%[email protected]", i)))
}
first := book("[email protected]")
second := book("[email protected]")
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: "[email protected]",
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: "[email protected]",
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.BookedMinutes != 120 {
t.Fatalf("expected 120 booked minutes (2×60min confirmed), got %d", row.BookedMinutes)
}
if row.CapacityMinutes <= 0 {
t.Fatalf("expected positive capacity from Mon–Fri 9–17 rules, got %d", row.CapacityMinutes)
}
expectedUtilization := row.BookedMinutes * 100 / row.CapacityMinutes
if row.UtilizationPercent != expectedUtilization {
t.Fatalf("expected utilization %d (booked/capacity), got %d", expectedUtilization, row.UtilizationPercent)
}
if row.RevenueCents != 240000 {
t.Fatalf("expected 240000 revenue cents (2×1200 Kč), got %d", row.RevenueCents)
}
}
func TestStaffMetricsBlockedDayReducesCapacity(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
ctx := context.Background()
principal := domain.Principal{Subject: "demo-owner", Role: "owner"}
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
staffID := "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0"
baseline, err := service.StaffMetrics(ctx, principal, 30)
if err != nil {
t.Fatalf("baseline metrics: %v", err)
}
capacityBefore := baseline.Staff[0].CapacityMinutes
if capacityBefore <= 0 {
t.Fatalf("expected positive baseline capacity, got %d", capacityBefore)
}
// Block the most recent weekday inside the window, tenant-wide. The
// tenant runs Mon–Fri 09:00–17:00, so one blocked weekday removes
// exactly 480 capacity minutes.
loc, _ := time.LoadLocation("Europe/Prague")
now := time.Now().UTC()
var blocked time.Time
for i := 1; i <= 7; i++ {
day := now.AddDate(0, 0, -i).In(loc)
if day.Weekday() >= time.Monday && day.Weekday() <= time.Friday {
blocked = time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc)
break
}
}
if _, err := repo.CreateBlockedDay(ctx, db.CreateBlockedDayParams{
TenantID: tenantID,
StartsAt: blocked,
EndsAt: blocked.AddDate(0, 0, 1),
Kind: "closure",
}); err != nil {
t.Fatalf("create blocked day: %v", err)
}
after, err := service.StaffMetrics(ctx, principal, 30)
if err != nil {
t.Fatalf("metrics after block: %v", err)
}
want := capacityBefore - 480
if got := after.Staff[0].CapacityMinutes; got != want {
t.Fatalf("expected capacity %d after tenant-wide block, got %d", want, got)
}
// A staff-scoped block on a different weekday removes another 480.
var secondBlocked time.Time
for i := 1; i <= 7; i++ {
day := now.AddDate(0, 0, -i).In(loc)
dayStart := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc)
if day.Weekday() >= time.Monday && day.Weekday() <= time.Friday && !dayStart.Equal(blocked) {
secondBlocked = dayStart
break
}
}
if _, err := repo.CreateBlockedDay(ctx, db.CreateBlockedDayParams{
TenantID: tenantID,
StaffID: &staffID,
StartsAt: secondBlocked,
EndsAt: secondBlocked.AddDate(0, 0, 1),
Kind: "vacation",
}); err != nil {
t.Fatalf("create staff block: %v", err)
}
final, err := service.StaffMetrics(ctx, principal, 30)
if err != nil {
t.Fatalf("metrics after staff block: %v", err)
}
if got := final.Staff[0].CapacityMinutes; got != want-480 {
t.Fatalf("expected capacity %d after staff block, got %d", want-480, got)
}
}
func TestServiceMetricsAggregatesPerService(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
ctx := context.Background()
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
serviceID := "d5d76a61-3d49-467c-8dd4-bf61ee754e39" // Signature treatment, 1200 Kč
sessionID := "4bf74c12-44dd-45ca-86bb-b104f16f2435"
now := time.Now().UTC()
seed := func(service *string, session *string, status string, ref string) {
t.Helper()
_, err := repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: tenantID,
ServiceID: service,
ClassSessionID: session,
CustomerName: "Client",
CustomerEmail: "[email protected]",
StartsAt: now.Add(-5 * time.Hour),
EndsAt: now.Add(-4 * time.Hour),
Status: status,
Reference: ref,
})
if err != nil {
t.Fatalf("seed booking: %v", err)
}
}
seed(&serviceID, nil, "confirmed", "BK-SVM-1")
seed(&serviceID, nil, "completed", "BK-SVM-2")
seed(&serviceID, nil, "cancelled", "BK-SVM-3")
seed(nil, &sessionID, "confirmed", "BK-SVM-4")
seed(&serviceID, nil, "confirmed", "BK-SVM-OLD")
// Move the old booking outside the 30-day window via a direct update.
old, err := repo.GetBookingByReference(ctx, "BK-SVM-OLD")
if err != nil {
t.Fatalf("fetch old booking: %v", err)
}
if err := repo.RescheduleBooking(ctx, old.ID, now.AddDate(0, 0, -40), now.AddDate(0, 0, -40).Add(time.Hour)); err != nil {
t.Fatalf("reschedule old booking: %v", err)
}
response, err := service.ServiceMetrics(ctx, domain.Principal{Subject: "demo-owner", Role: "owner"}, 30)
if err != nil {
t.Fatalf("service metrics: %v", err)
}
var row domain.ServiceMetric
for _, m := range response.Services {
if m.ServiceID == serviceID {
row = m
}
}
if row.Bookings != 3 {
t.Fatalf("expected 3 bookings in window, got %d", row.Bookings)
}
if row.Completed != 1 || row.Cancelled != 1 {
t.Fatalf("expected 1 completed + 1 cancelled, got %d/%d", row.Completed, row.Cancelled)
}
// Revenue counts non-cancelled bookings at list price: confirmed + completed.
if row.RevenueCents != 240000 {
t.Fatalf("expected 240000 revenue cents (2×1200 Kč), got %d", row.RevenueCents)
}
if response.ClassBookings != 1 {
t.Fatalf("expected 1 class booking, got %d", response.ClassBookings)
}
}