Files
Tomas DvorakandDevin e680e0c2b8 feat: scheduled report digests + per-session capacity (M33)
POST /v1/internal/jobs/reports/dispatch emails each tenant owner a
trailing-window digest (bookings, revenue, per-service and per-staff
breakdown) localized to the tenant locale; zero-activity tenants are
skipped. Metrics cores extracted to tenant-scoped variants so the job
needs no principal.

PATCH /v1/catalog/class-sessions/:id sets a session's seat count with
a confirmed-bookings floor — shrinking below it needs allowOverCapacity,
matching the waitlist-promotion convention. Session rows gain an inline
capacity editor with an overbook confirm state.

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

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

1054 lines
36 KiB
Go
Raw Permalink 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 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)
}
}
func TestListSessionRoster(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
for i := 0; i < 4; i++ {
bookClass(t, repo, fmt.Sprintf("seat%[email protected]", i))
}
bookClass(t, repo, "[email protected]")
bookClass(t, repo, "[email protected]")
roster, err := svc.ListSessionRoster(ctx, owner, demoSessionID)
if err != nil {
t.Fatalf("roster: %v", err)
}
if roster.Capacity != 4 || len(roster.Confirmed) != 4 || len(roster.Waitlist) != 2 {
t.Fatalf("unexpected roster %+v", roster)
}
if roster.Waitlist[0].CustomerEmail != "[email protected]" || roster.Waitlist[0].WaitlistPosition != 1 {
t.Fatalf("expected first waiter at position 1, got %+v", roster.Waitlist[0])
}
if roster.Waitlist[1].WaitlistPosition != 2 {
t.Fatalf("expected second waiter at position 2, got %+v", roster.Waitlist[1])
}
if roster.TemplateTitle != "Small group mobility class" || roster.LocationName != "Main Studio" {
t.Fatalf("unexpected roster header %+v", roster)
}
sessions, _ := svc.ListClassSessions(ctx, owner)
if sessions[0].WaitlistCount != 2 {
t.Fatalf("expected waitlistCount 2, got %d", sessions[0].WaitlistCount)
}
stranger := domain.Principal{Subject: "someone-else", Role: "staff"}
if _, err := svc.ListSessionRoster(ctx, stranger, demoSessionID); !errors.Is(err, ErrTenantMembership) {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
if _, err := svc.ListSessionRoster(ctx, owner, "00000000-0000-0000-0000-000000000099"); !errors.Is(err, ErrClassSessionNotFound) {
t.Fatalf("expected ErrClassSessionNotFound, got %v", err)
}
}
func TestPromoteBooking(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
for i := 0; i < 4; i++ {
bookClass(t, repo, fmt.Sprintf("seat%[email protected]", i))
}
first := bookClass(t, repo, "[email protected]")
second := bookClass(t, repo, "[email protected]")
// Full session: strict promote is rejected with ErrSessionFull.
if err := svc.PromoteBooking(ctx, owner, second.BookingID, domain.PromoteBookingRequest{}); !errors.Is(err, bookings.ErrSessionFull) {
t.Fatalf("expected ErrSessionFull, got %v", err)
}
// The explicit override confirms anyway — deliberate out-of-order
// overbooking, skipping the first waiter.
if err := svc.PromoteBooking(ctx, owner, second.BookingID, domain.PromoteBookingRequest{AllowOverCapacity: true}); err != nil {
t.Fatalf("over-capacity promote: %v", err)
}
booking, _ := repo.GetBookingByID(ctx, second.BookingID)
if booking.Status != "confirmed" {
t.Fatalf("expected confirmed after override, got %q", booking.Status)
}
// Confirmed bookings, unknown IDs, and foreign principals are rejected.
if err := svc.PromoteBooking(ctx, owner, second.BookingID, domain.PromoteBookingRequest{}); !errors.Is(err, bookings.ErrNotWaitlisted) {
t.Fatalf("expected ErrNotWaitlisted for confirmed booking, got %v", err)
}
if err := svc.PromoteBooking(ctx, owner, "00000000-0000-0000-0000-000000000099", domain.PromoteBookingRequest{}); !errors.Is(err, ErrBookingNotFound) {
t.Fatalf("expected ErrBookingNotFound, got %v", err)
}
stranger := domain.Principal{Subject: "someone-else", Role: "staff"}
if err := svc.PromoteBooking(ctx, stranger, first.BookingID, domain.PromoteBookingRequest{}); !errors.Is(err, ErrTenantMembership) {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
}
func TestPromoteBookingWithFreeSeat(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
// A waitlisted booking on a session with free capacity — an edge state the
// auto-promoter cannot reach, e.g. a queue entry left after manual edits.
session, err := repo.GetClassSessionByID(ctx, demoSessionID)
if err != nil {
t.Fatalf("get session: %v", err)
}
created, err := repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
ClassSessionID: &[]string{demoSessionID}[0],
BookingMode: "class",
CustomerName: "Queued Customer",
CustomerEmail: "[email protected]",
StartsAt: session.StartsAt,
EndsAt: session.EndsAt,
Status: "waitlisted",
Reference: "BOK-QUEUED",
ManageToken: "queued-token",
})
if err != nil {
t.Fatalf("seed waitlisted booking: %v", err)
}
if err := svc.PromoteBooking(ctx, owner, created.ID, domain.PromoteBookingRequest{}); err != nil {
t.Fatalf("promote with free seat: %v", err)
}
booking, _ := repo.GetBookingByID(ctx, created.ID)
if booking.Status != "confirmed" {
t.Fatalf("expected confirmed, got %q", booking.Status)
}
}
func TestUpdateClassTemplate(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
created, err := svc.CreateClassTemplate(ctx, owner, domain.UpsertClassTemplateRequest{
Title: "Yin Yoga", DurationMinutes: 75, Capacity: 10,
})
if err != nil {
t.Fatalf("create template: %v", err)
}
updated, err := svc.UpdateClassTemplate(ctx, owner, created.ID, domain.UpsertClassTemplateRequest{
Title: "Yin Yoga (večer)", DurationMinutes: 90, Capacity: 12,
})
if err != nil {
t.Fatalf("update template: %v", err)
}
if updated.Title != "Yin Yoga (večer)" || updated.DurationMinutes != 90 || updated.Capacity != 12 {
t.Fatalf("unexpected updated template: %+v", updated)
}
if _, err := svc.UpdateClassTemplate(ctx, owner, created.ID, domain.UpsertClassTemplateRequest{
Title: "", DurationMinutes: 90, Capacity: 12,
}); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput, got %v", err)
}
stranger := domain.Principal{Subject: "someone-else", Role: "staff"}
if _, err := svc.UpdateClassTemplate(ctx, stranger, created.ID, domain.UpsertClassTemplateRequest{
Title: "X", DurationMinutes: 60, Capacity: 5,
}); !errors.Is(err, ErrTenantMembership) {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
if _, err := svc.UpdateClassTemplate(ctx, owner, "00000000-0000-0000-0000-000000000099", domain.UpsertClassTemplateRequest{
Title: "X", DurationMinutes: 60, Capacity: 5,
}); !errors.Is(err, ErrClassTemplateNotFound) {
t.Fatalf("expected ErrClassTemplateNotFound, got %v", err)
}
}
func TestClassScheduleMaterializesSessions(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
schedules, err := svc.ListClassSchedules(ctx, owner)
if err != nil {
t.Fatalf("list schedules: %v", err)
}
if len(schedules) != 0 {
t.Fatalf("expected 0 schedules in fresh repo, got %d", len(schedules))
}
locID := "659f1cc0-a850-46d6-b3b8-cb15d55d8daf"
created, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{
TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7",
LocationID: &locID,
DayOfWeek: 1,
StartsLocal: "15:30",
})
if err != nil {
t.Fatalf("create schedule: %v", err)
}
if !created.Active || created.StartsLocal != "15:30:00" {
t.Fatalf("unexpected created schedule: %+v", created)
}
sessions, err := svc.ListClassSessions(ctx, owner)
if err != nil {
t.Fatalf("list sessions: %v", err)
}
var generated int
for _, session := range sessions {
if session.ScheduleID != nil && *session.ScheduleID == created.ID {
generated++
if session.StartsAt.Weekday() != time.Monday {
t.Fatalf("generated session on wrong weekday: %v", session.StartsAt)
}
}
}
if generated < 3 {
t.Fatalf("expected >=3 materialized sessions over the horizon, got %d", generated)
}
sessions2, err := svc.ListClassSessions(ctx, owner)
if err != nil {
t.Fatalf("list sessions 2: %v", err)
}
var generated2 int
for _, session := range sessions2 {
if session.ScheduleID != nil && *session.ScheduleID == created.ID {
generated2++
}
}
if generated2 != generated {
t.Fatalf("materialize not idempotent: %d then %d", generated, generated2)
}
if err := svc.DeleteClassSchedule(ctx, owner, created.ID); err != nil {
t.Fatalf("delete schedule: %v", err)
}
schedules, _ = svc.ListClassSchedules(ctx, owner)
if len(schedules) != 0 {
t.Fatalf("expected 0 schedules after delete, got %d", len(schedules))
}
}
func TestCreateClassScheduleValidation(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
if _, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{
TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7", DayOfWeek: 7, StartsLocal: "10:00",
}); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput for bad weekday, got %v", err)
}
if _, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{
TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7", DayOfWeek: 2, StartsLocal: "25:00",
}); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput for bad time, got %v", err)
}
if _, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{
TemplateID: "00000000-0000-0000-0000-000000000099", DayOfWeek: 2, StartsLocal: "10:00",
}); !errors.Is(err, ErrClassTemplateNotFound) {
t.Fatalf("expected ErrClassTemplateNotFound, got %v", err)
}
}
func TestUpdateClassScheduleRetimesFutureSessions(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
templateID := "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7" // 60min mobility class
created, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{
TemplateID: templateID, DayOfWeek: 1, StartsLocal: "10:00",
})
if err != nil {
t.Fatalf("create schedule: %v", err)
}
sessions, err := repo.ListClassSessionsBySchedule(ctx, tenantID, created.ID, time.Now().UTC())
if err != nil || len(sessions) == 0 {
t.Fatalf("expected materialized sessions, got %d err=%v", len(sessions), err)
}
// Seed a confirmed booking on the first session.
target := sessions[0]
if _, err := repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: tenantID,
ClassSessionID: &target.ID,
BookingMode: "class",
CustomerName: "Mover",
CustomerEmail: "[email protected]",
StartsAt: target.StartsAt,
EndsAt: target.EndsAt,
Status: "confirmed",
Reference: "BK-MOVE-1",
}); err != nil {
t.Fatalf("seed booking: %v", err)
}
originalLocalHour := target.StartsAt.In(time.FixedZone("x", 0)).UTC().In(time.Local).Hour()
_ = originalLocalHour
newTime := "15:30"
updated, err := svc.UpdateClassSchedule(ctx, owner, created.ID, domain.UpdateClassScheduleRequest{
StartsLocal: &newTime,
ApplyToFuture: true,
})
if err != nil {
t.Fatalf("update schedule: %v", err)
}
if updated.StartsLocal != "15:30:00" {
t.Fatalf("expected normalized 15:30:00, got %s", updated.StartsLocal)
}
loc, _ := time.LoadLocation("Europe/Prague")
moved, err := repo.ListClassSessionsBySchedule(ctx, tenantID, created.ID, time.Now().UTC())
if err != nil || len(moved) == 0 {
t.Fatalf("expected moved sessions, got %d err=%v", len(moved), err)
}
for _, session := range moved {
local := session.StartsAt.In(loc)
if local.Hour() != 15 || local.Minute() != 30 {
t.Fatalf("session not retimed: %v local=%v", session.StartsAt, local)
}
if session.EndsAt.Sub(session.StartsAt) != 60*time.Minute {
t.Fatalf("expected 60min duration preserved, got %v", session.EndsAt.Sub(session.StartsAt))
}
// Same local date as before — only the time-of-day moved.
if local.Weekday() != time.Monday {
t.Fatalf("session changed weekday: %v", local.Weekday())
}
}
// The seeded booking must follow its session's new window.
booking, err := repo.GetBookingByReference(ctx, "BK-MOVE-1")
if err != nil {
t.Fatalf("fetch booking: %v", err)
}
if !booking.StartsAt.Equal(moved[0].StartsAt) || !booking.EndsAt.Equal(moved[0].EndsAt) {
t.Fatalf("booking not synced: %v–%v vs session %v–%v", booking.StartsAt, booking.EndsAt, moved[0].StartsAt, moved[0].EndsAt)
}
}
func TestUpdateClassScheduleWithoutCascade(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
created, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{
TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7", DayOfWeek: 1, StartsLocal: "10:00",
})
if err != nil {
t.Fatalf("create schedule: %v", err)
}
before, _ := repo.ListClassSessionsBySchedule(ctx, tenantID, created.ID, time.Now().UTC())
newTime := "16:00"
if _, err := svc.UpdateClassSchedule(ctx, owner, created.ID, domain.UpdateClassScheduleRequest{
StartsLocal: &newTime,
}); err != nil {
t.Fatalf("update without cascade: %v", err)
}
after, _ := repo.ListClassSessionsBySchedule(ctx, tenantID, created.ID, time.Now().UTC())
if len(after) != len(before) {
t.Fatalf("session count changed without cascade: %d -> %d", len(before), len(after))
}
for i := range after {
if !after[i].StartsAt.Equal(before[i].StartsAt) {
t.Fatal("session moved without applyToFuture")
}
}
}
func TestUpdateClassSessionCapacityFloor(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
session, err := repo.CreateClassSession(ctx, db.CreateClassSessionParams{
TenantID: tenantID,
TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7",
StartsAt: time.Now().UTC().Add(48 * time.Hour),
EndsAt: time.Now().UTC().Add(49 * time.Hour),
Capacity: 4,
})
if err != nil {
t.Fatalf("create session: %v", err)
}
for _, name := range []string{"A", "B", "C"} {
if _, err := repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: tenantID,
ClassSessionID: &session.ID,
BookingMode: "class",
CustomerName: name,
CustomerEmail: name + "@example.com",
StartsAt: session.StartsAt,
EndsAt: session.EndsAt,
Status: "confirmed",
Reference: "BK-CAP-" + name,
}); err != nil {
t.Fatalf("seed booking %s: %v", name, err)
}
}
// Shrinking to 2 with 3 confirmed seats → conflict without override.
cap2 := 2
if _, err := svc.UpdateClassSession(ctx, owner, session.ID, domain.UpdateClassSessionRequest{Capacity: &cap2}); !errors.Is(err, ErrCapacityBelowBooked) {
t.Fatalf("expected ErrCapacityBelowBooked, got %v", err)
}
// With override it applies.
updated, err := svc.UpdateClassSession(ctx, owner, session.ID, domain.UpdateClassSessionRequest{Capacity: &cap2, AllowOverCapacity: true})
if err != nil {
t.Fatalf("override update: %v", err)
}
if updated.Capacity != 2 || updated.BookedCount != 3 {
t.Fatalf("expected capacity=2 booked=3, got %d/%d", updated.Capacity, updated.BookedCount)
}
// Growing always works.
cap8 := 8
updated, err = svc.UpdateClassSession(ctx, owner, session.ID, domain.UpdateClassSessionRequest{Capacity: &cap8})
if err != nil || updated.Capacity != 8 {
t.Fatalf("grow update: %v cap=%d", err, updated.Capacity)
}
// Validation floor.
cap0 := 0
if _, err := svc.UpdateClassSession(ctx, owner, session.ID, domain.UpdateClassSessionRequest{Capacity: &cap0}); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput for capacity 0, got %v", err)
}
}