diff --git a/cmd/root.go b/cmd/root.go index a8f2110e..ad33ea59 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -28,6 +28,7 @@ import ( signupcmd "github.com/launchdarkly/ldcli/cmd/signup" sourcemapscmd "github.com/launchdarkly/ldcli/cmd/sourcemaps" symbolscmd "github.com/launchdarkly/ldcli/cmd/symbols" + synccmd "github.com/launchdarkly/ldcli/cmd/sync" whoamicmd "github.com/launchdarkly/ldcli/cmd/whoami" "github.com/launchdarkly/ldcli/internal/analytics" "github.com/launchdarkly/ldcli/internal/config" @@ -299,6 +300,7 @@ func NewRootCommand( cmd.AddCommand(devcmd.NewDevServerCmd(clients.ResourcesClient, analyticsTrackerFn, clients.DevClient)) cmd.AddCommand(sourcemapscmd.NewSourcemapsCmd(clients.ResourcesClient, analyticsTrackerFn)) cmd.AddCommand(symbolscmd.NewSymbolsCmd(clients.ResourcesClient, analyticsTrackerFn)) + cmd.AddCommand(synccmd.NewSyncCmd(clients.ResourcesClient, analyticsTrackerFn)) cmd.AddCommand(whoamicmd.NewWhoAmICmd(clients.ResourcesClient)) resourcecmd.AddAllResourceCmds(cmd, clients.ResourcesClient, analyticsTrackerFn) diff --git a/cmd/root_test.go b/cmd/root_test.go index 2b34ee65..d8132e2d 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -367,6 +367,7 @@ func TestNewRootCommand_RegistersTopLevelCommands(t *testing.T) { "signup", "sourcemaps", "symbols", + "sync", "whoami", } { assert.True(t, registered[name], "%s is not registered on the root command", name) diff --git a/cmd/sync/output.go b/cmd/sync/output.go new file mode 100644 index 00000000..591185b1 --- /dev/null +++ b/cmd/sync/output.go @@ -0,0 +1,110 @@ +package sync + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/launchdarkly/ldcli/internal/output" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" +) + +type planOutputResource struct { + ProjectKey string `json:"projectKey"` + ResourceKind string `json:"resourceKind"` + LookupKey string `json:"lookupKey"` + Status syncapi.ResourceStatus `json:"status"` + SyncDirection syncapi.SyncDirection `json:"syncDirection"` + Diff json.RawMessage `json:"diff,omitempty"` + Error *syncapi.ResourceError `json:"error,omitempty"` +} + +type planOutputEnvelope struct { + Items []planOutputItem `json:"items"` +} + +type planOutputItem struct { + Key string `json:"key"` + Name string `json:"name"` +} + +func writePlanOutput( + out io.Writer, + outputKind string, + plans []syncapi.ProjectPlan, +) error { + resources := flattenPlanResources(plans) + + var outputValue any = planOutputEnvelope{Items: planOutputItems(resources)} + if outputKind == "json" { + outputValue = resources + } + + data, err := json.Marshal(outputValue) + if err != nil { + return fmt.Errorf("marshal plan output: %w", err) + } + + formatted, err := output.CmdOutput("list", outputKind, data) + if err != nil { + return err + } + if formatted == "" { + return nil + } + + if _, err := fmt.Fprintln(out, formatted); err != nil { + return fmt.Errorf("write plan output: %w", err) + } + + return nil +} + +func flattenPlanResources(plans []syncapi.ProjectPlan) []planOutputResource { + resources := make([]planOutputResource, 0) + + for _, plan := range plans { + for _, resource := range plan.Resources { + resources = append(resources, planOutputResource{ + ProjectKey: plan.ProjectKey, + ResourceKind: string(resource.ResourceKind), + LookupKey: resource.LookupKey, + Status: resource.Status, + SyncDirection: resource.SyncDirection, + Diff: resource.Diff, + Error: resource.Error, + }) + } + } + + return resources +} + +func planOutputItems(resources []planOutputResource) []planOutputItem { + items := make([]planOutputItem, 0, len(resources)) + + for _, resource := range resources { + details := fmt.Sprintf( + "status=%s direction=%s", + resource.Status, + resource.SyncDirection, + ) + if len(resource.Diff) > 0 { + details += " diff=" + string(resource.Diff) + } + if resource.Error != nil { + details += fmt.Sprintf( + " error=%s: %s", + resource.Error.Code, + resource.Error.Message, + ) + } + + items = append(items, planOutputItem{ + Key: resource.ProjectKey + "/" + resource.LookupKey, + Name: details, + }) + } + + return items +} diff --git a/cmd/sync/prompt.go b/cmd/sync/prompt.go new file mode 100644 index 00000000..2c36f30b --- /dev/null +++ b/cmd/sync/prompt.go @@ -0,0 +1,75 @@ +package sync + +import ( + "fmt" + "os" + + "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/config" + "github.com/launchdarkly/ldcli/internal/output" + "github.com/launchdarkly/ldcli/internal/resources" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" + syncsource "github.com/launchdarkly/ldcli/internal/sync/source" +) + +func NewPromptCmd(client resources.Client) *cobra.Command { + cmd := &cobra.Command{ + Use: "prompt", + Short: "Preview synchronization changes for local prompts", + Long: "Read local prompt variations and preview the changes LaunchDarkly would make without creating or applying a plan.", + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.NoArgs(cmd, args); err != nil { + return err + } + + return validators.Validate()(cmd, args) + }, + RunE: runPrompt(client), + } + + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + + return cmd +} + +func runPrompt(client resources.Client) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, _ []string) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + + workspace, err := syncsource.NewResolver(config.GetConfigFile()).Resolve(cwd) + if err != nil { + return err + } + + localResources, err := synclocal.Compile(os.DirFS(workspace.Root)) + if err != nil { + return err + } + + plans, err := syncapi.NewClient(client).Plan( + viper.GetString(cliflags.AccessTokenFlag), + viper.GetString(cliflags.BaseURIFlag), + workspace.Source, + true, + localResources, + ) + if err != nil { + return output.NewCmdOutputError(err, cliflags.GetOutputKind(cmd)) + } + + return writePlanOutput( + cmd.OutOrStdout(), + cliflags.GetOutputKind(cmd), + plans, + ) + } +} diff --git a/cmd/sync/prompt_test.go b/cmd/sync/prompt_test.go new file mode 100644 index 00000000..d7d5b2eb --- /dev/null +++ b/cmd/sync/prompt_test.go @@ -0,0 +1,288 @@ +package sync_test + +import ( + "encoding/json" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/cmd" + "github.com/launchdarkly/ldcli/internal/analytics" + "github.com/launchdarkly/ldcli/internal/resources" +) + +type recordedRequest struct { + Method string + Path string + ContentType string + Body []byte +} + +type recordingClient struct { + Requests []recordedRequest + Responses [][]byte +} + +var _ resources.Client = &recordingClient{} + +func (client *recordingClient) MakeRequest( + _ string, + method string, + path string, + contentType string, + _ url.Values, + body []byte, + _ bool, +) ([]byte, error) { + client.Requests = append(client.Requests, recordedRequest{ + Method: method, + Path: path, + ContentType: contentType, + Body: append([]byte(nil), body...), + }) + + return client.Responses[len(client.Requests)-1], nil +} + +func (*recordingClient) MakeUnauthenticatedRequest(string, string, []byte) ([]byte, error) { + return nil, nil +} + +func TestPromptPreview(t *testing.T) { + repository := initRepository(t) + writePrompt(t, repository, "project", "support", "default", true) + t.Chdir(repository) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + client := &recordingClient{ + Responses: [][]byte{[]byte(`{ + "resources": [{ + "resourceKind": "variation", + "lookupKey": "support/default", + "status": "local_changed", + "syncDirection": "code_canonical", + "diff": {"name": {"before": "Old", "after": "Default"}} + }] + }`)}, + } + + stdout, _, err := cmd.CallCmdCapturingStderr( + t, + cmd.APIClients{ResourcesClient: client}, + analytics.NoopClientFn{}.Tracker(), + []string{ + "sync", "prompt", + "--access-token", "token", + "--base-uri", "https://example.com", + "--output", "json", + }, + ) + + require.NoError(t, err) + require.Len(t, client.Requests, 1) + + request := client.Requests[0] + assert.Equal(t, "POST", request.Method) + assert.Equal( + t, + "https://example.com/api/v2/projects/project/ai-configs/sync/plan", + request.Path, + ) + assert.Equal(t, "application/json", request.ContentType) + + var body struct { + Source struct { + Type string `json:"type"` + Identifier string `json:"identifier"` + } `json:"source"` + DryRun bool `json:"dryRun"` + Resources []struct { + ResourceKind string `json:"resourceKind"` + LookupKey string `json:"lookupKey"` + Upsert bool `json:"upsert"` + Payload json.RawMessage `json:"payload"` + } `json:"resources"` + } + require.NoError(t, json.Unmarshal(request.Body, &body)) + assert.Equal(t, "git", body.Source.Type) + assert.Equal(t, "github.com/launchdarkly/example", body.Source.Identifier) + assert.True(t, body.DryRun) + require.Len(t, body.Resources, 1) + assert.Equal(t, "variation", body.Resources[0].ResourceKind) + assert.Equal(t, "support/default", body.Resources[0].LookupKey) + assert.True(t, body.Resources[0].Upsert) + assert.JSONEq(t, `{ + "mode": "completion", + "key": "default", + "name": "Default", + "messages": [{"role": "system", "content": "Say hello."}] + }`, string(body.Resources[0].Payload)) + assert.NotContains(t, string(request.Body), "fingerprint") + + var output []map[string]any + require.NoError(t, json.Unmarshal([]byte(stdout), &output)) + require.Len(t, output, 1) + assert.Equal(t, "project", output[0]["projectKey"]) + assert.Equal(t, "local_changed", output[0]["status"]) + assert.NotContains(t, output[0], "action") + assert.NotNil(t, output[0]["diff"]) +} + +func TestPromptPreviewUsesLocalSourceOutsideGit(t *testing.T) { + workspace := t.TempDir() + writePrompt(t, workspace, "project", "support", "default", false) + t.Chdir(workspace) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + client := &recordingClient{ + Responses: [][]byte{[]byte(`{ + "resources": [{ + "resourceKind": "variation", + "lookupKey": "support/default", + "status": "in_sync", + "syncDirection": "code_canonical" + }] + }`)}, + } + + _, _, err := cmd.CallCmdCapturingStderr( + t, + cmd.APIClients{ResourcesClient: client}, + analytics.NoopClientFn{}.Tracker(), + []string{ + "sync", "prompt", + "--access-token", "token", + "--base-uri", "https://example.com", + "--output", "json", + }, + ) + + require.NoError(t, err) + require.Len(t, client.Requests, 1) + + var body struct { + Source struct { + Type string `json:"type"` + Identifier string `json:"identifier"` + } `json:"source"` + } + require.NoError(t, json.Unmarshal(client.Requests[0].Body, &body)) + assert.Equal(t, "local", body.Source.Type) + assert.Regexp(t, `^sha256\.[0-9a-f]{64}$`, body.Source.Identifier) + assert.NotContains(t, string(client.Requests[0].Body), workspace) +} + +func TestPromptPreviewGroupsRequestsByProject(t *testing.T) { + repository := initRepository(t) + writePrompt(t, repository, "alpha", "support", "first", false) + writePrompt(t, repository, "zeta", "support", "second", false) + t.Chdir(repository) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + client := &recordingClient{ + Responses: [][]byte{ + []byte(`{"resources":[{"resourceKind":"variation","lookupKey":"support/first","status":"local_changed","syncDirection":"code_canonical","diff":{"name":{"before":"Old","after":"Default"}}}]}`), + []byte(`{"resources":[{"resourceKind":"variation","lookupKey":"support/second","status":"server_changed","syncDirection":"server_canonical"}]}`), + }, + } + + stdout, _, err := cmd.CallCmdCapturingStderr( + t, + cmd.APIClients{ResourcesClient: client}, + analytics.NoopClientFn{}.Tracker(), + []string{ + "sync", "prompt", + "--access-token", "token", + "--base-uri", "https://example.com", + "--output", "plaintext", + }, + ) + + require.NoError(t, err) + require.Len(t, client.Requests, 2) + assert.Contains(t, client.Requests[0].Path, "/projects/alpha/") + assert.Contains(t, client.Requests[1].Path, "/projects/zeta/") + assert.Contains(t, string(stdout), "server_changed") + assert.NotContains(t, string(stdout), "action=") + assert.Contains(t, string(stdout), "diff=") +} + +func TestPromptPreviewWithoutResourcesMakesNoRequest(t *testing.T) { + repository := initRepository(t) + require.NoError(t, os.MkdirAll( + filepath.Join(repository, ".launchdarkly", "project"), + 0o755, + )) + t.Chdir(repository) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + client := &recordingClient{} + stdout, _, err := cmd.CallCmdCapturingStderr( + t, + cmd.APIClients{ResourcesClient: client}, + analytics.NoopClientFn{}.Tracker(), + []string{ + "sync", "prompt", + "--access-token", "token", + "--output", "json", + }, + ) + + require.NoError(t, err) + assert.JSONEq(t, `[]`, string(stdout)) + assert.Empty(t, client.Requests) +} + +func initRepository(t *testing.T) string { + t.Helper() + + root := t.TempDir() + runGit(t, root, "init", "--quiet") + runGit(t, root, "remote", "add", "origin", "git@github.com:launchdarkly/example.git") + + return root +} + +func writePrompt( + t *testing.T, + root string, + projectKey string, + configKey string, + variationKey string, + upsert bool, +) { + t.Helper() + + dir := filepath.Join(root, ".launchdarkly", projectKey, "configs", configKey) + require.NoError(t, os.MkdirAll(dir, 0o755)) + + contents := `--- +formatVersion: 1 +upsert: ` + strconv.FormatBool(upsert) + ` +mode: completion +key: ` + variationKey + ` +name: Default +--- +Say hello. +` + require.NoError(t, os.WriteFile( + filepath.Join(dir, variationKey+".prompt.md"), + []byte(contents), + 0o644, + )) +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + + command := exec.Command("git", args...) + command.Dir = dir + output, err := command.CombinedOutput() + require.NoError(t, err, string(output)) +} diff --git a/cmd/sync/sync.go b/cmd/sync/sync.go new file mode 100644 index 00000000..46411635 --- /dev/null +++ b/cmd/sync/sync.go @@ -0,0 +1,40 @@ +package sync + +import ( + "github.com/spf13/cobra" + "github.com/spf13/viper" + + cmdAnalytics "github.com/launchdarkly/ldcli/cmd/analytics" + "github.com/launchdarkly/ldcli/cmd/cliflags" + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/internal/analytics" + "github.com/launchdarkly/ldcli/internal/resources" +) + +func NewSyncCmd( + client resources.Client, + analyticsTrackerFn analytics.TrackerFn, +) *cobra.Command { + cmd := &cobra.Command{ + Use: "sync", + Short: "Synchronize local resources with LaunchDarkly", + Args: cobra.MinimumNArgs(1), + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + tracker := analyticsTrackerFn( + viper.GetString(cliflags.AccessTokenFlag), + viper.GetString(cliflags.BaseURIFlag), + viper.GetBool(cliflags.AnalyticsOptOut), + ) + tracker.SendCommandRunEvent(cmdAnalytics.CmdRunEventProperties( + cmd, + "sync", + map[string]interface{}{"action": cmd.Name()}, + )) + }, + } + + cmd.AddCommand(NewPromptCmd(client)) + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + + return cmd +} diff --git a/cmd/templates.go b/cmd/templates.go index 46a5c1f2..d21a3186 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -19,6 +19,7 @@ Commands: {{rpad "login" 29}} Log in to your LaunchDarkly account {{rpad "signup" 29}} Create a new LaunchDarkly account {{rpad "dev-server" 29}} Run a development server to serve flags locally + {{rpad "sync" 29}} Synchronize local resources with LaunchDarkly Common resource commands: {{rpad "flags" 29}} List, create, and modify feature flags and their targeting diff --git a/cmd/templates_test.go b/cmd/templates_test.go index a4717eb3..7bf16f9a 100644 --- a/cmd/templates_test.go +++ b/cmd/templates_test.go @@ -19,6 +19,7 @@ func TestGetUsageTemplate_ListsTopLevelCommands(t *testing.T) { "login", "signup", "dev-server", + "sync", "flags", "environments", "projects", diff --git a/internal/sync/api/client.go b/internal/sync/api/client.go new file mode 100644 index 00000000..38ebc15f --- /dev/null +++ b/internal/sync/api/client.go @@ -0,0 +1,186 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/launchdarkly/ldcli/internal/resources" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +type ResourceStatus string + +const ( + ResourceStatusInSync ResourceStatus = "in_sync" + ResourceStatusLocalChanged ResourceStatus = "local_changed" + ResourceStatusServerChanged ResourceStatus = "server_changed" + ResourceStatusConflict ResourceStatus = "conflict" +) + +type SyncDirection string + +const ( + SyncDirectionCodeCanonical SyncDirection = "code_canonical" + SyncDirectionServerCanonical SyncDirection = "server_canonical" + SyncDirectionBoth SyncDirection = "both" +) + +type ResourceError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type PlannedResource struct { + ResourceKind syncdomain.Kind `json:"resourceKind"` + LookupKey string `json:"lookupKey"` + Status ResourceStatus `json:"status"` + SyncDirection SyncDirection `json:"syncDirection"` + Diff json.RawMessage `json:"diff,omitempty"` + Error *ResourceError `json:"error,omitempty"` +} + +type ProjectPlan struct { + ProjectKey string `json:"-"` + PlanID string `json:"planId,omitempty"` + ExpiresAt string `json:"expiresAt,omitempty"` + Resources []PlannedResource `json:"resources"` +} + +type planRequest struct { + Source sourceRequest `json:"source"` + DryRun bool `json:"dryRun"` + Resources []resourceInput `json:"resources"` +} + +type sourceRequest struct { + Type syncdomain.SourceType `json:"type"` + Identifier string `json:"identifier"` +} + +type resourceInput struct { + ResourceKind syncdomain.Kind `json:"resourceKind"` + LookupKey string `json:"lookupKey"` + Upsert bool `json:"upsert"` + Payload json.RawMessage `json:"payload"` +} + +type projectResources struct { + ProjectKey string + Resources []syncdomain.SyncedResource +} + +type Client struct { + transport resources.Client +} + +func NewClient(transport resources.Client) Client { + return Client{transport: transport} +} + +func (client Client) Plan( + accessToken string, + baseURI string, + source syncdomain.Source, + dryRun bool, + synced []syncdomain.SyncedResource, +) ([]ProjectPlan, error) { + plans := make([]ProjectPlan, 0) + + for _, project := range groupResourcesByProject(synced) { + plan, err := client.planProject(accessToken, baseURI, source, dryRun, project) + if err != nil { + return nil, err + } + + plans = append(plans, plan) + } + + return plans, nil +} + +func (client Client) planProject( + accessToken string, + baseURI string, + source syncdomain.Source, + dryRun bool, + project projectResources, +) (ProjectPlan, error) { + request := planRequest{ + Source: sourceRequest{ + Type: source.Type(), + Identifier: source.Identifier(), + }, + DryRun: dryRun, + Resources: make([]resourceInput, 0, len(project.Resources)), + } + + for _, resource := range project.Resources { + if resource.Kind != syncdomain.KindVariation { + return ProjectPlan{}, fmt.Errorf("unsupported sync resource kind %q", resource.Kind) + } + + request.Resources = append(request.Resources, resourceInput{ + ResourceKind: resource.Kind, + LookupKey: resource.LookupKey, + Upsert: resource.Upsert, + Payload: resource.Payload, + }) + } + + body, err := json.MarshalIndent(request, "", " ") + if err != nil { + return ProjectPlan{}, fmt.Errorf("marshal plan request: %w", err) + } + + endpoint, err := url.JoinPath( + baseURI, + "api/v2/projects", + project.ProjectKey, + "ai-configs/sync/plan", + ) + if err != nil { + return ProjectPlan{}, fmt.Errorf("build plan endpoint: %w", err) + } + + response, err := client.transport.MakeRequest( + accessToken, + http.MethodPost, + endpoint, + "application/json", + nil, + body, + false, + ) + if err != nil { + return ProjectPlan{}, err + } + + var plan ProjectPlan + if err := json.Unmarshal(response, &plan); err != nil { + return ProjectPlan{}, fmt.Errorf("decode plan response: %w", err) + } + + plan.ProjectKey = project.ProjectKey + + return plan, nil +} + +func groupResourcesByProject(synced []syncdomain.SyncedResource) []projectResources { + var projects []projectResources + byProject := make(map[string]int) + + for _, resource := range synced { + index, ok := byProject[resource.ProjectKey] + if !ok { + index = len(projects) + byProject[resource.ProjectKey] = index + projects = append(projects, projectResources{ProjectKey: resource.ProjectKey}) + } + + projects[index].Resources = append(projects[index].Resources, resource) + } + + return projects +} diff --git a/internal/sync/api/client_test.go b/internal/sync/api/client_test.go new file mode 100644 index 00000000..06ee6361 --- /dev/null +++ b/internal/sync/api/client_test.go @@ -0,0 +1,231 @@ +package api + +import ( + "encoding/json" + "errors" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/resources" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +type recordedRequest struct { + AccessToken string + Method string + Path string + ContentType string + Body []byte + IsBeta bool +} + +type recordingClient struct { + Requests []recordedRequest + Responses [][]byte + Err error +} + +var _ resources.Client = &recordingClient{} + +func (client *recordingClient) MakeRequest( + accessToken string, + method string, + path string, + contentType string, + _ url.Values, + body []byte, + isBeta bool, +) ([]byte, error) { + client.Requests = append(client.Requests, recordedRequest{ + AccessToken: accessToken, + Method: method, + Path: path, + ContentType: contentType, + Body: append([]byte(nil), body...), + IsBeta: isBeta, + }) + if client.Err != nil { + return nil, client.Err + } + + return client.Responses[len(client.Requests)-1], nil +} + +func (*recordingClient) MakeUnauthenticatedRequest(string, string, []byte) ([]byte, error) { + return nil, nil +} + +func TestClientPlan(t *testing.T) { + transport := &recordingClient{ + Responses: [][]byte{ + []byte(`{ + "resources": [{ + "resourceKind": "variation", + "lookupKey": "config/first", + "status": "local_changed", + "syncDirection": "code_canonical", + "diff": {"name": {"before": "Old", "after": "First"}} + }] + }`), + []byte(`{ + "resources": [{ + "resourceKind": "variation", + "lookupKey": "config/second", + "status": "server_changed", + "syncDirection": "server_canonical" + }] + }`), + }, + } + client := NewClient(transport) + source := requireSource(t, syncdomain.SourceTypeGit, "github.com/launchdarkly/example") + + plans, err := client.Plan( + "token", + "https://example.com", + source, + true, + []syncdomain.SyncedResource{ + variationResource("alpha", "config/first", "First", true), + variationResource("zeta", "config/second", "Second", false), + }, + ) + + require.NoError(t, err) + require.Len(t, transport.Requests, 2) + require.Len(t, plans, 2) + + firstRequest := transport.Requests[0] + assert.Equal(t, "POST", firstRequest.Method) + assert.Equal(t, "token", firstRequest.AccessToken) + assert.Equal(t, "application/json", firstRequest.ContentType) + assert.False(t, firstRequest.IsBeta) + assert.Equal( + t, + "https://example.com/api/v2/projects/alpha/ai-configs/sync/plan", + firstRequest.Path, + ) + + var request planRequest + require.NoError(t, json.Unmarshal(firstRequest.Body, &request)) + assert.Equal(t, syncdomain.SourceTypeGit, request.Source.Type) + assert.Equal(t, "github.com/launchdarkly/example", request.Source.Identifier) + assert.True(t, request.DryRun) + require.Len(t, request.Resources, 1) + assert.Equal(t, syncdomain.KindVariation, request.Resources[0].ResourceKind) + assert.Equal(t, "config/first", request.Resources[0].LookupKey) + assert.True(t, request.Resources[0].Upsert) + assert.JSONEq(t, `{"key":"first","name":"First"}`, string(request.Resources[0].Payload)) + assert.NotContains(t, string(firstRequest.Body), "fingerprint") + assert.NotContains(t, string(firstRequest.Body), "repoIdentifier") + + assert.Equal(t, "alpha", plans[0].ProjectKey) + require.Len(t, plans[0].Resources, 1) + assert.Equal(t, ResourceStatusLocalChanged, plans[0].Resources[0].Status) + assert.Equal(t, "zeta", plans[1].ProjectKey) + assert.Equal(t, ResourceStatusServerChanged, plans[1].Resources[0].Status) +} + +func TestClientPlanReturnsEmptyResultWithoutResources(t *testing.T) { + transport := &recordingClient{} + client := NewClient(transport) + + plans, err := client.Plan( + "token", + "https://example.com", + requireSource(t, syncdomain.SourceTypeLocal, "sha256.local"), + true, + nil, + ) + + require.NoError(t, err) + assert.Empty(t, plans) + assert.Empty(t, transport.Requests) +} + +func TestClientPlanRejectsUnsupportedResource(t *testing.T) { + transport := &recordingClient{} + client := NewClient(transport) + + _, err := client.Plan( + "token", + "https://example.com", + requireSource(t, syncdomain.SourceTypeGit, "github.com/acme/repo"), + true, + []syncdomain.SyncedResource{{ + ProjectKey: "project", + Kind: "unknown", + LookupKey: "unknown", + }}, + ) + + require.ErrorContains(t, err, `unsupported sync resource kind "unknown"`) + assert.Empty(t, transport.Requests) +} + +func TestClientPlanReturnsTransportError(t *testing.T) { + client := NewClient(&recordingClient{Err: errors.New("unavailable")}) + + _, err := client.Plan( + "token", + "https://example.com", + requireSource(t, syncdomain.SourceTypeGit, "github.com/acme/repo"), + true, + []syncdomain.SyncedResource{variationResource("project", "config/key", "Name", false)}, + ) + + require.ErrorContains(t, err, "unavailable") +} + +func TestClientPlanRejectsInvalidResponse(t *testing.T) { + client := NewClient(&recordingClient{Responses: [][]byte{[]byte(`not json`)}}) + + _, err := client.Plan( + "token", + "https://example.com", + requireSource(t, syncdomain.SourceTypeGit, "github.com/acme/repo"), + true, + []syncdomain.SyncedResource{variationResource("project", "config/key", "Name", false)}, + ) + + require.ErrorContains(t, err, "decode plan response") +} + +func variationResource( + projectKey string, + lookupKey string, + name string, + upsert bool, +) syncdomain.SyncedResource { + payload, err := json.Marshal(map[string]string{ + "key": lookupKey[len("config/"):], + "name": name, + }) + if err != nil { + panic(err) + } + + return syncdomain.SyncedResource{ + ProjectKey: projectKey, + Kind: syncdomain.KindVariation, + LookupKey: lookupKey, + Upsert: upsert, + Payload: payload, + } +} + +func requireSource( + t *testing.T, + sourceType syncdomain.SourceType, + identifier string, +) syncdomain.Source { + t.Helper() + + source, err := syncdomain.NewSource(sourceType, identifier) + require.NoError(t, err) + + return source +}