From 1669d8a8c1d34bddd717b2bc46e31500bbe58581 Mon Sep 17 00:00:00 2001 From: Qasim Date: Sat, 11 Apr 2026 07:28:03 -0400 Subject: [PATCH] [TW-4826] feat(scheduler): add extended flags, file input, and validation to configurations Add 16 new flags to scheduler configurations create/update commands covering availability, event booking, scheduler settings, and JSON file input with flag-override support. Extract helpers for validation and request building, and move validation before auth for fail-fast error handling. --- docs/commands/scheduler.md | 85 ++- internal/cli/scheduler/configurations.go | 162 ++--- .../scheduler/configurations_command_test.go | 129 ++++ .../cli/scheduler/configurations_helpers.go | 441 ++++++++++++ .../scheduler/configurations_helpers_test.go | 665 ++++++++++++++++++ internal/cli/scheduler/scheduler_test.go | 72 +- 6 files changed, 1452 insertions(+), 102 deletions(-) create mode 100644 internal/cli/scheduler/configurations_command_test.go create mode 100644 internal/cli/scheduler/configurations_helpers.go create mode 100644 internal/cli/scheduler/configurations_helpers_test.go diff --git a/docs/commands/scheduler.md b/docs/commands/scheduler.md index c5d5566..a7ad9bd 100644 --- a/docs/commands/scheduler.md +++ b/docs/commands/scheduler.md @@ -24,27 +24,94 @@ nylas scheduler configurations list --json nylas scheduler configurations show nylas scheduler configs show -# Create a new configuration -nylas scheduler configurations create \\ - --name "30 Min Meeting" \\ - --duration 30 \\ - --interval 15 +# Create a simple configuration +nylas scheduler configurations create \ + --name "30 Min Meeting" \ + --title "30 Min Meeting" \ + --participants alice@co.com \ + --duration 30 + +# Create with availability settings +nylas scheduler configurations create \ + --name "Product Demo" \ + --title "Product Demo" \ + --participants alice@co.com \ + --duration 30 \ + --interval 15 \ + --buffer-before 5 \ + --buffer-after 10 \ + --conferencing-provider "Google Meet" \ + --min-booking-notice 120 \ + --available-days-in-future 30 + +# Create from a JSON file +nylas scheduler configurations create --file config.json + +# Create from file with flag overrides +nylas scheduler configurations create --file config.json --duration 60 # Update a configuration -nylas scheduler configurations update \\ - --name "Updated Name" \\ - --duration 60 +nylas scheduler configurations update \ + --name "Updated Name" \ + --duration 60 \ + --buffer-before 10 + +# Update from a JSON file +nylas scheduler configurations update --file update.json # Delete a configuration nylas scheduler configurations delete -nylas scheduler configs delete -f # Skip confirmation +nylas scheduler configs delete -y # Skip confirmation +``` + +**Configuration Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--name` | string | Configuration name | +| `--participants` | strings | Participant emails (comma-separated, first is organizer) | +| `--duration` | int | Meeting duration in minutes (default: 30) | +| `--title` | string | Event title | +| `--description` | string | Event description | +| `--location` | string | Event location | +| `--interval` | int | Slot interval in minutes | +| `--round-to` | int | Round start times to nearest N minutes | +| `--availability-method` | string | `max-fairness` or `max-availability` | +| `--buffer-before` | int | Buffer minutes before meetings | +| `--buffer-after` | int | Buffer minutes after meetings | +| `--timezone` | string | Event timezone (e.g., `America/New_York`) | +| `--booking-type` | string | `booking` or `organizer-confirmation` | +| `--conferencing-provider` | string | `Google Meet`, `Zoom`, or `Microsoft Teams` | +| `--disable-emails` | bool | Disable email notifications | +| `--reminder-minutes` | ints | Reminder minutes (e.g., `10,60`) | +| `--min-booking-notice` | int | Minimum minutes before booking | +| `--min-cancellation-notice` | int | Minimum minutes before cancellation | +| `--confirmation-method` | string | `automatic` or `manual` | +| `--available-days-in-future` | int | Days in advance bookings are available | +| `--cancellation-policy` | string | Cancellation policy text | +| `--file` | string | JSON config file (flags override file values) | +| `--json` | bool | Output as JSON | + +**File Input:** + +The `--file` flag accepts a JSON file matching the API request structure. You can export an existing configuration with `--json`, edit it, and re-import: + +```bash +# Export → edit → recreate +nylas scheduler configs show abc123 --json > meeting.json +# Edit meeting.json... +nylas scheduler configs create --file meeting.json ``` +When both `--file` and flags are provided, flags take precedence over file values. + **Configuration Features:** - Duration and interval settings - Availability rules and windows - Buffer times before/after meetings +- Conferencing auto-creation (Google Meet, Zoom, Teams) - Booking limits and restrictions +- Reminder notifications - Custom event settings ### Scheduler Sessions diff --git a/internal/cli/scheduler/configurations.go b/internal/cli/scheduler/configurations.go index 41b8993..ce06e44 100644 --- a/internal/cli/scheduler/configurations.go +++ b/internal/cli/scheduler/configurations.go @@ -7,7 +7,6 @@ import ( "strings" "github.com/nylas/cli/internal/cli/common" - "github.com/nylas/cli/internal/domain" "github.com/nylas/cli/internal/ports" "github.com/spf13/cobra" ) @@ -93,30 +92,7 @@ func newConfigShowCmd() *cobra.Command { return struct{}{}, json.NewEncoder(cmd.OutOrStdout()).Encode(config) } - _, _ = common.Bold.Printf("Configuration: %s\n", config.Name) - fmt.Printf(" ID: %s\n", common.Cyan.Sprint(config.ID)) - fmt.Printf(" Slug: %s\n", common.Green.Sprint(config.Slug)) - fmt.Printf(" Duration: %d minutes\n", config.Availability.DurationMinutes) - - if len(config.Participants) > 0 { - fmt.Printf("\nParticipants (%d):\n", len(config.Participants)) - for i, p := range config.Participants { - fmt.Printf(" %d. %s <%s>", i+1, p.Name, p.Email) - if p.IsOrganizer { - fmt.Printf(" %s", common.Green.Sprint("(Organizer)")) - } - fmt.Println() - } - } - - fmt.Printf("\nEvent Booking:\n") - fmt.Printf(" Title: %s\n", config.EventBooking.Title) - if config.EventBooking.Description != "" { - fmt.Printf(" Description: %s\n", config.EventBooking.Description) - } - if config.EventBooking.Location != "" { - fmt.Printf(" Location: %s\n", config.EventBooking.Location) - } + formatConfigDetails(cmd.OutOrStdout(), config) return struct{}{}, nil }) @@ -139,47 +115,56 @@ func newConfigCreateCmd() *cobra.Command { location string jsonOutput bool ) + flags := &configFlags{} cmd := &cobra.Command{ Use: "create", Short: "Create a scheduler configuration", - Long: "Create a new scheduler configuration (meeting type).", + Long: `Create a new scheduler configuration (meeting type). + +Use flags for common settings, or --file for full JSON config input. +When both are provided, flags override file values.`, + Example: ` # Simple inline creation + nylas scheduler configs create --name "Quick Chat" --title "Quick Chat" \ + --participants alice@co.com --duration 15 + + # With availability settings + nylas scheduler configs create --name "Product Demo" --title "Demo" \ + --participants alice@co.com --duration 30 --interval 15 \ + --buffer-before 5 --buffer-after 10 --conferencing-provider "Google Meet" + + # From a JSON file + nylas scheduler configs create --file config.json + + # File as base, override specific values + nylas scheduler configs create --file config.json --duration 60`, RunE: func(cmd *cobra.Command, args []string) error { - // Validate participants - if len(participants) == 0 { - return common.NewUserError("at least one participant email is required", "Use --participant to specify email addresses") - } - for i, p := range participants { - p = strings.TrimSpace(p) - if p == "" { - return fmt.Errorf("participant email at position %d cannot be empty", i+1) - } - participants[i] = p + if err := validateConfigFlags(flags); err != nil { + return err } - _, err := common.WithClient(args, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { - // Build participants list - var participantsList []domain.ConfigurationParticipant - for i, email := range participants { - participantsList = append(participantsList, domain.ConfigurationParticipant{ - Email: email, - IsOrganizer: i == 0, // First participant is organizer - }) + if flags.file == "" { + if len(participants) == 0 { + return common.NewUserError("at least one participant email is required", "Use --participants to specify email addresses") } - - req := &domain.CreateSchedulerConfigurationRequest{ - Name: name, - Participants: participantsList, - Availability: domain.AvailabilityRules{ - DurationMinutes: duration, - }, - EventBooking: domain.EventBooking{ - Title: title, - Description: description, - Location: location, - }, + for i, p := range participants { + p = strings.TrimSpace(p) + if p == "" { + return fmt.Errorf("participant email at position %d cannot be empty", i+1) + } + participants[i] = p } + } + + req, err := buildCreateRequest(cmd, flags, name, participants, duration, title, description, location) + if err != nil { + return err + } + if err := validateCreateRequest(req); err != nil { + return err + } + _, err = common.WithClient(args, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { config, err := client.CreateSchedulerConfiguration(ctx, req) if err != nil { return struct{}{}, common.WrapCreateError("configuration", err) @@ -201,19 +186,16 @@ func newConfigCreateCmd() *cobra.Command { }, } - cmd.Flags().StringVar(&name, "name", "", "Configuration name (required)") + cmd.Flags().StringVar(&name, "name", "", "Configuration name") cmd.Flags().StringSliceVar(&participants, "participants", []string{}, "Participant emails (comma-separated, first is organizer)") cmd.Flags().IntVar(&duration, "duration", 30, "Meeting duration in minutes") - cmd.Flags().StringVar(&title, "title", "", "Event title (required)") + cmd.Flags().StringVar(&title, "title", "", "Event title") cmd.Flags().StringVar(&description, "description", "", "Event description") cmd.Flags().StringVar(&location, "location", "", "Event location") - - _ = cmd.MarkFlagRequired("name") - _ = cmd.MarkFlagRequired("participants") - _ = cmd.MarkFlagRequired("title") - cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") + registerConfigFlags(cmd, flags) + return cmd } @@ -225,38 +207,42 @@ func newConfigUpdateCmd() *cobra.Command { description string jsonOutput bool ) + flags := &configFlags{} cmd := &cobra.Command{ Use: "update ", Short: "Update a scheduler configuration", - Long: "Update an existing scheduler configuration.", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - configID := args[0] - _, err := common.WithClient(args, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { - req := &domain.UpdateSchedulerConfigurationRequest{} + Long: `Update an existing scheduler configuration. - if name != "" { - req.Name = &name - } +Use flags to set specific fields, or --file for full JSON update. +When both are provided, flags override file values.`, + Example: ` # Update specific fields + nylas scheduler configs update abc123 --name "Updated Name" --duration 60 - if cmd.Flags().Changed("duration") { - req.Availability = &domain.AvailabilityRules{ - DurationMinutes: duration, - } - } + # Add buffer times + nylas scheduler configs update abc123 --buffer-before 5 --buffer-after 10 - if cmd.Flags().Changed("title") || cmd.Flags().Changed("description") { - eventBooking := &domain.EventBooking{} - if cmd.Flags().Changed("title") { - eventBooking.Title = title - } - if cmd.Flags().Changed("description") { - eventBooking.Description = description - } - req.EventBooking = eventBooking - } + # From a JSON file + nylas scheduler configs update abc123 --file update.json + + # File as base, override specific values + nylas scheduler configs update abc123 --file update.json --duration 45`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateConfigFlags(flags); err != nil { + return err + } + configID := args[0] + req, err := buildUpdateRequest(cmd, flags, name, duration, title, description) + if err != nil { + return err + } + if err := validateUpdateRequest(req); err != nil { + return err + } + + _, err = common.WithClient(args, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { config, err := client.UpdateSchedulerConfiguration(ctx, configID, req) if err != nil { return struct{}{}, common.WrapUpdateError("configuration", err) @@ -280,6 +266,8 @@ func newConfigUpdateCmd() *cobra.Command { cmd.Flags().StringVar(&description, "description", "", "Event description") cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") + registerConfigFlags(cmd, flags) + return cmd } diff --git a/internal/cli/scheduler/configurations_command_test.go b/internal/cli/scheduler/configurations_command_test.go new file mode 100644 index 0000000..94062b5 --- /dev/null +++ b/internal/cli/scheduler/configurations_command_test.go @@ -0,0 +1,129 @@ +package scheduler + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/nylas/cli/internal/cli/common" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func isolateSchedulerCommandEnv(t *testing.T) { + t.Helper() + + tempDir := t.TempDir() + t.Setenv("HOME", tempDir) + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("NYLAS_DISABLE_KEYRING", "true") + t.Setenv("NYLAS_API_KEY", "") + t.Setenv("NYLAS_CLIENT_ID", "") + t.Setenv("NYLAS_CLIENT_SECRET", "") + t.Setenv("NYLAS_GRANT_ID", "") + + common.ResetCachedClient() + t.Cleanup(common.ResetCachedClient) +} + +func executeSchedulerCommand(t *testing.T, cmd *cobra.Command, args ...string) error { + t.Helper() + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs(args) + + return cmd.Execute() +} + +func TestConfigCreateCmd_ValidatesMissingNameBeforeAuth(t *testing.T) { + isolateSchedulerCommandEnv(t) + + err := executeSchedulerCommand(t, newConfigCreateCmd(), + "--participants", "alice@example.com", + "--title", "Team Sync", + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "--name flag is required") + assert.NotContains(t, err.Error(), "API key not configured") +} + +func TestConfigCreateCmd_ValidatesMissingTitleBeforeAuth(t *testing.T) { + isolateSchedulerCommandEnv(t) + + err := executeSchedulerCommand(t, newConfigCreateCmd(), + "--name", "Quick Chat", + "--participants", "alice@example.com", + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "--title flag is required") + assert.NotContains(t, err.Error(), "API key not configured") +} + +func TestConfigCreateCmd_ValidatesFileBeforeAuth(t *testing.T) { + isolateSchedulerCommandEnv(t) + + missingPath := filepath.Join(t.TempDir(), "missing.json") + err := executeSchedulerCommand(t, newConfigCreateCmd(), "--file", missingPath) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read "+missingPath) + assert.NotContains(t, err.Error(), "API key not configured") +} + +func TestConfigCreateCmd_ValidatesInvalidJSONBeforeAuth(t *testing.T) { + isolateSchedulerCommandEnv(t) + + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, "invalid.json") + require.NoError(t, os.WriteFile(filePath, []byte("{invalid"), 0o600)) + + err := executeSchedulerCommand(t, newConfigCreateCmd(), "--file", filePath) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse "+filePath) + assert.NotContains(t, err.Error(), "API key not configured") +} + +func TestConfigUpdateCmd_ValidatesFileBeforeAuth(t *testing.T) { + isolateSchedulerCommandEnv(t) + + missingPath := filepath.Join(t.TempDir(), "missing.json") + err := executeSchedulerCommand(t, newConfigUpdateCmd(), "config-123", "--file", missingPath) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read "+missingPath) + assert.NotContains(t, err.Error(), "API key not configured") +} + +func TestConfigUpdateCmd_ValidatesInvalidJSONBeforeAuth(t *testing.T) { + isolateSchedulerCommandEnv(t) + + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, "invalid.json") + require.NoError(t, os.WriteFile(filePath, []byte("{invalid"), 0o600)) + + err := executeSchedulerCommand(t, newConfigUpdateCmd(), "config-123", "--file", filePath) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse "+filePath) + assert.NotContains(t, err.Error(), "API key not configured") +} + +func TestConfigUpdateCmd_RequiresUpdateFieldsBeforeAuth(t *testing.T) { + isolateSchedulerCommandEnv(t) + + err := executeSchedulerCommand(t, newConfigUpdateCmd(), "config-123") + + require.Error(t, err) + assert.Contains(t, err.Error(), "No update fields provided") + assert.NotContains(t, err.Error(), "API key not configured") +} diff --git a/internal/cli/scheduler/configurations_helpers.go b/internal/cli/scheduler/configurations_helpers.go new file mode 100644 index 0000000..5648596 --- /dev/null +++ b/internal/cli/scheduler/configurations_helpers.go @@ -0,0 +1,441 @@ +package scheduler + +import ( + "fmt" + "io" + "strings" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/spf13/cobra" +) + +// configFlags holds all the extended configuration flags for create/update commands. +type configFlags struct { + // Availability + interval int + roundTo int + availabilityMethod string + bufferBefore int + bufferAfter int + + // Event booking + timezone string + bookingType string + conferencingProvider string + disableEmails bool + reminderMinutes []int + + // Scheduler settings + minBookingNotice int + minCancellationNotice int + confirmationMethod string + availableDaysInFuture int + cancellationPolicy string + + // File input + file string +} + +// registerConfigFlags adds all extended configuration flags to a command. +func registerConfigFlags(cmd *cobra.Command, f *configFlags) { + // Availability + cmd.Flags().IntVar(&f.interval, "interval", 0, "Slot interval in minutes") + cmd.Flags().IntVar(&f.roundTo, "round-to", 0, "Round start times to nearest N minutes") + cmd.Flags().StringVar(&f.availabilityMethod, "availability-method", "", "Availability method (max-fairness, max-availability)") + cmd.Flags().IntVar(&f.bufferBefore, "buffer-before", 0, "Buffer minutes before meetings") + cmd.Flags().IntVar(&f.bufferAfter, "buffer-after", 0, "Buffer minutes after meetings") + + // Event booking + cmd.Flags().StringVar(&f.timezone, "timezone", "", "Event timezone (e.g., America/New_York)") + cmd.Flags().StringVar(&f.bookingType, "booking-type", "", "Booking type (booking, organizer-confirmation)") + cmd.Flags().StringVar(&f.conferencingProvider, "conferencing-provider", "", "Conferencing provider (Google Meet, Zoom, Microsoft Teams)") + cmd.Flags().BoolVar(&f.disableEmails, "disable-emails", false, "Disable email notifications") + cmd.Flags().IntSliceVar(&f.reminderMinutes, "reminder-minutes", nil, "Reminder minutes (comma-separated, e.g., 10,60)") + + // Scheduler settings + cmd.Flags().IntVar(&f.minBookingNotice, "min-booking-notice", 0, "Minimum minutes before a booking can be made") + cmd.Flags().IntVar(&f.minCancellationNotice, "min-cancellation-notice", 0, "Minimum minutes before cancellation allowed") + cmd.Flags().StringVar(&f.confirmationMethod, "confirmation-method", "", "Confirmation method (automatic, manual)") + cmd.Flags().IntVar(&f.availableDaysInFuture, "available-days-in-future", 0, "How many days out bookings are available") + cmd.Flags().StringVar(&f.cancellationPolicy, "cancellation-policy", "", "Cancellation policy text") + + // File input + cmd.Flags().StringVar(&f.file, "file", "", "Path to JSON config file (flags override file values)") +} + +// validateConfigFlags validates enum flag values. +func validateConfigFlags(f *configFlags) error { + if f.availabilityMethod != "" { + if err := common.ValidateOneOf("availability-method", f.availabilityMethod, + []string{"max-fairness", "max-availability"}); err != nil { + return err + } + } + if f.bookingType != "" { + if err := common.ValidateOneOf("booking-type", f.bookingType, + []string{"booking", "organizer-confirmation"}); err != nil { + return err + } + } + if f.confirmationMethod != "" { + if err := common.ValidateOneOf("confirmation-method", f.confirmationMethod, + []string{"automatic", "manual"}); err != nil { + return err + } + } + if f.conferencingProvider != "" { + if err := common.ValidateOneOf("conferencing-provider", f.conferencingProvider, + []string{"Google Meet", "Zoom", "Microsoft Teams"}); err != nil { + return err + } + } + return nil +} + +// buildCreateRequest constructs a CreateSchedulerConfigurationRequest from file and/or flags. +func buildCreateRequest( + cmd *cobra.Command, + f *configFlags, + name string, + participants []string, + duration int, + title, description, location string, +) (*domain.CreateSchedulerConfigurationRequest, error) { + req := &domain.CreateSchedulerConfigurationRequest{} + + if f.file != "" { + if err := common.LoadJSONFile(f.file, req); err != nil { + return nil, err + } + } + + if f.file == "" || name != "" { + if name != "" { + req.Name = name + } + } + if f.file == "" || len(participants) > 0 { + if len(participants) > 0 { + req.Participants = buildParticipants(participants) + } + } + if f.file == "" || cmd.Flags().Changed("duration") { + req.Availability.DurationMinutes = duration + } + if f.file == "" || title != "" { + if title != "" { + req.EventBooking.Title = title + } + } + if f.file == "" || description != "" { + if description != "" { + req.EventBooking.Description = description + } + } + if f.file == "" || location != "" { + if location != "" { + req.EventBooking.Location = location + } + } + + applyAvailabilityFlags(cmd, f, &req.Availability) + applyEventBookingFlags(cmd, f, &req.EventBooking) + applySchedulerFlags(cmd, f, &req.Scheduler) + + return req, nil +} + +func buildParticipants(emails []string) []domain.ConfigurationParticipant { + var participants []domain.ConfigurationParticipant + for i, email := range emails { + participants = append(participants, domain.ConfigurationParticipant{ + Email: email, + IsOrganizer: i == 0, + }) + } + return participants +} + +func validateCreateRequest(req *domain.CreateSchedulerConfigurationRequest) error { + if req.Name == "" { + return common.ValidateRequiredFlag("--name", "") + } + if len(req.Participants) == 0 { + return common.NewUserError("at least one participant is required", "Use --participants or include participants in --file") + } + if req.EventBooking.Title == "" { + return common.ValidateRequiredFlag("--title", "") + } + return nil +} + +func applyAvailabilityFlags(cmd *cobra.Command, f *configFlags, avail *domain.AvailabilityRules) { + if cmd.Flags().Changed("interval") { + avail.IntervalMinutes = f.interval + } + if cmd.Flags().Changed("round-to") { + avail.RoundTo = f.roundTo + } + if cmd.Flags().Changed("availability-method") { + avail.AvailabilityMethod = f.availabilityMethod + } + if cmd.Flags().Changed("buffer-before") || cmd.Flags().Changed("buffer-after") { + if avail.Buffer == nil { + avail.Buffer = &domain.AvailabilityBuffer{} + } + if cmd.Flags().Changed("buffer-before") { + avail.Buffer.Before = f.bufferBefore + } + if cmd.Flags().Changed("buffer-after") { + avail.Buffer.After = f.bufferAfter + } + } +} + +func applyEventBookingFlags(cmd *cobra.Command, f *configFlags, booking *domain.EventBooking) { + if cmd.Flags().Changed("timezone") { + booking.Timezone = f.timezone + } + if cmd.Flags().Changed("booking-type") { + booking.BookingType = f.bookingType + } + if cmd.Flags().Changed("disable-emails") { + booking.DisableEmails = f.disableEmails + } + if cmd.Flags().Changed("reminder-minutes") { + booking.ReminderMinutes = f.reminderMinutes + } + if cmd.Flags().Changed("conferencing-provider") { + booking.Conferencing = &domain.ConferencingSettings{ + Provider: f.conferencingProvider, + Autocreate: true, + } + } +} + +func applySchedulerFlags(cmd *cobra.Command, f *configFlags, sched *domain.SchedulerSettings) { + if cmd.Flags().Changed("min-booking-notice") { + sched.MinBookingNotice = f.minBookingNotice + } + if cmd.Flags().Changed("min-cancellation-notice") { + sched.MinCancellationNotice = f.minCancellationNotice + } + if cmd.Flags().Changed("confirmation-method") { + sched.ConfirmationMethod = f.confirmationMethod + } + if cmd.Flags().Changed("available-days-in-future") { + sched.AvailableDaysInFuture = f.availableDaysInFuture + } + if cmd.Flags().Changed("cancellation-policy") { + sched.CancellationPolicy = f.cancellationPolicy + } +} + +func buildUpdateRequest( + cmd *cobra.Command, + f *configFlags, + name string, + duration int, + title, description string, +) (*domain.UpdateSchedulerConfigurationRequest, error) { + req := &domain.UpdateSchedulerConfigurationRequest{} + + if f.file != "" { + if err := common.LoadJSONFile(f.file, req); err != nil { + return nil, err + } + } + + if name != "" { + req.Name = &name + } + if cmd.Flags().Changed("duration") { + if req.Availability == nil { + req.Availability = &domain.AvailabilityRules{} + } + req.Availability.DurationMinutes = duration + } + if cmd.Flags().Changed("title") || cmd.Flags().Changed("description") { + if req.EventBooking == nil { + req.EventBooking = &domain.EventBooking{} + } + if cmd.Flags().Changed("title") { + req.EventBooking.Title = title + } + if cmd.Flags().Changed("description") { + req.EventBooking.Description = description + } + } + + if hasAvailabilityFlags(cmd) { + if req.Availability == nil { + req.Availability = &domain.AvailabilityRules{} + } + applyAvailabilityFlags(cmd, f, req.Availability) + } + if hasEventBookingFlags(cmd) { + if req.EventBooking == nil { + req.EventBooking = &domain.EventBooking{} + } + applyEventBookingFlags(cmd, f, req.EventBooking) + } + if hasSchedulerFlags(cmd) { + if req.Scheduler == nil { + req.Scheduler = &domain.SchedulerSettings{} + } + applySchedulerFlags(cmd, f, req.Scheduler) + } + + return req, nil +} + +func hasUpdateRequestChanges(req *domain.UpdateSchedulerConfigurationRequest) bool { + return req.Name != nil || + req.Slug != nil || + req.RequiresSessionAuth != nil || + req.Participants != nil || + req.Availability != nil || + req.EventBooking != nil || + req.Scheduler != nil || + req.AppearanceSettings != nil +} + +func validateUpdateRequest(req *domain.UpdateSchedulerConfigurationRequest) error { + if hasUpdateRequestChanges(req) { + return nil + } + return common.NewUserError( + "No update fields provided", + "Specify at least one field to update with flags or --file", + ) +} + +func hasAvailabilityFlags(cmd *cobra.Command) bool { + return cmd.Flags().Changed("interval") || + cmd.Flags().Changed("round-to") || + cmd.Flags().Changed("availability-method") || + cmd.Flags().Changed("buffer-before") || + cmd.Flags().Changed("buffer-after") +} + +func hasEventBookingFlags(cmd *cobra.Command) bool { + return cmd.Flags().Changed("timezone") || + cmd.Flags().Changed("booking-type") || + cmd.Flags().Changed("conferencing-provider") || + cmd.Flags().Changed("disable-emails") || + cmd.Flags().Changed("reminder-minutes") +} + +func hasSchedulerFlags(cmd *cobra.Command) bool { + return cmd.Flags().Changed("min-booking-notice") || + cmd.Flags().Changed("min-cancellation-notice") || + cmd.Flags().Changed("confirmation-method") || + cmd.Flags().Changed("available-days-in-future") || + cmd.Flags().Changed("cancellation-policy") +} + +// formatConfigDetails writes a human-readable configuration summary to w. +func formatConfigDetails(w io.Writer, config *domain.SchedulerConfiguration) { + _, _ = common.Bold.Fprintf(w, "Configuration: %s\n", config.Name) + _, _ = fmt.Fprintf(w, " ID: %s\n", common.Cyan.Sprint(config.ID)) + if config.Slug != "" { + _, _ = fmt.Fprintf(w, " Slug: %s\n", common.Green.Sprint(config.Slug)) + } + _, _ = fmt.Fprintf(w, " Duration: %d minutes\n", config.Availability.DurationMinutes) + if config.Availability.IntervalMinutes > 0 { + _, _ = fmt.Fprintf(w, " Interval: %d minutes\n", config.Availability.IntervalMinutes) + } + if config.Availability.RoundTo > 0 { + _, _ = fmt.Fprintf(w, " Round To: %d minutes\n", config.Availability.RoundTo) + } + if config.Availability.AvailabilityMethod != "" { + _, _ = fmt.Fprintf(w, " Availability Method: %s\n", config.Availability.AvailabilityMethod) + } + if config.Availability.Buffer != nil && (config.Availability.Buffer.Before > 0 || config.Availability.Buffer.After > 0) { + _, _ = fmt.Fprintf(w, " Buffer: %d min before, %d min after\n", + config.Availability.Buffer.Before, config.Availability.Buffer.After) + } + + if len(config.Participants) > 0 { + _, _ = fmt.Fprintf(w, "\nParticipants (%d):\n", len(config.Participants)) + for i, p := range config.Participants { + _, _ = fmt.Fprintf(w, " %d. %s <%s>", i+1, p.Name, p.Email) + if p.IsOrganizer { + _, _ = fmt.Fprintf(w, " %s", common.Green.Sprint("(Organizer)")) + } + _, _ = fmt.Fprintln(w) + } + } + + _, _ = fmt.Fprintf(w, "\nEvent Booking:\n") + _, _ = fmt.Fprintf(w, " Title: %s\n", config.EventBooking.Title) + if config.EventBooking.Description != "" { + _, _ = fmt.Fprintf(w, " Description: %s\n", config.EventBooking.Description) + } + if config.EventBooking.Location != "" { + _, _ = fmt.Fprintf(w, " Location: %s\n", config.EventBooking.Location) + } + if config.EventBooking.Timezone != "" { + _, _ = fmt.Fprintf(w, " Timezone: %s\n", config.EventBooking.Timezone) + } + if config.EventBooking.BookingType != "" { + _, _ = fmt.Fprintf(w, " Booking Type: %s\n", config.EventBooking.BookingType) + } + if config.EventBooking.Conferencing != nil { + label := config.EventBooking.Conferencing.Provider + if config.EventBooking.Conferencing.Autocreate { + label += " (autocreate)" + } + _, _ = fmt.Fprintf(w, " Conferencing: %s\n", label) + } + if config.EventBooking.DisableEmails { + _, _ = fmt.Fprintf(w, " Emails: disabled\n") + } + if len(config.EventBooking.ReminderMinutes) > 0 { + parts := make([]string, len(config.EventBooking.ReminderMinutes)) + for i, m := range config.EventBooking.ReminderMinutes { + parts[i] = fmt.Sprintf("%d", m) + } + _, _ = fmt.Fprintf(w, " Reminders: %s minutes\n", strings.Join(parts, ", ")) + } + + s := config.Scheduler + if s.AvailableDaysInFuture > 0 || s.MinBookingNotice > 0 || s.MinCancellationNotice > 0 || + s.ConfirmationMethod != "" || s.CancellationPolicy != "" { + _, _ = fmt.Fprintf(w, "\nScheduler Settings:\n") + if s.AvailableDaysInFuture > 0 { + _, _ = fmt.Fprintf(w, " Available Days: %d\n", s.AvailableDaysInFuture) + } + if s.MinBookingNotice > 0 { + _, _ = fmt.Fprintf(w, " Min Booking Notice: %d minutes\n", s.MinBookingNotice) + } + if s.MinCancellationNotice > 0 { + _, _ = fmt.Fprintf(w, " Min Cancellation Notice: %d minutes\n", s.MinCancellationNotice) + } + if s.ConfirmationMethod != "" { + _, _ = fmt.Fprintf(w, " Confirmation: %s\n", s.ConfirmationMethod) + } + if s.CancellationPolicy != "" { + _, _ = fmt.Fprintf(w, " Cancellation Policy: %s\n", s.CancellationPolicy) + } + } + + if a := config.AppearanceSettings; a != nil { + if a.CompanyName != "" || a.Color != "" || a.SubmitText != "" || a.ThankYouMessage != "" { + _, _ = fmt.Fprintf(w, "\nAppearance:\n") + if a.CompanyName != "" { + _, _ = fmt.Fprintf(w, " Company: %s\n", a.CompanyName) + } + if a.Color != "" { + _, _ = fmt.Fprintf(w, " Color: %s\n", a.Color) + } + if a.SubmitText != "" { + _, _ = fmt.Fprintf(w, " Submit Text: %s\n", a.SubmitText) + } + if a.ThankYouMessage != "" { + _, _ = fmt.Fprintf(w, " Thank You: %s\n", a.ThankYouMessage) + } + } + } +} diff --git a/internal/cli/scheduler/configurations_helpers_test.go b/internal/cli/scheduler/configurations_helpers_test.go new file mode 100644 index 0000000..58316c3 --- /dev/null +++ b/internal/cli/scheduler/configurations_helpers_test.go @@ -0,0 +1,665 @@ +package scheduler + +import ( + "bytes" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/nylas/cli/internal/domain" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func strPtr(v string) *string { + return &v +} + +// newTestCmd creates a command with all config flags registered for testing. +func newTestCmd() (*cobra.Command, *configFlags) { + cmd := &cobra.Command{Use: "test"} + f := &configFlags{} + registerConfigFlags(cmd, f) + return cmd, f +} + +func TestRegisterConfigFlags_Create(t *testing.T) { + cmd, _ := newTestCmd() + + expectedFlags := []string{ + // Availability + "interval", + "round-to", + "availability-method", + "buffer-before", + "buffer-after", + // Event booking + "timezone", + "booking-type", + "conferencing-provider", + "disable-emails", + "reminder-minutes", + // Scheduler settings + "min-booking-notice", + "min-cancellation-notice", + "confirmation-method", + "available-days-in-future", + "cancellation-policy", + // File input + "file", + } + + for _, flagName := range expectedFlags { + t.Run(flagName, func(t *testing.T) { + flag := cmd.Flags().Lookup(flagName) + assert.NotNil(t, flag, "expected flag %q to be registered", flagName) + }) + } +} + +func TestValidateConfigFlags(t *testing.T) { + tests := []struct { + name string + flags configFlags + expectError bool + errContains string + }{ + { + name: "empty flags are valid", + flags: configFlags{}, + expectError: false, + }, + { + name: "valid availability method max-fairness", + flags: configFlags{availabilityMethod: "max-fairness"}, + expectError: false, + }, + { + name: "valid availability method max-availability", + flags: configFlags{availabilityMethod: "max-availability"}, + expectError: false, + }, + { + name: "invalid availability method", + flags: configFlags{availabilityMethod: "random"}, + expectError: true, + errContains: "availability-method", + }, + { + name: "valid booking type booking", + flags: configFlags{bookingType: "booking"}, + expectError: false, + }, + { + name: "valid booking type organizer-confirmation", + flags: configFlags{bookingType: "organizer-confirmation"}, + expectError: false, + }, + { + name: "invalid booking type", + flags: configFlags{bookingType: "instant"}, + expectError: true, + errContains: "booking-type", + }, + { + name: "valid confirmation method automatic", + flags: configFlags{confirmationMethod: "automatic"}, + expectError: false, + }, + { + name: "valid confirmation method manual", + flags: configFlags{confirmationMethod: "manual"}, + expectError: false, + }, + { + name: "invalid confirmation method", + flags: configFlags{confirmationMethod: "none"}, + expectError: true, + errContains: "confirmation-method", + }, + { + name: "valid conferencing provider Google Meet", + flags: configFlags{conferencingProvider: "Google Meet"}, + expectError: false, + }, + { + name: "valid conferencing provider Zoom", + flags: configFlags{conferencingProvider: "Zoom"}, + expectError: false, + }, + { + name: "valid conferencing provider Microsoft Teams", + flags: configFlags{conferencingProvider: "Microsoft Teams"}, + expectError: false, + }, + { + name: "invalid conferencing provider", + flags: configFlags{conferencingProvider: "Webex"}, + expectError: true, + errContains: "conferencing-provider", + }, + { + name: "all valid enum values together", + flags: configFlags{ + availabilityMethod: "max-fairness", + bookingType: "booking", + confirmationMethod: "automatic", + conferencingProvider: "Zoom", + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := tt.flags + err := validateConfigFlags(&f) + if tt.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestBuildCreateRequest_FromFileOnly(t *testing.T) { + fileData := domain.CreateSchedulerConfigurationRequest{ + Name: "File Config", + Participants: []domain.ConfigurationParticipant{ + {Email: "organizer@example.com", IsOrganizer: true}, + }, + Availability: domain.AvailabilityRules{DurationMinutes: 45}, + EventBooking: domain.EventBooking{ + Title: "File Meeting", + Timezone: "America/Chicago", + }, + } + + data, err := json.Marshal(fileData) + require.NoError(t, err) + + dir := t.TempDir() + filePath := dir + "/config.json" + require.NoError(t, os.WriteFile(filePath, data, 0600)) + + cmd, f := newTestCmd() + f.file = filePath + + req, err := buildCreateRequest(cmd, f, "", nil, 0, "", "", "") + require.NoError(t, err) + require.NotNil(t, req) + + assert.Equal(t, "File Config", req.Name) + assert.Equal(t, 45, req.Availability.DurationMinutes) + assert.Equal(t, "File Meeting", req.EventBooking.Title) + assert.Equal(t, "America/Chicago", req.EventBooking.Timezone) + require.Len(t, req.Participants, 1) + assert.Equal(t, "organizer@example.com", req.Participants[0].Email) +} + +func TestBuildCreateRequest_FlagsOverrideFile(t *testing.T) { + fileData := domain.CreateSchedulerConfigurationRequest{ + Name: "File Config", + Availability: domain.AvailabilityRules{DurationMinutes: 45}, + EventBooking: domain.EventBooking{Title: "File Meeting"}, + } + + data, err := json.Marshal(fileData) + require.NoError(t, err) + + dir := t.TempDir() + filePath := dir + "/config.json" + require.NoError(t, os.WriteFile(filePath, data, 0600)) + + cmd, f := newTestCmd() + f.file = filePath + + // Simulate flag being explicitly set + require.NoError(t, cmd.Flags().Set("interval", "15")) + + req, err := buildCreateRequest(cmd, f, "Flag Config", nil, 0, "Flag Meeting", "", "") + require.NoError(t, err) + require.NotNil(t, req) + + // Flag-provided name and title override file values + assert.Equal(t, "Flag Config", req.Name) + assert.Equal(t, "Flag Meeting", req.EventBooking.Title) + // File value preserved when not overridden by flag + assert.Equal(t, 45, req.Availability.DurationMinutes) + // Interval set via flag + assert.Equal(t, 15, req.Availability.IntervalMinutes) +} + +func TestBuildCreateRequest_FlagsOnly(t *testing.T) { + cmd, f := newTestCmd() + + require.NoError(t, cmd.Flags().Set("timezone", "America/New_York")) + require.NoError(t, cmd.Flags().Set("buffer-before", "5")) + require.NoError(t, cmd.Flags().Set("buffer-after", "10")) + require.NoError(t, cmd.Flags().Set("availability-method", "max-availability")) + + req, err := buildCreateRequest( + cmd, f, + "My Config", + []string{"alice@example.com", "bob@example.com"}, + 60, + "Team Meeting", "Monthly sync", "Conference Room A", + ) + require.NoError(t, err) + require.NotNil(t, req) + + assert.Equal(t, "My Config", req.Name) + assert.Equal(t, 60, req.Availability.DurationMinutes) + assert.Equal(t, "Team Meeting", req.EventBooking.Title) + assert.Equal(t, "Monthly sync", req.EventBooking.Description) + assert.Equal(t, "Conference Room A", req.EventBooking.Location) + assert.Equal(t, "America/New_York", req.EventBooking.Timezone) + assert.Equal(t, "max-availability", req.Availability.AvailabilityMethod) + + require.NotNil(t, req.Availability.Buffer) + assert.Equal(t, 5, req.Availability.Buffer.Before) + assert.Equal(t, 10, req.Availability.Buffer.After) + + require.Len(t, req.Participants, 2) + assert.Equal(t, "alice@example.com", req.Participants[0].Email) + assert.True(t, req.Participants[0].IsOrganizer) + assert.Equal(t, "bob@example.com", req.Participants[1].Email) + assert.False(t, req.Participants[1].IsOrganizer) +} + +func TestBuildUpdateRequest_FromFileOnly(t *testing.T) { + name := "File Update Config" + fileData := domain.UpdateSchedulerConfigurationRequest{ + Name: &name, + Availability: &domain.AvailabilityRules{ + DurationMinutes: 30, + IntervalMinutes: 15, + }, + EventBooking: &domain.EventBooking{ + Title: "Updated Meeting", + Timezone: "Europe/London", + }, + } + + data, err := json.Marshal(fileData) + require.NoError(t, err) + + dir := t.TempDir() + filePath := dir + "/update.json" + require.NoError(t, os.WriteFile(filePath, data, 0600)) + + cmd, f := newTestCmd() + f.file = filePath + + req, err := buildUpdateRequest(cmd, f, "", 0, "", "") + require.NoError(t, err) + require.NotNil(t, req) + + require.NotNil(t, req.Name) + assert.Equal(t, "File Update Config", *req.Name) + require.NotNil(t, req.Availability) + assert.Equal(t, 30, req.Availability.DurationMinutes) + assert.Equal(t, 15, req.Availability.IntervalMinutes) + require.NotNil(t, req.EventBooking) + assert.Equal(t, "Updated Meeting", req.EventBooking.Title) + assert.Equal(t, "Europe/London", req.EventBooking.Timezone) +} + +func TestBuildUpdateRequest_FlagsOnly(t *testing.T) { + cmd, f := newTestCmd() + + require.NoError(t, cmd.Flags().Set("interval", "20")) + require.NoError(t, cmd.Flags().Set("min-booking-notice", "60")) + + req, err := buildUpdateRequest(cmd, f, "Updated Name", 0, "", "") + require.NoError(t, err) + require.NotNil(t, req) + + require.NotNil(t, req.Name) + assert.Equal(t, "Updated Name", *req.Name) + + require.NotNil(t, req.Availability) + assert.Equal(t, 20, req.Availability.IntervalMinutes) + + require.NotNil(t, req.Scheduler) + assert.Equal(t, 60, req.Scheduler.MinBookingNotice) +} + +func TestBuildUpdateRequest_FlagsOverrideFile(t *testing.T) { + origName := "File Name" + fileData := domain.UpdateSchedulerConfigurationRequest{ + Name: &origName, + Availability: &domain.AvailabilityRules{ + DurationMinutes: 45, + }, + } + + data, err := json.Marshal(fileData) + require.NoError(t, err) + + dir := t.TempDir() + filePath := dir + "/update.json" + require.NoError(t, os.WriteFile(filePath, data, 0600)) + + cmd, f := newTestCmd() + // Register a duration flag as the real update command would have + var dur int + cmd.Flags().IntVar(&dur, "duration", 0, "Meeting duration in minutes") + f.file = filePath + + // Duration flag overrides file value + require.NoError(t, cmd.Flags().Set("duration", "90")) + + req, err := buildUpdateRequest(cmd, f, "Flag Name", 90, "", "") + require.NoError(t, err) + require.NotNil(t, req) + + // Flag-provided name overrides file name + require.NotNil(t, req.Name) + assert.Equal(t, "Flag Name", *req.Name) + + // Duration flag overrides file value + require.NotNil(t, req.Availability) + assert.Equal(t, 90, req.Availability.DurationMinutes) +} + +func TestValidateCreateRequest(t *testing.T) { + tests := []struct { + name string + req *domain.CreateSchedulerConfigurationRequest + errContains string + }{ + { + name: "missing name", + req: &domain.CreateSchedulerConfigurationRequest{ + Participants: []domain.ConfigurationParticipant{{Email: "alice@example.com"}}, + EventBooking: domain.EventBooking{Title: "Team Sync"}, + }, + errContains: "--name flag is required", + }, + { + name: "missing participants", + req: &domain.CreateSchedulerConfigurationRequest{ + Name: "Config", + EventBooking: domain.EventBooking{Title: "Team Sync"}, + }, + errContains: "at least one participant is required", + }, + { + name: "missing title", + req: &domain.CreateSchedulerConfigurationRequest{ + Name: "Config", + Participants: []domain.ConfigurationParticipant{{Email: "alice@example.com"}}, + }, + errContains: "--title flag is required", + }, + { + name: "valid request", + req: &domain.CreateSchedulerConfigurationRequest{ + Name: "Config", + Participants: []domain.ConfigurationParticipant{{Email: "alice@example.com"}}, + EventBooking: domain.EventBooking{Title: "Team Sync"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateCreateRequest(tt.req) + if tt.errContains == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + }) + } +} + +func TestValidateUpdateRequest(t *testing.T) { + tests := []struct { + name string + req *domain.UpdateSchedulerConfigurationRequest + errContains string + }{ + { + name: "empty request", + req: &domain.UpdateSchedulerConfigurationRequest{}, + errContains: "No update fields provided", + }, + { + name: "name update", + req: &domain.UpdateSchedulerConfigurationRequest{ + Name: strPtr("Updated Name"), + }, + }, + { + name: "explicit participants update counts as change", + req: &domain.UpdateSchedulerConfigurationRequest{ + Participants: []domain.ConfigurationParticipant{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateUpdateRequest(tt.req) + if tt.errContains == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + }) + } +} + +func TestFormatConfigDetails(t *testing.T) { + config := &domain.SchedulerConfiguration{ + ID: "cfg-123", + Name: "Team Sync", + Slug: "team-sync", + Availability: domain.AvailabilityRules{ + DurationMinutes: 30, + IntervalMinutes: 15, + RoundTo: 10, + AvailabilityMethod: "max-fairness", + Buffer: &domain.AvailabilityBuffer{ + Before: 5, + After: 10, + }, + }, + Participants: []domain.ConfigurationParticipant{ + {Name: "Alice", Email: "alice@example.com", IsOrganizer: true}, + {Name: "Bob", Email: "bob@example.com"}, + }, + EventBooking: domain.EventBooking{ + Title: "Team Meeting", + Description: "Weekly sync", + Location: "Conference Room", + Timezone: "America/New_York", + BookingType: "booking", + Conferencing: &domain.ConferencingSettings{ + Provider: "Google Meet", + Autocreate: true, + }, + DisableEmails: false, + ReminderMinutes: []int{10, 60}, + }, + Scheduler: domain.SchedulerSettings{ + AvailableDaysInFuture: 30, + MinBookingNotice: 60, + MinCancellationNotice: 120, + ConfirmationMethod: "automatic", + CancellationPolicy: "No refunds", + }, + AppearanceSettings: &domain.AppearanceSettings{ + CompanyName: "Acme Corp", + Color: "#ff5733", + SubmitText: "Book Now", + ThankYouMessage: "Thanks!", + }, + } + + var buf bytes.Buffer + formatConfigDetails(&buf, config) + output := buf.String() + + assert.Contains(t, output, "Team Sync") + assert.Contains(t, output, "cfg-123") + assert.Contains(t, output, "team-sync") + assert.Contains(t, output, "30 minutes") + assert.Contains(t, output, "Interval: 15 minutes") + assert.Contains(t, output, "Round To: 10 minutes") + assert.Contains(t, output, "max-fairness") + assert.Contains(t, output, "5 min before") + assert.Contains(t, output, "10 min after") + assert.Contains(t, output, "Participants (2)") + assert.Contains(t, output, "alice@example.com") + assert.Contains(t, output, "bob@example.com") + assert.Contains(t, output, "(Organizer)") + assert.Contains(t, output, "Team Meeting") + assert.Contains(t, output, "Weekly sync") + assert.Contains(t, output, "Conference Room") + assert.Contains(t, output, "America/New_York") + assert.Contains(t, output, "booking") + assert.Contains(t, output, "Google Meet (autocreate)") + assert.Contains(t, output, "10, 60") + assert.Contains(t, output, "Available Days: 30") + assert.Contains(t, output, "Min Booking Notice: 60 minutes") + assert.Contains(t, output, "Min Cancellation Notice: 120 minutes") + assert.Contains(t, output, "automatic") + assert.Contains(t, output, "No refunds") + assert.Contains(t, output, "Acme Corp") + assert.Contains(t, output, "#ff5733") + assert.Contains(t, output, "Book Now") + assert.Contains(t, output, "Thanks!") +} + +func TestFormatConfigDetails_MinimalConfig(t *testing.T) { + config := &domain.SchedulerConfiguration{ + ID: "cfg-456", + Name: "Minimal Config", + Availability: domain.AvailabilityRules{ + DurationMinutes: 30, + }, + EventBooking: domain.EventBooking{ + Title: "Quick Call", + }, + } + + var buf bytes.Buffer + formatConfigDetails(&buf, config) + output := buf.String() + + // Required fields present + assert.Contains(t, output, "Minimal Config") + assert.Contains(t, output, "cfg-456") + assert.Contains(t, output, "Quick Call") + + // Optional sections NOT present + assert.NotContains(t, output, "Slug:") + assert.NotContains(t, output, "Interval:") + assert.NotContains(t, output, "Round To:") + assert.NotContains(t, output, "Availability Method:") + assert.NotContains(t, output, "Buffer:") + assert.NotContains(t, output, "Participants (") + assert.NotContains(t, output, "Timezone:") + assert.NotContains(t, output, "Booking Type:") + assert.NotContains(t, output, "Conferencing:") + assert.NotContains(t, output, "Emails: disabled") + assert.NotContains(t, output, "Reminders:") + assert.NotContains(t, output, "Scheduler Settings:") + assert.NotContains(t, output, "Appearance:") +} + +func TestBuildParticipants(t *testing.T) { + t.Run("first is organizer", func(t *testing.T) { + participants := buildParticipants([]string{"a@example.com", "b@example.com", "c@example.com"}) + require.Len(t, participants, 3) + assert.True(t, participants[0].IsOrganizer) + assert.False(t, participants[1].IsOrganizer) + assert.False(t, participants[2].IsOrganizer) + }) + + t.Run("single participant is organizer", func(t *testing.T) { + participants := buildParticipants([]string{"only@example.com"}) + require.Len(t, participants, 1) + assert.True(t, participants[0].IsOrganizer) + assert.Equal(t, "only@example.com", participants[0].Email) + }) + + t.Run("empty returns nil", func(t *testing.T) { + participants := buildParticipants(nil) + assert.Nil(t, participants) + }) +} + +func TestHasAvailabilityFlags(t *testing.T) { + t.Run("no flags changed returns false", func(t *testing.T) { + cmd, _ := newTestCmd() + assert.False(t, hasAvailabilityFlags(cmd)) + }) + + t.Run("interval changed returns true", func(t *testing.T) { + cmd, _ := newTestCmd() + require.NoError(t, cmd.Flags().Set("interval", "10")) + assert.True(t, hasAvailabilityFlags(cmd)) + }) + + t.Run("buffer-before changed returns true", func(t *testing.T) { + cmd, _ := newTestCmd() + require.NoError(t, cmd.Flags().Set("buffer-before", "5")) + assert.True(t, hasAvailabilityFlags(cmd)) + }) +} + +func TestHasEventBookingFlags(t *testing.T) { + t.Run("no flags changed returns false", func(t *testing.T) { + cmd, _ := newTestCmd() + assert.False(t, hasEventBookingFlags(cmd)) + }) + + t.Run("timezone changed returns true", func(t *testing.T) { + cmd, _ := newTestCmd() + require.NoError(t, cmd.Flags().Set("timezone", "UTC")) + assert.True(t, hasEventBookingFlags(cmd)) + }) +} + +func TestHasSchedulerFlags(t *testing.T) { + t.Run("no flags changed returns false", func(t *testing.T) { + cmd, _ := newTestCmd() + assert.False(t, hasSchedulerFlags(cmd)) + }) + + t.Run("confirmation-method changed returns true", func(t *testing.T) { + cmd, _ := newTestCmd() + require.NoError(t, cmd.Flags().Set("confirmation-method", "manual")) + assert.True(t, hasSchedulerFlags(cmd)) + }) +} + +func TestFormatConfigDetails_DisableEmails(t *testing.T) { + config := &domain.SchedulerConfiguration{ + ID: "cfg-789", + Name: "No Email Config", + Availability: domain.AvailabilityRules{ + DurationMinutes: 30, + }, + EventBooking: domain.EventBooking{ + Title: "Silent Meeting", + DisableEmails: true, + }, + } + + var buf bytes.Buffer + formatConfigDetails(&buf, config) + output := buf.String() + + assert.True(t, strings.Contains(output, "Emails: disabled")) +} diff --git a/internal/cli/scheduler/scheduler_test.go b/internal/cli/scheduler/scheduler_test.go index 6751310..fdeef74 100644 --- a/internal/cli/scheduler/scheduler_test.go +++ b/internal/cli/scheduler/scheduler_test.go @@ -108,14 +108,10 @@ func TestConfigCreateCmd(t *testing.T) { assert.Equal(t, "create", cmd.Use) }) - t.Run("has_required_flags", func(t *testing.T) { + t.Run("has_base_flags", func(t *testing.T) { assert.NotNil(t, cmd.Flags().Lookup("name")) assert.NotNil(t, cmd.Flags().Lookup("participants")) assert.NotNil(t, cmd.Flags().Lookup("title")) - }) - - t.Run("has_event_flags", func(t *testing.T) { - assert.NotNil(t, cmd.Flags().Lookup("title")) assert.NotNil(t, cmd.Flags().Lookup("description")) assert.NotNil(t, cmd.Flags().Lookup("location")) }) @@ -125,6 +121,38 @@ func TestConfigCreateCmd(t *testing.T) { assert.NotNil(t, flag) assert.Equal(t, "30", flag.DefValue) }) + + t.Run("has_availability_flags", func(t *testing.T) { + assert.NotNil(t, cmd.Flags().Lookup("interval")) + assert.NotNil(t, cmd.Flags().Lookup("round-to")) + assert.NotNil(t, cmd.Flags().Lookup("availability-method")) + assert.NotNil(t, cmd.Flags().Lookup("buffer-before")) + assert.NotNil(t, cmd.Flags().Lookup("buffer-after")) + }) + + t.Run("has_event_booking_flags", func(t *testing.T) { + assert.NotNil(t, cmd.Flags().Lookup("timezone")) + assert.NotNil(t, cmd.Flags().Lookup("booking-type")) + assert.NotNil(t, cmd.Flags().Lookup("conferencing-provider")) + assert.NotNil(t, cmd.Flags().Lookup("disable-emails")) + assert.NotNil(t, cmd.Flags().Lookup("reminder-minutes")) + }) + + t.Run("has_scheduler_flags", func(t *testing.T) { + assert.NotNil(t, cmd.Flags().Lookup("min-booking-notice")) + assert.NotNil(t, cmd.Flags().Lookup("min-cancellation-notice")) + assert.NotNil(t, cmd.Flags().Lookup("confirmation-method")) + assert.NotNil(t, cmd.Flags().Lookup("available-days-in-future")) + assert.NotNil(t, cmd.Flags().Lookup("cancellation-policy")) + }) + + t.Run("has_file_flag", func(t *testing.T) { + assert.NotNil(t, cmd.Flags().Lookup("file")) + }) + + t.Run("has_examples", func(t *testing.T) { + assert.NotEmpty(t, cmd.Example) + }) } func TestConfigUpdateCmd(t *testing.T) { @@ -134,12 +162,44 @@ func TestConfigUpdateCmd(t *testing.T) { assert.Equal(t, "update ", cmd.Use) }) - t.Run("has_update_flags", func(t *testing.T) { + t.Run("has_base_flags", func(t *testing.T) { assert.NotNil(t, cmd.Flags().Lookup("name")) assert.NotNil(t, cmd.Flags().Lookup("duration")) assert.NotNil(t, cmd.Flags().Lookup("title")) assert.NotNil(t, cmd.Flags().Lookup("description")) }) + + t.Run("has_availability_flags", func(t *testing.T) { + assert.NotNil(t, cmd.Flags().Lookup("interval")) + assert.NotNil(t, cmd.Flags().Lookup("round-to")) + assert.NotNil(t, cmd.Flags().Lookup("availability-method")) + assert.NotNil(t, cmd.Flags().Lookup("buffer-before")) + assert.NotNil(t, cmd.Flags().Lookup("buffer-after")) + }) + + t.Run("has_event_booking_flags", func(t *testing.T) { + assert.NotNil(t, cmd.Flags().Lookup("timezone")) + assert.NotNil(t, cmd.Flags().Lookup("booking-type")) + assert.NotNil(t, cmd.Flags().Lookup("conferencing-provider")) + assert.NotNil(t, cmd.Flags().Lookup("disable-emails")) + assert.NotNil(t, cmd.Flags().Lookup("reminder-minutes")) + }) + + t.Run("has_scheduler_flags", func(t *testing.T) { + assert.NotNil(t, cmd.Flags().Lookup("min-booking-notice")) + assert.NotNil(t, cmd.Flags().Lookup("min-cancellation-notice")) + assert.NotNil(t, cmd.Flags().Lookup("confirmation-method")) + assert.NotNil(t, cmd.Flags().Lookup("available-days-in-future")) + assert.NotNil(t, cmd.Flags().Lookup("cancellation-policy")) + }) + + t.Run("has_file_flag", func(t *testing.T) { + assert.NotNil(t, cmd.Flags().Lookup("file")) + }) + + t.Run("has_examples", func(t *testing.T) { + assert.NotEmpty(t, cmd.Example) + }) } func TestConfigDeleteCmd(t *testing.T) {