mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
feat(catalog): add service and staff management end-to-end
Services and staff existed as schema + booking FK targets but had no management surface. Adds full CRUD: repository (pgx + in-memory with matching FK semantics), catalog service validation with tenant isolation, MaxStaff entitlement enforcement, protected API routes, OpenAPI schemas, and dashboard screens for both entities. Dashboard summary now resolves staff names on bookings and computes setup completion from real configured state. Booking modal selects from the tenant's actual services; blocked days can be scoped per staff member. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
7a6223c07b
commit
09f780e7a6
21 files changed
+2422
-15
No files matched your search
@@ -0,0 +1,261 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user