Files
Bookra/apps/backend/internal/bookings/customer_service_test.go
T
Tomas DvorakandDevin e5cb9bd543 feat(bookings): session-to-session move for class bookings
Class customers can move a booking to another future session of the
same template from the manage page. Full target sessions place the
booking on the waitlist; freeing a confirmed seat drains the old
session's queue via the existing promotion path. Reminders re-arm
for the new time.

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

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

374 lines
13 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)
}
}
}
const (
moveTenantID = "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
moveTemplateID = "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7"
moveSessionID = "4bf74c12-44dd-45ca-86bb-b104f16f2435"
moveLocationID = "659f1cc0-a850-46d6-b3b8-cb15d55d8daf"
)
func seedMoveBooking(t *testing.T, repo *db.MemoryRepository, sessionID, reference, status string) db.BookingRecord {
t.Helper()
ctx := context.Background()
session, err := repo.GetClassSessionByID(ctx, sessionID)
if err != nil {
t.Fatalf("get session: %v", err)
}
if status == "waitlisted" {
if err := repo.AppendWaitlistEntry(ctx, db.WaitlistEntryParams{
TenantID: moveTenantID,
ClassSessionID: sessionID,
CustomerName: "Move Customer",
CustomerEmail: "[email protected]",
Position: 1,
}); err != nil {
t.Fatalf("seed waitlist entry: %v", err)
}
}
created, err := repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: moveTenantID,
ClassSessionID: &sessionID,
BookingMode: "class",
CustomerName: "Move Customer",
CustomerEmail: "[email protected]",
StartsAt: session.StartsAt,
EndsAt: session.EndsAt,
Status: status,
Reference: reference,
ManageToken: "move-token",
})
if err != nil {
t.Fatalf("create booking: %v", err)
}
booking, err := repo.GetBookingByReference(ctx, created.Reference)
if err != nil {
t.Fatalf("get booking: %v", err)
}
return booking
}
func newTargetSession(t *testing.T, repo *db.MemoryRepository, templateID string, capacity int32) db.ClassSessionRecord {
t.Helper()
session, err := repo.CreateClassSession(context.Background(), db.CreateClassSessionParams{
TenantID: moveTenantID,
TemplateID: templateID,
LocationID: &[]string{moveLocationID}[0],
StartsAt: time.Now().Add(96 * time.Hour),
EndsAt: time.Now().Add(97 * time.Hour),
Capacity: capacity,
})
if err != nil {
t.Fatalf("create target session: %v", err)
}
return session
}
func TestListSessionMoveTargets(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewCustomerService(repo, nil, "http://localhost:3000")
ctx := context.Background()
booking := seedMoveBooking(t, repo, moveSessionID, "BK-MOVE-1", "confirmed")
target := newTargetSession(t, repo, moveTemplateID, 4)
// A session of a different template must not be offered.
otherTemplate, err := repo.CreateClassTemplate(ctx, db.CreateClassTemplateParams{TenantID: moveTenantID, Title: "Other class", DurationMinutes: 60, Capacity: 8})
if err != nil {
t.Fatalf("create other template: %v", err)
}
other := newTargetSession(t, repo, otherTemplate.ID, 8)
targets, err := svc.ListSessionMoveTargets(ctx, booking.Reference, "move-token")
if err != nil {
t.Fatalf("list targets: %v", err)
}
if len(targets) != 1 || targets[0].SessionID != target.ID {
t.Fatalf("expected only the same-template target, got %+v", targets)
}
if targets[0].Remaining != 4 || targets[0].Location != "Main Studio" {
t.Fatalf("unexpected target payload: %+v", targets[0])
}
if targets[0].SessionID == other.ID {
t.Fatalf("other-template session leaked into targets")
}
if _, err := svc.ListSessionMoveTargets(ctx, booking.Reference, "wrong"); err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized, got %v", err)
}
}
func TestMoveBookingToSessionConfirmed(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewCustomerService(repo, nil, "http://localhost:3000")
ctx := context.Background()
booking := seedMoveBooking(t, repo, moveSessionID, "BK-MOVE-2", "confirmed")
target := newTargetSession(t, repo, moveTemplateID, 4)
response, err := svc.MoveBookingToSession(ctx, booking.Reference, "move-token", target.ID)
if err != nil {
t.Fatalf("move: %v", err)
}
if response.Status != "confirmed" {
t.Fatalf("expected confirmed, got %q", response.Status)
}
moved, _ := repo.GetBookingByID(ctx, booking.ID)
if moved.ClassSessionID == nil || *moved.ClassSessionID != target.ID {
t.Fatalf("booking not retargeted: %+v", moved.ClassSessionID)
}
if !moved.StartsAt.Equal(target.StartsAt) || !moved.EndsAt.Equal(target.EndsAt) {
t.Fatalf("booking times not aligned to target session")
}
}
func TestMoveBookingToFullSessionWaitlists(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewCustomerService(repo, nil, "http://localhost:3000")
ctx := context.Background()
target := newTargetSession(t, repo, moveTemplateID, 1)
// Fill the single seat.
seedMoveBooking(t, repo, target.ID, "BK-TARGET-FULL", "confirmed")
booking := seedMoveBooking(t, repo, moveSessionID, "BK-MOVE-3", "confirmed")
response, err := svc.MoveBookingToSession(ctx, booking.Reference, "move-token", target.ID)
if err != nil {
t.Fatalf("move: %v", err)
}
if response.Status != "waitlisted" {
t.Fatalf("expected waitlisted, got %q", response.Status)
}
position, _ := repo.GetWaitlistPosition(ctx, target.ID, booking.ID)
if position != 1 {
t.Fatalf("expected waitlist position 1, got %d", position)
}
}
func TestMoveBookingWaitlistedResolvesOldQueue(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewCustomerService(repo, nil, "http://localhost:3000")
ctx := context.Background()
booking := seedMoveBooking(t, repo, moveSessionID, "BK-MOVE-4", "waitlisted")
target := newTargetSession(t, repo, moveTemplateID, 4)
response, err := svc.MoveBookingToSession(ctx, booking.Reference, "move-token", target.ID)
if err != nil {
t.Fatalf("move: %v", err)
}
if response.Status != "confirmed" {
t.Fatalf("expected confirmed, got %q", response.Status)
}
// The booking must depart the old session's roster entirely — positions
// are derived from waitlisted bookings on the session.
roster, _ := repo.ListBookingsBySession(ctx, moveSessionID)
for _, entry := range roster {
if entry.ID == booking.ID {
t.Fatalf("booking still on the old session roster")
}
}
}
func TestMoveBookingFreesSeatAndPromotes(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewCustomerService(repo, nil, "http://localhost:3000")
ctx := context.Background()
// Demo session has capacity 4: three fillers + the moving booking fill it,
// one queued booking waits. The move drops confirmed to 3 → queue head
// promotes.
for i := 0; i < 3; i++ {
seedMoveBooking(t, repo, moveSessionID, "BK-FILL-"+string(rune('A'+i)), "confirmed")
}
queued := seedMoveBooking(t, repo, moveSessionID, "BK-QUEUED", "waitlisted")
moving := seedMoveBooking(t, repo, moveSessionID, "BK-MOVE-5", "confirmed")
target := newTargetSession(t, repo, moveTemplateID, 4)
if _, err := svc.MoveBookingToSession(ctx, moving.Reference, "move-token", target.ID); err != nil {
t.Fatalf("move: %v", err)
}
promoted, _ := repo.GetBookingByID(ctx, queued.ID)
if promoted.Status != "confirmed" {
t.Fatalf("expected freed seat to promote queued booking, got %q", promoted.Status)
}
}
func TestMoveBookingRejections(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewCustomerService(repo, nil, "http://localhost:3000")
ctx := context.Background()
// Appointment booking has no session to move between.
appointment := newBookingFixture(t, repo)
if _, err := svc.MoveBookingToSession(ctx, appointment.Reference, "secret-manage-token", moveSessionID); err != ErrInvalidReschedule {
t.Fatalf("appointment: expected ErrInvalidReschedule, got %v", err)
}
booking := seedMoveBooking(t, repo, moveSessionID, "BK-MOVE-6", "confirmed")
target := newTargetSession(t, repo, moveTemplateID, 4)
// Same session is a no-op request.
if _, err := svc.MoveBookingToSession(ctx, booking.Reference, "move-token", moveSessionID); err != ErrInvalidReschedule {
t.Fatalf("same session: expected ErrInvalidReschedule, got %v", err)
}
// Bad token.
if _, err := svc.MoveBookingToSession(ctx, booking.Reference, "wrong", target.ID); err != ErrUnauthorized {
t.Fatalf("bad token: expected ErrUnauthorized, got %v", err)
}
// Cancelled booking.
_ = repo.UpdateBookingStatus(ctx, booking.ID, "cancelled")
if _, err := svc.MoveBookingToSession(ctx, booking.Reference, "move-token", target.ID); err != ErrBookingCancelled {
t.Fatalf("cancelled: expected ErrBookingCancelled, got %v", err)
}
}