package catalog import ( "context" "errors" "testing" "bookra/apps/backend/internal/db" "bookra/apps/backend/internal/domain" ) var owner = domain.Principal{Subject: "demo-owner", Email: "demo@bookra.dev", 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) } }