mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
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>
402 lines
12 KiB
Go
402 lines
12 KiB
Go
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)
|
||
}
|
||
}
|