diff --git a/cmd/dev_server/dev_server.go b/cmd/dev_server/dev_server.go index 549d4dd51..948865233 100644 --- a/cmd/dev_server/dev_server.go +++ b/cmd/dev_server/dev_server.go @@ -72,6 +72,7 @@ func NewDevServerCmd(client resources.Client, analyticsTrackerFn analytics.Track cmd.AddCommand(NewRemoveProjectCmd(client)) cmd.AddCommand(NewAddProjectCmd(client)) cmd.AddCommand(NewUpdateProjectCmd(client)) + cmd.AddCommand(NewImportProjectCmd()) cmd.AddGroup(&cobra.Group{ID: "overrides", Title: "Override commands:"}) cmd.AddCommand(NewAddOverrideCmd(client)) diff --git a/cmd/dev_server/import_project.go b/cmd/dev_server/import_project.go new file mode 100644 index 000000000..6e53f9765 --- /dev/null +++ b/cmd/dev_server/import_project.go @@ -0,0 +1,90 @@ +package dev_server + +import ( + "context" + "fmt" + "log" + + "github.com/adrg/xdg" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/launchdarkly/ldcli/cmd/cliflags" + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/cmd/validators" + "github.com/launchdarkly/ldcli/internal/dev_server/db" + "github.com/launchdarkly/ldcli/internal/dev_server/model" +) + +const ImportFileFlag = "file" + +func NewImportProjectCmd() *cobra.Command { + cmd := &cobra.Command{ + GroupID: "projects", + Args: validators.Validate(), + Long: `Import a project into the dev server database from a JSON file. + +The JSON file format matches the output from: + ldcli dev-server get-project --project= \ + --expand=overrides --expand=availableVariations + +Examples: + # Export project data (while dev server is running) + ldcli dev-server get-project --project=my-project \ + --expand=overrides --expand=availableVariations > backup.json + + # Later, import the project from backup + ldcli dev-server import-project --project=my-project --file=backup.json`, + RunE: importProject(), + Short: "import project from file", + Use: "import-project", + } + + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + + cmd.Flags().String(cliflags.ProjectFlag, "", "The project key to create") + _ = cmd.MarkFlagRequired(cliflags.ProjectFlag) + _ = cmd.Flags().SetAnnotation(cliflags.ProjectFlag, "required", []string{"true"}) + _ = viper.BindPFlag(cliflags.ProjectFlag, cmd.Flags().Lookup(cliflags.ProjectFlag)) + + cmd.Flags().String(ImportFileFlag, "", "Path to JSON file containing project data") + _ = cmd.MarkFlagRequired(ImportFileFlag) + _ = cmd.Flags().SetAnnotation(ImportFileFlag, "required", []string{"true"}) + _ = viper.BindPFlag(ImportFileFlag, cmd.Flags().Lookup(ImportFileFlag)) + + return cmd +} + +func importProject() func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + projectKey := viper.GetString(cliflags.ProjectFlag) + filepath := viper.GetString(ImportFileFlag) + + // Get database path (same logic as dev_server.go) + dbFilePath, err := xdg.StateFile("ldcli/dev_server.db") + if err != nil { + return fmt.Errorf("unable to get database path: %w", err) + } + + // Open database + sqlStore, err := db.NewSqlite(ctx, dbFilePath) + if err != nil { + return fmt.Errorf("unable to open database: %w", err) + } + + // Set store on context + ctx = model.ContextWithStore(ctx, sqlStore) + + // Import project from file + err = model.ImportProjectFromFile(ctx, projectKey, filepath) + if err != nil { + return fmt.Errorf("unable to import project: %w", err) + } + + log.Printf("Successfully imported project '%s' from %s", projectKey, filepath) + fmt.Fprintf(cmd.OutOrStdout(), "Successfully imported project '%s' from %s\n", projectKey, filepath) + + return nil + } +} diff --git a/cmd/dev_server/import_project_test.go b/cmd/dev_server/import_project_test.go new file mode 100644 index 000000000..b6938353d --- /dev/null +++ b/cmd/dev_server/import_project_test.go @@ -0,0 +1,547 @@ +package dev_server_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" + "github.com/launchdarkly/ldcli/internal/dev_server/db" + "github.com/launchdarkly/ldcli/internal/dev_server/model" +) + +func TestImportProjectCommand(t *testing.T) { + t.Run("imports project successfully from valid JSON file", func(t *testing.T) { + // Create temporary database + tmpDir, err := os.MkdirTemp("", "import-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + dbPath := filepath.Join(tmpDir, "test.db") + + // Create test seed data file + seedFile := filepath.Join(tmpDir, "seed.json") + seedData := map[string]interface{}{ + "context": map[string]interface{}{ + "kind": "user", + "key": "test-user", + }, + "sourceEnvironmentKey": "production", + "flagsState": map[string]interface{}{ + "feature-flag-1": map[string]interface{}{ + "value": true, + "version": 1, + "trackEvents": false, + }, + "feature-flag-2": map[string]interface{}{ + "value": "enabled", + "version": 2, + "trackEvents": false, + }, + }, + "availableVariations": map[string]interface{}{ + "feature-flag-1": []interface{}{ + map[string]interface{}{ + "_id": "var-true", + "name": "On", + "description": "Feature enabled", + "value": true, + }, + map[string]interface{}{ + "_id": "var-false", + "name": "Off", + "value": false, + }, + }, + "feature-flag-2": []interface{}{ + map[string]interface{}{ + "_id": "var-enabled", + "value": "enabled", + }, + map[string]interface{}{ + "_id": "var-disabled", + "value": "disabled", + }, + }, + }, + "overrides": map[string]interface{}{ + "feature-flag-1": map[string]interface{}{ + "value": false, + "version": 1, + }, + }, + } + + data, err := json.MarshalIndent(seedData, "", " ") + require.NoError(t, err) + err = os.WriteFile(seedFile, data, 0644) + require.NoError(t, err) + + // Test the seed functionality directly (not through cobra command to avoid CLI setup complexity) + ctx := context.Background() + sqlStore, err := db.NewSqlite(ctx, dbPath) + require.NoError(t, err) + + ctx = model.ContextWithStore(ctx, sqlStore) + + // Import project from file + err = model.ImportProjectFromFile(ctx, "test-project", seedFile) + require.NoError(t, err) + + // Verify project was created + project, err := sqlStore.GetDevProject(ctx, "test-project") + require.NoError(t, err) + require.NotNil(t, project) + + // Verify project fields + assert.Equal(t, "test-project", project.Key) + assert.Equal(t, "production", project.SourceEnvironmentKey) + assert.Equal(t, ldcontext.NewBuilder("user").Key("test-user").Build(), project.Context) + + // Verify flags state + assert.Len(t, project.AllFlagsState, 2) + assert.Equal(t, ldvalue.Bool(true), project.AllFlagsState["feature-flag-1"].Value) + assert.Equal(t, 1, project.AllFlagsState["feature-flag-1"].Version) + assert.Equal(t, ldvalue.String("enabled"), project.AllFlagsState["feature-flag-2"].Value) + assert.Equal(t, 2, project.AllFlagsState["feature-flag-2"].Version) + + // Verify available variations + variations, err := sqlStore.GetAvailableVariationsForProject(ctx, "test-project") + require.NoError(t, err) + assert.Len(t, variations, 2) + assert.Len(t, variations["feature-flag-1"], 2) + assert.Len(t, variations["feature-flag-2"], 2) + + // Verify overrides + overrides, err := sqlStore.GetOverridesForProject(ctx, "test-project") + require.NoError(t, err) + assert.Len(t, overrides, 1) + assert.Equal(t, "feature-flag-1", overrides[0].FlagKey) + assert.Equal(t, ldvalue.Bool(false), overrides[0].Value) + assert.True(t, overrides[0].Active) + }) + + t.Run("rejects importing when project already exists", func(t *testing.T) { + // Create temporary database + tmpDir, err := os.MkdirTemp("", "import-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + dbPath := filepath.Join(tmpDir, "test.db") + ctx := context.Background() + sqlStore, err := db.NewSqlite(ctx, dbPath) + require.NoError(t, err) + ctx = model.ContextWithStore(ctx, sqlStore) + + // Insert an existing project with the same key + existingProject := model.Project{ + Key: "existing-project", + SourceEnvironmentKey: "test", + Context: ldcontext.NewBuilder("user").Key("existing").Build(), + AllFlagsState: model.FlagsState{}, + AvailableVariations: []model.FlagVariation{}, + } + err = sqlStore.InsertProject(ctx, existingProject) + require.NoError(t, err) + + // Create seed data file for the same project key + seedFile := filepath.Join(tmpDir, "seed.json") + seedData := map[string]interface{}{ + "context": map[string]interface{}{ + "kind": "user", + "key": "test-user", + }, + "sourceEnvironmentKey": "production", + "flagsState": map[string]interface{}{ + "flag-1": map[string]interface{}{ + "value": true, + "version": 1, + }, + }, + } + + data, err := json.Marshal(seedData) + require.NoError(t, err) + err = os.WriteFile(seedFile, data, 0644) + require.NoError(t, err) + + // Attempt to import with same project key should fail + err = model.ImportProjectFromFile(ctx, "existing-project", seedFile) + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + }) + + t.Run("allows importing different project when database has other projects", func(t *testing.T) { + // Create temporary database + tmpDir, err := os.MkdirTemp("", "import-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + dbPath := filepath.Join(tmpDir, "test.db") + ctx := context.Background() + sqlStore, err := db.NewSqlite(ctx, dbPath) + require.NoError(t, err) + ctx = model.ContextWithStore(ctx, sqlStore) + + // Insert an existing project + existingProject := model.Project{ + Key: "project-1", + SourceEnvironmentKey: "test", + Context: ldcontext.NewBuilder("user").Key("existing").Build(), + AllFlagsState: model.FlagsState{}, + AvailableVariations: []model.FlagVariation{}, + } + err = sqlStore.InsertProject(ctx, existingProject) + require.NoError(t, err) + + // Create seed data file for a DIFFERENT project + seedFile := filepath.Join(tmpDir, "seed.json") + seedData := map[string]interface{}{ + "context": map[string]interface{}{ + "kind": "user", + "key": "test-user", + }, + "sourceEnvironmentKey": "production", + "flagsState": map[string]interface{}{ + "flag-1": map[string]interface{}{ + "value": true, + "version": 1, + }, + }, + } + + data, err := json.Marshal(seedData) + require.NoError(t, err) + err = os.WriteFile(seedFile, data, 0644) + require.NoError(t, err) + + // Import a different project should succeed + err = model.ImportProjectFromFile(ctx, "project-2", seedFile) + require.NoError(t, err) + + // Verify both projects exist + project1, err := sqlStore.GetDevProject(ctx, "project-1") + require.NoError(t, err) + assert.Equal(t, "project-1", project1.Key) + + project2, err := sqlStore.GetDevProject(ctx, "project-2") + require.NoError(t, err) + assert.Equal(t, "project-2", project2.Key) + }) + + t.Run("validates required fields in import data", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "import-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + dbPath := filepath.Join(tmpDir, "test.db") + ctx := context.Background() + sqlStore, err := db.NewSqlite(ctx, dbPath) + require.NoError(t, err) + ctx = model.ContextWithStore(ctx, sqlStore) + + testCases := []struct { + name string + seedData map[string]interface{} + expectedErr string + }{ + { + name: "missing sourceEnvironmentKey", + seedData: map[string]interface{}{ + "context": map[string]interface{}{ + "kind": "user", + "key": "test", + }, + "flagsState": map[string]interface{}{ + "flag": map[string]interface{}{ + "value": true, + "version": 1, + }, + }, + }, + expectedErr: "sourceEnvironmentKey is required", + }, + { + name: "missing flagsState", + seedData: map[string]interface{}{ + "context": map[string]interface{}{ + "kind": "user", + "key": "test", + }, + "sourceEnvironmentKey": "test", + }, + expectedErr: "flagsState is required", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + seedFile := filepath.Join(tmpDir, tc.name+".json") + data, err := json.Marshal(tc.seedData) + require.NoError(t, err) + err = os.WriteFile(seedFile, data, 0644) + require.NoError(t, err) + + err = model.ImportProjectFromFile(ctx, "test-project", seedFile) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErr) + }) + } + }) + + t.Run("handles complex import data with all fields", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "import-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + dbPath := filepath.Join(tmpDir, "test.db") + ctx := context.Background() + sqlStore, err := db.NewSqlite(ctx, dbPath) + require.NoError(t, err) + ctx = model.ContextWithStore(ctx, sqlStore) + + // Create comprehensive seed data + seedFile := filepath.Join(tmpDir, "comprehensive-seed.json") + seedData := map[string]interface{}{ + "context": map[string]interface{}{ + "kind": "user", + "key": "user-123", + "email": "test@example.com", + "name": "Test User", + }, + "sourceEnvironmentKey": "staging", + "flagsState": map[string]interface{}{ + "bool-flag": map[string]interface{}{ + "value": true, + "version": 5, + "trackEvents": true, + }, + "string-flag": map[string]interface{}{ + "value": "test-value", + "version": 3, + "trackEvents": false, + }, + "number-flag": map[string]interface{}{ + "value": 42, + "version": 1, + "trackEvents": false, + }, + "json-flag": map[string]interface{}{ + "value": map[string]interface{}{ + "nested": "object", + "count": 10, + }, + "version": 2, + "trackEvents": false, + }, + }, + "availableVariations": map[string]interface{}{ + "bool-flag": []interface{}{ + map[string]interface{}{ + "_id": "bool-true", + "name": "True Variation", + "description": "Boolean true", + "value": true, + }, + map[string]interface{}{ + "_id": "bool-false", + "name": "False Variation", + "value": false, + }, + }, + "string-flag": []interface{}{ + map[string]interface{}{ + "_id": "string-1", + "value": "test-value", + }, + map[string]interface{}{ + "_id": "string-2", + "value": "other-value", + }, + }, + }, + "overrides": map[string]interface{}{ + "bool-flag": map[string]interface{}{ + "value": false, + "version": 1, + }, + "number-flag": map[string]interface{}{ + "value": 100, + "version": 1, + }, + }, + } + + data, err := json.MarshalIndent(seedData, "", " ") + require.NoError(t, err) + err = os.WriteFile(seedFile, data, 0644) + require.NoError(t, err) + + // Import + err = model.ImportProjectFromFile(ctx, "comprehensive-project", seedFile) + require.NoError(t, err) + + // Verify all data was imported + project, err := sqlStore.GetDevProject(ctx, "comprehensive-project") + require.NoError(t, err) + + assert.Equal(t, "staging", project.SourceEnvironmentKey) + assert.Len(t, project.AllFlagsState, 4) + + // Verify variations + variations, err := sqlStore.GetAvailableVariationsForProject(ctx, "comprehensive-project") + require.NoError(t, err) + assert.Len(t, variations["bool-flag"], 2) + assert.Len(t, variations["string-flag"], 2) + + // Verify overrides + overrides, err := sqlStore.GetOverridesForProject(ctx, "comprehensive-project") + require.NoError(t, err) + assert.Len(t, overrides, 2) + + overrideMap := make(map[string]model.Override) + for _, o := range overrides { + overrideMap[o.FlagKey] = o + } + + assert.Equal(t, ldvalue.Bool(false), overrideMap["bool-flag"].Value) + assert.Equal(t, ldvalue.Int(100), overrideMap["number-flag"].Value) + }) + + t.Run("handles import data without optional fields", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "import-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + dbPath := filepath.Join(tmpDir, "test.db") + ctx := context.Background() + sqlStore, err := db.NewSqlite(ctx, dbPath) + require.NoError(t, err) + ctx = model.ContextWithStore(ctx, sqlStore) + + // Minimal seed data with only required fields + seedFile := filepath.Join(tmpDir, "minimal-seed.json") + seedData := map[string]interface{}{ + "context": map[string]interface{}{ + "kind": "user", + "key": "minimal-user", + }, + "sourceEnvironmentKey": "production", + "flagsState": map[string]interface{}{ + "simple-flag": map[string]interface{}{ + "value": "simple-value", + "version": 1, + }, + }, + // No availableVariations + // No overrides + } + + data, err := json.Marshal(seedData) + require.NoError(t, err) + err = os.WriteFile(seedFile, data, 0644) + require.NoError(t, err) + + // Import + err = model.ImportProjectFromFile(ctx, "minimal-project", seedFile) + require.NoError(t, err) + + // Verify + project, err := sqlStore.GetDevProject(ctx, "minimal-project") + require.NoError(t, err) + assert.Equal(t, "minimal-project", project.Key) + assert.Len(t, project.AllFlagsState, 1) + + // Verify no variations or overrides + variations, err := sqlStore.GetAvailableVariationsForProject(ctx, "minimal-project") + require.NoError(t, err) + assert.Empty(t, variations) + + overrides, err := sqlStore.GetOverridesForProject(ctx, "minimal-project") + require.NoError(t, err) + assert.Empty(t, overrides) + }) + + t.Run("preserves variation metadata", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "import-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + dbPath := filepath.Join(tmpDir, "test.db") + ctx := context.Background() + sqlStore, err := db.NewSqlite(ctx, dbPath) + require.NoError(t, err) + ctx = model.ContextWithStore(ctx, sqlStore) + + seedFile := filepath.Join(tmpDir, "variations-seed.json") + seedData := map[string]interface{}{ + "context": map[string]interface{}{ + "kind": "user", + "key": "test", + }, + "sourceEnvironmentKey": "test", + "flagsState": map[string]interface{}{ + "documented-flag": map[string]interface{}{ + "value": "default", + "version": 1, + }, + }, + "availableVariations": map[string]interface{}{ + "documented-flag": []interface{}{ + map[string]interface{}{ + "_id": "var-1", + "name": "Default Variation", + "description": "This is the default variation used in most cases", + "value": "default", + }, + map[string]interface{}{ + "_id": "var-2", + "name": "Alternative", + "description": "Alternative variation for special cases", + "value": "alternative", + }, + }, + }, + } + + data, err := json.Marshal(seedData) + require.NoError(t, err) + err = os.WriteFile(seedFile, data, 0644) + require.NoError(t, err) + + err = model.ImportProjectFromFile(ctx, "metadata-project", seedFile) + require.NoError(t, err) + + // Verify variation metadata is preserved + variations, err := sqlStore.GetAvailableVariationsForProject(ctx, "metadata-project") + require.NoError(t, err) + require.Len(t, variations["documented-flag"], 2) + + var defaultVar, altVar *model.Variation + for _, v := range variations["documented-flag"] { + if v.Id == "var-1" { + defaultVar = lo.ToPtr(v) + } else if v.Id == "var-2" { + altVar = lo.ToPtr(v) + } + } + + require.NotNil(t, defaultVar) + require.NotNil(t, altVar) + + assert.Equal(t, "Default Variation", *defaultVar.Name) + assert.Equal(t, "This is the default variation used in most cases", *defaultVar.Description) + assert.Equal(t, ldvalue.String("default"), defaultVar.Value) + + assert.Equal(t, "Alternative", *altVar.Name) + assert.Equal(t, "Alternative variation for special cases", *altVar.Description) + assert.Equal(t, ldvalue.String("alternative"), altVar.Value) + }) +} diff --git a/cmd/dev_server/projects.go b/cmd/dev_server/projects.go index 703dc8825..5b02c1c1a 100644 --- a/cmd/dev_server/projects.go +++ b/cmd/dev_server/projects.go @@ -52,10 +52,18 @@ func NewGetProjectCmd(client resources.Client) *cobra.Command { cmd := &cobra.Command{ GroupID: "projects", Args: validators.Validate(), - Long: "get the specified project and its configuration for syncing from the LaunchDarkly Service", - RunE: getProject(client), - Short: "get a project", - Use: "get-project", + Long: `get the specified project and its configuration for syncing from the LaunchDarkly Service + +Examples: + # Get project with basic information + ldcli dev-server get-project --project=my-project + + # Get project with all data (for import/backup) + ldcli dev-server get-project --project=my-project \ + --expand=overrides --expand=availableVariations > backup.json`, + RunE: getProject(client), + Short: "get a project", + Use: "get-project", } cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) @@ -65,6 +73,9 @@ func NewGetProjectCmd(client resources.Client) *cobra.Command { _ = cmd.Flags().SetAnnotation(cliflags.ProjectFlag, "required", []string{"true"}) _ = viper.BindPFlag(cliflags.ProjectFlag, cmd.Flags().Lookup(cliflags.ProjectFlag)) + cmd.Flags().StringSlice("expand", []string{}, "Expand options: overrides, availableVariations") + _ = viper.BindPFlag("expand", cmd.Flags().Lookup("expand")) + return cmd } @@ -72,10 +83,28 @@ func getProject(client resources.Client) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, args []string) error { path := getDevServerUrl() + "/dev/projects/" + viper.GetString(cliflags.ProjectFlag) - res, err := client.MakeUnauthenticatedRequest( + + // Add expand query parameters if specified + // Try to get from command flags first, then fall back to viper + expandOptions, err := cmd.Flags().GetStringSlice("expand") + if err != nil { + expandOptions = viper.GetStringSlice("expand") + } + + // Build query parameters + query := make(map[string][]string) + if len(expandOptions) > 0 { + query["expand"] = expandOptions + } + + res, err := client.MakeRequest( + "", // no auth token needed for dev server "GET", path, - nil, + "application/json", + query, + nil, // no body + false, // not beta ) if err != nil { return output.NewCmdOutputError(err, viper.GetString(cliflags.OutputFlag)) diff --git a/internal/dev_server/model/import_project.go b/internal/dev_server/model/import_project.go new file mode 100644 index 000000000..f5cada7ba --- /dev/null +++ b/internal/dev_server/model/import_project.go @@ -0,0 +1,127 @@ +package model + +import ( + "context" + "encoding/json" + "os" + + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" + "github.com/pkg/errors" +) + +// ImportData represents the JSON structure from the project endpoint +// matching the format from /dev/projects/{projectKey}?expand=overrides&expand=availableVariations +type ImportData struct { + Context ldcontext.Context `json:"context"` + SourceEnvironmentKey string `json:"sourceEnvironmentKey"` + FlagsState FlagsState `json:"flagsState"` + Overrides *FlagsState `json:"overrides,omitempty"` + AvailableVariations *map[string][]ImportVariation `json:"availableVariations,omitempty"` +} + +// ImportVariation represents a variation in the import data format +type ImportVariation struct { + Id string `json:"_id"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Value ldvalue.Value `json:"value"` +} + +// ImportProject imports a project from import data into the database. +// Returns an error if the project already exists. +func ImportProject(ctx context.Context, projectKey string, importData ImportData) error { + store := StoreFromContext(ctx) + + // Check if project already exists + existingProject, err := store.GetDevProject(ctx, projectKey) + if err != nil { + // ErrNotFound is expected - it means the project doesn't exist yet, which is what we want + if _, ok := err.(ErrNotFound); !ok { + return errors.Wrap(err, "unable to check if project exists") + } + // Project doesn't exist, continue with import + } else if existingProject != nil { + // Project exists, cannot import + return errors.Errorf("project '%s' already exists, cannot import", projectKey) + } + + // Create project from import data + project := Project{ + Key: projectKey, + SourceEnvironmentKey: importData.SourceEnvironmentKey, + Context: importData.Context, + AllFlagsState: importData.FlagsState, + AvailableVariations: []FlagVariation{}, + } + + // Convert available variations if present + if importData.AvailableVariations != nil { + for flagKey, variations := range *importData.AvailableVariations { + for _, v := range variations { + project.AvailableVariations = append(project.AvailableVariations, FlagVariation{ + FlagKey: flagKey, + Variation: Variation{ + Id: v.Id, + Name: v.Name, + Description: v.Description, + Value: v.Value, + }, + }) + } + } + } + + // Insert project into database + err = store.InsertProject(ctx, project) + if err != nil { + return errors.Wrap(err, "unable to insert project") + } + + // Import overrides if present + if importData.Overrides != nil { + for flagKey, flagState := range *importData.Overrides { + // Use store directly instead of UpsertOverride to avoid observer notifications + override := Override{ + ProjectKey: projectKey, + FlagKey: flagKey, + Value: flagState.Value, + Active: true, + Version: 1, + } + _, err = store.UpsertOverride(ctx, override) + if err != nil { + return errors.Wrapf(err, "unable to import override for flag %s", flagKey) + } + } + } + + return nil +} + +// ImportProjectFromFile reads a JSON file and imports the project data. +func ImportProjectFromFile(ctx context.Context, projectKey, filepath string) error { + // Read file + data, err := os.ReadFile(filepath) + if err != nil { + return errors.Wrapf(err, "unable to read file %s", filepath) + } + + // Parse JSON + var importData ImportData + err = json.Unmarshal(data, &importData) + if err != nil { + return errors.Wrap(err, "unable to parse JSON") + } + + // Validate required fields + if importData.SourceEnvironmentKey == "" { + return errors.New("sourceEnvironmentKey is required in import data") + } + if importData.FlagsState == nil { + return errors.New("flagsState is required in import data") + } + + // Import the project + return ImportProject(ctx, projectKey, importData) +} diff --git a/internal/dev_server/model/import_project_test.go b/internal/dev_server/model/import_project_test.go new file mode 100644 index 000000000..33e7fb500 --- /dev/null +++ b/internal/dev_server/model/import_project_test.go @@ -0,0 +1,303 @@ +package model_test + +import ( + "context" + "encoding/json" + "errors" + "os" + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" + "github.com/launchdarkly/ldcli/internal/dev_server/model" + "github.com/launchdarkly/ldcli/internal/dev_server/model/mocks" +) + +func TestImportProject(t *testing.T) { + ctx := context.Background() + mockController := gomock.NewController(t) + store := mocks.NewMockStore(mockController) + ctx = model.ContextWithStore(ctx, store) + + projectKey := "test-project" + seedData := model.ImportData{ + Context: ldcontext.NewBuilder("user").Key("test-user").Build(), + SourceEnvironmentKey: "test-env", + FlagsState: model.FlagsState{ + "flag-1": model.FlagState{Value: ldvalue.Bool(true), Version: 1}, + "flag-2": model.FlagState{Value: ldvalue.String("hello"), Version: 2}, + }, + AvailableVariations: &map[string][]model.ImportVariation{ + "flag-1": { + { + Id: "var-1", + Name: lo.ToPtr("True"), + Description: lo.ToPtr("True variation"), + Value: ldvalue.Bool(true), + }, + { + Id: "var-2", + Name: lo.ToPtr("False"), + Value: ldvalue.Bool(false), + }, + }, + "flag-2": { + { + Id: "var-3", + Value: ldvalue.String("hello"), + }, + { + Id: "var-4", + Value: ldvalue.String("world"), + }, + }, + }, + Overrides: &model.FlagsState{ + "flag-1": model.FlagState{Value: ldvalue.Bool(false), Version: 1}, + }, + } + + t.Run("Returns error if project already exists", func(t *testing.T) { + existingProject := &model.Project{Key: projectKey} + store.EXPECT().GetDevProject(gomock.Any(), projectKey).Return(existingProject, nil) + + err := model.ImportProject(ctx, projectKey, seedData) + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + assert.Contains(t, err.Error(), projectKey) + }) + + t.Run("Returns error if checking project existence fails", func(t *testing.T) { + store.EXPECT().GetDevProject(gomock.Any(), projectKey).Return(nil, errors.New("db error")) + + err := model.ImportProject(ctx, projectKey, seedData) + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to check if project exists") + assert.Contains(t, err.Error(), "db error") + }) + + t.Run("Returns error if insert project fails", func(t *testing.T) { + store.EXPECT().GetDevProject(gomock.Any(), projectKey).Return(nil, nil) + store.EXPECT().InsertProject(gomock.Any(), gomock.Any()).Return(errors.New("insert failed")) + + err := model.ImportProject(ctx, projectKey, seedData) + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to insert project") + assert.Contains(t, err.Error(), "insert failed") + }) + + t.Run("Returns error if upserting override fails", func(t *testing.T) { + store.EXPECT().GetDevProject(gomock.Any(), projectKey).Return(nil, nil) + store.EXPECT().InsertProject(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().UpsertOverride(gomock.Any(), gomock.Any()).Return(model.Override{}, errors.New("override failed")) + + err := model.ImportProject(ctx, projectKey, seedData) + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to import override") + assert.Contains(t, err.Error(), "override failed") + }) + + t.Run("Successfully imports project without overrides", func(t *testing.T) { + seedDataNoOverrides := model.ImportData{ + Context: ldcontext.NewBuilder("user").Key("test-user").Build(), + SourceEnvironmentKey: "test-env", + FlagsState: model.FlagsState{ + "flag-1": model.FlagState{Value: ldvalue.Bool(true), Version: 1}, + }, + } + + store.EXPECT().GetDevProject(gomock.Any(), projectKey).Return(nil, nil) + store.EXPECT().InsertProject(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, project model.Project) error { + assert.Equal(t, projectKey, project.Key) + assert.Equal(t, "test-env", project.SourceEnvironmentKey) + assert.Equal(t, ldcontext.NewBuilder("user").Key("test-user").Build(), project.Context) + assert.Equal(t, seedDataNoOverrides.FlagsState, project.AllFlagsState) + return nil + }, + ) + + err := model.ImportProject(ctx, projectKey, seedDataNoOverrides) + require.NoError(t, err) + }) + + t.Run("Successfully imports project with overrides and variations", func(t *testing.T) { + store.EXPECT().GetDevProject(gomock.Any(), projectKey).Return(nil, nil) + store.EXPECT().InsertProject(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, project model.Project) error { + // Verify project fields + assert.Equal(t, projectKey, project.Key) + assert.Equal(t, "test-env", project.SourceEnvironmentKey) + assert.Equal(t, seedData.Context, project.Context) + assert.Equal(t, seedData.FlagsState, project.AllFlagsState) + + // Verify available variations were converted correctly + assert.Len(t, project.AvailableVariations, 4) // 2 for flag-1, 2 for flag-2 + + // Check that all variations are present + foundFlags := make(map[string]int) + for _, fv := range project.AvailableVariations { + foundFlags[fv.FlagKey]++ + } + assert.Equal(t, 2, foundFlags["flag-1"]) + assert.Equal(t, 2, foundFlags["flag-2"]) + + return nil + }, + ) + store.EXPECT().UpsertOverride(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, override model.Override) (model.Override, error) { + assert.Equal(t, projectKey, override.ProjectKey) + assert.Equal(t, "flag-1", override.FlagKey) + assert.Equal(t, ldvalue.Bool(false), override.Value) + assert.True(t, override.Active) + return override, nil + }, + ) + + err := model.ImportProject(ctx, projectKey, seedData) + require.NoError(t, err) + }) +} + +func TestImportProjectFromFile(t *testing.T) { + ctx := context.Background() + mockController := gomock.NewController(t) + store := mocks.NewMockStore(mockController) + ctx = model.ContextWithStore(ctx, store) + + projectKey := "test-project" + + t.Run("Returns error if file does not exist", func(t *testing.T) { + err := model.ImportProjectFromFile(ctx, projectKey, "/nonexistent/file.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to read file") + }) + + t.Run("Returns error if JSON is invalid", func(t *testing.T) { + tmpFile, err := os.CreateTemp("", "invalid-*.json") + require.NoError(t, err) + defer os.Remove(tmpFile.Name()) + + _, err = tmpFile.WriteString("{ invalid json }") + require.NoError(t, err) + tmpFile.Close() + + err = model.ImportProjectFromFile(ctx, projectKey, tmpFile.Name()) + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to parse JSON") + }) + + t.Run("Returns error if sourceEnvironmentKey is missing", func(t *testing.T) { + tmpFile, err := os.CreateTemp("", "missing-env-*.json") + require.NoError(t, err) + defer os.Remove(tmpFile.Name()) + + seedData := map[string]interface{}{ + "context": map[string]interface{}{ + "kind": "user", + "key": "test-user", + }, + "flagsState": map[string]interface{}{ + "flag-1": map[string]interface{}{ + "value": true, + "version": 1, + }, + }, + } + + data, err := json.Marshal(seedData) + require.NoError(t, err) + _, err = tmpFile.Write(data) + require.NoError(t, err) + tmpFile.Close() + + err = model.ImportProjectFromFile(ctx, projectKey, tmpFile.Name()) + require.Error(t, err) + assert.Contains(t, err.Error(), "sourceEnvironmentKey is required") + }) + + t.Run("Returns error if flagsState is missing", func(t *testing.T) { + tmpFile, err := os.CreateTemp("", "missing-flags-*.json") + require.NoError(t, err) + defer os.Remove(tmpFile.Name()) + + seedData := map[string]interface{}{ + "context": map[string]interface{}{ + "kind": "user", + "key": "test-user", + }, + "sourceEnvironmentKey": "test-env", + } + + data, err := json.Marshal(seedData) + require.NoError(t, err) + _, err = tmpFile.Write(data) + require.NoError(t, err) + tmpFile.Close() + + err = model.ImportProjectFromFile(ctx, projectKey, tmpFile.Name()) + require.Error(t, err) + assert.Contains(t, err.Error(), "flagsState is required") + }) + + t.Run("Successfully imports from valid JSON file", func(t *testing.T) { + tmpFile, err := os.CreateTemp("", "valid-*.json") + require.NoError(t, err) + defer os.Remove(tmpFile.Name()) + + seedData := map[string]interface{}{ + "context": map[string]interface{}{ + "kind": "user", + "key": "test-user", + }, + "sourceEnvironmentKey": "test-env", + "flagsState": map[string]interface{}{ + "flag-1": map[string]interface{}{ + "value": true, + "version": 1, + "trackEvents": false, + }, + }, + "availableVariations": map[string]interface{}{ + "flag-1": []interface{}{ + map[string]interface{}{ + "_id": "var-1", + "name": "True", + "value": true, + }, + map[string]interface{}{ + "_id": "var-2", + "name": "False", + "value": false, + }, + }, + }, + } + + data, err := json.Marshal(seedData) + require.NoError(t, err) + _, err = tmpFile.Write(data) + require.NoError(t, err) + tmpFile.Close() + + store.EXPECT().GetDevProject(gomock.Any(), projectKey).Return(nil, model.NewErrNotFound("project", projectKey)) + store.EXPECT().InsertProject(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, project model.Project) error { + assert.Equal(t, projectKey, project.Key) + assert.Equal(t, "test-env", project.SourceEnvironmentKey) + assert.Len(t, project.AllFlagsState, 1) + assert.Len(t, project.AvailableVariations, 2) + return nil + }, + ) + + err = model.ImportProjectFromFile(ctx, projectKey, tmpFile.Name()) + require.NoError(t, err) + }) +}