mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
The reminder pipeline fired regardless of what happened to the booking after creation: cancelled customers got reminded about bookings that weren't happening, reschedules kept the old reminder time, and waitlisted bookings armed jobs too. Pending jobs are now cancelled on both cancel paths, deleted and re-armed on reschedule, and dispatch joins bookings.status to skip anything not confirmed. Reschedule also gained the same availability-window and blocked-day validation as booking creation, closing a 3am-reschedule hole via the manage URL. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
643 lines
20 KiB
Go
643 lines
20 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 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",
|
||
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.
|
||
seed(1, 0, "confirmed")
|
||
seed(2, 0, "confirmed")
|
||
seed(3, 0, "cancelled")
|
||
// Last 7 days: 1 confirmed booking that already ended.
|
||
seed(-2, 0, "confirmed")
|
||
// 7-14 days ago: 2 completed bookings.
|
||
seed(-8, 0, "confirmed")
|
||
seed(-9, 0, "confirmed")
|
||
|
||
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) != 4 {
|
||
t.Fatalf("expected 4 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)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|