Files
Bookra/apps/backend/internal/bookings/service_test.go
T
Tomas DvorakandDevin d9aa29dbcb feat(booking): stepped public booking flow with service/staff selection
The public booking page showed a capped flat preview of slots with no way
to pick a service, day, or staff member. Replaces it with a stepped flow:
pick service, optional staff preference, pick a day, pick a time.

Backend: availability accepts serviceId/staffId/date filters (default
response unchanged for the embed widget), a new public catalog endpoint
exposes bookable services and active staff, and slots carry staffName.
Slot generation and booking creation now honor tenant-wide and per-staff
blocked days; appointment requests must fit inside a matching
availability rule and reference tenant-owned, active staff and locations.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-18 10:18:03 +02:00

402 lines
12 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"
"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)
}
}