diff --git a/apptrust/commands/flags.go b/apptrust/commands/flags.go index 1347225..13e9ba0 100644 --- a/apptrust/commands/flags.go +++ b/apptrust/commands/flags.go @@ -49,6 +49,11 @@ const ( ExcludeReposFlag = "exclude-repos" IncludeReposFlag = "include-repos" PropsFlag = "props" + TagFlag = "tag" + BuildsFlag = "builds" + ReleaseBundlesFlag = "release-bundles" + SourceVersionFlag = "source-version" + PackagesFlag = "packages" ) // Flag keys mapped to their corresponding components.Flag definition. @@ -82,6 +87,11 @@ var flagsMap = map[string]components.Flag{ ExcludeReposFlag: components.NewStringFlag(ExcludeReposFlag, "Semicolon-separated list of repositories to exclude.", func(f *components.StringFlag) { f.Mandatory = false }), IncludeReposFlag: components.NewStringFlag(IncludeReposFlag, "Semicolon-separated list of repositories to include.", func(f *components.StringFlag) { f.Mandatory = false }), PropsFlag: components.NewStringFlag(PropsFlag, "Semicolon-separated list of properties in the form of 'key1=value1;key2=value2;...' to be added to each artifact.", func(f *components.StringFlag) { f.Mandatory = false }), + TagFlag: components.NewStringFlag(TagFlag, "A tag to associate with the version.", func(f *components.StringFlag) { f.Mandatory = false }), + BuildsFlag: components.NewStringFlag(BuildsFlag, "List of builds in format 'name1:number1[:timestamp1];name2:number2[:timestamp2]'", func(f *components.StringFlag) { f.Mandatory = false }), + ReleaseBundlesFlag: components.NewStringFlag(ReleaseBundlesFlag, "List of release bundles in format 'name1:version1;name2:version2'", func(f *components.StringFlag) { f.Mandatory = false }), + SourceVersionFlag: components.NewStringFlag(SourceVersionFlag, "Source versions in format 'app1:version1;app2:version2'", func(f *components.StringFlag) { f.Mandatory = false }), + PackagesFlag: components.NewStringFlag(PackagesFlag, "List of packages in format 'name1;name2'", func(f *components.StringFlag) { f.Mandatory = false }), } var commandFlags = map[string][]string{ @@ -91,10 +101,13 @@ var commandFlags = map[string][]string{ accessToken, serverId, ApplicationKeyFlag, + TagFlag, + PackagesFlag, PackageTypeFlag, - PackageNameFlag, - PackageVersionFlag, PackageRepositoryFlag, + BuildsFlag, + ReleaseBundlesFlag, + SourceVersionFlag, SpecFlag, SpecVarsFlag, }, @@ -133,13 +146,18 @@ var commandFlags = map[string][]string{ user, accessToken, serverId, + ApplicationKeyFlag, + PackagesFlag, + PackageTypeFlag, }, - PackageUnbind: { url, user, accessToken, serverId, + ApplicationKeyFlag, + PackagesFlag, + PackageTypeFlag, }, Ping: { diff --git a/apptrust/commands/utils/utils.go b/apptrust/commands/utils/utils.go index 2d6d0f8..2d19e7d 100644 --- a/apptrust/commands/utils/utils.go +++ b/apptrust/commands/utils/utils.go @@ -13,6 +13,11 @@ import ( "github.com/jfrog/jfrog-client-go/utils/errorutils" ) +const ( + EntrySeparator = ";" + PartSeparator = ":" +) + func AssertValueProvided(c *components.Context, fieldName string) error { if c.GetStringFlagValue(fieldName) == "" { return errorutils.CheckErrorf("the --%s option is mandatory", fieldName) @@ -81,3 +86,53 @@ func ValidateEnumFlag(flagName, value string, defaultValue string, allowedValues return "", errorutils.CheckErrorf("invalid value for --%s: '%s'. Allowed values: %s", flagName, value, coreutils.ListToText(allowedValues)) } + +// ParsePackagesFlag parses a comma-separated list of package name:version pairs into a slice of maps. +// Each map contains keys "name" and "version". Returns an error if any entry is not in the expected format. +// Example input: "pkg1:1.0.0,pkg2:2.0.0" => []map[string]string{{"name": "pkg1", "version": "1.0.0"}, {"name": "pkg2", "version": "2.0.0"}} +func ParsePackagesFlag(flagValue string) ([]map[string]string, error) { + if flagValue == "" { + return nil, nil + } + pairs := strings.Split(flagValue, ",") + var result []map[string]string + for _, pair := range pairs { + parts := strings.SplitN(strings.TrimSpace(pair), PartSeparator, 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid package format: %s (expected :)", pair) + } + result = append(result, map[string]string{"name": parts[0], "version": parts[1]}) + } + return result, nil +} + +// ParseDelimitedSlice splits a delimited string into a slice of string slices. +// Example: input "a:1;b:2" returns [][]string{{"a","1"},{"b","2"}} +func ParseDelimitedSlice(input string) [][]string { + var result [][]string + if input == "" { + return result + } + entries := strings.Split(input, EntrySeparator) + for _, entry := range entries { + parts := strings.Split(entry, PartSeparator) + result = append(result, parts) + } + return result +} + +// ParseNameVersionPairs parses a delimited string (e.g., "name1:version1;name2:version2") into a slice of [2]string pairs. +// Returns an error if any entry does not have exactly two parts. +func ParseNameVersionPairs(input string) ([][2]string, error) { + var result [][2]string + if input == "" { + return result, nil + } + for _, parts := range ParseDelimitedSlice(input) { + if len(parts) != 2 { + return nil, fmt.Errorf("invalid format: %v", parts) + } + result = append(result, [2]string{parts[0], parts[1]}) + } + return result, nil +} diff --git a/apptrust/commands/utils/utils_test.go b/apptrust/commands/utils/utils_test.go index 310bd2c..298e259 100644 --- a/apptrust/commands/utils/utils_test.go +++ b/apptrust/commands/utils/utils_test.go @@ -114,3 +114,86 @@ func TestValidateEnumFlag(t *testing.T) { }) } } + +func TestParsePackagesFlag(t *testing.T) { + tests := []struct { + name string + input string + expected []map[string]string + expectErr bool + }{ + {"empty string", "", nil, false}, + {"single package", "foo:1.0.0", []map[string]string{{"name": "foo", "version": "1.0.0"}}, false}, + {"multiple packages", "foo:1.0.0,bar:2.0.0", []map[string]string{{"name": "foo", "version": "1.0.0"}, {"name": "bar", "version": "2.0.0"}}, false}, + {"spaces", " foo:1.0.0 , bar:2.0.0 ", []map[string]string{{"name": "foo", "version": "1.0.0"}, {"name": "bar", "version": "2.0.0"}}, false}, + {"invalid format", "foo", nil, true}, + {"missing version", "foo:", []map[string]string{{"name": "foo", "version": ""}}, false}, + {"missing name", ":1.0.0", []map[string]string{{"name": "", "version": "1.0.0"}}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParsePackagesFlag(tt.input) + if tt.expectErr { + if err == nil { + t.Errorf("expected error for input %q, got nil", tt.input) + } + return + } + if err != nil { + t.Errorf("unexpected error for input %q: %v", tt.input, err) + } + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("ParsePackagesFlag(%q) = %v, want %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestParseDelimitedSlice(t *testing.T) { + tests := []struct { + name string + input string + expected [][]string + }{ + {"empty string", "", nil}, + {"single entry", "foo:bar", [][]string{{"foo", "bar"}}}, + {"multiple entries", "foo:bar;baz:qux", [][]string{{"foo", "bar"}, {"baz", "qux"}}}, + {"entries with extra parts", "a:1:2;b:3", [][]string{{"a", "1", "2"}, {"b", "3"}}}, + {"trailing separator", "foo:bar;", [][]string{{"foo", "bar"}, {""}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseDelimitedSlice(tt.input) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("ParseDelimitedSlice(%q) = %v, want %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestParseNameVersionPairs(t *testing.T) { + tests := []struct { + name string + input string + expected [][2]string + expectErr bool + }{ + {"empty string", "", nil, false}, + {"single pair", "foo:1.0.0", [][2]string{{"foo", "1.0.0"}}, false}, + {"multiple pairs", "foo:1.0.0;bar:2.0.0", [][2]string{{"foo", "1.0.0"}, {"bar", "2.0.0"}}, false}, + {"spaces", " foo:1.0.0 ; bar:2.0.0 ", [][2]string{{" foo", "1.0.0 "}, {" bar", "2.0.0 "}}, false}, + {"invalid format", "foo", nil, true}, + {"too many parts", "foo:1.0.0:extra", nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseNameVersionPairs(tt.input) + if tt.expectErr { + assert.Error(t, err, "expected error for input %q", tt.input) + return + } + assert.NoError(t, err, "unexpected error for input %q: %v", tt.input, err) + assert.Equal(t, tt.expected, result, "ParseNameVersionPairs(%q) = %v, want %v", tt.input, result, tt.expected) + }) + } +} diff --git a/apptrust/commands/version/create_app_version_cmd.go b/apptrust/commands/version/create_app_version_cmd.go index 94f42b9..aef9a2e 100644 --- a/apptrust/commands/version/create_app_version_cmd.go +++ b/apptrust/commands/version/create_app_version_cmd.go @@ -27,7 +27,10 @@ type createAppVersionCommand struct { } type createVersionSpec struct { - Packages []model.CreateVersionPackage `json:"packages"` + Packages []model.CreateVersionPackage `json:"packages,omitempty"` + Builds []model.CreateVersionBuild `json:"builds,omitempty"` + ReleaseBundles []model.CreateVersionReleaseBundle `json:"release_bundles,omitempty"` + Versions []model.CreateVersionReference `json:"versions,omitempty"` } func (cv *createAppVersionCommand) Run() error { @@ -64,35 +67,76 @@ func (cv *createAppVersionCommand) prepareAndRunCommand(ctx *components.Context) } func (cv *createAppVersionCommand) buildRequestPayload(ctx *components.Context) (*model.CreateAppVersionRequest, error) { - var packages []model.CreateVersionPackage + var ( + sources *model.CreateVersionSources + err error + ) + if ctx.IsFlagSet(commands.SpecFlag) { - err := loadPackagesFromSpec(ctx, &packages) - if errorutils.CheckError(err) != nil { - return nil, err - } + sources, err = cv.loadFromSpec(ctx) } else { - packages = append(packages, model.CreateVersionPackage{ - Type: ctx.GetStringFlagValue(commands.PackageTypeFlag), - Name: ctx.GetStringFlagValue(commands.PackageNameFlag), - Version: ctx.GetStringFlagValue(commands.PackageVersionFlag), - Repository: ctx.GetStringFlagValue(commands.PackageRepositoryFlag), - }) + sources, err = cv.buildSourcesFromFlags(ctx) + } + + if err != nil { + return nil, err } return &model.CreateAppVersionRequest{ - ApplicationKey: ctx.GetStringFlagValue(commands.ApplicationKeyFlag), - Version: ctx.Arguments[0], - Packages: packages, + ApplicationKey: ctx.Arguments[0], + Version: ctx.Arguments[1], + Sources: sources, + Tag: ctx.GetStringFlagValue(commands.TagFlag), }, nil } -func loadPackagesFromSpec(ctx *components.Context, packages *[]model.CreateVersionPackage) error { +func (cv *createAppVersionCommand) buildSourcesFromFlags(ctx *components.Context) (*model.CreateVersionSources, error) { + sources := &model.CreateVersionSources{} + if ctx.IsFlagSet(commands.PackagesFlag) { + parsedPkgs, err := utils.ParsePackagesFlag(ctx.GetStringFlagValue(commands.PackagesFlag)) + if err != nil { + return nil, err + } + for _, pkg := range parsedPkgs { + sources.Packages = append(sources.Packages, model.CreateVersionPackage{ + Type: ctx.GetStringFlagValue(commands.PackageTypeFlag), + Name: pkg["name"], + Version: pkg["version"], + Repository: ctx.GetStringFlagValue(commands.PackageRepositoryFlag), + }) + } + } + if buildsStr := ctx.GetStringFlagValue(commands.BuildsFlag); buildsStr != "" { + builds, err := cv.parseBuilds(buildsStr) + if err != nil { + return nil, err + } + sources.Builds = builds + } + if rbStr := ctx.GetStringFlagValue(commands.ReleaseBundlesFlag); rbStr != "" { + releaseBundles, err := cv.parseReleaseBundles(rbStr) + if err != nil { + return nil, err + } + sources.ReleaseBundles = releaseBundles + } + if srcVersionsStr := ctx.GetStringFlagValue(commands.SourceVersionFlag); srcVersionsStr != "" { + sourceVersions, err := cv.parseSourceVersions(srcVersionsStr) + if err != nil { + return nil, err + } + sources.Versions = sourceVersions + } + return sources, nil +} + +func (cv *createAppVersionCommand) loadFromSpec(ctx *components.Context) (*model.CreateVersionSources, error) { specFilePath := ctx.GetStringFlagValue(commands.SpecFlag) spec := new(createVersionSpec) - specVars := coreutils.SpecVarsStringToMap(ctx.GetStringFlagValue("spec-vars")) + specVars := coreutils.SpecVarsStringToMap(ctx.GetStringFlagValue(commands.SpecVarsFlag)) content, err := fileutils.ReadFile(specFilePath) if errorutils.CheckError(err) != nil { - return err + return nil, err } if len(specVars) > 0 { @@ -101,32 +145,95 @@ func loadPackagesFromSpec(ctx *components.Context, packages *[]model.CreateVersi err = json.Unmarshal(content, spec) if errorutils.CheckError(err) != nil { - return err + return nil, err } - // add spec packages to the packages list - *packages = append(*packages, spec.Packages...) - return nil + // Validation: if all sources are empty, return error + if (len(spec.Packages) == 0) && (len(spec.Builds) == 0) && (len(spec.ReleaseBundles) == 0) && (len(spec.Versions) == 0) { + return nil, errorutils.CheckErrorf("Spec file is empty: must provide at least one source (packages, builds, release_bundles, or versions)") + } + + sources := &model.CreateVersionSources{ + Packages: spec.Packages, + Builds: spec.Builds, + ReleaseBundles: spec.ReleaseBundles, + Versions: spec.Versions, + } + + return sources, nil +} + +func (cv *createAppVersionCommand) parseBuilds(buildsStr string) ([]model.CreateVersionBuild, error) { + var builds []model.CreateVersionBuild + for _, parts := range utils.ParseDelimitedSlice(buildsStr) { + if len(parts) < 2 || len(parts) > 3 { + return nil, errorutils.CheckErrorf("invalid build format: %v", parts) + } + build := model.CreateVersionBuild{ + Name: parts[0], + Number: parts[1], + } + if len(parts) == 3 { + build.Started = parts[2] + } + builds = append(builds, build) + } + return builds, nil +} + +func (cv *createAppVersionCommand) parseReleaseBundles(rbStr string) ([]model.CreateVersionReleaseBundle, error) { + pairs, err := utils.ParseNameVersionPairs(rbStr) + if err != nil { + return nil, errorutils.CheckErrorf("invalid release bundle format: %v", err) + } + var bundles []model.CreateVersionReleaseBundle + for _, pair := range pairs { + bundles = append(bundles, model.CreateVersionReleaseBundle{ + Name: pair[0], + Version: pair[1], + }) + } + return bundles, nil +} + +func (cv *createAppVersionCommand) parseSourceVersions(sourceVersionsStr string) ([]model.CreateVersionReference, error) { + pairs, err := utils.ParseNameVersionPairs(sourceVersionsStr) + if err != nil { + return nil, errorutils.CheckErrorf("invalid source version format: %v", err) + } + var refs []model.CreateVersionReference + for _, pair := range pairs { + refs = append(refs, model.CreateVersionReference{ + ApplicationKey: pair[0], + Version: pair[1], + }) + } + return refs, nil } func validateCreateAppVersionContext(ctx *components.Context) error { - if len(ctx.Arguments) != 1 { + if err := validateNoSpecAndFlagsTogether(ctx); err != nil { + return err + } + if len(ctx.Arguments) != 2 { return pluginsCommon.WrongNumberOfArgumentsHandler(ctx) } - // Use spec flag if provided, if not check for package flags - err := utils.AssertValueProvided(ctx, commands.SpecFlag) - if err != nil { - err = utils.AssertValueProvided(ctx, commands.PackageNameFlag) - if err != nil { - return handleMissingPackageDetailsError() - } - err = utils.AssertValueProvided(ctx, commands.PackageVersionFlag) - if err != nil { - return handleMissingPackageDetailsError() - } - err = utils.AssertValueProvided(ctx, commands.PackageRepositoryFlag) - if err != nil { + hasSource := ctx.IsFlagSet(commands.SpecFlag) || + ctx.IsFlagSet(commands.PackageNameFlag) || + ctx.IsFlagSet(commands.BuildsFlag) || + ctx.IsFlagSet(commands.ReleaseBundlesFlag) || + ctx.IsFlagSet(commands.SourceVersionFlag) + + if !hasSource { + return errorutils.CheckErrorf( + "At least one source flag is required to create an application version. Please provide one of the following: --%s, --%s, --%s, --%s, or --%s.", + commands.SpecFlag, commands.PackageNameFlag, commands.BuildsFlag, commands.ReleaseBundlesFlag, commands.SourceVersionFlag) + } + + // Validate package details if used + if ctx.IsFlagSet(commands.PackageNameFlag) { + if !ctx.IsFlagSet(commands.PackageVersionFlag) || !ctx.IsFlagSet(commands.PackageRepositoryFlag) { return handleMissingPackageDetailsError() } } @@ -143,8 +250,13 @@ func GetCreateAppVersionCommand(appContext app.Context) components.Command { Aliases: []string{"vc"}, Arguments: []components.Argument{ { - Name: "version-name", - Description: "The name of the version.", + Name: "app-key", + Description: "The application key of the application for which the version is being created.", + Optional: false, + }, + { + Name: "version", + Description: "The version number (in SemVer format) for the new application version.", Optional: false, }, }, @@ -157,3 +269,21 @@ func handleMissingPackageDetailsError() error { return errorutils.CheckErrorf("Missing packages information. Please provide the following flags --%s or the set of: --%s, --%s, --%s, --%s", commands.SpecFlag, commands.PackageTypeFlag, commands.PackageNameFlag, commands.PackageVersionFlag, commands.PackageRepositoryFlag) } + +// Returns error if both --spec and any other source flag are set +func validateNoSpecAndFlagsTogether(ctx *components.Context) error { + if ctx.IsFlagSet(commands.SpecFlag) { + otherSourceFlags := []string{ + commands.PackageNameFlag, + commands.BuildsFlag, + commands.ReleaseBundlesFlag, + commands.SourceVersionFlag, + } + for _, flag := range otherSourceFlags { + if ctx.IsFlagSet(flag) { + return errorutils.CheckErrorf("--spec provided: all other source flags (e.g., --%s) are not allowed.", flag) + } + } + } + return nil +} diff --git a/apptrust/commands/version/create_app_version_cmd_test.go b/apptrust/commands/version/create_app_version_cmd_test.go index 1664118..b77b09f 100644 --- a/apptrust/commands/version/create_app_version_cmd_test.go +++ b/apptrust/commands/version/create_app_version_cmd_test.go @@ -7,72 +7,375 @@ import ( mockversions "github.com/jfrog/jfrog-cli-application/apptrust/service/versions/mocks" "go.uber.org/mock/gomock" + "github.com/jfrog/jfrog-cli-application/apptrust/commands" "github.com/jfrog/jfrog-cli-application/apptrust/model" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/stretchr/testify/assert" ) -func TestCreateAppVersionCommand_Run(t *testing.T) { +func TestCreateAppVersionCommand(t *testing.T) { + tests := []struct { + name string + request *model.CreateAppVersionRequest + shouldError bool + errorMessage string + }{ + { + name: "success", + request: &model.CreateAppVersionRequest{ + ApplicationKey: "app-key", + Version: "1.0.0", + Sources: &model.CreateVersionSources{ + Packages: []model.CreateVersionPackage{{ + Type: "type", + Name: "name", + Version: "1.0.0", + Repository: "repo", + }}, + }, + }, + }, + { + name: "context error", + request: &model.CreateAppVersionRequest{ApplicationKey: "app-key", Version: "1.0.0", Sources: &model.CreateVersionSources{Packages: []model.CreateVersionPackage{{Type: "type", Name: "name", Version: "1.0.0", Repository: "repo"}}}}, + shouldError: true, + errorMessage: "context error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := &components.Context{ + Arguments: []string{"app-key", "1.0.0"}, + } + ctx.AddStringFlag("url", "https://example.com") + + mockVersionService := mockversions.NewMockVersionService(ctrl) + if tt.shouldError { + mockVersionService.EXPECT().CreateAppVersion(gomock.Any(), tt.request). + Return(errors.New(tt.errorMessage)).Times(1) + } else { + mockVersionService.EXPECT().CreateAppVersion(gomock.Any(), tt.request). + Return(nil).Times(1) + } + + cmd := &createAppVersionCommand{ + versionService: mockVersionService, + serverDetails: &config.ServerDetails{Url: "https://example.com"}, + requestPayload: tt.request, + } + + err := cmd.Run() + if tt.shouldError { + assert.Error(t, err) + assert.Equal(t, tt.errorMessage, err.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestCreateAppVersionCommand_SpecAndFlags_Error(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - serverDetails := &config.ServerDetails{Url: "https://example.com"} - requestPayload := &model.CreateAppVersionRequest{ - ApplicationKey: "app-key", - Version: "1.0.0", - Packages: []model.CreateVersionPackage{ - { - Type: "type", - Name: "name", - Version: "1.0.0", - Repository: "repo", - }, - }, + testSpecPath := "./testfiles/test-spec.json" + ctx := &components.Context{ + Arguments: []string{"app-key", "1.0.0"}, } + ctx.AddStringFlag(commands.SpecFlag, testSpecPath) + ctx.AddStringFlag(commands.PackageNameFlag, "name") + ctx.AddStringFlag("url", "https://example.com") mockVersionService := mockversions.NewMockVersionService(ctrl) - mockVersionService.EXPECT().CreateAppVersion(gomock.Any(), requestPayload). - Return(nil).Times(1) cmd := &createAppVersionCommand{ versionService: mockVersionService, - serverDetails: serverDetails, - requestPayload: requestPayload, } - err := cmd.Run() - assert.NoError(t, err) + err := cmd.prepareAndRunCommand(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "--spec provided") } -func TestCreateAppVersionCommand_Run_ContextError(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - serverDetails := &config.ServerDetails{Url: "https://example.com"} - requestPayload := &model.CreateAppVersionRequest{ - ApplicationKey: "app-key", - Version: "1.0.0", - Packages: []model.CreateVersionPackage{ - { - Type: "type", - Name: "name", - Version: "1.0.0", - Repository: "repo", +func TestCreateAppVersionCommand_FlagsSuite(t *testing.T) { + tests := []struct { + name string + ctxSetup func(*components.Context) + expectsError bool + errorContains string + expectsPayload *model.CreateAppVersionRequest + }{ + { + name: "all flags", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.TagFlag, "release-tag") + ctx.AddStringFlag(commands.PackagesFlag, "pkg1:1.2.3,pkg2:2.3.4") + ctx.AddStringFlag(commands.BuildsFlag, "build1:1.0.0:2024-01-01;build2:2.0.0:2024-02-01") + ctx.AddStringFlag(commands.ReleaseBundlesFlag, "rb1:1.0.0;rb2:2.0.0") + ctx.AddStringFlag(commands.SourceVersionFlag, "source-app:3.2.1") + }, + expectsPayload: &model.CreateAppVersionRequest{ + ApplicationKey: "app-key", + Version: "1.0.0", + Tag: "release-tag", + Sources: &model.CreateVersionSources{ + Packages: []model.CreateVersionPackage{ + {Name: "pkg1", Version: "1.2.3"}, + {Name: "pkg2", Version: "2.3.4"}, + }, + Builds: []model.CreateVersionBuild{ + {Name: "build1", Number: "1.0.0", Started: "2024-01-01"}, + {Name: "build2", Number: "2.0.0", Started: "2024-02-01"}, + }, + ReleaseBundles: []model.CreateVersionReleaseBundle{ + {Name: "rb1", Version: "1.0.0"}, + {Name: "rb2", Version: "2.0.0"}, + }, + Versions: []model.CreateVersionReference{ + {ApplicationKey: "source-app", Version: "3.2.1"}, + }, + }, }, }, + { + name: "spec only", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.SpecFlag, "/file1.txt") + }, + expectsPayload: nil, + expectsError: true, + errorContains: "no such file or directory", + }, + { + name: "spec-vars only", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + ctx.AddStringFlag(commands.SpecVarsFlag, "key1:val1,key2:val2") + }, + expectsPayload: nil, + expectsError: true, + errorContains: "At least one source flag is required to create an application version. Please provide one of the following: --spec, --package-name, --builds, --release-bundles, or --source-version.", + }, + { + name: "empty flags", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", "1.0.0"} + }, + expectsPayload: nil, + expectsError: true, + errorContains: "At least one source flag is required to create an application version. Please provide one of the following: --spec, --package-name, --builds, --release-bundles, or --source-version.", + }, } - mockVersionService := mockversions.NewMockVersionService(ctrl) - mockVersionService.EXPECT().CreateAppVersion(gomock.Any(), requestPayload). - Return(errors.New("context error")).Times(1) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() - cmd := &createAppVersionCommand{ - versionService: mockVersionService, - serverDetails: serverDetails, - requestPayload: requestPayload, + ctx := &components.Context{} + tt.ctxSetup(ctx) + ctx.AddStringFlag("url", "https://example.com") + + var actualPayload *model.CreateAppVersionRequest + mockVersionService := mockversions.NewMockVersionService(ctrl) + if !tt.expectsError { + mockVersionService.EXPECT().CreateAppVersion(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ interface{}, req *model.CreateAppVersionRequest) error { + actualPayload = req + return nil + }).Times(1) + } + + cmd := &createAppVersionCommand{ + versionService: mockVersionService, + } + + err := cmd.prepareAndRunCommand(ctx) + if tt.expectsError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectsPayload, actualPayload) + } + }) } +} - err := cmd.Run() +func TestParseBuilds(t *testing.T) { + cmd := &createAppVersionCommand{} + + // Test basic build parsing + builds, err := cmd.parseBuilds("build1:1.0.0:2024-01-01;build2:2.0.0:2024-02-01") + assert.NoError(t, err) + assert.Len(t, builds, 2) + assert.Equal(t, "build1", builds[0].Name) + assert.Equal(t, "1.0.0", builds[0].Number) + assert.Equal(t, "build2", builds[1].Name) + assert.Equal(t, "2.0.0", builds[1].Number) + + // Test invalid format + _, err = cmd.parseBuilds("build1") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid build format") + + // Test too many parts + _, err = cmd.parseBuilds("build1:1.0.0:timestamp:extra") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid build format") +} + +func TestParseReleaseBundles(t *testing.T) { + cmd := &createAppVersionCommand{} + + // Test basic release bundle parsing + rbs, err := cmd.parseReleaseBundles("rb1:1.0.0;rb2:2.0.0") + assert.NoError(t, err) + assert.Equal(t, 2, len(rbs)) + assert.Equal(t, "rb1", rbs[0].Name) + assert.Equal(t, "1.0.0", rbs[0].Version) + assert.Equal(t, "rb2", rbs[1].Name) + assert.Equal(t, "2.0.0", rbs[1].Version) + + // Test invalid format + _, err = cmd.parseReleaseBundles("rb1") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid release bundle format") + + // Test invalid format with too many parts + _, err = cmd.parseReleaseBundles("rb1:1.0.0:extra") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid release bundle format") +} + +func TestParseSourceVersions(t *testing.T) { + cmd := &createAppVersionCommand{} + + // Test basic source versions parsing + svs, err := cmd.parseSourceVersions("app1:1.0.0;app2:2.0.0") + assert.NoError(t, err) + assert.Equal(t, 2, len(svs)) + assert.Equal(t, "app1", svs[0].ApplicationKey) + assert.Equal(t, "1.0.0", svs[0].Version) + assert.Equal(t, "app2", svs[1].ApplicationKey) + assert.Equal(t, "2.0.0", svs[1].Version) + + // Test invalid format + _, err = cmd.parseSourceVersions("app1") assert.Error(t, err) - assert.Equal(t, "context error", err.Error()) + assert.Contains(t, err.Error(), "invalid source version format") + + // Test invalid format with too many parts + _, err = cmd.parseSourceVersions("app1:1.0.0:extra") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid source version format") +} + +func TestCreateAppVersionCommand_SpecFileSuite(t *testing.T) { + tests := []struct { + name string + specPath string + args []string + expectsError bool + errorContains string + expectsPayload *model.CreateAppVersionRequest + }{ + { + name: "minimal spec file", + specPath: "./testfiles/minimal-spec.json", + args: []string{"app-min", "0.1.0"}, + expectsPayload: &model.CreateAppVersionRequest{ + ApplicationKey: "app-min", + Version: "0.1.0", + Sources: &model.CreateVersionSources{ + Packages: []model.CreateVersionPackage{{ + Type: "npm", + Name: "pkg-min", + Version: "0.1.0", + Repository: "repo-min", + }}, + }, + }, + }, + { + name: "invalid spec file", + specPath: "./testfiles/invalid-spec.json", + args: []string{"app-invalid", "0.1.0"}, + expectsError: true, + errorContains: "invalid character", + }, + { + name: "unknown fields in spec file", + specPath: "./testfiles/unknown-fields-spec.json", + args: []string{"app-unknown", "0.2.0"}, + expectsPayload: &model.CreateAppVersionRequest{ + ApplicationKey: "app-unknown", + Version: "0.2.0", + Sources: &model.CreateVersionSources{ + Packages: []model.CreateVersionPackage{{ + Type: "npm", + Name: "pkg-unknown", + Version: "0.2.0", + Repository: "repo-unknown", + }}, + }, + }, + }, + { + name: "empty spec file", + specPath: "./testfiles/empty-spec.json", + args: []string{"app-empty", "0.0.1"}, + expectsError: true, + errorContains: "Spec file is empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := &components.Context{ + Arguments: tt.args, + } + ctx.AddStringFlag(commands.SpecFlag, tt.specPath) + ctx.AddStringFlag("url", "https://example.com") + + var actualPayload *model.CreateAppVersionRequest + mockVersionService := mockversions.NewMockVersionService(ctrl) + if !tt.expectsError { + mockVersionService.EXPECT().CreateAppVersion(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ interface{}, req *model.CreateAppVersionRequest) error { + actualPayload = req + return nil + }).Times(1) + } + + cmd := &createAppVersionCommand{ + versionService: mockVersionService, + serverDetails: &config.ServerDetails{Url: "https://example.com"}, + } + + err := cmd.prepareAndRunCommand(ctx) + if tt.expectsError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectsPayload, actualPayload) + } + }) + } } diff --git a/apptrust/commands/version/testfiles/conflicting-appkey-spec.json b/apptrust/commands/version/testfiles/conflicting-appkey-spec.json new file mode 100644 index 0000000..da360ae --- /dev/null +++ b/apptrust/commands/version/testfiles/conflicting-appkey-spec.json @@ -0,0 +1,6 @@ +{ + "application_key": "should-be-ignored", + "packages": [ + {"type": "npm", "name": "pkg-conflict", "version": "0.3.0", "repository": "repo-conflict"} + ] +} diff --git a/apptrust/commands/version/testfiles/empty-spec.json b/apptrust/commands/version/testfiles/empty-spec.json new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/apptrust/commands/version/testfiles/empty-spec.json @@ -0,0 +1,2 @@ +{ +} diff --git a/apptrust/commands/version/testfiles/invalid-spec.json b/apptrust/commands/version/testfiles/invalid-spec.json new file mode 100644 index 0000000..d646366 --- /dev/null +++ b/apptrust/commands/version/testfiles/invalid-spec.json @@ -0,0 +1,5 @@ +{ + "packages": [ + {"type": "npm", "name": "pkg-invalid", "version": "0.1.0", "repository": "repo-invalid"} + ] +// missing closing brace and invalid JSON diff --git a/apptrust/commands/version/testfiles/minimal-spec.json b/apptrust/commands/version/testfiles/minimal-spec.json new file mode 100644 index 0000000..782e3ff --- /dev/null +++ b/apptrust/commands/version/testfiles/minimal-spec.json @@ -0,0 +1,5 @@ +{ + "packages": [ + {"type": "npm", "name": "pkg-min", "version": "0.1.0", "repository": "repo-min"} + ] +} diff --git a/apptrust/commands/version/testfiles/test-spec.json b/apptrust/commands/version/testfiles/test-spec.json new file mode 100644 index 0000000..e785e1e --- /dev/null +++ b/apptrust/commands/version/testfiles/test-spec.json @@ -0,0 +1,28 @@ +{ + "packages": [ + { + "name": "pkg1", + "version": "1.0.0", + "repository": "repo1", + "type": "npm" + } + ], + "builds": [ + { + "name": "build1", + "number": "5" + } + ], + "release_bundles": [ + { + "name": "rb1", + "version": "1.0.0" + } + ], + "versions": [ + { + "application_key": "app1", + "version": "1.0.0" + } + ] +} diff --git a/apptrust/commands/version/testfiles/unknown-fields-spec.json b/apptrust/commands/version/testfiles/unknown-fields-spec.json new file mode 100644 index 0000000..562a09a --- /dev/null +++ b/apptrust/commands/version/testfiles/unknown-fields-spec.json @@ -0,0 +1,6 @@ +{ + "packages": [ + {"type": "npm", "name": "pkg-unknown", "version": "0.2.0", "repository": "repo-unknown"} + ], + "unknown_field": "should be ignored" +} diff --git a/apptrust/commands/version/testfiles/with-vars-spec.json b/apptrust/commands/version/testfiles/with-vars-spec.json new file mode 100644 index 0000000..fef20de --- /dev/null +++ b/apptrust/commands/version/testfiles/with-vars-spec.json @@ -0,0 +1,5 @@ +{ + "packages": [ + {"type": "npm", "name": "${PKG_NAME}", "version": "${PKG_VERSION}", "repository": "${PKG_REPO}"} + ] +} diff --git a/apptrust/model/create_app_version_request.go b/apptrust/model/create_app_version_request.go index 13eaf4b..04087dd 100644 --- a/apptrust/model/create_app_version_request.go +++ b/apptrust/model/create_app_version_request.go @@ -1,9 +1,10 @@ package model type CreateAppVersionRequest struct { - ApplicationKey string `json:"application_key"` - Version string `json:"version"` - Packages []CreateVersionPackage `json:"packages"` + ApplicationKey string `json:"application_key"` + Version string `json:"version"` + Sources *CreateVersionSources `json:"sources,omitempty"` + Tag string `json:"tag,omitempty"` } type CreateVersionPackage struct { @@ -12,3 +13,35 @@ type CreateVersionPackage struct { Version string `json:"version"` Repository string `json:"repository"` } + +type CreateVersionSources struct { + Packages []CreateVersionPackage `json:"packages,omitempty"` + Builds []CreateVersionBuild `json:"builds,omitempty"` + ReleaseBundles []CreateVersionReleaseBundle `json:"release_bundles,omitempty"` + Versions []CreateVersionReference `json:"versions,omitempty"` +} + +type CreateVersionArtifact struct { + Path string `json:"path"` + SHA256 string `json:"sha256,omitempty"` +} + +type CreateVersionBuild struct { + RepositoryKey string `json:"repository_key,omitempty"` + Name string `json:"name"` + Number string `json:"number"` + Started string `json:"started,omitempty"` + IncludeDependencies bool `json:"include_dependencies,omitempty"` +} + +type CreateVersionReleaseBundle struct { + ProjectKey string `json:"project_key"` + RepositoryKey string `json:"repository_key"` + Name string `json:"name"` + Version string `json:"version"` +} + +type CreateVersionReference struct { + ApplicationKey string `json:"application_key,omitempty"` + Version string `json:"version"` +} diff --git a/apptrust/service/versions/version_service.go b/apptrust/service/versions/version_service.go index 5f3363e..25141df 100644 --- a/apptrust/service/versions/version_service.go +++ b/apptrust/service/versions/version_service.go @@ -25,7 +25,8 @@ func NewVersionService() VersionService { } func (vs *versionService) CreateAppVersion(ctx service.Context, request *model.CreateAppVersionRequest) error { - response, responseBody, err := ctx.GetHttpClient().Post("/v1/applications/version", request, nil) + endpoint := fmt.Sprintf("/v1/applications/%s/versions/", request.ApplicationKey) + response, responseBody, err := ctx.GetHttpClient().Post(endpoint, request, nil) if err != nil { return err } diff --git a/apptrust/service/versions/version_service_test.go b/apptrust/service/versions/version_service_test.go index dfd660f..d49c6de 100644 --- a/apptrust/service/versions/version_service_test.go +++ b/apptrust/service/versions/version_service_test.go @@ -30,7 +30,7 @@ func TestCreateAppVersion(t *testing.T) { }{ { name: "success", - request: &model.CreateAppVersionRequest{}, + request: &model.CreateAppVersionRequest{ApplicationKey: "test-app", Version: "1.0.0"}, mockResponse: &http.Response{StatusCode: 201}, mockResponseBody: "{}", mockError: nil, @@ -38,7 +38,7 @@ func TestCreateAppVersion(t *testing.T) { }, { name: "failure", - request: &model.CreateAppVersionRequest{}, + request: &model.CreateAppVersionRequest{ApplicationKey: "test-app", Version: "1.0.0"}, mockResponse: &http.Response{StatusCode: 400}, mockResponseBody: "error", mockError: nil, @@ -46,7 +46,7 @@ func TestCreateAppVersion(t *testing.T) { }, { name: "http client error", - request: &model.CreateAppVersionRequest{}, + request: &model.CreateAppVersionRequest{ApplicationKey: "test-app", Version: "1.0.0"}, mockResponse: nil, mockResponseBody: "", mockError: errors.New("http client error"), @@ -57,7 +57,7 @@ func TestCreateAppVersion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockHttpClient := mockhttp.NewMockApptrustHttpClient(ctrl) - mockHttpClient.EXPECT().Post("/v1/applications/version", tt.request, nil). + mockHttpClient.EXPECT().Post("/v1/applications/test-app/versions/", tt.request, nil). Return(tt.mockResponse, []byte(tt.mockResponseBody), tt.mockError).Times(1) mockCtx := mockservice.NewMockContext(ctrl)