Files
Bookra/apps/backend/internal/bookings/customer_service_test.go
T
Tomas DvorakandDevin 796c06ae66 feat(bookings): customer self-service rebooking with real slot picker
CustomerBookingView now carries tenantSlug/serviceId/staffId/
classSessionId so the manage page can query live availability; class
bookings resolve their session title as the display label. Reschedule
rejects waitlisted/completed bookings and session-bound class bookings
(400), collapsing a dead branch in the appointment path.

The manage page reschedule dialog replaces free-form date/time inputs
with a date → availability-slot picker filtered to appointment mode,
so customers can only move to a genuinely free slot with the correct
duration; 409 surfaces "slot just taken". Actions are status-gated:
reschedule for confirmed/pending appointments, cancel for live
bookings, explanatory note for class bookings.

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

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

152 lines
5.3 KiB
Go

package bookings
import (
"context"
"testing"
"time"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
func newBookingFixture(t *testing.T, repo *db.MemoryRepository) db.BookingRecord {
t.Helper()
created, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
CustomerName: "Test Customer",
CustomerEmail: "[email protected]",
StartsAt: time.Now().Add(24 * time.Hour),
EndsAt: time.Now().Add(25 * time.Hour),
Status: "confirmed",
Reference: "BOK-TEST-1",
ManageToken: "secret-manage-token",
})
if err != nil {
t.Fatalf("create booking: %v", err)
}
booking, err := repo.GetBookingByReference(context.Background(), created.Reference)
if err != nil {
t.Fatalf("get booking: %v", err)
}
return booking
}
func TestGetBookingRequiresManageToken(t *testing.T) {
repo := db.NewMemoryRepository()
booking := newBookingFixture(t, repo)
svc := NewCustomerService(repo, nil, "http://localhost:3000")
if _, err := svc.GetBookingByReference(context.Background(), booking.Reference, ""); err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized for missing token, got %v", err)
}
if _, err := svc.GetBookingByReference(context.Background(), booking.Reference, "wrong"); err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized for wrong token, got %v", err)
}
view, err := svc.GetBookingByReference(context.Background(), booking.Reference, "secret-manage-token")
if err != nil {
t.Fatalf("expected authorized view, got %v", err)
}
if view.Reference != booking.Reference {
t.Fatalf("expected reference %s, got %s", booking.Reference, view.Reference)
}
}
func TestRescheduleRequiresManageToken(t *testing.T) {
repo := db.NewMemoryRepository()
booking := newBookingFixture(t, repo)
svc := NewCustomerService(repo, nil, "http://localhost:3000")
req := domain.RescheduleBookingRequest{
NewStartsAt: time.Now().Add(48 * time.Hour).Format(time.RFC3339),
NewEndsAt: time.Now().Add(49 * time.Hour).Format(time.RFC3339),
}
if err := svc.RescheduleBooking(context.Background(), booking.Reference, req, ""); err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized, got %v", err)
}
if err := svc.RescheduleBooking(context.Background(), booking.Reference, req, "wrong"); err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized, got %v", err)
}
}
func TestCancelRequiresManageToken(t *testing.T) {
repo := db.NewMemoryRepository()
booking := newBookingFixture(t, repo)
svc := NewCustomerService(repo, nil, "http://localhost:3000")
if err := svc.CancelBooking(context.Background(), booking.Reference, ""); err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized, got %v", err)
}
if err := svc.CancelBooking(context.Background(), booking.Reference, "wrong"); err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized, got %v", err)
}
if err := svc.CancelBooking(context.Background(), booking.Reference, "secret-manage-token"); err != nil {
t.Fatalf("expected successful cancel, got %v", err)
}
}
func TestRescheduleRejectsConflictingSlot(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewCustomerService(repo, nil, "http://localhost:3000")
base := time.Now().Add(48 * time.Hour)
// Existing booking in the same tenant occupying the target slot.
_, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
StartsAt: base,
EndsAt: base.Add(time.Hour),
Status: "confirmed",
Reference: "BOK-OCCUPIED",
ManageToken: "token-a",
})
if err != nil {
t.Fatalf("create occupying booking: %v", err)
}
booking := newBookingFixture(t, repo)
err = svc.RescheduleBooking(context.Background(), booking.Reference, domain.RescheduleBookingRequest{
NewStartsAt: base.Add(30 * time.Minute).Format(time.RFC3339), // overlaps BOK-OCCUPIED
NewEndsAt: base.Add(90 * time.Minute).Format(time.RFC3339),
}, "secret-manage-token")
if err != ErrBookingConflict {
t.Fatalf("expected ErrBookingConflict, got %v", err)
}
}
func TestRescheduleRejectsClassWaitlistedAndCompleted(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewCustomerService(repo, nil, "http://localhost:3000")
sessionID := "4bf74c12-44dd-45ca-86bb-b104f16f2435"
future := time.Now().Add(48 * time.Hour)
cases := []struct {
name string
status string
sessionID *string
}{
{"class booking", "confirmed", &sessionID},
{"waitlisted booking", "waitlisted", &sessionID},
{"completed booking", "completed", nil},
}
for i, tc := range cases {
_, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
ClassSessionID: tc.sessionID,
StartsAt: future,
EndsAt: future.Add(time.Hour),
Status: tc.status,
Reference: "BOK-RS-" + string(rune('A'+i)),
ManageToken: "tok",
})
if err != nil {
t.Fatalf("%s: create booking: %v", tc.name, err)
}
err = svc.RescheduleBooking(context.Background(), "BOK-RS-"+string(rune('A'+i)), domain.RescheduleBookingRequest{
NewStartsAt: future.Add(2 * time.Hour).Format(time.RFC3339),
NewEndsAt: future.Add(3 * time.Hour).Format(time.RFC3339),
}, "tok")
if err != ErrInvalidReschedule {
t.Fatalf("%s: expected ErrInvalidReschedule, got %v", tc.name, err)
}
}
}