Files
Bookra/apps/backend/internal/bookings/service_test.go
T
Tomas DvorakandDevin 0dc26e0c16 feat(dashboard): real business metrics with week-over-week trends
Dashboard KPIs were three flat counters with no trend data — the cards
rendered a neutral dash where the design intends movement indicators.
Summary now queries three trailing windows and emits four KPIs with
real deltas: bookings this week, completed (new CompletedCount metric
in both repositories), cancellations, and utilization in percentage
points. KPI labels localize in the frontend via a code-to-key map so
Czech operators no longer see English labels on live data.

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

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

521 lines
16 KiB
Go
Raw 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 bookings
import (
"context"
"errors"
"fmt"
"net/url"
"testing"
"time"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
func TestCreateAppointmentRejectsConflict(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{})
if err != nil {
t.Fatalf("availability: %v", err)
}
var appointment domain.TimeSlot
for _, slot := range availability.Slots {
if slot.Mode == "appointment" {
appointment = slot
break
}
}
if appointment.StartsAt == "" {
t.Fatal("expected appointment slot")
}
first, err := service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "appointment",
ServiceID: appointment.ServiceID,
StaffID: appointment.StaffID,
LocationID: appointment.LocationID,
CustomerName: "First",
CustomerEmail: "[email protected]",
StartsAt: appointment.StartsAt,
EndsAt: appointment.EndsAt,
})
if err != nil {
t.Fatalf("first create: %v", err)
}
if first.Status != "confirmed" {
t.Fatalf("expected confirmed, got %s", first.Status)
}
_, err = service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "appointment",
ServiceID: appointment.ServiceID,
StaffID: appointment.StaffID,
LocationID: appointment.LocationID,
CustomerName: "Second",
CustomerEmail: "[email protected]",
StartsAt: appointment.StartsAt,
EndsAt: appointment.EndsAt,
})
if err == nil {
t.Fatal("expected conflict error")
}
if err != ErrBookingConflict {
t.Fatalf("expected ErrBookingConflict, got %v", err)
}
}
func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{})
if err != nil {
t.Fatalf("availability: %v", err)
}
var classSlot domain.TimeSlot
for _, slot := range availability.Slots {
if slot.Mode == "class" {
classSlot = slot
break
}
}
if classSlot.ClassSessionID == nil {
t.Fatal("expected class slot")
}
for i := 0; i < 4; i++ {
response, err := service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "class",
ClassSessionID: classSlot.ClassSessionID,
LocationID: classSlot.LocationID,
CustomerName: "Capacity",
CustomerEmail: "[email protected]",
StartsAt: classSlot.StartsAt,
EndsAt: classSlot.EndsAt,
})
if err != nil {
t.Fatalf("create within capacity: %v", err)
}
if response.Status != "confirmed" {
t.Fatalf("expected confirmed within capacity, got %s", response.Status)
}
}
response, err := service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "class",
ClassSessionID: classSlot.ClassSessionID,
LocationID: classSlot.LocationID,
CustomerName: "Waitlist",
CustomerEmail: "[email protected]",
StartsAt: classSlot.StartsAt,
EndsAt: classSlot.EndsAt,
})
if err != nil {
t.Fatalf("create waitlist: %v", err)
}
if response.Status != "waitlisted" {
t.Fatalf("expected waitlisted, got %s", response.Status)
}
}
func TestCreateAppointmentRequiresTenantService(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
_, err := service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "appointment",
CustomerName: "Missing Service",
CustomerEmail: "[email protected]",
StartsAt: time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339),
EndsAt: time.Now().UTC().Add(25 * time.Hour).Format(time.RFC3339),
})
if !errors.Is(err, ErrInvalidBooking) {
t.Fatalf("expected ErrInvalidBooking, got %v", err)
}
}
func TestCreateClassRequiresExistingSession(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
missingSessionID := "11111111-1111-1111-1111-111111111111"
_, err := service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "class",
ClassSessionID: &missingSessionID,
CustomerName: "Missing Session",
CustomerEmail: "[email protected]",
StartsAt: time.Now().UTC().Add(48 * time.Hour).Format(time.RFC3339),
EndsAt: time.Now().UTC().Add(49 * time.Hour).Format(time.RFC3339),
})
if !errors.Is(err, ErrInvalidBooking) {
t.Fatalf("expected ErrInvalidBooking, got %v", err)
}
}
func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{})
if err != nil {
t.Fatalf("availability: %v", err)
}
if len(availability.Slots) == 0 {
t.Fatal("expected slots")
}
for _, slot := range availability.Slots {
startsAt, err := time.Parse(time.RFC3339, slot.StartsAt)
if err != nil {
t.Fatalf("parse startsAt: %v", err)
}
if startsAt.Before(time.Now().UTC().Add(90 * time.Minute)) {
t.Fatalf("expected upcoming slot, got %s", slot.StartsAt)
}
}
}
func TestCreateSchedulesReminderJobForUpcomingAppointment(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{Date: nextRuleDay(t, 2)})
if err != nil {
t.Fatalf("availability: %v", err)
}
var appointment domain.TimeSlot
for _, slot := range availability.Slots {
if slot.Mode == "appointment" {
appointment = slot
break
}
}
if appointment.StartsAt == "" {
t.Fatal("expected appointment slot")
}
_, err = service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "appointment",
ServiceID: appointment.ServiceID,
StaffID: appointment.StaffID,
LocationID: appointment.LocationID,
CustomerName: "Reminder",
CustomerEmail: "[email protected]",
StartsAt: appointment.StartsAt,
EndsAt: appointment.EndsAt,
})
if err != nil {
t.Fatalf("create: %v", err)
}
reminders, err := repo.ListDueReminderJobs(context.Background(), time.Now().UTC().Add(365*24*time.Hour), 10)
if err != nil {
t.Fatalf("list reminder jobs: %v", err)
}
if len(reminders) == 0 {
t.Fatal("expected reminder job to be scheduled")
}
}
// nextRuleDay returns the first day at least minOffset days out whose weekday
// has a seeded availability rule (Mon–Fri), formatted YYYY-MM-DD.
func nextRuleDay(t *testing.T, minOffset int) string {
t.Helper()
for offset := minOffset; offset < minOffset+7; offset++ {
day := time.Now().AddDate(0, 0, offset)
if day.Weekday() >= time.Monday && day.Weekday() <= time.Friday {
return day.Format("2006-01-02")
}
}
t.Fatal("no rule day found")
return ""
}
func TestAvailabilityDateFilterReturnsFullDaySlots(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
day := nextRuleDay(t, 2)
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{Date: day})
if err != nil {
t.Fatalf("availability: %v", err)
}
if len(availability.Slots) == 0 {
t.Fatal("expected day slots")
}
tenantLocation, err := time.LoadLocation(availability.Timezone)
if err != nil {
t.Fatalf("load timezone: %v", err)
}
for _, slot := range availability.Slots {
if slot.Mode != "appointment" {
t.Fatalf("expected only appointment slots, got %s", slot.Mode)
}
startsAt, err := time.Parse(time.RFC3339, slot.StartsAt)
if err != nil {
t.Fatalf("parse startsAt: %v", err)
}
if startsAt.In(tenantLocation).Format("2006-01-02") != day {
t.Fatalf("slot %s outside requested day %s", slot.StartsAt, day)
}
if slot.StaffName == nil || *slot.StaffName == "" {
t.Fatal("expected staffName on slot")
}
}
}
func TestAvailabilityHonorsTenantWideBlockedDay(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
day := nextRuleDay(t, 2)
dayStart, err := time.Parse("2006-01-02", day)
if err != nil {
t.Fatalf("parse day: %v", err)
}
if _, err := repo.CreateBlockedDay(context.Background(), db.CreateBlockedDayParams{
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
StartsAt: dayStart.UTC(),
EndsAt: dayStart.Add(26 * time.Hour).UTC(),
Kind: "blocked",
Reason: "holiday",
}); err != nil {
t.Fatalf("create blocked day: %v", err)
}
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{Date: day})
if err != nil {
t.Fatalf("availability: %v", err)
}
if len(availability.Slots) != 0 {
t.Fatalf("expected no slots on blocked day, got %d", len(availability.Slots))
}
}
func TestAvailabilityStaffFilterValidatesTenantStaff(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
foreignStaff := "22222222-2222-2222-2222-222222222222"
_, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{StaffID: foreignStaff})
if !errors.Is(err, ErrInvalidBooking) {
t.Fatalf("expected ErrInvalidBooking, got %v", err)
}
}
func TestCatalogReturnsServicesAndActiveStaff(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
catalog, err := service.Catalog(context.Background(), "studio-atelier")
if err != nil {
t.Fatalf("catalog: %v", err)
}
if len(catalog.Services) == 0 {
t.Fatal("expected services")
}
if len(catalog.Staff) == 0 {
t.Fatal("expected staff")
}
for _, staff := range catalog.Staff {
if staff.DisplayName == "" {
t.Fatal("expected display name")
}
}
}
func TestCreateAppointmentRejectsTimeOutsideAvailabilityWindow(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
catalog, err := service.Catalog(context.Background(), "studio-atelier")
if err != nil {
t.Fatalf("catalog: %v", err)
}
serviceID := catalog.Services[0].ID
staffID := catalog.Staff[0].ID
// 03:00 on a rule day is outside the seeded 09:00–17:00 window.
day := nextRuleDay(t, 2)
startsAt, err := time.Parse(time.RFC3339, day+"T03:00:00Z")
if err != nil {
t.Fatalf("parse startsAt: %v", err)
}
_, err = service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "appointment",
ServiceID: &serviceID,
StaffID: &staffID,
CustomerName: "Night Owl",
CustomerEmail: "[email protected]",
StartsAt: startsAt.Format(time.RFC3339),
EndsAt: startsAt.Add(time.Hour).Format(time.RFC3339),
})
if !errors.Is(err, ErrInvalidBooking) {
t.Fatalf("expected ErrInvalidBooking, got %v", err)
}
}
func TestCreateAppointmentRejectsForeignStaff(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{})
if err != nil {
t.Fatalf("availability: %v", err)
}
var appointment domain.TimeSlot
for _, slot := range availability.Slots {
if slot.Mode == "appointment" {
appointment = slot
break
}
}
if appointment.StartsAt == "" {
t.Fatal("expected appointment slot")
}
foreignStaff := "22222222-2222-2222-2222-222222222222"
_, err = service.Create(context.Background(), domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "appointment",
ServiceID: appointment.ServiceID,
StaffID: &foreignStaff,
CustomerName: "Impersonator",
CustomerEmail: "[email protected]",
StartsAt: appointment.StartsAt,
EndsAt: appointment.EndsAt,
})
if !errors.Is(err, ErrInvalidBooking) {
t.Fatalf("expected ErrInvalidBooking, got %v", err)
}
}
func TestCustomerCancelPromotesWaitlist(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
customerService := NewCustomerService(repo, nil, "http://localhost:3000")
ctx := context.Background()
session, err := repo.GetClassSessionByID(ctx, "4bf74c12-44dd-45ca-86bb-b104f16f2435")
if err != nil {
t.Fatalf("get session: %v", err)
}
sessionID := session.ID
book := func(email string) domain.CreateBookingResponse {
resp, err := service.Create(ctx, domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "class",
ClassSessionID: &sessionID,
CustomerName: "Test User",
CustomerEmail: email,
StartsAt: session.StartsAt.UTC().Format(time.RFC3339),
EndsAt: session.EndsAt.UTC().Format(time.RFC3339),
})
if err != nil {
t.Fatalf("book %s: %v", email, err)
}
return resp
}
var confirmed []domain.CreateBookingResponse
for i := 0; i < 4; i++ {
confirmed = append(confirmed, book(fmt.Sprintf("seat%[email protected]", i)))
}
waiter := book("[email protected]")
if waiter.Status != "waitlisted" {
t.Fatalf("expected waitlisted, got %s", waiter.Status)
}
// Customer cancels a confirmed seat via the management token.
manageURL, _ := url.Parse(confirmed[0].ManageURL)
token := manageURL.Query().Get("token")
if err := customerService.CancelBooking(ctx, confirmed[0].Reference, token); err != nil {
t.Fatalf("customer cancel: %v", err)
}
booking, _ := repo.GetBookingByID(ctx, waiter.BookingID)
if booking.Status != "confirmed" {
t.Fatalf("expected waiter promoted to confirmed, got %q", booking.Status)
}
}
func TestDashboardSummaryComputesKPIsWithTrends(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
ctx := context.Background()
membership, err := repo.GetTenantMembershipBySubject(ctx, "demo-owner")
if err != nil {
t.Fatalf("membership: %v", err)
}
tenantID := membership.Tenant.ID
now := time.Now().UTC()
seed := func(offsetDays int, hours int, status string) {
t.Helper()
starts := now.AddDate(0, 0, offsetDays).Add(time.Duration(hours) * time.Hour)
_, err := repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: tenantID,
BookingMode: "appointment",
StartsAt: starts,
EndsAt: starts.Add(time.Hour),
Status: status,
Reference: "BK-T",
})
if err != nil {
t.Fatalf("seed booking: %v", err)
}
}
// Next 7 days: 3 bookings, one cancelled.
seed(1, 0, "confirmed")
seed(2, 0, "confirmed")
seed(3, 0, "cancelled")
// Last 7 days: 1 confirmed booking that already ended.
seed(-2, 0, "confirmed")
// 7-14 days ago: 2 completed bookings.
seed(-8, 0, "confirmed")
seed(-9, 0, "confirmed")
summary, err := service.DashboardSummary(ctx, domain.Principal{Subject: "demo-owner", Role: "owner"})
if err != nil {
t.Fatalf("dashboard summary: %v", err)
}
if len(summary.KPIs) != 4 {
t.Fatalf("expected 4 kpis, got %d", len(summary.KPIs))
}
byCode := map[string]domain.DashboardKPI{}
for _, kpi := range summary.KPIs {
byCode[kpi.Code] = kpi
}
bookings := byCode["bookings_this_week"]
if bookings.Value != "3" || bookings.Change != "+2" || bookings.Trend != "up" {
t.Fatalf("bookings kpi = %+v", bookings)
}
completed := byCode["completed"]
if completed.Value != "1" || completed.Change != "-1" || completed.Trend != "down" {
t.Fatalf("completed kpi = %+v", completed)
}
cancellations := byCode["cancellations"]
if cancellations.Value != "1" || cancellations.Change != "+1" || cancellations.Trend != "up" {
t.Fatalf("cancellations kpi = %+v", cancellations)
}
utilization := byCode["utilization"]
if utilization.Value != "66%" || utilization.Trend != "down" {
t.Fatalf("utilization kpi = %+v", utilization)
}
}