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: "customer@example.com", 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: "move@example.com", 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: "move@example.com", 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) } }