mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
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>
374 lines
13 KiB
Go
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)
|
|
}
|
|
}
|