mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
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>
1054 lines
36 KiB
Go
1054 lines
36 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)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|