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>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 15:42:04 +02:00
1 parent 6c3b2bf6bf
commit e5cb9bd543
10 files changed
+927 -10

No files matched your search

@@ -149,3 +149,225 @@ func TestRescheduleRejectsClassWaitlistedAndCompleted(t *testing.T) {
}
}
}
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)
}
}