mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
The dashboard promised a new-clients card the API never produced; the metric now counts distinct customer emails whose earliest-ever booking lands in the window, joining the other four KPIs with the same trend comparison. The card grid rebalances for five items. Reminder dispatch also had no trigger — the endpoint existed but nothing called it. A scheduled GitHub Actions workflow now posts to it every 15 minutes using the job-runner key, so reminders actually fire once the two secrets are configured. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
651 lines
21 KiB
Go
651 lines
21 KiB
Go
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)
|
||
|
||
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)
|
||
}
|
||
}
|