Files
Bookra/apps/backend/internal/catalog/service_test.go
T
Tomas DvorakandDevin 668a54ae1d feat(bookings): dashboard cancellation and waitlist promotion
- POST /v1/catalog/bookings/{id}/cancel lets businesses cancel
  tenant-owned bookings; two-step confirm in the detail modal
- Shared PromoteWaitlist helper promotes earliest waitlisted bookings
  when a class seat frees, on both dashboard and customer cancel paths;
  promoted customers get a confirmation email, waitlist ledger marked
  resolved
- waitlisted status surfaced in bookings UI: stats card, filter, warning
  badge, demo seed, CS+EN strings
- BookingRecord gains created_at for fair ordering; GetBookingByID and
  ResolveWaitlistEntry added to the repository surface

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

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

643 lines
21 KiB
Go

package catalog
import (
"context"
"errors"
"fmt"
"testing"
"time"
"bookra/apps/backend/internal/bookings"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
func boolPtr(v bool) *bool { return &v }
var owner = domain.Principal{Subject: "demo-owner", Email: "[email protected]", Role: "owner"}
type stubBilling struct {
entitlements domain.PlanEntitlements
}
func (s stubBilling) GetEntitlements(_ context.Context, _ string) (domain.PlanEntitlements, error) {
return s.entitlements, nil
}
func newCatalogService(repo db.Repository) *Service {
return NewService(repo, stubBilling{entitlements: domain.PlanEntitlements{MaxStaff: -1}}, nil)
}
func TestListServicesReturnsSeededService(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
services, err := svc.ListServices(context.Background(), owner)
if err != nil {
t.Fatalf("list services: %v", err)
}
if len(services) != 1 {
t.Fatalf("expected 1 seeded service, got %d", len(services))
}
if services[0].Name != "Signature treatment" {
t.Fatalf("unexpected service name %q", services[0].Name)
}
}
func TestServiceCRUDLifecycle(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
created, err := svc.CreateService(ctx, owner, domain.CreateServiceRequest{
Name: " Haircut ",
DurationMinutes: 45,
BufferBeforeMinutes: 5,
BufferAfterMinutes: 10,
PriceCents: 90000,
})
if err != nil {
t.Fatalf("create service: %v", err)
}
if created.Name != "Haircut" {
t.Fatalf("expected trimmed name, got %q", created.Name)
}
if created.TenantID == "" {
t.Fatal("expected tenant id on created service")
}
services, err := svc.ListServices(ctx, owner)
if err != nil {
t.Fatalf("list services: %v", err)
}
if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services))
}
zero := 0
if _, err := svc.UpdateService(ctx, owner, created.ID, domain.UpdateServiceRequest{DurationMinutes: &zero}); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput for zero duration, got %v", err)
}
name := "Deluxe haircut"
price := 110000
updated, err := svc.UpdateService(ctx, owner, created.ID, domain.UpdateServiceRequest{Name: &name, PriceCents: &price})
if err != nil {
t.Fatalf("update service: %v", err)
}
if updated.Name != "Deluxe haircut" || updated.PriceCents != 110000 {
t.Fatalf("unexpected update result %+v", updated)
}
if updated.DurationMinutes != 45 || updated.BufferAfterMinutes != 10 {
t.Fatalf("partial update clobbered untouched fields %+v", updated)
}
if err := svc.DeleteService(ctx, owner, created.ID); err != nil {
t.Fatalf("delete service: %v", err)
}
services, err = svc.ListServices(ctx, owner)
if err != nil {
t.Fatalf("list services: %v", err)
}
if len(services) != 1 {
t.Fatalf("expected 1 service after delete, got %d", len(services))
}
}
func TestServiceValidation(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
cases := []struct {
name string
req domain.CreateServiceRequest
}{
{"empty name", domain.CreateServiceRequest{Name: " ", DurationMinutes: 30}},
{"zero duration", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 0}},
{"huge duration", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 2000}},
{"negative buffer", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 30, BufferBeforeMinutes: -1}},
{"negative price", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 30, PriceCents: -5}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := svc.CreateService(ctx, owner, tc.req); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput, got %v", err)
}
})
}
}
func TestServiceNotFoundAndMembership(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
missingID := "00000000-0000-0000-0000-000000000001"
if _, err := svc.UpdateService(ctx, owner, missingID, domain.UpdateServiceRequest{}); !errors.Is(err, ErrServiceNotFound) {
t.Fatalf("expected ErrServiceNotFound, got %v", err)
}
if err := svc.DeleteService(ctx, owner, missingID); !errors.Is(err, ErrServiceNotFound) {
t.Fatalf("expected ErrServiceNotFound, got %v", err)
}
stranger := domain.Principal{Subject: "someone-else", Role: "owner"}
if _, err := svc.ListServices(ctx, stranger); !errors.Is(err, ErrTenantMembership) {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
if _, err := svc.CreateService(ctx, stranger, domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 30}); !errors.Is(err, ErrTenantMembership) {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
}
func TestStaffCRUDLifecycle(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
locations, err := svc.ListLocations(ctx, owner)
if err != nil || len(locations) == 0 {
t.Fatalf("expected seeded location, got %v err=%v", locations, err)
}
locationID := locations[0].ID
created, err := svc.CreateStaff(ctx, owner, domain.CreateStaffRequest{
DisplayName: " Anna Novak ",
LocationID: &locationID,
})
if err != nil {
t.Fatalf("create staff: %v", err)
}
if created.DisplayName != "Anna Novak" {
t.Fatalf("expected trimmed name, got %q", created.DisplayName)
}
if !created.Active {
t.Fatal("new staff member should default to active")
}
if created.LocationID == nil || *created.LocationID != locationID {
t.Fatalf("expected location assignment %s, got %+v", locationID, created.LocationID)
}
staff, err := svc.ListStaff(ctx, owner)
if err != nil {
t.Fatalf("list staff: %v", err)
}
if len(staff) != 2 {
t.Fatalf("expected seeded member + new member, got %d", len(staff))
}
inactive := false
cleared := ""
updated, err := svc.UpdateStaff(ctx, owner, created.ID, domain.UpdateStaffRequest{
Active: &inactive,
LocationID: &cleared,
})
if err != nil {
t.Fatalf("update staff: %v", err)
}
if updated.Active {
t.Fatal("expected staff member to be deactivated")
}
if updated.LocationID != nil {
t.Fatalf("expected cleared location, got %+v", updated.LocationID)
}
if updated.DisplayName != "Anna Novak" {
t.Fatalf("name should be untouched, got %q", updated.DisplayName)
}
if err := svc.DeleteStaff(ctx, owner, created.ID); err != nil {
t.Fatalf("delete staff: %v", err)
}
staff, err = svc.ListStaff(ctx, owner)
if err != nil {
t.Fatalf("list staff: %v", err)
}
if len(staff) != 1 {
t.Fatalf("expected 1 staff member after delete, got %d", len(staff))
}
}
func TestCreateStaffEnforcesPlanLimit(t *testing.T) {
repo := db.NewMemoryRepository()
// Demo tenant seeds one member; a limit of 1 must reject a second.
svc := NewService(repo, stubBilling{entitlements: domain.PlanEntitlements{MaxStaff: 1}}, nil)
_, err := svc.CreateStaff(context.Background(), owner, domain.CreateStaffRequest{DisplayName: "Second"})
if !errors.Is(err, ErrPlanLimitReached) {
t.Fatalf("expected ErrPlanLimitReached, got %v", err)
}
}
func TestCreateStaffRejectsForeignLocation(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
foreign := "00000000-0000-0000-0000-000000000099"
_, err := svc.CreateStaff(context.Background(), owner, domain.CreateStaffRequest{
DisplayName: "Anna",
LocationID: &foreign,
})
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput, got %v", err)
}
}
func TestStaffNotFoundAndValidation(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
if _, err := svc.CreateStaff(ctx, owner, domain.CreateStaffRequest{DisplayName: " "}); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput for blank name, got %v", err)
}
missingID := "00000000-0000-0000-0000-000000000001"
if _, err := svc.UpdateStaff(ctx, owner, missingID, domain.UpdateStaffRequest{}); !errors.Is(err, ErrStaffNotFound) {
t.Fatalf("expected ErrStaffNotFound, got %v", err)
}
if err := svc.DeleteStaff(ctx, owner, missingID); !errors.Is(err, ErrStaffNotFound) {
t.Fatalf("expected ErrStaffNotFound, got %v", err)
}
stranger := domain.Principal{Subject: "someone-else", Role: "staff"}
if _, err := svc.ListStaff(ctx, stranger); !errors.Is(err, ErrTenantMembership) {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
}
const demoStaffID = "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0"
func TestListStaffWorkingHours(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
hours, err := svc.ListStaffWorkingHours(context.Background(), owner, demoStaffID)
if err != nil {
t.Fatalf("list staff working hours: %v", err)
}
if len(hours) != 5 {
t.Fatalf("expected 5 seeded weekday rules, got %d", len(hours))
}
for _, wh := range hours {
if wh.Open != "09:00" || wh.Close != "17:00" {
t.Fatalf("day %d: expected 09:00-17:00, got %s-%s", wh.DayOfWeek, wh.Open, wh.Close)
}
}
}
func TestUpdateStaffWorkingHours(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
err := svc.UpdateStaffWorkingHours(ctx, owner, demoStaffID, 1, domain.UpdateWorkingHoursRequest{
IsOpen: boolPtr(true),
Open: "08:00",
Close: "12:00",
})
if err != nil {
t.Fatalf("update staff hours: %v", err)
}
hours, err := svc.ListStaffWorkingHours(ctx, owner, demoStaffID)
if err != nil {
t.Fatalf("list staff hours: %v", err)
}
found := false
for _, wh := range hours {
if wh.DayOfWeek == 1 {
found = true
if wh.Open != "08:00" || wh.Close != "12:00" {
t.Fatalf("expected Monday 08:00-12:00, got %s-%s", wh.Open, wh.Close)
}
}
}
if !found {
t.Fatal("Monday rule missing after update")
}
// Closing a day removes the rule.
err = svc.UpdateStaffWorkingHours(ctx, owner, demoStaffID, 2, domain.UpdateWorkingHoursRequest{
IsOpen: boolPtr(false),
})
if err != nil {
t.Fatalf("close staff day: %v", err)
}
hours, _ = svc.ListStaffWorkingHours(ctx, owner, demoStaffID)
for _, wh := range hours {
if wh.DayOfWeek == 2 {
t.Fatal("Tuesday rule should have been removed")
}
}
}
func TestStaffWorkingHoursRejectsForeignStaff(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
missing := "00000000-0000-0000-0000-000000000099"
if _, err := svc.ListStaffWorkingHours(ctx, owner, missing); !errors.Is(err, ErrStaffNotFound) {
t.Fatalf("expected ErrStaffNotFound, got %v", err)
}
err := svc.UpdateStaffWorkingHours(ctx, owner, missing, 1, domain.UpdateWorkingHoursRequest{
IsOpen: boolPtr(true), Open: "09:00", Close: "17:00",
})
if !errors.Is(err, ErrStaffNotFound) {
t.Fatalf("expected ErrStaffNotFound, got %v", err)
}
err = svc.UpdateStaffWorkingHours(ctx, owner, demoStaffID, 1, domain.UpdateWorkingHoursRequest{
IsOpen: boolPtr(true), Open: "9am", Close: "17:00",
})
if !errors.Is(err, ErrInvalidBooking) {
t.Fatalf("expected ErrInvalidBooking for bad time, got %v", err)
}
}
func TestClassTemplateCRUD(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
templates, err := svc.ListClassTemplates(ctx, owner)
if err != nil {
t.Fatalf("list templates: %v", err)
}
if len(templates) != 1 || templates[0].Title != "Small group mobility class" {
t.Fatalf("expected seeded template, got %+v", templates)
}
created, err := svc.CreateClassTemplate(ctx, owner, domain.UpsertClassTemplateRequest{
Title: " Yoga Basics ",
DurationMinutes: 45,
Capacity: 8,
})
if err != nil {
t.Fatalf("create template: %v", err)
}
if created.Title != "Yoga Basics" {
t.Fatalf("expected trimmed title, got %q", created.Title)
}
if err := svc.DeleteClassTemplate(ctx, owner, created.ID); err != nil {
t.Fatalf("delete template: %v", err)
}
templates, _ = svc.ListClassTemplates(ctx, owner)
if len(templates) != 1 {
t.Fatalf("expected 1 template after delete, got %d", len(templates))
}
}
func TestClassTemplateValidation(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
cases := []domain.UpsertClassTemplateRequest{
{Title: " ", DurationMinutes: 60, Capacity: 4},
{Title: "X", DurationMinutes: 0, Capacity: 4},
{Title: "X", DurationMinutes: 60, Capacity: 0},
}
for i, req := range cases {
if _, err := svc.CreateClassTemplate(ctx, owner, req); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("case %d: expected ErrInvalidInput, got %v", i, err)
}
}
missing := "00000000-0000-0000-0000-000000000099"
if err := svc.DeleteClassTemplate(ctx, owner, missing); !errors.Is(err, ErrClassTemplateNotFound) {
t.Fatalf("expected ErrClassTemplateNotFound, got %v", err)
}
}
func TestDeleteClassTemplateCascadesSessions(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
if err := svc.DeleteClassTemplate(ctx, owner, "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7"); err != nil {
t.Fatalf("delete template: %v", err)
}
sessions, err := svc.ListClassSessions(ctx, owner)
if err != nil {
t.Fatalf("list sessions: %v", err)
}
if len(sessions) != 0 {
t.Fatalf("expected sessions to cascade-delete, got %d", len(sessions))
}
}
func TestListClassSessionsEnrichment(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
sessions, err := svc.ListClassSessions(context.Background(), owner)
if err != nil {
t.Fatalf("list sessions: %v", err)
}
if len(sessions) != 1 {
t.Fatalf("expected 1 seeded session, got %d", len(sessions))
}
s := sessions[0]
if s.TemplateTitle != "Small group mobility class" {
t.Fatalf("expected template title, got %q", s.TemplateTitle)
}
if s.LocationName != "Main Studio" {
t.Fatalf("expected location name, got %q", s.LocationName)
}
if s.Capacity != 4 || s.BookedCount != 0 {
t.Fatalf("expected capacity 4 booked 0, got %d/%d", s.Capacity, s.BookedCount)
}
}
func TestCreateClassSession(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
startsAt := time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339)
cap := 6
created, err := svc.CreateClassSession(ctx, owner, domain.CreateClassSessionRequest{
TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7",
StartsAt: startsAt,
Capacity: &cap,
})
if err != nil {
t.Fatalf("create session: %v", err)
}
if created.Capacity != 6 {
t.Fatalf("expected override capacity 6, got %d", created.Capacity)
}
if !created.EndsAt.Equal(created.StartsAt.Add(60 * time.Minute)) {
t.Fatalf("expected end = start + 60m, got %s -> %s", created.StartsAt, created.EndsAt)
}
}
func TestCreateClassSessionValidation(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
future := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
past := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
template := "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7"
badLocation := "00000000-0000-0000-0000-000000000099"
badCap := 0
cases := []struct {
name string
req domain.CreateClassSessionRequest
want error
}{
{"missing template", domain.CreateClassSessionRequest{TemplateID: "00000000-0000-0000-0000-000000000099", StartsAt: future}, ErrClassTemplateNotFound},
{"bad start format", domain.CreateClassSessionRequest{TemplateID: template, StartsAt: "tomorrow"}, ErrInvalidInput},
{"past start", domain.CreateClassSessionRequest{TemplateID: template, StartsAt: past}, ErrInvalidInput},
{"foreign location", domain.CreateClassSessionRequest{TemplateID: template, StartsAt: future, LocationID: &badLocation}, ErrLocationNotFound},
{"zero capacity", domain.CreateClassSessionRequest{TemplateID: template, StartsAt: future, Capacity: &badCap}, ErrInvalidInput},
}
for _, tc := range cases {
if _, err := svc.CreateClassSession(ctx, owner, tc.req); !errors.Is(err, tc.want) {
t.Fatalf("%s: expected %v, got %v", tc.name, tc.want, err)
}
}
}
func TestDeleteClassSessionPreservesBooking(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
// Book the seeded class session via the public booking service.
bookingSvc := bookings.NewService(repo, nil, "")
sessionID := "4bf74c12-44dd-45ca-86bb-b104f16f2435"
session, err := repo.GetClassSessionByID(ctx, sessionID)
if err != nil {
t.Fatalf("get seeded session: %v", err)
}
_, err = bookingSvc.Create(ctx, domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
CustomerName: "Kate",
CustomerEmail: "[email protected]",
BookingMode: "class",
ClassSessionID: &sessionID,
StartsAt: session.StartsAt.UTC().Format(time.RFC3339),
EndsAt: session.EndsAt.UTC().Format(time.RFC3339),
})
if err != nil {
t.Fatalf("book class session: %v", err)
}
sessions, _ := svc.ListClassSessions(ctx, owner)
if sessions[0].BookedCount != 1 {
t.Fatalf("expected bookedCount 1, got %d", sessions[0].BookedCount)
}
if err := svc.DeleteClassSession(ctx, owner, sessionID); err != nil {
t.Fatalf("delete session: %v", err)
}
// The booking survives but no longer references the session.
bookingsList, err := repo.ListBookingsByTenantBetween(ctx, sessions[0].TenantID, time.Now().Add(-time.Hour), time.Now().Add(200*time.Hour))
if err != nil {
t.Fatalf("list bookings: %v", err)
}
if len(bookingsList) != 1 {
t.Fatalf("expected booking to survive, got %d", len(bookingsList))
}
if bookingsList[0].ClassSessionID != nil {
t.Fatal("expected booking class_session_id to be nulled")
}
}
const demoSessionID = "4bf74c12-44dd-45ca-86bb-b104f16f2435"
func bookClass(t *testing.T, repo db.Repository, email string) domain.CreateBookingResponse {
t.Helper()
session, err := repo.GetClassSessionByID(context.Background(), demoSessionID)
if err != nil {
t.Fatalf("get session: %v", err)
}
bookingSvc := bookings.NewService(repo, nil, "")
resp, err := bookingSvc.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
CustomerName: "Test User",
CustomerEmail: email,
BookingMode: "class",
ClassSessionID: &[]string{demoSessionID}[0],
StartsAt: session.StartsAt.UTC().Format(time.RFC3339),
EndsAt: session.EndsAt.UTC().Format(time.RFC3339),
})
if err != nil {
t.Fatalf("book class for %s: %v", email, err)
}
return resp
}
func TestCancelBookingLifecycle(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
resp := bookClass(t, repo, "[email protected]")
if err := svc.CancelBooking(ctx, owner, resp.BookingID); err != nil {
t.Fatalf("cancel booking: %v", err)
}
booking, _ := repo.GetBookingByID(ctx, resp.BookingID)
if booking.Status != "cancelled" {
t.Fatalf("expected cancelled, got %q", booking.Status)
}
if err := svc.CancelBooking(ctx, owner, resp.BookingID); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput on second cancel, got %v", err)
}
if err := svc.CancelBooking(ctx, owner, "00000000-0000-0000-0000-000000000099"); !errors.Is(err, ErrBookingNotFound) {
t.Fatalf("expected ErrBookingNotFound, got %v", err)
}
stranger := domain.Principal{Subject: "someone-else", Role: "staff"}
if err := svc.CancelBooking(ctx, stranger, resp.BookingID); !errors.Is(err, ErrTenantMembership) {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
}
func TestCancelBookingPromotesWaitlist(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
// Seeded session capacity is 4 — fill it, then overflow onto the waitlist.
var confirmed []domain.CreateBookingResponse
for i := 0; i < 4; i++ {
confirmed = append(confirmed, bookClass(t, repo, fmt.Sprintf("seat%[email protected]", i)))
}
first := bookClass(t, repo, "[email protected]")
second := bookClass(t, repo, "[email protected]")
booking, _ := repo.GetBookingByID(ctx, first.BookingID)
if booking.Status != "waitlisted" {
t.Fatalf("expected waitlisted, got %q", booking.Status)
}
// Cancel a confirmed seat -> earliest waiter is promoted, second stays queued.
if err := svc.CancelBooking(ctx, owner, confirmed[0].BookingID); err != nil {
t.Fatalf("cancel: %v", err)
}
booking, _ = repo.GetBookingByID(ctx, first.BookingID)
if booking.Status != "confirmed" {
t.Fatalf("expected first waiter promoted, got %q", booking.Status)
}
booking, _ = repo.GetBookingByID(ctx, second.BookingID)
if booking.Status != "waitlisted" {
t.Fatalf("expected second waiter still queued, got %q", booking.Status)
}
// Free another seat -> second waiter promoted.
if err := svc.CancelBooking(ctx, owner, confirmed[1].BookingID); err != nil {
t.Fatalf("cancel second: %v", err)
}
booking, _ = repo.GetBookingByID(ctx, second.BookingID)
if booking.Status != "confirmed" {
t.Fatalf("expected second waiter promoted, got %q", booking.Status)
}
}