From 1e2628c35913713a76700c7da3c60d267b771048 Mon Sep 17 00:00:00 2001 From: Adam Jenkins Date: Fri, 12 Dec 2025 19:23:49 -0400 Subject: [PATCH 1/3] Seed from file --- cmd/dev_server/dev_server.go | 1 + cmd/dev_server/seed.go | 78 ++++ cmd/dev_server/seed_test.go | 489 +++++++++++++++++++++++++ internal/dev_server/model/seed.go | 123 +++++++ internal/dev_server/model/seed_test.go | 302 +++++++++++++++ 5 files changed, 993 insertions(+) create mode 100644 cmd/dev_server/seed.go create mode 100644 cmd/dev_server/seed_test.go create mode 100644 internal/dev_server/model/seed.go create mode 100644 internal/dev_server/model/seed_test.go diff --git a/cmd/dev_server/dev_server.go b/cmd/dev_server/dev_server.go index 549d4dd51..86049b2de 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(NewSeedCmd()) cmd.AddGroup(&cobra.Group{ID: "overrides", Title: "Override commands:"}) cmd.AddCommand(NewAddOverrideCmd(client)) diff --git a/cmd/dev_server/seed.go b/cmd/dev_server/seed.go new file mode 100644 index 000000000..e26ceac3c --- /dev/null +++ b/cmd/dev_server/seed.go @@ -0,0 +1,78 @@ +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 SeedFileFlag = "file" + +func NewSeedCmd() *cobra.Command { + cmd := &cobra.Command{ + GroupID: "projects", + Args: validators.Validate(), + Long: "Seed the dev server database from a JSON file. Database must be empty.", + RunE: seed(), + Short: "seed database from file", + Use: "seed", + } + + 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(SeedFileFlag, "", "Path to JSON file containing project data") + _ = cmd.MarkFlagRequired(SeedFileFlag) + _ = cmd.Flags().SetAnnotation(SeedFileFlag, "required", []string{"true"}) + _ = viper.BindPFlag(SeedFileFlag, cmd.Flags().Lookup(SeedFileFlag)) + + return cmd +} + +func seed() func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + projectKey := viper.GetString(cliflags.ProjectFlag) + filepath := viper.GetString(SeedFileFlag) + + // 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 seed database: %w", err) + } + + log.Printf("Successfully seeded project '%s' from %s", projectKey, filepath) + fmt.Fprintf(cmd.OutOrStdout(), "Successfully seeded project '%s' from %s\n", projectKey, filepath) + + return nil + } +} diff --git a/cmd/dev_server/seed_test.go b/cmd/dev_server/seed_test.go new file mode 100644 index 000000000..6414d24a0 --- /dev/null +++ b/cmd/dev_server/seed_test.go @@ -0,0 +1,489 @@ +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 TestSeedCommand(t *testing.T) { + t.Run("seeds database successfully from valid JSON file", func(t *testing.T) { + // Create temporary database + tmpDir, err := os.MkdirTemp("", "seed-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 seeding into non-empty database", func(t *testing.T) { + // Create temporary database + tmpDir, err := os.MkdirTemp("", "seed-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: "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 + 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 seed should fail + err = model.ImportProjectFromFile(ctx, "new-project", seedFile) + require.Error(t, err) + assert.Contains(t, err.Error(), "database not empty") + }) + + t.Run("validates required fields in seed data", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "seed-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 seed data with all fields", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "seed-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 seed data without optional fields", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "seed-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("", "seed-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/internal/dev_server/model/seed.go b/internal/dev_server/model/seed.go new file mode 100644 index 000000000..57ea10f14 --- /dev/null +++ b/internal/dev_server/model/seed.go @@ -0,0 +1,123 @@ +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" +) + +// SeedData represents the JSON structure from the project endpoint +// matching the format from /dev/projects/{projectKey}?expand=overrides&expand=availableVariations +type SeedData struct { + Context ldcontext.Context `json:"context"` + SourceEnvironmentKey string `json:"sourceEnvironmentKey"` + FlagsState FlagsState `json:"flagsState"` + Overrides *FlagsState `json:"overrides,omitempty"` + AvailableVariations *map[string][]SeedVariation `json:"availableVariations,omitempty"` +} + +// SeedVariation represents a variation in the seed data format +type SeedVariation struct { + Id string `json:"_id"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Value ldvalue.Value `json:"value"` +} + +// ImportProjectFromSeed imports a project from seed data into the database. +// Returns an error if the database is not empty (contains any projects). +func ImportProjectFromSeed(ctx context.Context, projectKey string, seedData SeedData) error { + store := StoreFromContext(ctx) + + // Validate database is empty + existingKeys, err := store.GetDevProjectKeys(ctx) + if err != nil { + return errors.Wrap(err, "unable to check existing projects") + } + if len(existingKeys) > 0 { + return errors.Errorf("database not empty (found %d project(s)), seeding only allowed on clean database", len(existingKeys)) + } + + // Create project from seed data + project := Project{ + Key: projectKey, + SourceEnvironmentKey: seedData.SourceEnvironmentKey, + Context: seedData.Context, + AllFlagsState: seedData.FlagsState, + AvailableVariations: []FlagVariation{}, + } + + // Convert available variations if present + if seedData.AvailableVariations != nil { + for flagKey, variations := range *seedData.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 seedData.Overrides != nil { + for flagKey, flagState := range *seedData.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 seedData SeedData + err = json.Unmarshal(data, &seedData) + if err != nil { + return errors.Wrap(err, "unable to parse JSON") + } + + // Validate required fields + if seedData.SourceEnvironmentKey == "" { + return errors.New("sourceEnvironmentKey is required in seed data") + } + if seedData.FlagsState == nil { + return errors.New("flagsState is required in seed data") + } + + // Import the project + return ImportProjectFromSeed(ctx, projectKey, seedData) +} diff --git a/internal/dev_server/model/seed_test.go b/internal/dev_server/model/seed_test.go new file mode 100644 index 000000000..4c7fd9396 --- /dev/null +++ b/internal/dev_server/model/seed_test.go @@ -0,0 +1,302 @@ +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 TestImportProjectFromSeed(t *testing.T) { + ctx := context.Background() + mockController := gomock.NewController(t) + store := mocks.NewMockStore(mockController) + ctx = model.ContextWithStore(ctx, store) + + projectKey := "test-project" + seedData := model.SeedData{ + 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.SeedVariation{ + "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 database is not empty", func(t *testing.T) { + store.EXPECT().GetDevProjectKeys(gomock.Any()).Return([]string{"existing-project"}, nil) + + err := model.ImportProjectFromSeed(ctx, projectKey, seedData) + require.Error(t, err) + assert.Contains(t, err.Error(), "database not empty") + assert.Contains(t, err.Error(), "found 1 project") + }) + + t.Run("Returns error if checking existing projects fails", func(t *testing.T) { + store.EXPECT().GetDevProjectKeys(gomock.Any()).Return(nil, errors.New("db error")) + + err := model.ImportProjectFromSeed(ctx, projectKey, seedData) + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to check existing projects") + assert.Contains(t, err.Error(), "db error") + }) + + t.Run("Returns error if insert project fails", func(t *testing.T) { + store.EXPECT().GetDevProjectKeys(gomock.Any()).Return([]string{}, nil) + store.EXPECT().InsertProject(gomock.Any(), gomock.Any()).Return(errors.New("insert failed")) + + err := model.ImportProjectFromSeed(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().GetDevProjectKeys(gomock.Any()).Return([]string{}, 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.ImportProjectFromSeed(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.SeedData{ + 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().GetDevProjectKeys(gomock.Any()).Return([]string{}, 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.ImportProjectFromSeed(ctx, projectKey, seedDataNoOverrides) + require.NoError(t, err) + }) + + t.Run("Successfully imports project with overrides and variations", func(t *testing.T) { + store.EXPECT().GetDevProjectKeys(gomock.Any()).Return([]string{}, 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.ImportProjectFromSeed(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().GetDevProjectKeys(gomock.Any()).Return([]string{}, 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.Len(t, project.AllFlagsState, 1) + assert.Len(t, project.AvailableVariations, 2) + return nil + }, + ) + + err = model.ImportProjectFromFile(ctx, projectKey, tmpFile.Name()) + require.NoError(t, err) + }) +} From 0335c97f5cfdc8703ec99ec6a0adea5933e49071 Mon Sep 17 00:00:00 2001 From: Adam Jenkins Date: Sat, 13 Dec 2025 09:45:55 -0400 Subject: [PATCH 2/3] Adjust get-project command to accept --expand --- cmd/dev_server/projects.go | 41 ++++++++++++++++++++++++++++++++------ cmd/dev_server/seed.go | 20 +++++++++++++++---- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/cmd/dev_server/projects.go b/cmd/dev_server/projects.go index 703dc8825..488213b85 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 seeding/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/cmd/dev_server/seed.go b/cmd/dev_server/seed.go index e26ceac3c..b06e53df1 100644 --- a/cmd/dev_server/seed.go +++ b/cmd/dev_server/seed.go @@ -22,10 +22,22 @@ func NewSeedCmd() *cobra.Command { cmd := &cobra.Command{ GroupID: "projects", Args: validators.Validate(), - Long: "Seed the dev server database from a JSON file. Database must be empty.", - RunE: seed(), - Short: "seed database from file", - Use: "seed", + Long: `Seed the dev server database from a JSON file. Database must be empty. + +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, seed a clean database from backup + ldcli dev-server seed --project=my-project --file=backup.json`, + RunE: seed(), + Short: "seed database from file", + Use: "seed", } cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) From 688364f424139058dc73f832da3d013db4467525 Mon Sep 17 00:00:00 2001 From: Adam Jenkins Date: Sat, 13 Dec 2025 15:00:47 -0400 Subject: [PATCH 3/3] Rename to import --- cmd/dev_server/dev_server.go | 2 +- cmd/dev_server/{seed.go => import_project.go} | 34 +++---- .../{seed_test.go => import_project_test.go} | 92 +++++++++++++++---- cmd/dev_server/projects.go | 2 +- .../model/{seed.go => import_project.go} | 70 +++++++------- .../{seed_test.go => import_project_test.go} | 45 ++++----- 6 files changed, 154 insertions(+), 91 deletions(-) rename cmd/dev_server/{seed.go => import_project.go} (65%) rename cmd/dev_server/{seed_test.go => import_project_test.go} (82%) rename internal/dev_server/model/{seed.go => import_project.go} (50%) rename internal/dev_server/model/{seed_test.go => import_project_test.go} (84%) diff --git a/cmd/dev_server/dev_server.go b/cmd/dev_server/dev_server.go index 86049b2de..948865233 100644 --- a/cmd/dev_server/dev_server.go +++ b/cmd/dev_server/dev_server.go @@ -72,7 +72,7 @@ func NewDevServerCmd(client resources.Client, analyticsTrackerFn analytics.Track cmd.AddCommand(NewRemoveProjectCmd(client)) cmd.AddCommand(NewAddProjectCmd(client)) cmd.AddCommand(NewUpdateProjectCmd(client)) - cmd.AddCommand(NewSeedCmd()) + cmd.AddCommand(NewImportProjectCmd()) cmd.AddGroup(&cobra.Group{ID: "overrides", Title: "Override commands:"}) cmd.AddCommand(NewAddOverrideCmd(client)) diff --git a/cmd/dev_server/seed.go b/cmd/dev_server/import_project.go similarity index 65% rename from cmd/dev_server/seed.go rename to cmd/dev_server/import_project.go index b06e53df1..6e53f9765 100644 --- a/cmd/dev_server/seed.go +++ b/cmd/dev_server/import_project.go @@ -16,13 +16,13 @@ import ( "github.com/launchdarkly/ldcli/internal/dev_server/model" ) -const SeedFileFlag = "file" +const ImportFileFlag = "file" -func NewSeedCmd() *cobra.Command { +func NewImportProjectCmd() *cobra.Command { cmd := &cobra.Command{ GroupID: "projects", Args: validators.Validate(), - Long: `Seed the dev server database from a JSON file. Database must be empty. + 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= \ @@ -33,11 +33,11 @@ Examples: ldcli dev-server get-project --project=my-project \ --expand=overrides --expand=availableVariations > backup.json - # Later, seed a clean database from backup - ldcli dev-server seed --project=my-project --file=backup.json`, - RunE: seed(), - Short: "seed database from file", - Use: "seed", + # 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()) @@ -47,19 +47,19 @@ Examples: _ = cmd.Flags().SetAnnotation(cliflags.ProjectFlag, "required", []string{"true"}) _ = viper.BindPFlag(cliflags.ProjectFlag, cmd.Flags().Lookup(cliflags.ProjectFlag)) - cmd.Flags().String(SeedFileFlag, "", "Path to JSON file containing project data") - _ = cmd.MarkFlagRequired(SeedFileFlag) - _ = cmd.Flags().SetAnnotation(SeedFileFlag, "required", []string{"true"}) - _ = viper.BindPFlag(SeedFileFlag, cmd.Flags().Lookup(SeedFileFlag)) + 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 seed() func(*cobra.Command, []string) error { +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(SeedFileFlag) + filepath := viper.GetString(ImportFileFlag) // Get database path (same logic as dev_server.go) dbFilePath, err := xdg.StateFile("ldcli/dev_server.db") @@ -79,11 +79,11 @@ func seed() func(*cobra.Command, []string) error { // Import project from file err = model.ImportProjectFromFile(ctx, projectKey, filepath) if err != nil { - return fmt.Errorf("unable to seed database: %w", err) + return fmt.Errorf("unable to import project: %w", err) } - log.Printf("Successfully seeded project '%s' from %s", projectKey, filepath) - fmt.Fprintf(cmd.OutOrStdout(), "Successfully seeded project '%s' from %s\n", projectKey, filepath) + 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/seed_test.go b/cmd/dev_server/import_project_test.go similarity index 82% rename from cmd/dev_server/seed_test.go rename to cmd/dev_server/import_project_test.go index 6414d24a0..b6938353d 100644 --- a/cmd/dev_server/seed_test.go +++ b/cmd/dev_server/import_project_test.go @@ -17,10 +17,10 @@ import ( "github.com/launchdarkly/ldcli/internal/dev_server/model" ) -func TestSeedCommand(t *testing.T) { - t.Run("seeds database successfully from valid JSON file", func(t *testing.T) { +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("", "seed-test-*") + tmpDir, err := os.MkdirTemp("", "import-test-*") require.NoError(t, err) defer os.RemoveAll(tmpDir) @@ -128,9 +128,9 @@ func TestSeedCommand(t *testing.T) { assert.True(t, overrides[0].Active) }) - t.Run("rejects seeding into non-empty database", func(t *testing.T) { + t.Run("rejects importing when project already exists", func(t *testing.T) { // Create temporary database - tmpDir, err := os.MkdirTemp("", "seed-test-*") + tmpDir, err := os.MkdirTemp("", "import-test-*") require.NoError(t, err) defer os.RemoveAll(tmpDir) @@ -140,7 +140,7 @@ func TestSeedCommand(t *testing.T) { require.NoError(t, err) ctx = model.ContextWithStore(ctx, sqlStore) - // Insert an existing project + // Insert an existing project with the same key existingProject := model.Project{ Key: "existing-project", SourceEnvironmentKey: "test", @@ -151,7 +151,7 @@ func TestSeedCommand(t *testing.T) { err = sqlStore.InsertProject(ctx, existingProject) require.NoError(t, err) - // Create seed data file + // Create seed data file for the same project key seedFile := filepath.Join(tmpDir, "seed.json") seedData := map[string]interface{}{ "context": map[string]interface{}{ @@ -172,14 +172,72 @@ func TestSeedCommand(t *testing.T) { err = os.WriteFile(seedFile, data, 0644) require.NoError(t, err) - // Attempt to seed should fail - err = model.ImportProjectFromFile(ctx, "new-project", seedFile) + // 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(), "database not empty") + 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 seed data", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "seed-test-*") + 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) @@ -238,8 +296,8 @@ func TestSeedCommand(t *testing.T) { } }) - t.Run("handles complex seed data with all fields", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "seed-test-*") + 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) @@ -357,8 +415,8 @@ func TestSeedCommand(t *testing.T) { assert.Equal(t, ldvalue.Int(100), overrideMap["number-flag"].Value) }) - t.Run("handles seed data without optional fields", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "seed-test-*") + 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) @@ -412,7 +470,7 @@ func TestSeedCommand(t *testing.T) { }) t.Run("preserves variation metadata", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "seed-test-*") + tmpDir, err := os.MkdirTemp("", "import-test-*") require.NoError(t, err) defer os.RemoveAll(tmpDir) diff --git a/cmd/dev_server/projects.go b/cmd/dev_server/projects.go index 488213b85..5b02c1c1a 100644 --- a/cmd/dev_server/projects.go +++ b/cmd/dev_server/projects.go @@ -58,7 +58,7 @@ Examples: # Get project with basic information ldcli dev-server get-project --project=my-project - # Get project with all data (for seeding/backup) + # 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), diff --git a/internal/dev_server/model/seed.go b/internal/dev_server/model/import_project.go similarity index 50% rename from internal/dev_server/model/seed.go rename to internal/dev_server/model/import_project.go index 57ea10f14..f5cada7ba 100644 --- a/internal/dev_server/model/seed.go +++ b/internal/dev_server/model/import_project.go @@ -10,50 +10,54 @@ import ( "github.com/pkg/errors" ) -// SeedData represents the JSON structure from the project endpoint +// ImportData represents the JSON structure from the project endpoint // matching the format from /dev/projects/{projectKey}?expand=overrides&expand=availableVariations -type SeedData struct { - Context ldcontext.Context `json:"context"` - SourceEnvironmentKey string `json:"sourceEnvironmentKey"` - FlagsState FlagsState `json:"flagsState"` - Overrides *FlagsState `json:"overrides,omitempty"` - AvailableVariations *map[string][]SeedVariation `json:"availableVariations,omitempty"` +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"` } -// SeedVariation represents a variation in the seed data format -type SeedVariation struct { +// 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"` } -// ImportProjectFromSeed imports a project from seed data into the database. -// Returns an error if the database is not empty (contains any projects). -func ImportProjectFromSeed(ctx context.Context, projectKey string, seedData SeedData) error { +// 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) - // Validate database is empty - existingKeys, err := store.GetDevProjectKeys(ctx) + // Check if project already exists + existingProject, err := store.GetDevProject(ctx, projectKey) if err != nil { - return errors.Wrap(err, "unable to check existing projects") - } - if len(existingKeys) > 0 { - return errors.Errorf("database not empty (found %d project(s)), seeding only allowed on clean database", len(existingKeys)) + // 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 seed data + // Create project from import data project := Project{ Key: projectKey, - SourceEnvironmentKey: seedData.SourceEnvironmentKey, - Context: seedData.Context, - AllFlagsState: seedData.FlagsState, + SourceEnvironmentKey: importData.SourceEnvironmentKey, + Context: importData.Context, + AllFlagsState: importData.FlagsState, AvailableVariations: []FlagVariation{}, } // Convert available variations if present - if seedData.AvailableVariations != nil { - for flagKey, variations := range *seedData.AvailableVariations { + if importData.AvailableVariations != nil { + for flagKey, variations := range *importData.AvailableVariations { for _, v := range variations { project.AvailableVariations = append(project.AvailableVariations, FlagVariation{ FlagKey: flagKey, @@ -75,8 +79,8 @@ func ImportProjectFromSeed(ctx context.Context, projectKey string, seedData Seed } // Import overrides if present - if seedData.Overrides != nil { - for flagKey, flagState := range *seedData.Overrides { + if importData.Overrides != nil { + for flagKey, flagState := range *importData.Overrides { // Use store directly instead of UpsertOverride to avoid observer notifications override := Override{ ProjectKey: projectKey, @@ -104,20 +108,20 @@ func ImportProjectFromFile(ctx context.Context, projectKey, filepath string) err } // Parse JSON - var seedData SeedData - err = json.Unmarshal(data, &seedData) + var importData ImportData + err = json.Unmarshal(data, &importData) if err != nil { return errors.Wrap(err, "unable to parse JSON") } // Validate required fields - if seedData.SourceEnvironmentKey == "" { - return errors.New("sourceEnvironmentKey is required in seed data") + if importData.SourceEnvironmentKey == "" { + return errors.New("sourceEnvironmentKey is required in import data") } - if seedData.FlagsState == nil { - return errors.New("flagsState is required in seed data") + if importData.FlagsState == nil { + return errors.New("flagsState is required in import data") } // Import the project - return ImportProjectFromSeed(ctx, projectKey, seedData) + return ImportProject(ctx, projectKey, importData) } diff --git a/internal/dev_server/model/seed_test.go b/internal/dev_server/model/import_project_test.go similarity index 84% rename from internal/dev_server/model/seed_test.go rename to internal/dev_server/model/import_project_test.go index 4c7fd9396..33e7fb500 100644 --- a/internal/dev_server/model/seed_test.go +++ b/internal/dev_server/model/import_project_test.go @@ -18,21 +18,21 @@ import ( "github.com/launchdarkly/ldcli/internal/dev_server/model/mocks" ) -func TestImportProjectFromSeed(t *testing.T) { +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.SeedData{ + 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.SeedVariation{ + AvailableVariations: &map[string][]model.ImportVariation{ "flag-1": { { Id: "var-1", @@ -62,47 +62,48 @@ func TestImportProjectFromSeed(t *testing.T) { }, } - t.Run("Returns error if database is not empty", func(t *testing.T) { - store.EXPECT().GetDevProjectKeys(gomock.Any()).Return([]string{"existing-project"}, nil) + 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.ImportProjectFromSeed(ctx, projectKey, seedData) + err := model.ImportProject(ctx, projectKey, seedData) require.Error(t, err) - assert.Contains(t, err.Error(), "database not empty") - assert.Contains(t, err.Error(), "found 1 project") + assert.Contains(t, err.Error(), "already exists") + assert.Contains(t, err.Error(), projectKey) }) - t.Run("Returns error if checking existing projects fails", func(t *testing.T) { - store.EXPECT().GetDevProjectKeys(gomock.Any()).Return(nil, errors.New("db error")) + 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.ImportProjectFromSeed(ctx, projectKey, seedData) + err := model.ImportProject(ctx, projectKey, seedData) require.Error(t, err) - assert.Contains(t, err.Error(), "unable to check existing projects") + 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().GetDevProjectKeys(gomock.Any()).Return([]string{}, nil) + store.EXPECT().GetDevProject(gomock.Any(), projectKey).Return(nil, nil) store.EXPECT().InsertProject(gomock.Any(), gomock.Any()).Return(errors.New("insert failed")) - err := model.ImportProjectFromSeed(ctx, projectKey, seedData) + 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().GetDevProjectKeys(gomock.Any()).Return([]string{}, nil) + 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.ImportProjectFromSeed(ctx, projectKey, seedData) + 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.SeedData{ + seedDataNoOverrides := model.ImportData{ Context: ldcontext.NewBuilder("user").Key("test-user").Build(), SourceEnvironmentKey: "test-env", FlagsState: model.FlagsState{ @@ -110,7 +111,7 @@ func TestImportProjectFromSeed(t *testing.T) { }, } - store.EXPECT().GetDevProjectKeys(gomock.Any()).Return([]string{}, nil) + 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) @@ -121,12 +122,12 @@ func TestImportProjectFromSeed(t *testing.T) { }, ) - err := model.ImportProjectFromSeed(ctx, projectKey, seedDataNoOverrides) + 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().GetDevProjectKeys(gomock.Any()).Return([]string{}, nil) + 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 @@ -159,7 +160,7 @@ func TestImportProjectFromSeed(t *testing.T) { }, ) - err := model.ImportProjectFromSeed(ctx, projectKey, seedData) + err := model.ImportProject(ctx, projectKey, seedData) require.NoError(t, err) }) } @@ -285,7 +286,7 @@ func TestImportProjectFromFile(t *testing.T) { require.NoError(t, err) tmpFile.Close() - store.EXPECT().GetDevProjectKeys(gomock.Any()).Return([]string{}, nil) + 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)