From 170b1a4f8fbb9b281ad42dc3e56a50deb0acbbb4 Mon Sep 17 00:00:00 2001 From: Luke Duan Date: Fri, 30 Oct 2026 13:23:56 -0600 Subject: [PATCH 01/11] fix: introduce name match --- internal/services/workbench/image_builds.go | 20 ++- internal/services/workbench/images.go | 52 +++++-- .../services/workbench/job_submissions.go | 36 ++++- internal/services/workbench/jobs.go | 50 ++++-- internal/services/workbench/snapshots.go | 46 ++++-- internal/services/workbench/util.go | 143 +++++++++++++++--- internal/services/workbench/util_test.go | 142 ++++++++++++++--- internal/services/workbench/workbench.go | 17 ++- internal/services/workbench/workbenches.go | 48 ++++-- internal/services/workbench/workspaces.go | 48 ++++-- 10 files changed, 481 insertions(+), 121 deletions(-) diff --git a/internal/services/workbench/image_builds.go b/internal/services/workbench/image_builds.go index bc3447d..503a6c4 100644 --- a/internal/services/workbench/image_builds.go +++ b/internal/services/workbench/image_builds.go @@ -19,21 +19,31 @@ import ( ) // GetImageBuilds gets the image builds in the given WorkbenchService. If given -// any ids, only the matching resources will be returned. Otherwise, all +// any ids/names, only the matching resources will be returned. Otherwise, all // resources are returned. func GetImageBuilds( ctx context.Context, s *WorkbenchService, - ids []string, + args []string, sortSpec string, ) (types.ImageBuilds, error) { - logutil.GetLogger().Info("getting image-builds", "ids", ids, "sort", sortSpec) + logutil.GetLogger().Info("getting image-builds", "args", args, "sort", sortSpec) sortSpec = normalizeSortSpec(sortSpec, map[string][]string{ constants.SortKeyAge: {constants.SortKeyCreatedAt}, }) - return result.Split(Get[types.ImageBuilds]( - ctx, s, postgrest.Endpoint(constants.WorkbenchEndpointImageBuilds), IDsOrControllerIDsQuery(ids, sortSpec), + imageBuilds, err := result.Split(Get[types.ImageBuilds]( + ctx, s, postgrest.Endpoint(constants.WorkbenchEndpointImageBuilds), IDsControllerIDsOrNamesQuery(args, sortSpec), )) + if err != nil { + return nil, err + } + return selectByIDThenName( + args, + imageBuilds, + func(b types.ImageBuild) string { return b.ID }, + func(b types.ImageBuild) string { return b.ControllerID }, + func(b types.ImageBuild) string { return b.Name }, + ), nil } // PrintImageBuilds returns a function that prints the given image builds. diff --git a/internal/services/workbench/images.go b/internal/services/workbench/images.go index bee5de1..b7b4cb6 100644 --- a/internal/services/workbench/images.go +++ b/internal/services/workbench/images.go @@ -19,16 +19,16 @@ import ( "github.com/sassoftware/wbctl/internal/ui" ) -// GetImages gets the images in the given WorkbenchService. If given any ids, -// only the matching resources will be returned. Otherwise, all resources are -// returned. +// GetImages gets the images in the given WorkbenchService. If given any +// ids/names, only the matching resources will be returned. Otherwise, all +// resources are returned. func GetImages( ctx context.Context, s *WorkbenchService, - ids []string, + args []string, sortSpec string, ) (types.Images, error) { - logutil.GetLogger().Info("getting images", "ids", ids, "sort", sortSpec) + logutil.GetLogger().Info("getting images", "args", args, "sort", sortSpec) sortSpec = normalizeSortSpec(sortSpec, map[string][]string{ constants.SortKeyAge: {constants.SortKeyCreatedAt}, constants.SortKeyImageBuildID: {constants.SortKeyLatestImageBuildID}, @@ -39,14 +39,34 @@ func GetImages( constants.SortKeyImageTag, }, }) - return result.Split(Get[types.Images]( - ctx, s, postgrest.Endpoint(constants.WorkbenchEndpointImages), IDsOrControllerIDsQuery(ids, sortSpec), + images, err := result.Split(Get[types.Images]( + ctx, s, postgrest.Endpoint(constants.WorkbenchEndpointImages), IDsControllerIDsOrNamesQuery(args, sortSpec), )) + if err != nil { + return nil, err + } + return selectByIDThenName( + args, + images, + func(i types.Image) string { return i.ID }, + func(i types.Image) string { return i.ControllerID }, + func(i types.Image) string { return i.Name }, + ), nil +} + +func resolveImageIDs(ctx context.Context, s *WorkbenchService, args []string) ([]string, error) { + return resolveIDs( + ctx, s, args, GetImages, func(i types.Image) string { return i.ID }, constants.ImageType, + ) } -// BulkDeleteImages deletes the images with the given ids. -func BulkDeleteImages(ctx context.Context, s *WorkbenchService, ids []string) ([]string, error) { - logutil.GetLogger().Info("deleting images", "ids", ids) +// BulkDeleteImages deletes the images with the given ids/names. +func BulkDeleteImages(ctx context.Context, s *WorkbenchService, args []string) ([]string, error) { + logutil.GetLogger().Info("deleting images", "args", args) + ids, err := resolveImageIDs(ctx, s, args) + if err != nil { + return nil, err + } return result.Split(Post[[]string]( ctx, s, @@ -57,7 +77,7 @@ func BulkDeleteImages(ctx context.Context, s *WorkbenchService, ids []string) ([ // CreateImage creates an image. func CreateImage(ctx context.Context, s *WorkbenchService, body types.Image) (types.Image, error) { - logutil.GetLogger().Info("deleting images", "body", body) + logutil.GetLogger().Info("creating image", "body", body) return result.Split(Post[types.Image]( ctx, s, @@ -75,14 +95,18 @@ func RebuildImage(ctx context.Context, s *WorkbenchService, id string) (bool, er } // BulkUpdateImages applies the given types.Image to all the images with the -// given ids. +// given ids/names. func BulkUpdateImages( ctx context.Context, s *WorkbenchService, - ids []string, + args []string, body types.Image, ) (types.Images, error) { - logutil.GetLogger().Info("updating images", "ids", ids, "body", body) + logutil.GetLogger().Info("updating images", "args", args, "body", body) + ids, err := resolveImageIDs(ctx, s, args) + if err != nil { + return nil, err + } return result.Split(Post[types.Images]( ctx, s, diff --git a/internal/services/workbench/job_submissions.go b/internal/services/workbench/job_submissions.go index 5d026a1..bffffa7 100644 --- a/internal/services/workbench/job_submissions.go +++ b/internal/services/workbench/job_submissions.go @@ -21,27 +21,47 @@ import ( ) // GetJobSubmissions gets the job submissions in the given WorkbenchService. If -// given any ids, only the matching resources will be returned. Otherwise, all +// given any ids/names, only the matching resources will be returned. Otherwise, all // resources are returned. func GetJobSubmissions( ctx context.Context, s *WorkbenchService, - ids []string, + args []string, sortSpec string, ) (types.JobSubmissions, error) { - logutil.GetLogger().Info("getting job-submissions", "ids", ids, "sort", sortSpec) + logutil.GetLogger().Info("getting job-submissions", "args", args, "sort", sortSpec) sortSpec = normalizeSortSpec(sortSpec, map[string][]string{ constants.SortKeyAge: {constants.SortKeyCreatedAt}, constants.SortKeyState: {constants.SortKeyCurrentState}, }) - return result.Split(Get[types.JobSubmissions]( - ctx, s, postgrest.Endpoint(constants.WorkbenchEndpointJobRuns), IDsOrControllerIDsQuery(ids, sortSpec), + jobSubmissions, err := result.Split(Get[types.JobSubmissions]( + ctx, s, postgrest.Endpoint(constants.WorkbenchEndpointJobRuns), IDsControllerIDsOrJobNamesQuery(args, sortSpec), )) + if err != nil { + return nil, err + } + return selectByIDThenName( + args, + jobSubmissions, + func(j types.JobSubmission) string { return j.ID }, + func(j types.JobSubmission) string { return j.ControllerID }, + func(j types.JobSubmission) string { return j.JobName }, + ), nil +} + +func resolveJobSubmissionIDs(ctx context.Context, s *WorkbenchService, ids []string) ([]string, error) { + return resolveIDs( + ctx, s, ids, GetJobSubmissions, func(j types.JobSubmission) string { return j.ID }, constants.JobSubmissionType, + ) } -// BulkDeleteJobSubmissions deletes the job submission with the given id. -func BulkDeleteJobSubmissions(ctx context.Context, s *WorkbenchService, ids []string) ([]string, error) { - logutil.GetLogger().Info("deleting job-submissions", "ids", ids) +// BulkDeleteJobSubmissions deletes the job submissions with the given ids/names. +func BulkDeleteJobSubmissions(ctx context.Context, s *WorkbenchService, args []string) ([]string, error) { + logutil.GetLogger().Info("deleting job-submissions", "args", args) + ids, err := resolveJobSubmissionIDs(ctx, s, args) + if err != nil { + return nil, err + } return result.Split(Post[[]string]( ctx, s, diff --git a/internal/services/workbench/jobs.go b/internal/services/workbench/jobs.go index a5b3010..d2a0812 100644 --- a/internal/services/workbench/jobs.go +++ b/internal/services/workbench/jobs.go @@ -18,28 +18,48 @@ import ( "github.com/sassoftware/wbctl/internal/ui" ) -// GetJobs gets the jobs in the given WorkbenchService. If given any ids, only -// the matching resources will be returned. Otherwise, all resources are +// GetJobs gets the jobs in the given WorkbenchService. If given any ids/names, +// only the matching resources will be returned. Otherwise, all resources are // returned. func GetJobs( ctx context.Context, s *WorkbenchService, - ids []string, + args []string, sortSpec string, ) (types.Jobs, error) { - logutil.GetLogger().Info("getting jobs", "ids", ids, "sort", sortSpec) + logutil.GetLogger().Info("getting jobs", "args", args, "sort", sortSpec) sortSpec = normalizeSortSpec(sortSpec, map[string][]string{ constants.SortKeyAge: {constants.SortKeyCreatedAt}, constants.SortKeyState: {constants.SortKeyCurrentState}, }) - return result.Split(Get[types.Jobs]( - ctx, s, postgrest.Endpoint(constants.WorkbenchEndpointJobs), IDsQuery(ids, sortSpec), + jobs, err := result.Split(Get[types.Jobs]( + ctx, s, postgrest.Endpoint(constants.WorkbenchEndpointJobs), IDsOrNamesQuery(args, sortSpec), )) + if err != nil { + return nil, err + } + return selectByIDThenName( + args, + jobs, + func(j types.Job) string { return j.ID }, + nil, + func(j types.Job) string { return j.Name }, + ), nil +} + +func resolveJobIDs(ctx context.Context, s *WorkbenchService, args []string) ([]string, error) { + return resolveIDs( + ctx, s, args, GetJobs, func(j types.Job) string { return j.ID }, constants.JobType, + ) } -// BulkDeleteJobs deletes the job with the given id. -func BulkDeleteJobs(ctx context.Context, s *WorkbenchService, ids []string) ([]string, error) { - logutil.GetLogger().Info("deleting jobs", "ids", ids) +// BulkDeleteJobs deletes the jobs with the given ids/names. +func BulkDeleteJobs(ctx context.Context, s *WorkbenchService, args []string) ([]string, error) { + logutil.GetLogger().Info("deleting jobs", "args", args) + ids, err := resolveJobIDs(ctx, s, args) + if err != nil { + return nil, err + } return result.Split(Post[[]string]( ctx, s, @@ -59,15 +79,19 @@ func CreateJob(ctx context.Context, s *WorkbenchService, body types.Job) (types. )) } -// BulkUpdateJobs applies the given types.Job to all the jobs with the given -// ids. +// BulkUpdateJobs applies the given types.Job to all the jobs with the given +// ids/names. func BulkUpdateJobs( ctx context.Context, s *WorkbenchService, - ids []string, + args []string, body types.Job, ) (types.Jobs, error) { - logutil.GetLogger().Info("updating jobs", "ids", ids, "body", body) + logutil.GetLogger().Info("updating jobs", "args", args, "body", body) + ids, err := resolveJobIDs(ctx, s, args) + if err != nil { + return nil, err + } return result.Split(Post[types.Jobs]( ctx, s, diff --git a/internal/services/workbench/snapshots.go b/internal/services/workbench/snapshots.go index a778dcd..bc9ee9d 100644 --- a/internal/services/workbench/snapshots.go +++ b/internal/services/workbench/snapshots.go @@ -22,29 +22,49 @@ import ( ) // GetSnapshots gets the snapshots in the given WorkbenchService. If given any -// ids, only the matching resources will be returned. Otherwise, all resources +// ids/names, only the matching resources will be returned. Otherwise, all resources // are returned. func GetSnapshots( ctx context.Context, s *WorkbenchService, - ids []string, + args []string, sortSpec string, ) (types.Snapshots, error) { - logutil.GetLogger().Info("getting snapshots", "ids", ids, "sort", sortSpec) + logutil.GetLogger().Info("getting snapshots", "args", args, "sort", sortSpec) sortSpec = normalizeSortSpec(sortSpec, map[string][]string{ constants.SortKeyAge: {constants.SortKeyCreatedAt}, constants.SortKeySource: {constants.SortKeySpecSourcePVCName}, constants.SortKeySize: {constants.SortKeyVolumeStatusRestoreSize}, constants.SortKeyReady: {constants.SortKeyVolumeStatusReadyToUse}, }) - return result.Split(Get[types.Snapshots]( - ctx, s, postgrest.Endpoint(constants.WorkbenchEndpointSnapshots), IDsOrControllerIDsQuery(ids, sortSpec), + snapshots, err := result.Split(Get[types.Snapshots]( + ctx, s, postgrest.Endpoint(constants.WorkbenchEndpointSnapshots), IDsControllerIDsOrNamesQuery(args, sortSpec), )) + if err != nil { + return nil, err + } + return selectByIDThenName( + args, + snapshots, + func(s types.Snapshot) string { return s.ID }, + func(s types.Snapshot) string { return s.ControllerID }, + func(s types.Snapshot) string { return s.Name }, + ), nil +} + +func resolveSnapshotIDs(ctx context.Context, s *WorkbenchService, args []string) ([]string, error) { + return resolveIDs( + ctx, s, args, GetSnapshots, func(s types.Snapshot) string { return s.ID }, constants.SnapshotType, + ) } -// BulkDeleteSnapshots deletes the snapshot with the given id. -func BulkDeleteSnapshots(ctx context.Context, s *WorkbenchService, ids []string) ([]string, error) { - logutil.GetLogger().Info("deleting snapshots", "ids", ids) +// BulkDeleteSnapshots deletes the snapshots with the given ids/names. +func BulkDeleteSnapshots(ctx context.Context, s *WorkbenchService, args []string) ([]string, error) { + logutil.GetLogger().Info("deleting snapshots", "args", args) + ids, err := resolveSnapshotIDs(ctx, s, args) + if err != nil { + return nil, err + } return result.Split(Post[[]string]( ctx, s, @@ -69,14 +89,18 @@ func CreateSnapshot( } // BulkUpdateSnapshots applies the given types.Snapshot to all the snapshots -// with the given ids. +// with the given ids/names. func BulkUpdateSnapshots( ctx context.Context, s *WorkbenchService, - ids []string, + args []string, body types.Snapshot, ) (types.Snapshots, error) { - logutil.GetLogger().Info("updating snapshots", "ids", ids, "body", body) + logutil.GetLogger().Info("updating snapshots", "args", args, "body", body) + ids, err := resolveSnapshotIDs(ctx, s, args) + if err != nil { + return nil, err + } return result.Split(Post[types.Snapshots]( ctx, s, diff --git a/internal/services/workbench/util.go b/internal/services/workbench/util.go index 07e8b8d..4233c35 100644 --- a/internal/services/workbench/util.go +++ b/internal/services/workbench/util.go @@ -34,42 +34,139 @@ func IDsQuery(ids []string, sortSpec string) url.Values { return query } -// IDsOrControllerIDsQuery returns a PostgREST compatible query for the given -// IDs and sort spec that will match either the actual id or the controller_id +// IDsControllerIDsOrNamesQuery returns a PostgREST compatible query for the +// given IDs/names and sort spec that will match the id, controller_id, or name // columns. -func IDsOrControllerIDsQuery(ids []string, sortSpec string) url.Values { +func IDsControllerIDsOrNamesQuery(args []string, sortSpec string) url.Values { + return buildFilterQuery(args, "controller_id", "name", sortSpec) +} + +// IDsOrNamesQuery returns a PostgREST compatible query for the given IDs/names +// and sort spec that will match the id or name columns. +func IDsOrNamesQuery(args []string, sortSpec string) url.Values { + return buildFilterQuery(args, "", "name", sortSpec) +} + +// IDsControllerIDsOrJobNamesQuery returns a PostgREST compatible query for the +// given IDs/names and sort spec that will match the id, controller_id, or job_name +// columns. +func IDsControllerIDsOrJobNamesQuery(args []string, sortSpec string) url.Values { + return buildFilterQuery(args, "controller_id", "job_name", sortSpec) +} + +// buildFilterQuery generates the query for the given IDs/names. UUID values +// are matched against the id column, while other values are matched against +// the controllerIDColumn if provided. Every value is matched against the +// nameColumn. +func buildFilterQuery(args []string, controllerIDColumn, nameColumn, sortSpec string) url.Values { query := url.Values{} if sortSpec != "" { query["order"] = []string{sortSpec} } + if len(args) == 0 { + return query + } uuidIDs := []string{} - controllerIDs := []string{} - for _, id := range ids { - if isUUID(id) { - uuidIDs = append(uuidIDs, id) + nonUUIDs := []string{} + for _, arg := range args { + if isUUID(arg) { + uuidIDs = append(uuidIDs, arg) } else { - controllerIDs = append(controllerIDs, id) + nonUUIDs = append(nonUUIDs, arg) } } - switch { - case len(uuidIDs) > 0 && len(controllerIDs) > 0: - query["or"] = []string{ - "(id.in.(" + strings.Join( - uuidIDs, - ",", - ) + "),controller_id.in.(" + strings.Join( - controllerIDs, - ",", - ) + "))", - } - case len(uuidIDs) > 0: - query["id"] = []string{"in.(" + strings.Join(uuidIDs, ",") + ")"} - case len(controllerIDs) > 0: - query["controller_id"] = []string{"in.(" + strings.Join(controllerIDs, ",") + ")"} + clauses := []string{} + if len(uuidIDs) > 0 { + clauses = append(clauses, "id.in.("+strings.Join(uuidIDs, ",")+")") } + if controllerIDColumn != "" && len(nonUUIDs) > 0 { + clauses = append(clauses, controllerIDColumn+".in.("+strings.Join(nonUUIDs, ",")+")") + } + clauses = append(clauses, nameColumn+".in.("+strings.Join(args, ",")+")") + query["or"] = []string{"(" + strings.Join(clauses, ",") + ")"} return query } +// selectByIDThenName narrows resources to those requested by args. It provides precedence +// for selecting by ID > Name. Each arg is resolved independendently and no resource is +// returned twice. +func selectByIDThenName[T any, TS ~[]T]( + args []string, + resources TS, + getIDFunc func(T) string, + getControllerIDFunc func(T) string, + getNameFunc func(T) string, +) TS { + if len(args) == 0 { + return resources + } + selected := make([]bool, len(resources)) + for _, arg := range args { + if arg == "" { + continue + } + var byID, byName []int + for i, resource := range resources { + switch { + case getIDFunc(resource) == arg || + (getControllerIDFunc != nil && getControllerIDFunc(resource) == arg): + byID = append(byID, i) + case getNameFunc(resource) == arg: + byName = append(byName, i) + } + } + matches := byID + if len(matches) == 0 { + matches = byName + } + for _, i := range matches { + selected[i] = true + } + } + filtered := make(TS, 0, len(resources)) + for i, resource := range resources { + if selected[i] { + filtered = append(filtered, resource) + } + } + return filtered +} + +// resolveIDs converts the given args, which may be ids, controller_ids, or names, +// into the database ids of the matching resources. +func resolveIDs[T any, TS ~[]T]( + ctx context.Context, + s *WorkbenchService, + args []string, + getter func(context.Context, *WorkbenchService, []string, string) (TS, error), + getIDFunc func(T) string, + resourceType string, +) ([]string, error) { + if len(args) == 0 { + return args, nil + } + resources, err := getter(ctx, s, args, "") + if err != nil { + return nil, err + } + if len(resources) == 0 { + return nil, errors.New( + i18n.Format( + i18n.ResourceNotFoundWithID, + i18n.A{ + constants.I18nArgTypeKey: resourceType, + constants.I18nArgKeyID: strings.Join(args, ", "), + }, + ), + ) + } + resolved := make([]string, 0, len(resources)) + for _, resource := range resources { + resolved = append(resolved, getIDFunc(resource)) + } + return resolved, nil +} + // Auth0IDsQuery returns an Auth0 compatible query for the given IDs. func Auth0IDsQuery(ids []string, sortSpec string) url.Values { if len(ids) == 0 && sortSpec == "" { diff --git a/internal/services/workbench/util_test.go b/internal/services/workbench/util_test.go index e1de8da..87538e0 100644 --- a/internal/services/workbench/util_test.go +++ b/internal/services/workbench/util_test.go @@ -11,7 +11,39 @@ import ( "github.com/stretchr/testify/require" ) -func TestIDsOrControllerIDsQuery(t *testing.T) { +func TestIDsQuery(t *testing.T) { + tests := []struct { + name string + ids []string + expected url.Values + sort string + }{ + { + name: "empty ids", + ids: nil, + expected: url.Values{}, + }, + { + name: "single id", + ids: []string{"id-a"}, + expected: url.Values{"id": []string{"in.(id-a)"}}, + }, + { + name: "multiple ids", + ids: []string{"id-a", "id-b"}, + expected: url.Values{"id": []string{"in.(id-a,id-b)"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := workbench.IDsQuery(tt.ids, tt.sort) + require.Equal(t, tt.expected, actual) + }) + } +} + +func TestIDsControllerIDsOrNamesQuery(t *testing.T) { tests := []struct { name string ids []string @@ -28,38 +60,95 @@ func TestIDsOrControllerIDsQuery(t *testing.T) { ids: []string{"123e4567-e89b-12d3-a456-426614174000", "123e4567-e89b-12d3-a456-426614174001"}, sort: "id.asc", expected: url.Values{ - "id": []string{"in.(123e4567-e89b-12d3-a456-426614174000,123e4567-e89b-12d3-a456-426614174001)"}, + "or": []string{ + "(id.in.(123e4567-e89b-12d3-a456-426614174000,123e4567-e89b-12d3-a456-426614174001),name.in.(123e4567-e89b-12d3-a456-426614174000,123e4567-e89b-12d3-a456-426614174001))", + }, "order": []string{"id.asc"}, }, }, { - name: "all controller ids", - ids: []string{"controller-a", "controller-b"}, - expected: url.Values{"controller_id": []string{"in.(controller-a,controller-b)"}}, + name: "all controller ids / names", + ids: []string{"controller-a", "name-a"}, + expected: url.Values{ + "or": []string{ + "(controller_id.in.(controller-a,name-a),name.in.(controller-a,name-a))", + }, + }, }, { - name: "mixed uuid and controller ids", - ids: []string{"123e4567-e89b-12d3-a456-426614174000", "controller-a"}, + name: "mixed uuid, controller id, names", + ids: []string{"123e4567-e89b-12d3-a456-426614174000", "controller-a", "name-a"}, expected: url.Values{ - "or": []string{"(id.in.(123e4567-e89b-12d3-a456-426614174000),controller_id.in.(controller-a))"}, + "or": []string{ + "(id.in.(123e4567-e89b-12d3-a456-426614174000),controller_id.in.(controller-a,name-a),name.in.(123e4567-e89b-12d3-a456-426614174000,controller-a,name-a))", + }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - actual := workbench.IDsOrControllerIDsQuery(tt.ids, tt.sort) + actual := workbench.IDsControllerIDsOrNamesQuery(tt.ids, tt.sort) require.Equal(t, tt.expected, actual) }) } } -func TestIDsQuery(t *testing.T) { +func TestIDsOrNamesQuery(t *testing.T) { tests := []struct { name string ids []string + sort string expected url.Values + }{ + { + name: "empty ids", + ids: nil, + expected: url.Values{}, + }, + { + name: "all uuids", + ids: []string{"123e4567-e89b-12d3-a456-426614174000", "123e4567-e89b-12d3-a456-426614174001"}, + sort: "id.asc", + expected: url.Values{ + "or": []string{ + "(id.in.(123e4567-e89b-12d3-a456-426614174000,123e4567-e89b-12d3-a456-426614174001),name.in.(123e4567-e89b-12d3-a456-426614174000,123e4567-e89b-12d3-a456-426614174001))", + }, + "order": []string{"id.asc"}, + }, + }, + { + name: "all names", + ids: []string{"name-a", "name-b"}, + expected: url.Values{ + "or": []string{"(name.in.(name-a,name-b))"}, + }, + }, + { + name: "mixed uuid and names", + ids: []string{"123e4567-e89b-12d3-a456-426614174000", "name-a"}, + expected: url.Values{ + "or": []string{ + "(id.in.(123e4567-e89b-12d3-a456-426614174000),name.in.(123e4567-e89b-12d3-a456-426614174000,name-a))", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := workbench.IDsOrNamesQuery(tt.ids, tt.sort) + require.Equal(t, tt.expected, actual) + }) + } +} + +func TestIDsControllerIDsOrJobNamesQuery(t *testing.T) { + tests := []struct { + name string + ids []string sort string + expected url.Values }{ { name: "empty ids", @@ -67,20 +156,39 @@ func TestIDsQuery(t *testing.T) { expected: url.Values{}, }, { - name: "single id", - ids: []string{"id-a"}, - expected: url.Values{"id": []string{"in.(id-a)"}}, + name: "all uuids", + ids: []string{"123e4567-e89b-12d3-a456-426614174000", "123e4567-e89b-12d3-a456-426614174001"}, + sort: "id.asc", + expected: url.Values{ + "or": []string{ + "(id.in.(123e4567-e89b-12d3-a456-426614174000,123e4567-e89b-12d3-a456-426614174001),job_name.in.(123e4567-e89b-12d3-a456-426614174000,123e4567-e89b-12d3-a456-426614174001))", + }, + "order": []string{"id.asc"}, + }, }, { - name: "multiple ids", - ids: []string{"id-a", "id-b"}, - expected: url.Values{"id": []string{"in.(id-a,id-b)"}}, + name: "all controller ids / job names", + ids: []string{"controller-a", "job-a"}, + expected: url.Values{ + "or": []string{ + "(controller_id.in.(controller-a,job-a),job_name.in.(controller-a,job-a))", + }, + }, + }, + { + name: "mixed uuid, controller id, job names", + ids: []string{"123e4567-e89b-12d3-a456-426614174000", "controller-a", "job-a"}, + expected: url.Values{ + "or": []string{ + "(id.in.(123e4567-e89b-12d3-a456-426614174000),controller_id.in.(controller-a,job-a),job_name.in.(123e4567-e89b-12d3-a456-426614174000,controller-a,job-a))", + }, + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - actual := workbench.IDsQuery(tt.ids, tt.sort) + actual := workbench.IDsControllerIDsOrJobNamesQuery(tt.ids, tt.sort) require.Equal(t, tt.expected, actual) }) } diff --git a/internal/services/workbench/workbench.go b/internal/services/workbench/workbench.go index 05259db..99eddd0 100644 --- a/internal/services/workbench/workbench.go +++ b/internal/services/workbench/workbench.go @@ -17,6 +17,7 @@ import ( "github.com/sassoftware/wbctl/internal/httputil" "github.com/sassoftware/wbctl/internal/i18n" "github.com/sassoftware/wbctl/internal/maputil" + "github.com/sassoftware/wbctl/internal/sliceutil" "github.com/sassoftware/wbctl/internal/types" ) @@ -127,7 +128,7 @@ func GetValidDataplaneID(ctx context.Context, svc *WorkbenchService, dataplaneID return dataplanes[0].ID, nil } -// GetValidWorkspaceRequests takes the given worksaceIDs, fetches those +// GetValidWorkspaceRequests takes the given workspaceIDs, fetches those // workspaces from the server, and returns the database id and full workspace // request body for each. func GetValidWorkspaceRequests( @@ -149,12 +150,16 @@ func GetValidWorkspaceRequests( ), ) } - id := workspaces[0].ControllerID + workspace, err := sliceutil.GetAndEnsureOnlyOne(workspaces) + if err != nil { + return nil, err + } + id := workspace.ControllerID if id == "" { - id = workspaces[0].ID + id = workspace.ID } - mountName := workspaces[0].Name - homeDir := isHomeDir(workspaces[0]) + mountName := workspace.Name + homeDir := isHomeDir(workspace) if homeDir { mountName = "sas" } @@ -164,7 +169,7 @@ func GetValidWorkspaceRequests( MountName: mountName, }, HomeDir: homeDir, - DbID: workspaces[0].ID, + DbID: workspace.ID, }) } return validWorkspaces, nil diff --git a/internal/services/workbench/workbenches.go b/internal/services/workbench/workbenches.go index 488359b..af01a2d 100644 --- a/internal/services/workbench/workbenches.go +++ b/internal/services/workbench/workbenches.go @@ -20,27 +20,47 @@ import ( ) // GetWorkbenches gets the workbenches in the given WorkbenchService. If given -// any ids, only the matching resources will be returned. Otherwise, all +// any ids/names, only the matching resources will be returned. Otherwise, all // resources are returned. func GetWorkbenches( ctx context.Context, s *WorkbenchService, - ids []string, + args []string, sortSpec string, ) (types.Workbenches, error) { - logutil.GetLogger().Info("getting workbenches", "ids", ids, "sort", sortSpec) + logutil.GetLogger().Info("getting workbenches", "args", args, "sort", sortSpec) sortSpec = normalizeSortSpec(sortSpec, map[string][]string{ constants.SortKeyAge: {constants.SortKeyCreatedAt}, constants.SortKeyState: {constants.SortKeyCurrentState}, }) - return result.Split(Get[types.Workbenches]( - ctx, s, postgrest.Endpoint(constants.WorkbenchEndpointWorkbenches), IDsOrControllerIDsQuery(ids, sortSpec), + workbenches, err := result.Split(Get[types.Workbenches]( + ctx, s, postgrest.Endpoint(constants.WorkbenchEndpointWorkbenches), IDsControllerIDsOrNamesQuery(args, sortSpec), )) + if err != nil { + return nil, err + } + return selectByIDThenName( + args, + workbenches, + func(w types.Workbench) string { return w.ID }, + func(w types.Workbench) string { return w.ControllerID }, + func(w types.Workbench) string { return w.Name }, + ), nil +} + +func resolveWorkbenchIDs(ctx context.Context, s *WorkbenchService, args []string) ([]string, error) { + return resolveIDs( + ctx, s, args, GetWorkbenches, func(w types.Workbench) string { return w.ID }, constants.WorkbenchType, + ) } -// BulkDeleteWorkbenches deletes the workbench with the given id. -func BulkDeleteWorkbenches(ctx context.Context, s *WorkbenchService, ids []string) ([]string, error) { - logutil.GetLogger().Info("deleting workbenches", "ids", ids) +// BulkDeleteWorkbenches deletes the workbenches with the given ids/names. +func BulkDeleteWorkbenches(ctx context.Context, s *WorkbenchService, args []string) ([]string, error) { + logutil.GetLogger().Info("deleting workbenches", "args", args) + ids, err := resolveWorkbenchIDs(ctx, s, args) + if err != nil { + return nil, err + } return result.Split(Post[[]string]( ctx, s, @@ -64,15 +84,19 @@ func CreateWorkbench( )) } -// BulkUpdateWorkbenches applies the given types.Workbench to all the -// workbenches with the given ids. +// BulkUpdateWorkbenches applies the given types.Workbench to all the +// workbenches with the given ids/names. func BulkUpdateWorkbenches( ctx context.Context, s *WorkbenchService, - ids []string, + args []string, body types.Workbench, ) (types.Workbenches, error) { - logutil.GetLogger().Info("updating workbenches", "ids", ids, "body", body) + logutil.GetLogger().Info("updating workbenches", "args", args, "body", body) + ids, err := resolveWorkbenchIDs(ctx, s, args) + if err != nil { + return nil, err + } return bulkUpdateForUniqueNameConstrainedResources[types.Workbench, types.Workbenches]( ctx, s, diff --git a/internal/services/workbench/workspaces.go b/internal/services/workbench/workspaces.go index ad46b7e..c290147 100644 --- a/internal/services/workbench/workspaces.go +++ b/internal/services/workbench/workspaces.go @@ -21,29 +21,49 @@ import ( ) // GetWorkspaces gets the workspaces in the given WorkbenchService. If given any -// ids, only the matching resources will be returned. Otherwise, all resources +// ids/names, only the matching resources will be returned. Otherwise, all resources // are returned. func GetWorkspaces( ctx context.Context, s *WorkbenchService, - ids []string, + args []string, sortSpec string, ) (types.Workspaces, error) { - logutil.GetLogger().Info("getting volumes", "ids", ids, "sort", sortSpec) + logutil.GetLogger().Info("getting volumes", "args", args, "sort", sortSpec) sortSpec = normalizeSortSpec(sortSpec, map[string][]string{ constants.SortKeyAge: {constants.SortKeyCreatedAt}, constants.SortKeySize: {constants.SortKeyStorageSizeG}, constants.SortKeyHomeDir: {constants.SortKeyPropertiesHomeDirectory}, constants.SortKeyStorageClassName: {constants.SortKeyStorageSpecStorageClassName}, }) - return result.Split(Get[types.Workspaces]( - ctx, s, postgrest.Endpoint(constants.WorkbenchEndpointWorkspaces), IDsOrControllerIDsQuery(ids, sortSpec), + workspaces, err := result.Split(Get[types.Workspaces]( + ctx, s, postgrest.Endpoint(constants.WorkbenchEndpointWorkspaces), IDsControllerIDsOrNamesQuery(args, sortSpec), )) + if err != nil { + return nil, err + } + return selectByIDThenName( + args, + workspaces, + func(w types.Workspace) string { return w.ID }, + func(w types.Workspace) string { return w.ControllerID }, + func(w types.Workspace) string { return w.Name }, + ), nil +} + +func resolveWorkspaceIDs(ctx context.Context, s *WorkbenchService, args []string) ([]string, error) { + return resolveIDs( + ctx, s, args, GetWorkspaces, func(w types.Workspace) string { return w.ID }, constants.VolumeType, + ) } -// BulkDeleteWorkspaces deletes the workspaces with the given ids. -func BulkDeleteWorkspaces(ctx context.Context, s *WorkbenchService, ids []string) ([]string, error) { - logutil.GetLogger().Info("deleting volumes", "ids", ids) +// BulkDeleteWorkspaces deletes the workspaces with the given ids/names. +func BulkDeleteWorkspaces(ctx context.Context, s *WorkbenchService, args []string) ([]string, error) { + logutil.GetLogger().Info("deleting volumes", "args", args) + ids, err := resolveWorkspaceIDs(ctx, s, args) + if err != nil { + return nil, err + } return result.Split(Post[[]string]( ctx, s, @@ -67,15 +87,19 @@ func CreateWorkspace( )) } -// BulkUpdateWorkspaces applies the given types.Workspace to all the workspaces -// with the given ids. +// BulkUpdateWorkspaces applies the given types.Workspace to all the +// workspaces with the given ids/names. func BulkUpdateWorkspaces( ctx context.Context, s *WorkbenchService, - ids []string, + args []string, body types.Workspace, ) (types.Workspaces, error) { - logutil.GetLogger().Info("updating volumes", "ids", ids) + logutil.GetLogger().Info("updating volumes", "args", args) + ids, err := resolveWorkspaceIDs(ctx, s, args) + if err != nil { + return nil, err + } return bulkUpdateForUniqueNameConstrainedResources[types.Workspace, types.Workspaces]( ctx, s, From 1338b5e8baf71dfd28aa2c5583235ae9c3cfb5cc Mon Sep 17 00:00:00 2001 From: Luke Duan Date: Mon, 2 Nov 2026 15:05:51 -0700 Subject: [PATCH 02/11] test: update unit tests --- .../services/workbench/image_builds_test.go | 31 ++- internal/services/workbench/images_test.go | 109 +++++++---- .../workbench/job_submissions_test.go | 94 ++++++---- internal/services/workbench/jobs_test.go | 114 ++++++----- internal/services/workbench/snapshots_test.go | 109 +++++++---- internal/services/workbench/util.go | 2 +- .../services/workbench/util_internal_test.go | 177 ++++++++++++++++++ .../services/workbench/workbenches_test.go | 140 +++++++++----- .../services/workbench/workspaces_test.go | 132 ++++++++----- 9 files changed, 636 insertions(+), 272 deletions(-) create mode 100644 internal/services/workbench/util_internal_test.go diff --git a/internal/services/workbench/image_builds_test.go b/internal/services/workbench/image_builds_test.go index ac3ed11..6a07a6a 100644 --- a/internal/services/workbench/image_builds_test.go +++ b/internal/services/workbench/image_builds_test.go @@ -6,7 +6,6 @@ package workbench_test import ( "context" "encoding/json" - "fmt" "net/http" "strings" "testing" @@ -51,6 +50,13 @@ const ( "controller_id": "controller-build-2", "created_by": "carol", "image_id": "img-controller" + }, + { + "id": "ib-name", + "name": "build-name", + "controller_id": "ctrl-name", + "created_by": "ted", + "image_id": "img-name" } ] ` @@ -211,6 +217,13 @@ var ( CreatedBy: "carol", ImageID: "img-controller", }, + { + ID: "ib-name", + Name: "build-name", + ControllerID: "ctrl-name", + CreatedBy: "ted", + ImageID: "img-name", + }, } ) @@ -225,13 +238,13 @@ func TestGetImageBuilds(t *testing.T) { }, }, { - name: "get image builds success with mixed uuid and controller ids query", + name: "get image builds success with mixed uuid, controller id, names query", request: getRequest{ - ids: []string{"123e4567-e89b-12d3-a456-426614174000", "controller-build-2"}, + ids: []string{"123e4567-e89b-12d3-a456-426614174000", "controller-build-2", "build-name"}, sort: "id.asc", }, mockEndpoints: getImageBuildsGetEndpoints( - "or=(id.in.(123e4567-e89b-12d3-a456-426614174000),controller_id.in.(controller-build-2))&order=id.asc", + "or=(id.in.(123e4567-e89b-12d3-a456-426614174000),controller_id.in.(controller-build-2,build-name),name.in.(123e4567-e89b-12d3-a456-426614174000,controller-build-2,build-name))&order=id.asc", http.StatusOK, imageBuildsGetMixedIDsResponseJSON, ), @@ -298,9 +311,9 @@ func TestGetImageBuilds(t *testing.T) { request: getRequest{ ids: []string{"id-a"}, }, - mockEndpoints: getImageBuildsGetEndpoints("controller_id=in.(id-a)", http.StatusOK, `[`), + mockEndpoints: getImageBuildsGetEndpoints("or=(controller_id.in.(id-a),name.in.(id-a))", http.StatusOK, `[`), expected: getExpected[types.ImageBuilds]{ - err: "GET %s/postgrest/image_builds?controller_id=in.%%28id-a%%29 failed\n" + + err: "GET ${URL}/postgrest/image_builds?or=%28controller_id.in.%28id-a%29%2Cname.in.%28id-a%29%29 failed\n" + " Caused by:\n" + "\t* unexpected EOF", }, @@ -310,9 +323,9 @@ func TestGetImageBuilds(t *testing.T) { request: getRequest{ ids: []string{"id-a"}, }, - mockEndpoints: getImageBuildsGetEndpoints("controller_id=in.(id-a)", http.StatusUnauthorized, `unauthorized`), + mockEndpoints: getImageBuildsGetEndpoints("or=(controller_id.in.(id-a),name.in.(id-a))", http.StatusUnauthorized, `unauthorized`), expected: getExpected[types.ImageBuilds]{ - err: "GET %s/postgrest/image_builds?controller_id=in.%%28id-a%%29 failed\n" + + err: "GET ${URL}/postgrest/image_builds?or=%28controller_id.in.%28id-a%29%2Cname.in.%28id-a%29%29 failed\n" + " Caused by:\n" + "\t* 401 unauthorized", }, @@ -327,7 +340,7 @@ func TestGetImageBuilds(t *testing.T) { actual, err := workbench.GetImageBuilds(context.Background(), svc, tt.request.ids, tt.request.sort) if tt.expected.err != "" { - require.EqualError(t, err, fmt.Sprintf(tt.expected.err, srv.URL)) + require.EqualError(t, err, strings.ReplaceAll(tt.expected.err, "${URL}", srv.URL)) return } diff --git a/internal/services/workbench/images_test.go b/internal/services/workbench/images_test.go index d82184e..7c41822 100644 --- a/internal/services/workbench/images_test.go +++ b/internal/services/workbench/images_test.go @@ -36,7 +36,7 @@ const ( ] ` - imagesGetTwoResponseJSON = ` + imagesGetMixedResponseJSON = ` [ { "id": "123e4567-e89b-12d3-a456-426614174000", @@ -51,6 +51,13 @@ const ( "image_name": "app-controller", "image_registry": "registry.controller.com", "controller_id": "controller-app-2" + }, + { + "id": "img-name", + "name": "image-name", + "image_name": "image-name", + "image_registry": "registry.name.com", + "controller_id": "ctrl-name" } ] ` @@ -241,8 +248,6 @@ const ( ] ` - imagesDeleteSuccessResponseJSON = `["img-1", "img-2"]` - expectedImagesEmptyTableNonAdmin = "" + "ID\tCONTROLLER_ID\tNAME\tDESCRIPTION\tIMAGE\tIMAGE_BUILD_ID\tSTATE\tMESSAGE\tAGE" @@ -347,6 +352,9 @@ Pod pod-1: } ] ` + imagesResolveIDsQuery = "or=(controller_id.in.(img-1,img-2),name.in.(img-1,img-2))" + imagesResolveIDsResponseJSON = `[{"id":"img-1"},{"id":"img-2"}]` + imagesDeleteSuccessResponseJSON = `["img-1", "img-2"]` expectedDeleteImageRequestJSON = `{"ids":["img-1","img-2"]}` expectedCreateImageRequestJSON = `{"param":{"name":"image-1"}}` createImageOneResponseBodyJSON = `{"id":"img-1","name":"image-1"}` @@ -365,7 +373,7 @@ var ( }, } - expectedResponseTwoImages = types.Images{ + expectedResponseMixedImages = types.Images{ { ID: "123e4567-e89b-12d3-a456-426614174000", Name: "app-uuid", @@ -380,6 +388,13 @@ var ( ImageRegistry: "registry.controller.com", ControllerID: "controller-app-2", }, + { + ID: "img-name", + Name: "image-name", + ImageName: "image-name", + ImageRegistry: "registry.name.com", + ControllerID: "ctrl-name", + }, } ) @@ -391,6 +406,8 @@ func TestDeleteImage(t *testing.T) { ids: []string{"img-1", "img-2"}, }, mockEndpoints: getImagesDeleteEndpoints( + imagesResolveIDsQuery, + imagesResolveIDsResponseJSON, expectedDeleteImageRequestJSON, http.StatusOK, imagesDeleteSuccessResponseJSON, @@ -405,6 +422,8 @@ func TestDeleteImage(t *testing.T) { ids: []string{"img-1", "img-2"}, }, mockEndpoints: getImagesDeleteEndpoints( + imagesResolveIDsQuery, + imagesResolveIDsResponseJSON, expectedDeleteImageRequestJSON, http.StatusUnauthorized, `unauthorized`, @@ -547,6 +566,8 @@ func TestBulkUpdateImages(t *testing.T) { body: types.Image{Name: "updated-name"}, }, mockEndpoints: getImagesBulkUpdateEndpoints( + imagesResolveIDsQuery, + imagesResolveIDsResponseJSON, expectedBulkUpdateImageRequestJSON, http.StatusOK, imagesBulkUpdateResponseJSON, @@ -561,6 +582,8 @@ func TestBulkUpdateImages(t *testing.T) { name: "bulk update images non-200 from server", request: bulkupdateRequest[types.Image]{ids: []string{"img-1", "img-2"}, body: types.Image{Name: "updated-name"}}, mockEndpoints: getImagesBulkUpdateEndpoints( + imagesResolveIDsQuery, + imagesResolveIDsResponseJSON, expectedBulkUpdateImageRequestJSON, http.StatusUnauthorized, `unauthorized`, @@ -575,6 +598,8 @@ func TestBulkUpdateImages(t *testing.T) { name: "bulk update images invalid json response", request: bulkupdateRequest[types.Image]{ids: []string{"img-1", "img-2"}, body: types.Image{Name: "updated-name"}}, mockEndpoints: getImagesBulkUpdateEndpoints( + imagesResolveIDsQuery, + imagesResolveIDsResponseJSON, expectedBulkUpdateImageRequestJSON, http.StatusOK, `[`, @@ -664,18 +689,18 @@ func TestGetImages(t *testing.T) { expected: getExpected[types.Images]{result: expectedResponseOneImage}, }, { - name: "get images success with mixed uuid and controller ids query", + name: "get images success with mixed uuid, controller id, names query", request: getRequest{ - ids: []string{"123e4567-e89b-12d3-a456-426614174000", "controller-app-2"}, + ids: []string{"123e4567-e89b-12d3-a456-426614174000", "controller-app-2", "image-name"}, sort: "id.asc", }, mockEndpoints: getImagesGetEndpoints( - "or=(id.in.(123e4567-e89b-12d3-a456-426614174000),controller_id.in.(controller-app-2))&order=id.asc", + "or=(id.in.(123e4567-e89b-12d3-a456-426614174000),controller_id.in.(controller-app-2,image-name),name.in.(123e4567-e89b-12d3-a456-426614174000,controller-app-2,image-name))&order=id.asc", http.StatusOK, - imagesGetTwoResponseJSON, + imagesGetMixedResponseJSON, ), expected: getExpected[types.Images]{ - result: expectedResponseTwoImages, + result: expectedResponseMixedImages, }, }, { @@ -755,18 +780,18 @@ func TestGetImages(t *testing.T) { { name: "get images invalid json response", request: getRequest{ids: []string{"id-a"}}, - mockEndpoints: getImagesGetEndpoints("controller_id=in.(id-a)", http.StatusOK, "["), + mockEndpoints: getImagesGetEndpoints("or=(controller_id.in.(id-a),name.in.(id-a))", http.StatusOK, "["), expected: getExpected[types.Images]{ - err: "GET ${URL}/postgrest/images?controller_id=in.%28id-a%29 failed\n" + + err: "GET ${URL}/postgrest/images?or=%28controller_id.in.%28id-a%29%2Cname.in.%28id-a%29%29 failed\n" + " Caused by:\n" + "\t* unexpected EOF"}, }, { name: "get images non-200 from server", request: getRequest{ids: []string{"id-a"}}, - mockEndpoints: getImagesGetEndpoints("controller_id=in.(id-a)", http.StatusUnauthorized, "unauthorized"), + mockEndpoints: getImagesGetEndpoints("or=(controller_id.in.(id-a),name.in.(id-a))", http.StatusUnauthorized, "unauthorized"), expected: getExpected[types.Images]{ - err: "GET ${URL}/postgrest/images?controller_id=in.%28id-a%29 failed\n" + + err: "GET ${URL}/postgrest/images?or=%28controller_id.in.%28id-a%29%2Cname.in.%28id-a%29%29 failed\n" + " Caused by:\n" + "\t* 401 unauthorized"}, }, @@ -886,24 +911,25 @@ func createTokenWithRole(role string) string { } func getImagesDeleteEndpoints( + expectedQuery string, + resolveResponseBody string, expectedBody string, responseStatus int, responseBody string, ) []stubs.StubHTTPEndpoint { - return []stubs.StubHTTPEndpoint{ - { - Request: stubs.StubHTTPRequest{ - Method: http.MethodPost, - URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCImageBulkDelete), - ExpectedHeaders: authHeaders, - ExpectedBody: expectedBody, - }, - Response: stubs.StubHTTPResponse{ - Status: responseStatus, - Body: responseBody, - }, - }, - } + endpoints := getImagesGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody) + return append(endpoints, stubs.StubHTTPEndpoint{ + Request: stubs.StubHTTPRequest{ + Method: http.MethodPost, + URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCImageBulkDelete), + ExpectedHeaders: authHeaders, + ExpectedBody: expectedBody, + }, + Response: stubs.StubHTTPResponse{ + Status: responseStatus, + Body: responseBody, + }, + }) } func getImagesCreateEndpoints( @@ -928,24 +954,25 @@ func getImagesCreateEndpoints( } func getImagesBulkUpdateEndpoints( + expectedQuery string, + resolveResponseBody string, expectedBody string, responseStatus int, responseBody string, ) []stubs.StubHTTPEndpoint { - return []stubs.StubHTTPEndpoint{ - { - Request: stubs.StubHTTPRequest{ - Method: http.MethodPost, - URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCImageBulkUpdate), - ExpectedHeaders: authHeaders, - ExpectedBody: expectedBody, - }, - Response: stubs.StubHTTPResponse{ - Status: responseStatus, - Body: responseBody, - }, - }, - } + endpoints := getImagesGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody) + return append(endpoints, stubs.StubHTTPEndpoint{ + Request: stubs.StubHTTPRequest{ + Method: http.MethodPost, + URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCImageBulkUpdate), + ExpectedHeaders: authHeaders, + ExpectedBody: expectedBody, + }, + Response: stubs.StubHTTPResponse{ + Status: responseStatus, + Body: responseBody, + }, + }) } func getImagesGetEndpoints( diff --git a/internal/services/workbench/job_submissions_test.go b/internal/services/workbench/job_submissions_test.go index 22babeb..46b2d01 100644 --- a/internal/services/workbench/job_submissions_test.go +++ b/internal/services/workbench/job_submissions_test.go @@ -57,6 +57,15 @@ const ( "current_state": "Queued", "runtime": 0.00, "log_url": "https://logs/controller" + }, + { + "id": "js-job-name", + "controller_id": "ctrl-job-name", + "job_name": "job-name-job", + "username": "carol", + "current_state": "Queued", + "runtime": 0.00, + "log_url": "https://logs/controller" } ] ` @@ -334,6 +343,9 @@ Pod pod-1: Nominated node: node-a IPs: 10.0.0.1 ` + jobRunResolveIDsQuery = "or=(controller_id.in.(js-1,js-2),job_name.in.(js-1,js-2))" + jobRunResolveIDsResponseJSON = `[{"id":"js-1"},{"id":"js-2"}]` + jobRunDeleteSuccessResponseJSON = `["js-1", "js-2"]` expectedDeleteJobRunRequestJSON = `{"ids":["js-1", "js-2"]}` expectedCreateJobRunRequestJSON = `{"param":{"job_name":"daily-job"}}` createJobRunOneResponseBodyJSON = `{"id":"js-1","job_name":"daily-job"}` @@ -353,7 +365,7 @@ var ( }, } - expectedResponseTwoSubmissions = types.JobSubmissions{ + expectedResponseMixedSubmissions = types.JobSubmissions{ { ID: "123e4567-e89b-12d3-a456-426614174000", ControllerID: "ctrl-uuid", @@ -372,6 +384,15 @@ var ( Runtime: 0, LogURL: "https://logs/controller", }, + { + ID: "js-job-name", + ControllerID: "ctrl-job-name", + JobName: "job-name-job", + Username: "carol", + CurrentState: "Queued", + Runtime: 0, + LogURL: "https://logs/controller", + }, } ) @@ -383,9 +404,11 @@ func TestDeleteJobSubmission(t *testing.T) { ids: []string{"js-1", "js-2"}, }, mockEndpoints: getJobSubmissionDeleteEndpoints( + jobRunResolveIDsQuery, + jobRunResolveIDsResponseJSON, expectedDeleteJobRunRequestJSON, http.StatusOK, - `["js-1", "js-2"]`, + jobRunDeleteSuccessResponseJSON, ), expected: deleteExpected{ result: []string{"js-1", "js-2"}, @@ -397,6 +420,8 @@ func TestDeleteJobSubmission(t *testing.T) { ids: []string{"js-1", "js-2"}, }, mockEndpoints: getJobSubmissionDeleteEndpoints( + jobRunResolveIDsQuery, + jobRunResolveIDsResponseJSON, expectedDeleteJobRunRequestJSON, http.StatusUnauthorized, "unauthorized", @@ -500,6 +525,20 @@ func TestGetJobSubmissions(t *testing.T) { result: expectedResponseOneSubmission, }, }, + { + name: "get success with mixed uuid, controller ids, names query", + request: getRequest{ + ids: []string{"123e4567-e89b-12d3-a456-426614174000", "controller-job-2", "job-name-job"}, + }, + mockEndpoints: getJobSubmissionsGetEndpoints( + "or=(id.in.(123e4567-e89b-12d3-a456-426614174000),controller_id.in.(controller-job-2,job-name-job),job_name.in.(123e4567-e89b-12d3-a456-426614174000,controller-job-2,job-name-job))", + http.StatusOK, + jobSubmissionsGetMixedIDsResponseJSON, + ), + expected: getExpected[types.JobSubmissions]{ + result: expectedResponseMixedSubmissions, + }, + }, { name: "sort by job_name asc", request: getRequest{ @@ -668,32 +707,18 @@ func TestGetJobSubmissions(t *testing.T) { result: expectedResponseOneSubmission, }, }, - { - name: "success with mixed uuid and controller ids query", - request: getRequest{ - ids: []string{"123e4567-e89b-12d3-a456-426614174000", "controller-job-2"}, - }, - mockEndpoints: getJobSubmissionsGetEndpoints( - "or=(id.in.(123e4567-e89b-12d3-a456-426614174000),controller_id.in.(controller-job-2))", - http.StatusOK, - jobSubmissionsGetMixedIDsResponseJSON, - ), - expected: getExpected[types.JobSubmissions]{ - result: expectedResponseTwoSubmissions, - }, - }, { name: "invalid json response", request: getRequest{ ids: []string{"id-a"}, }, mockEndpoints: getJobSubmissionsGetEndpoints( - "controller_id=in.(id-a)", + "or=(controller_id.in.(id-a),job_name.in.(id-a))", http.StatusOK, "[", ), expected: getExpected[types.JobSubmissions]{ - err: "GET ${URL}/postgrest/job_runs?controller_id=in.%28id-a%29 failed\n" + + err: "GET ${URL}/postgrest/job_runs?or=%28controller_id.in.%28id-a%29%2Cjob_name.in.%28id-a%29%29 failed\n" + " Caused by:\n" + "\t* unexpected EOF", }, @@ -704,12 +729,12 @@ func TestGetJobSubmissions(t *testing.T) { ids: []string{"id-a"}, }, mockEndpoints: getJobSubmissionsGetEndpoints( - "controller_id=in.(id-a)", + "or=(controller_id.in.(id-a),job_name.in.(id-a))", http.StatusUnauthorized, "unauthorized", ), expected: getExpected[types.JobSubmissions]{ - err: "GET ${URL}/postgrest/job_runs?controller_id=in.%28id-a%29 failed\n" + + err: "GET ${URL}/postgrest/job_runs?or=%28controller_id.in.%28id-a%29%2Cjob_name.in.%28id-a%29%29 failed\n" + " Caused by:\n" + "\t* 401 unauthorized", }, @@ -852,24 +877,25 @@ func TestDescribeJobSubmissions(t *testing.T) { } func getJobSubmissionDeleteEndpoints( + expectedQuery string, + resolveResponseBody string, expectedBody string, responseStatus int, responseBody string, ) []stubs.StubHTTPEndpoint { - return []stubs.StubHTTPEndpoint{ - { - Request: stubs.StubHTTPRequest{ - Method: http.MethodPost, - URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCJobRunBulkDelete), - ExpectedHeaders: authHeaders, - ExpectedBody: expectedBody, - }, - Response: stubs.StubHTTPResponse{ - Body: responseBody, - Status: responseStatus, - }, - }, - } + endpoints := getJobSubmissionsGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody) + return append(endpoints, stubs.StubHTTPEndpoint{ + Request: stubs.StubHTTPRequest{ + Method: http.MethodPost, + URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCJobRunBulkDelete), + ExpectedHeaders: authHeaders, + ExpectedBody: expectedBody, + }, + Response: stubs.StubHTTPResponse{ + Body: responseBody, + Status: responseStatus, + }, + }) } func getJobSubmissionCreateEndpoints( diff --git a/internal/services/workbench/jobs_test.go b/internal/services/workbench/jobs_test.go index 00f2c90..bd81e49 100644 --- a/internal/services/workbench/jobs_test.go +++ b/internal/services/workbench/jobs_test.go @@ -34,17 +34,17 @@ const ( ] ` - jobsGetTwoResponseJSON = ` + jobsGetMixedResponseJSON = ` [ { - "id": "id-a", - "name": "job-a", - "application_id": "app-a" + "id": "123e4567-e89b-12d3-a456-426614174000", + "name": "job-uuid", + "application_id": "app-uuid" }, { - "id": "id-b", - "name": "job-b", - "application_id": "app-b" + "id": "job-name-id", + "name": "job-name", + "application_id": "app-name" } ] ` @@ -352,6 +352,9 @@ Schedule: 0 * * * * } ] ` + jobsResolveIDsQuery = "or=(name.in.(job-1,job-2))" + jobsResolveIDsResponseJSON = `[{"id":"job-1"},{"id":"job-2"}]` + jobsDeleteSuccessResponseJSON = `["job-1", "job-2"]` expectedDeleteJobRequestJSON = `{"ids":["job-1","job-2"]}` createJobOneResponseBodyJSON = `{"id":"job-1","name":"daily-job"}` expectedCreateJobRequestJSON = `{"param":{"name":"daily-job"}}` @@ -363,9 +366,9 @@ var ( {ID: "job-1", Name: "daily-job", Description: "first", ApplicationID: "app-1"}, } - expectedResponseTwoJobs = types.Jobs{ - {ID: "id-a", Name: "job-a", ApplicationID: "app-a"}, - {ID: "id-b", Name: "job-b", ApplicationID: "app-b"}, + expectedResponseMixedJobs = types.Jobs{ + {ID: "123e4567-e89b-12d3-a456-426614174000", Name: "job-uuid", ApplicationID: "app-uuid"}, + {ID: "job-name-id", Name: "job-name", ApplicationID: "app-name"}, } ) @@ -377,9 +380,11 @@ func TestDeleteJob(t *testing.T) { ids: []string{"job-1", "job-2"}, }, mockEndpoints: getJobsDeleteEndpoints( + jobsResolveIDsQuery, + jobsResolveIDsResponseJSON, expectedDeleteJobRequestJSON, http.StatusOK, - `["job-1", "job-2"]`, + jobsDeleteSuccessResponseJSON, ), expected: deleteExpected{ result: []string{"job-1", "job-2"}, @@ -391,6 +396,8 @@ func TestDeleteJob(t *testing.T) { ids: []string{"job-1", "job-2"}, }, mockEndpoints: getJobsDeleteEndpoints( + jobsResolveIDsQuery, + jobsResolveIDsResponseJSON, expectedDeleteJobRequestJSON, http.StatusUnauthorized, "unauthorized", @@ -489,6 +496,8 @@ func TestBulkUpdateJobs(t *testing.T) { body: types.Job{Name: "updated-name"}, }, mockEndpoints: getJobsBulkUpdateEndpoints( + jobsResolveIDsQuery, + jobsResolveIDsResponseJSON, expectedBulkUpdateJobRequestJSON, http.StatusOK, jobsBulkUpdateResponseJSON, @@ -507,6 +516,8 @@ func TestBulkUpdateJobs(t *testing.T) { body: types.Job{Name: "updated-name"}, }, mockEndpoints: getJobsBulkUpdateEndpoints( + jobsResolveIDsQuery, + jobsResolveIDsResponseJSON, expectedBulkUpdateJobRequestJSON, http.StatusUnauthorized, "unauthorized", @@ -524,6 +535,8 @@ func TestBulkUpdateJobs(t *testing.T) { body: types.Job{Name: "updated-name"}, }, mockEndpoints: getJobsBulkUpdateEndpoints( + jobsResolveIDsQuery, + jobsResolveIDsResponseJSON, expectedBulkUpdateJobRequestJSON, http.StatusOK, "[", @@ -567,15 +580,18 @@ func TestGetJobs(t *testing.T) { }, }, { - name: "get jobs success with ids query", - request: getRequest{ids: []string{"id-a"}}, + name: "get jobs success with mixed ids, names query", + request: getRequest{ + ids: []string{"123e4567-e89b-12d3-a456-426614174000", "job-name"}, + sort: "id.asc", + }, mockEndpoints: getJobsGetEndpoints( - "id=in.(id-a)", + "or=(id.in.(123e4567-e89b-12d3-a456-426614174000),name.in.(123e4567-e89b-12d3-a456-426614174000,job-name))&order=id.asc", http.StatusOK, - jobsGetTwoResponseJSON, + jobsGetMixedResponseJSON, ), expected: getExpected[types.Jobs]{ - result: expectedResponseTwoJobs, + result: expectedResponseMixedJobs, }, }, { @@ -666,14 +682,14 @@ func TestGetJobs(t *testing.T) { name: "get jobs invalid json response", request: getRequest{ids: []string{"id-a"}}, mockEndpoints: getJobsGetEndpoints( - "id=in.(id-a)", + "or=(name.in.(id-a))", http.StatusOK, "[", ), expected: getExpected[types.Jobs]{ // jobs do not have controller ids so this query is for `id` // even though it is not a uuid. - err: "GET ${URL}/postgrest/jobs?id=in.%28id-a%29 failed\n" + + err: "GET ${URL}/postgrest/jobs?or=%28name.in.%28id-a%29%29 failed\n" + " Caused by:\n" + "\t* unexpected EOF", }, @@ -682,12 +698,12 @@ func TestGetJobs(t *testing.T) { name: "get jobs non-200 from server", request: getRequest{ids: []string{"id-a"}}, mockEndpoints: getJobsGetEndpoints( - "id=in.(id-a)", + "or=(name.in.(id-a))", http.StatusUnauthorized, "unauthorized", ), expected: getExpected[types.Jobs]{ - err: "GET ${URL}/postgrest/jobs?id=in.%28id-a%29 failed\n" + + err: "GET ${URL}/postgrest/jobs?or=%28name.in.%28id-a%29%29 failed\n" + " Caused by:\n" + "\t* 401 unauthorized", }, @@ -852,24 +868,25 @@ func TestDescribeJobs(t *testing.T) { } func getJobsDeleteEndpoints( + expectedQuery string, + resolveResponseBody string, expectedBody string, responseStatus int, responseBody string, ) []stubs.StubHTTPEndpoint { - return []stubs.StubHTTPEndpoint{ - { - Request: stubs.StubHTTPRequest{ - Method: http.MethodPost, - URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCJobBulkDelete), - ExpectedHeaders: authHeaders, - ExpectedBody: expectedBody, - }, - Response: stubs.StubHTTPResponse{ - Status: responseStatus, - Body: responseBody, - }, - }, - } + endpoints := getJobsGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody) + return append(endpoints, stubs.StubHTTPEndpoint{ + Request: stubs.StubHTTPRequest{ + Method: http.MethodPost, + URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCJobBulkDelete), + ExpectedHeaders: authHeaders, + ExpectedBody: expectedBody, + }, + Response: stubs.StubHTTPResponse{ + Status: responseStatus, + Body: responseBody, + }, + }) } func getJobsCreateEndpoints( @@ -894,24 +911,25 @@ func getJobsCreateEndpoints( } func getJobsBulkUpdateEndpoints( + expectedQuery string, + resolveResponseBody string, expectedBody string, responseStatus int, responseBody string, ) []stubs.StubHTTPEndpoint { - return []stubs.StubHTTPEndpoint{ - { - Request: stubs.StubHTTPRequest{ - Method: http.MethodPost, - URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCJobBulkUpdate), - ExpectedHeaders: authHeaders, - ExpectedBody: expectedBody, - }, - Response: stubs.StubHTTPResponse{ - Status: responseStatus, - Body: responseBody, - }, - }, - } + endpoints := getJobsGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody) + return append(endpoints, stubs.StubHTTPEndpoint{ + Request: stubs.StubHTTPRequest{ + Method: http.MethodPost, + URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCJobBulkUpdate), + ExpectedHeaders: authHeaders, + ExpectedBody: expectedBody, + }, + Response: stubs.StubHTTPResponse{ + Status: responseStatus, + Body: responseBody, + }, + }) } func getJobsGetEndpoints( diff --git a/internal/services/workbench/snapshots_test.go b/internal/services/workbench/snapshots_test.go index 3311d41..7f64e6a 100644 --- a/internal/services/workbench/snapshots_test.go +++ b/internal/services/workbench/snapshots_test.go @@ -6,7 +6,6 @@ package workbench_test import ( "context" "encoding/json" - "fmt" "net/http" "strings" "testing" @@ -51,6 +50,13 @@ const ( "name": "snapshot-controller", "username": "bob", "description": "controller" + }, + { + "id": "snap-name", + "controller_id": "ctrl-name", + "name": "snapshot-name", + "username": "carol", + "description": "name" } ] ` @@ -333,6 +339,9 @@ Volume Status: Content Name: Creation Time: ` + snapshotResolveIDsQuery = "or=(controller_id.in.(snap-1,snap-2),name.in.(snap-1,snap-2))" + snapshotResolveIDsResponseJSON = `[{"id":"snap-1"},{"id":"snap-2"}]` + snapshotDeleteSuccessResponseJSON = `["snap-1", "snap-2"]` expectedDeleteSnapshotRequestJSON = `{"ids":["snap-1","snap-2"]}` createSnapshotOneResponseBodyJSON = `{"id":"snap-1","name":"snapshot-1"}` expectedCreateSnapshotRequestJSON = `{"param":{"name":"snapshot-1"}}` @@ -350,7 +359,7 @@ var ( }, } - expectedResponseTwoSnapshots = types.Snapshots{ + expectedResponseMixedSnapshots = types.Snapshots{ { ID: "123e4567-e89b-12d3-a456-426614174000", ControllerID: "ctrl-uuid", @@ -365,6 +374,13 @@ var ( Username: "bob", Description: "controller", }, + { + ID: "snap-name", + ControllerID: "ctrl-name", + Name: "snapshot-name", + Username: "carol", + Description: "name", + }, } ) @@ -376,9 +392,11 @@ func TestDeleteSnapshot(t *testing.T) { ids: []string{"snap-1", "snap-2"}, }, mockEndpoints: getSnapshotDeleteEndpoints( + snapshotResolveIDsQuery, + snapshotResolveIDsResponseJSON, expectedDeleteSnapshotRequestJSON, http.StatusOK, - `["snap-1","snap-2"]`, + snapshotDeleteSuccessResponseJSON, ), expected: deleteExpected{ result: []string{"snap-1", "snap-2"}, @@ -390,6 +408,8 @@ func TestDeleteSnapshot(t *testing.T) { ids: []string{"snap-1", "snap-2"}, }, mockEndpoints: getSnapshotDeleteEndpoints( + snapshotResolveIDsQuery, + snapshotResolveIDsResponseJSON, expectedDeleteSnapshotRequestJSON, http.StatusUnauthorized, `unauthorized`, @@ -486,6 +506,8 @@ func TestBulkUpdateSnapshots(t *testing.T) { body: types.Snapshot{Name: "updated-name"}, }, mockEndpoints: getSnapshotBulkUpdateEndpoints( + snapshotResolveIDsQuery, + snapshotResolveIDsResponseJSON, expectedBulkUpdateSnapshotRequestJSON, http.StatusOK, snapshotsBulkUpdateResponseJSON, @@ -504,6 +526,8 @@ func TestBulkUpdateSnapshots(t *testing.T) { body: types.Snapshot{Name: "updated-name"}, }, mockEndpoints: getSnapshotBulkUpdateEndpoints( + snapshotResolveIDsQuery, + snapshotResolveIDsResponseJSON, expectedBulkUpdateSnapshotRequestJSON, http.StatusUnauthorized, `unauthorized`, @@ -521,6 +545,8 @@ func TestBulkUpdateSnapshots(t *testing.T) { body: types.Snapshot{Name: "updated-name"}, }, mockEndpoints: getSnapshotBulkUpdateEndpoints( + snapshotResolveIDsQuery, + snapshotResolveIDsResponseJSON, expectedBulkUpdateSnapshotRequestJSON, http.StatusOK, `[`, @@ -564,19 +590,18 @@ func TestGetSnapshots(t *testing.T) { }, }, { - name: "get snapshots success with mixed uuid and controller ids query", + name: "get snapshots success with mixed uuid, controller id, names query", request: getRequest{ - ids: []string{"123e4567-e89b-12d3-a456-426614174000", "controller-snapshot-2"}, + ids: []string{"123e4567-e89b-12d3-a456-426614174000", "controller-snapshot-2", "snapshot-name"}, sort: "id.asc", }, mockEndpoints: getSnapshotsGetEndpoints( - "or=(id.in.(123e4567-e89b-12d3-a456-426614174000),"+ - "controller_id.in.(controller-snapshot-2))&order=id.asc", + "or=(id.in.(123e4567-e89b-12d3-a456-426614174000),controller_id.in.(controller-snapshot-2,snapshot-name),name.in.(123e4567-e89b-12d3-a456-426614174000,controller-snapshot-2,snapshot-name))&order=id.asc", http.StatusOK, snapshotsGetMixedIDsResponseJSON, ), expected: getExpected[types.Snapshots]{ - result: expectedResponseTwoSnapshots, + result: expectedResponseMixedSnapshots, }, }, { @@ -712,12 +737,12 @@ func TestGetSnapshots(t *testing.T) { ids: []string{"id-a"}, }, mockEndpoints: getSnapshotsGetEndpoints( - "controller_id=in.(id-a)", + "or=(controller_id.in.(id-a),name.in.(id-a))", http.StatusOK, `[`, ), expected: getExpected[types.Snapshots]{ - err: "GET %s/postgrest/snapshots?controller_id=in.%%28id-a%%29 failed\n" + + err: "GET ${URL}/postgrest/snapshots?or=%28controller_id.in.%28id-a%29%2Cname.in.%28id-a%29%29 failed\n" + " Caused by:\n" + "\t* unexpected EOF", }, @@ -728,12 +753,12 @@ func TestGetSnapshots(t *testing.T) { ids: []string{"id-a"}, }, mockEndpoints: getSnapshotsGetEndpoints( - "controller_id=in.(id-a)", + "or=(controller_id.in.(id-a),name.in.(id-a))", http.StatusUnauthorized, `unauthorized`, ), expected: getExpected[types.Snapshots]{ - err: "GET %s/postgrest/snapshots?controller_id=in.%%28id-a%%29 failed\n" + + err: "GET ${URL}/postgrest/snapshots?or=%28controller_id.in.%28id-a%29%2Cname.in.%28id-a%29%29 failed\n" + " Caused by:\n" + "\t* 401 unauthorized", }, @@ -748,7 +773,7 @@ func TestGetSnapshots(t *testing.T) { actual, err := workbench.GetSnapshots(context.Background(), svc, tt.request.ids, tt.request.sort) if tt.expected.err != "" { - require.EqualError(t, err, fmt.Sprintf(tt.expected.err, srv.URL)) + require.EqualError(t, err, strings.ReplaceAll(tt.expected.err, "${URL}", srv.URL)) return } require.NoError(t, err) @@ -872,24 +897,25 @@ func TestDescribeSnapshots(t *testing.T) { } func getSnapshotDeleteEndpoints( + expectedQuery string, + resolveResponseBody string, expectedBody string, responseStatus int, responseBody string, ) []stubs.StubHTTPEndpoint { - return []stubs.StubHTTPEndpoint{ - { - Request: stubs.StubHTTPRequest{ - Method: http.MethodPost, - URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCSnapshotBulkDelete), - ExpectedHeaders: authHeaders, - ExpectedBody: expectedBody, - }, - Response: stubs.StubHTTPResponse{ - Body: responseBody, - Status: responseStatus, - }, - }, - } + endpoints := getSnapshotsGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody) + return append(endpoints, stubs.StubHTTPEndpoint{ + Request: stubs.StubHTTPRequest{ + Method: http.MethodPost, + URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCSnapshotBulkDelete), + ExpectedHeaders: authHeaders, + ExpectedBody: expectedBody, + }, + Response: stubs.StubHTTPResponse{ + Body: responseBody, + Status: responseStatus, + }, + }) } func getSnapshotCreateEndpoints( @@ -914,24 +940,25 @@ func getSnapshotCreateEndpoints( } func getSnapshotBulkUpdateEndpoints( + expectedQuery string, + resolveResponseBody string, expectedBody string, responseStatus int, responseBody string, ) []stubs.StubHTTPEndpoint { - return []stubs.StubHTTPEndpoint{ - { - Request: stubs.StubHTTPRequest{ - Method: http.MethodPost, - URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCSnapshotBulkUpdate), - ExpectedHeaders: authHeaders, - ExpectedBody: expectedBody, - }, - Response: stubs.StubHTTPResponse{ - Status: responseStatus, - Body: responseBody, - }, - }, - } + endpoints := getSnapshotsGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody) + return append(endpoints, stubs.StubHTTPEndpoint{ + Request: stubs.StubHTTPRequest{ + Method: http.MethodPost, + URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCSnapshotBulkUpdate), + ExpectedHeaders: authHeaders, + ExpectedBody: expectedBody, + }, + Response: stubs.StubHTTPResponse{ + Status: responseStatus, + Body: responseBody, + }, + }) } func getSnapshotsGetEndpoints( diff --git a/internal/services/workbench/util.go b/internal/services/workbench/util.go index 4233c35..5e7a57f 100644 --- a/internal/services/workbench/util.go +++ b/internal/services/workbench/util.go @@ -88,7 +88,7 @@ func buildFilterQuery(args []string, controllerIDColumn, nameColumn, sortSpec st } // selectByIDThenName narrows resources to those requested by args. It provides precedence -// for selecting by ID > Name. Each arg is resolved independendently and no resource is +// for selecting by ID > Name. Each arg is resolved independently and no resource is // returned twice. func selectByIDThenName[T any, TS ~[]T]( args []string, diff --git a/internal/services/workbench/util_internal_test.go b/internal/services/workbench/util_internal_test.go new file mode 100644 index 0000000..e913a1a --- /dev/null +++ b/internal/services/workbench/util_internal_test.go @@ -0,0 +1,177 @@ +// Copyright © 2026, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package workbench + +import ( + "context" + "errors" + "testing" + + "github.com/sassoftware/wbctl/internal/constants" + "github.com/sassoftware/wbctl/internal/i18n" + "github.com/stretchr/testify/require" +) + +type testResource struct { + ID string + ControllerID string + Name string +} + +type testResources []testResource + +func (r testResource) id() string { return r.ID } +func (r testResource) controllerID() string { return r.ControllerID } +func (r testResource) name() string { return r.Name } + +func TestSelectByIDThenName(t *testing.T) { + a := testResource{ID: "id-a", ControllerID: "ctrl-a", Name: "name-a"} + b := testResource{ID: "id-b", ControllerID: "ctrl-b", Name: "name-b"} + // c has a name equal to a's ID, to exercise ID > name precedence. + c := testResource{ID: "id-c", ControllerID: "ctrl-c", Name: "id-a"} + // d shares a name with b. + d := testResource{ID: "id-d", ControllerID: "ctrl-d", Name: "name-b"} + + tests := []struct { + name string + args []string + resources testResources + expected testResources + }{ + { + name: "no args returns all resources", + args: nil, + resources: testResources{a, b}, + expected: testResources{a, b}, + }, + { + name: "match by id", + args: []string{"id-a"}, + resources: testResources{a, b}, + expected: testResources{a}, + }, + { + name: "match by controller id", + args: []string{"ctrl-b"}, + resources: testResources{a, b}, + expected: testResources{b}, + }, + { + name: "match by name", + args: []string{"name-a"}, + resources: testResources{a, b}, + expected: testResources{a}, + }, + { + name: "id takes precedence over name", + args: []string{"id-a"}, + resources: testResources{a, c}, + expected: testResources{a}, + }, + { + name: "name used when no id matches", + args: []string{"name-b"}, + resources: testResources{a, b, d}, + expected: testResources{b, d}, + }, + { + name: "no match returns empty", + args: []string{"missing"}, + resources: testResources{a, b}, + expected: testResources{}, + }, + { + name: "multiple args are resolved independently", + args: []string{"id-a", "name-b"}, + resources: testResources{a, b, c, d}, + expected: testResources{a, b, d}, + }, + { + name: "resource matched by several args is returned once", + args: []string{"id-a", "name-a", "ctrl-a"}, + resources: testResources{a, b}, + expected: testResources{a}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := selectByIDThenName( + tt.args, + tt.resources, + testResource.id, + testResource.controllerID, + testResource.name, + ) + require.Equal(t, tt.expected, actual) + }) + } +} + +func TestResolveIDs(t *testing.T) { + tests := []struct { + name string + args []string + getter func(context.Context, *WorkbenchService, []string, string) (testResources, error) + expected []string + expectedErr string + }{ + { + name: "empty args returns args without calling getter", + args: nil, + getter: func(context.Context, *WorkbenchService, []string, string) (testResources, error) { + t.Error("getter must not be called") + return nil, nil + }, + expected: nil, + }, + { + name: "resolves to ids", + args: []string{"name-a", "ctrl-b"}, + getter: func(_ context.Context, _ *WorkbenchService, args []string, sortSpec string) (testResources, error) { + require.Equal(t, []string{"name-a", "ctrl-b"}, args) + require.Empty(t, sortSpec) + return testResources{{ID: "id-a"}, {ID: "id-b"}}, nil + }, + expected: []string{"id-a", "id-b"}, + }, + { + name: "getter error is returned", + args: []string{"name-a"}, + getter: func(context.Context, *WorkbenchService, []string, string) (testResources, error) { + return nil, errors.New("getter err") + }, + expectedErr: "getter err", + }, + { + name: "no matches returns not found error", + args: []string{"ws-1", "ws-2"}, + getter: func(context.Context, *WorkbenchService, []string, string) (testResources, error) { + return testResources{}, nil + }, + expectedErr: i18n.Format( + i18n.ResourceNotFoundWithID, + i18n.A{ + constants.I18nArgTypeKey: constants.VolumeType, + constants.I18nArgKeyID: "ws-1, ws-2", + }, + ), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual, err := resolveIDs( + context.Background(), nil, tt.args, tt.getter, testResource.id, constants.VolumeType, + ) + if tt.expectedErr != "" { + require.EqualError(t, err, tt.expectedErr) + require.Nil(t, actual) + return + } + require.NoError(t, err) + require.Equal(t, tt.expected, actual) + }) + } +} diff --git a/internal/services/workbench/workbenches_test.go b/internal/services/workbench/workbenches_test.go index 211d223..a261bbc 100644 --- a/internal/services/workbench/workbenches_test.go +++ b/internal/services/workbench/workbenches_test.go @@ -55,6 +55,13 @@ const ( "name": "wb-controller", "username": "bob", "application_id": "app-2" + }, + { + "id": "wb-name", + "controller_id": "ctrl-name", + "name": "workbench-name", + "username": "carol", + "application_id": "app-3" } ] ` @@ -323,6 +330,9 @@ Properties: "POST ${URL}/postgrest/rpc/workbench_update failed" + "\n Caused by:" + "\n\t* invalid character 'i' looking for beginning of value" + workbenchResolveIDsQuery = "or=(controller_id.in.(wb-1,wb-2),name.in.(wb-1,wb-2))" + workbenchResolveIDsResponseJSON = `[{"id":"wb-1"},{"id":"wb-2"}]` + workbenchDeleteSuccessResponseJSON = `["wb-1", "wb-2"]` expectedDeleteWorkbenchRequestJSON = `{"ids":["wb-1","wb-2"]}` createWorkbenchResponseJSON = `{"id":"wb-1","name":"wb-one"}` expectedBulkUpdateWorkbenchRequestJSON = `{"ids":["wb-1","wb-2"],"workbench":{"desired_state":"STOPPED"}}` @@ -344,7 +354,7 @@ var ( }, } - expectedResponseTwoWorkbenches = types.Workbenches{ + expectedResponseMixedWorkbenches = types.Workbenches{ { ID: "123e4567-e89b-12d3-a456-426614174000", ControllerID: "ctrl-uuid", @@ -359,6 +369,13 @@ var ( Username: "bob", ApplicationID: "app-2", }, + { + ID: "wb-name", + ControllerID: "ctrl-name", + Name: "workbench-name", + Username: "carol", + ApplicationID: "app-3", + }, } ) @@ -370,9 +387,11 @@ func TestDeleteWorkbench(t *testing.T) { ids: []string{"wb-1", "wb-2"}, }, mockEndpoints: getWorkbenchesDeleteEndpoints( + workbenchResolveIDsQuery, + workbenchResolveIDsResponseJSON, expectedDeleteWorkbenchRequestJSON, http.StatusOK, - `["wb-1", "wb-2"]`, + workbenchDeleteSuccessResponseJSON, ), expected: deleteExpected{ result: []string{"wb-1", "wb-2"}, @@ -384,6 +403,8 @@ func TestDeleteWorkbench(t *testing.T) { ids: []string{"wb-1", "wb-2"}, }, mockEndpoints: getWorkbenchesDeleteEndpoints( + workbenchResolveIDsQuery, + workbenchResolveIDsResponseJSON, expectedDeleteWorkbenchRequestJSON, http.StatusUnauthorized, "unauthorized", @@ -483,6 +504,8 @@ func TestBulkUpdateWorkbenches(t *testing.T) { body: types.Workbench{DesiredState: "STOPPED"}, }, mockEndpoints: getWorkbenchesBulkUpdateEndpoints( + workbenchResolveIDsQuery, + workbenchResolveIDsResponseJSON, expectedBulkUpdateWorkbenchRequestJSON, http.StatusOK, workbenchesBulkUpdateResponseJSON, @@ -501,6 +524,8 @@ func TestBulkUpdateWorkbenches(t *testing.T) { body: types.Workbench{DesiredState: "STOPPED"}, }, mockEndpoints: getWorkbenchesBulkUpdateEndpoints( + workbenchResolveIDsQuery, + workbenchResolveIDsResponseJSON, expectedBulkUpdateWorkbenchRequestJSON, http.StatusUnauthorized, "unauthorized", @@ -518,6 +543,8 @@ func TestBulkUpdateWorkbenches(t *testing.T) { body: types.Workbench{DesiredState: "STOPPED"}, }, mockEndpoints: getWorkbenchesBulkUpdateEndpoints( + workbenchResolveIDsQuery, + workbenchResolveIDsResponseJSON, expectedBulkUpdateWorkbenchRequestJSON, http.StatusOK, "[", @@ -535,6 +562,8 @@ func TestBulkUpdateWorkbenches(t *testing.T) { body: types.Workbench{Name: "wb-one-renamed"}, }, mockEndpoints: getWorkbenchesUpdateEndpoints( + `or=(controller_id.in.(wb-1),name.in.(wb-1))`, + `[{"id":"wb-1"}]`, expectedUpdateWorkbenchRequest2JSON, http.StatusOK, `{"id":"wb-1","name":"wb-one-renamed"}`, @@ -549,6 +578,13 @@ func TestBulkUpdateWorkbenches(t *testing.T) { ids: []string{"wb-1", "wb-2"}, body: types.Workbench{Name: "wb-one-renamed"}, }, + mockEndpoints: getWorkbenchesBulkUpdateEndpoints( + workbenchResolveIDsQuery, + workbenchResolveIDsResponseJSON, + "", + http.StatusOK, + "", + ), expected: bulkupdateExpected[types.Workbenches]{ err: i18n.Format(i18n.BulkUpdateNameError, i18n.A{constants.I18nArgKeyType: constants.WorkbenchesType}), @@ -561,6 +597,8 @@ func TestBulkUpdateWorkbenches(t *testing.T) { body: types.Workbench{Name: "UPDATED"}, }, mockEndpoints: getWorkbenchesUpdateEndpoints( + `or=(controller_id.in.(wb-1),name.in.(wb-1))`, + `[{"id":"wb-1"}]`, expectedUpdateWorkbenchRequestJSON, http.StatusOK, "[invalid", @@ -602,19 +640,18 @@ func TestGetWorkbenches(t *testing.T) { }, }, { - name: "get workbench success with mixed uuid and controller ids query", + name: "get workbench success with mixed uuid, controller ids, names query", request: getRequest{ - ids: []string{"123e4567-e89b-12d3-a456-426614174000", "controller-wb-2"}, + ids: []string{"123e4567-e89b-12d3-a456-426614174000", "controller-wb-2", "workbench-name"}, sort: "id.asc", }, mockEndpoints: getWorkbenchesGetEndpoints( - "or=(id.in.(123e4567-e89b-12d3-a456-426614174000),"+ - "controller_id.in.(controller-wb-2))&order=id.asc", + "or=(id.in.(123e4567-e89b-12d3-a456-426614174000),controller_id.in.(controller-wb-2,workbench-name),name.in.(123e4567-e89b-12d3-a456-426614174000,controller-wb-2,workbench-name))&order=id.asc", http.StatusOK, workbenchesGetMixedIDsResponseJSON, ), expected: getExpected[types.Workbenches]{ - result: expectedResponseTwoWorkbenches, + result: expectedResponseMixedWorkbenches, }, }, { @@ -763,12 +800,12 @@ func TestGetWorkbenches(t *testing.T) { ids: []string{"id-a"}, }, mockEndpoints: getWorkbenchesGetEndpoints( - "controller_id=in.(id-a)", + "or=(controller_id.in.(id-a),name.in.(id-a))", http.StatusOK, "[", ), expected: getExpected[types.Workbenches]{ - err: "GET ${URL}/postgrest/workbenches?controller_id=in.%28id-a%29 failed\n" + + err: "GET ${URL}/postgrest/workbenches?or=%28controller_id.in.%28id-a%29%2Cname.in.%28id-a%29%29 failed\n" + " Caused by:\n" + "\t* unexpected EOF", }, @@ -779,12 +816,12 @@ func TestGetWorkbenches(t *testing.T) { ids: []string{"id-a"}, }, mockEndpoints: getWorkbenchesGetEndpoints( - "controller_id=in.(id-a)", + "or=(controller_id.in.(id-a),name.in.(id-a))", http.StatusUnauthorized, "unauthorized", ), expected: getExpected[types.Workbenches]{ - err: "GET ${URL}/postgrest/workbenches?controller_id=in.%28id-a%29 failed\n" + + err: "GET ${URL}/postgrest/workbenches?or=%28controller_id.in.%28id-a%29%2Cname.in.%28id-a%29%29 failed\n" + " Caused by:\n" + "\t* 401 unauthorized", }, @@ -922,24 +959,25 @@ func TestDescribeWorkbenches(t *testing.T) { } func getWorkbenchesDeleteEndpoints( + expectedQuery string, + resolveResponseBody string, expectedBody string, responseStatus int, responseBody string, ) []stubs.StubHTTPEndpoint { - return []stubs.StubHTTPEndpoint{ - { - Request: stubs.StubHTTPRequest{ - Method: http.MethodPost, - URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkbenchBulkDelete), - ExpectedHeaders: authHeaders, - ExpectedBody: expectedBody, - }, - Response: stubs.StubHTTPResponse{ - Status: responseStatus, - Body: responseBody, - }, + endpoints := getWorkbenchesGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody) + return append(endpoints, stubs.StubHTTPEndpoint{ + Request: stubs.StubHTTPRequest{ + Method: http.MethodPost, + URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkbenchBulkDelete), + ExpectedHeaders: authHeaders, + ExpectedBody: expectedBody, }, - } + Response: stubs.StubHTTPResponse{ + Status: responseStatus, + Body: responseBody, + }, + }) } func getWorkbenchesCreateEndpoints( @@ -964,45 +1002,47 @@ func getWorkbenchesCreateEndpoints( } func getWorkbenchesBulkUpdateEndpoints( + expectedQuery string, + resolveResponseBody string, expectedBody string, responseStatus int, responseBody string, ) []stubs.StubHTTPEndpoint { - return []stubs.StubHTTPEndpoint{ - { - Request: stubs.StubHTTPRequest{ - Method: http.MethodPost, - URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkbenchBulkUpdate), - ExpectedHeaders: authHeaders, - ExpectedBody: expectedBody, - }, - Response: stubs.StubHTTPResponse{ - Status: responseStatus, - Body: responseBody, - }, + endpoints := getWorkbenchesGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody) + return append(endpoints, stubs.StubHTTPEndpoint{ + Request: stubs.StubHTTPRequest{ + Method: http.MethodPost, + URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkbenchBulkUpdate), + ExpectedHeaders: authHeaders, + ExpectedBody: expectedBody, }, - } + Response: stubs.StubHTTPResponse{ + Status: responseStatus, + Body: responseBody, + }, + }) } func getWorkbenchesUpdateEndpoints( + expectedQuery string, + resolveResponseBody string, expectedBody string, responseStatus int, responseBody string, ) []stubs.StubHTTPEndpoint { - return []stubs.StubHTTPEndpoint{ - { - Request: stubs.StubHTTPRequest{ - Method: http.MethodPost, - URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkbenchUpdate), - ExpectedHeaders: authHeaders, - ExpectedBody: expectedBody, - }, - Response: stubs.StubHTTPResponse{ - Status: responseStatus, - Body: responseBody, - }, + endpoints := getWorkbenchesGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody) + return append(endpoints, stubs.StubHTTPEndpoint{ + Request: stubs.StubHTTPRequest{ + Method: http.MethodPost, + URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkbenchUpdate), + ExpectedHeaders: authHeaders, + ExpectedBody: expectedBody, }, - } + Response: stubs.StubHTTPResponse{ + Status: responseStatus, + Body: responseBody, + }, + }) } func getWorkbenchesGetEndpoints( diff --git a/internal/services/workbench/workspaces_test.go b/internal/services/workbench/workspaces_test.go index 1f53a1d..941233f 100644 --- a/internal/services/workbench/workspaces_test.go +++ b/internal/services/workbench/workspaces_test.go @@ -50,6 +50,12 @@ const ( "controller_id": "controller-ws-2", "name": "workspace-controller", "username": "bob" + }, + { + "id": "ws-name", + "controller_id": "ctrl-name", + "name": "workspace-name", + "username": "carol" } ] ` @@ -299,6 +305,9 @@ Properties: } ] ` + workspaceResolveIDsQuery = "or=(controller_id.in.(ws-1,ws-2),name.in.(ws-1,ws-2))" + workspaceResolveIDsResponseJSON = `[{"id":"ws-1"},{"id":"ws-2"}]` + workspaceDeleteSuccessResponseJSON = `["ws-1", "ws-2"]` expectedDeleteWorkspaceRequestJSON = `{"ids":["ws-1", "ws-2"]}` expectedBulkUpdateWorkspaceRequestJSON = `{"ids":["ws-1","ws-2"],"workspace":` + `{"description":"updated-description","pvc_status":{"status":{}}}}` @@ -333,6 +342,12 @@ var ( Name: "workspace-controller", Username: "bob", }, + { + ID: "ws-name", + ControllerID: "ctrl-name", + Name: "workspace-name", + Username: "carol", + }, } ) @@ -344,9 +359,11 @@ func TestDeleteWorkspace(t *testing.T) { ids: []string{"ws-1", "ws-2"}, }, mockEndpoints: getWorkspaceDeleteEndpoints( + workspaceResolveIDsQuery, + workspaceResolveIDsResponseJSON, expectedDeleteWorkspaceRequestJSON, http.StatusOK, - `["ws-1", "ws-2"]`, + workspaceDeleteSuccessResponseJSON, ), expected: deleteExpected{ result: []string{"ws-1", "ws-2"}, @@ -358,6 +375,8 @@ func TestDeleteWorkspace(t *testing.T) { ids: []string{"ws-1", "ws-2"}, }, mockEndpoints: getWorkspaceDeleteEndpoints( + workspaceResolveIDsQuery, + workspaceResolveIDsResponseJSON, expectedDeleteWorkspaceRequestJSON, http.StatusUnauthorized, `unauthorized`, @@ -459,6 +478,8 @@ func TestBulkUpdateWorkspaces(t *testing.T) { body: types.Workspace{Description: "updated-description"}, }, mockEndpoints: getWorkspaceBulkUpdateEndpoints( + workspaceResolveIDsQuery, + workspaceResolveIDsResponseJSON, expectedBulkUpdateWorkspaceRequestJSON, http.StatusOK, workspacesBulkUpdateResponseJSON, @@ -477,6 +498,8 @@ func TestBulkUpdateWorkspaces(t *testing.T) { body: types.Workspace{Description: "updated-description"}, }, mockEndpoints: getWorkspaceBulkUpdateEndpoints( + workspaceResolveIDsQuery, + workspaceResolveIDsResponseJSON, expectedBulkUpdateWorkspaceRequestJSON, http.StatusUnauthorized, `unauthorized`, @@ -494,6 +517,8 @@ func TestBulkUpdateWorkspaces(t *testing.T) { body: types.Workspace{Description: "updated-description"}, }, mockEndpoints: getWorkspaceBulkUpdateEndpoints( + workspaceResolveIDsQuery, + workspaceResolveIDsResponseJSON, expectedBulkUpdateWorkspaceRequestJSON, http.StatusOK, `[`, @@ -511,6 +536,8 @@ func TestBulkUpdateWorkspaces(t *testing.T) { body: types.Workspace{Name: "workspace-one-renamed"}, }, mockEndpoints: getWorkspaceUpdateEndpoints( + `or=(controller_id.in.(ws-1),name.in.(ws-1))`, + `[{"id":"ws-1"}]`, expectedUpdateWorkspaceRequestJSON, http.StatusOK, createWorkspaceRenamedResponseJSON, @@ -525,6 +552,13 @@ func TestBulkUpdateWorkspaces(t *testing.T) { ids: []string{"ws-1", "ws-2"}, body: types.Workspace{Name: "workspace-one-renamed"}, }, + mockEndpoints: getWorkspaceBulkUpdateEndpoints( + workspaceResolveIDsQuery, + workspaceResolveIDsResponseJSON, + "", + http.StatusOK, + "", + ), expected: bulkupdateExpected[types.Workspaces]{ err: i18n.Format(i18n.BulkUpdateNameError, i18n.A{constants.I18nArgKeyType: constants.VolumesType}), @@ -564,14 +598,13 @@ func TestGetWorkspaces(t *testing.T) { }, }, { - name: "get workspaces success with mixed uuid and controller ids query", + name: "get workspaces success with mixed uuid, controller ids, names query", request: getRequest{ - ids: []string{"123e4567-e89b-12d3-a456-426614174000", "controller-ws-2"}, + ids: []string{"123e4567-e89b-12d3-a456-426614174000", "controller-ws-2", "ws-name"}, sort: "id.asc", }, mockEndpoints: getWorkspaceGetEndpoints( - "or=(id.in.(123e4567-e89b-12d3-a456-426614174000),"+ - "controller_id.in.(controller-ws-2))&order=id.asc", + "or=(id.in.(123e4567-e89b-12d3-a456-426614174000),controller_id.in.(controller-ws-2,ws-name),name.in.(123e4567-e89b-12d3-a456-426614174000,controller-ws-2,ws-name))&order=id.asc", http.StatusOK, workspacesGetMixedIDsResponseJSON, ), @@ -725,12 +758,12 @@ func TestGetWorkspaces(t *testing.T) { ids: []string{"id-a"}, }, mockEndpoints: getWorkspaceGetEndpoints( - "controller_id=in.(id-a)", + "or=(controller_id.in.(id-a),name.in.(id-a))", http.StatusOK, `[`, ), expected: getExpected[types.Workspaces]{ - err: "GET ${URL}/postgrest/workspaces?controller_id=in.%28id-a%29 failed\n" + + err: "GET ${URL}/postgrest/workspaces?or=%28controller_id.in.%28id-a%29%2Cname.in.%28id-a%29%29 failed\n" + " Caused by:\n" + "\t* unexpected EOF", }, @@ -741,12 +774,12 @@ func TestGetWorkspaces(t *testing.T) { ids: []string{"id-a"}, }, mockEndpoints: getWorkspaceGetEndpoints( - "controller_id=in.(id-a)", + "or=(controller_id.in.(id-a),name.in.(id-a))", http.StatusUnauthorized, `unauthorized`, ), expected: getExpected[types.Workspaces]{ - err: "GET ${URL}/postgrest/workspaces?controller_id=in.%28id-a%29 failed\n" + + err: "GET ${URL}/postgrest/workspaces?or=%28controller_id.in.%28id-a%29%2Cname.in.%28id-a%29%29 failed\n" + " Caused by:\n" + "\t* 401 unauthorized", }, @@ -892,66 +925,69 @@ func TestDescribeWorkspaces(t *testing.T) { } func getWorkspaceUpdateEndpoints( + expectedQuery string, + resolveResponseBody string, expectedBody string, responseStatus int, responseBody string, ) []stubs.StubHTTPEndpoint { - return []stubs.StubHTTPEndpoint{ - { - Request: stubs.StubHTTPRequest{ - Method: http.MethodPost, - URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkspaceUpdate), - ExpectedHeaders: authHeaders, - ExpectedBody: expectedBody, - }, - Response: stubs.StubHTTPResponse{ - Status: responseStatus, - Body: responseBody, - }, + endpoints := getWorkspaceGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody) + return append(endpoints, stubs.StubHTTPEndpoint{ + Request: stubs.StubHTTPRequest{ + Method: http.MethodPost, + URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkspaceUpdate), + ExpectedHeaders: authHeaders, + ExpectedBody: expectedBody, }, - } + Response: stubs.StubHTTPResponse{ + Status: responseStatus, + Body: responseBody, + }, + }) } func getWorkspaceBulkUpdateEndpoints( + expectedQuery string, + resolveResponseBody string, expectedBody string, responseStatus int, responseBody string, ) []stubs.StubHTTPEndpoint { - return []stubs.StubHTTPEndpoint{ - { - Request: stubs.StubHTTPRequest{ - Method: http.MethodPost, - URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkspaceBulkUpdate), - ExpectedHeaders: authHeaders, - ExpectedBody: expectedBody, - }, - Response: stubs.StubHTTPResponse{ - Status: responseStatus, - Body: responseBody, - }, + endpoints := getWorkspaceGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody) + return append(endpoints, stubs.StubHTTPEndpoint{ + Request: stubs.StubHTTPRequest{ + Method: http.MethodPost, + URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkspaceBulkUpdate), + ExpectedHeaders: authHeaders, + ExpectedBody: expectedBody, }, - } + Response: stubs.StubHTTPResponse{ + Status: responseStatus, + Body: responseBody, + }, + }) } func getWorkspaceDeleteEndpoints( + expectedQuery string, + resolveResponseBody string, expectedBody string, responseStatus int, responseBody string, ) []stubs.StubHTTPEndpoint { - return []stubs.StubHTTPEndpoint{ - { - Request: stubs.StubHTTPRequest{ - Method: http.MethodPost, - URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkspaceBulkDelete), - ExpectedHeaders: authHeaders, - ExpectedBody: expectedBody, - }, - Response: stubs.StubHTTPResponse{ - Body: responseBody, - Status: responseStatus, - }, + endpoints := getWorkspaceGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody) + return append(endpoints, stubs.StubHTTPEndpoint{ + Request: stubs.StubHTTPRequest{ + Method: http.MethodPost, + URL: postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkspaceBulkDelete), + ExpectedHeaders: authHeaders, + ExpectedBody: expectedBody, }, - } + Response: stubs.StubHTTPResponse{ + Body: responseBody, + Status: responseStatus, + }, + }) } func getWorkspaceCreateEndpoints( From a7eb4d8c39d3c91b85a5863334e33b5335aa3b0c Mon Sep 17 00:00:00 2001 From: Luke Duan Date: Tue, 3 Nov 2026 11:03:55 -0700 Subject: [PATCH 03/11] fix: allow doc strings to differentiate for names --- doc/wbctl cancel job-submission.md | 2 +- doc/wbctl delete images.md | 2 +- doc/wbctl delete job-submissions.md | 2 +- doc/wbctl delete jobs.md | 2 +- doc/wbctl delete snapshots.md | 2 +- doc/wbctl delete volumes.md | 2 +- doc/wbctl delete workbenches.md | 2 +- doc/wbctl describe image-builds.md | 2 +- doc/wbctl describe images.md | 2 +- doc/wbctl describe job-submissions.md | 2 +- doc/wbctl describe jobs.md | 2 +- doc/wbctl describe snapshots.md | 2 +- doc/wbctl describe volumes.md | 2 +- doc/wbctl describe workbenches.md | 2 +- doc/wbctl edit image.md | 2 +- doc/wbctl edit job.md | 2 +- doc/wbctl edit snapshot.md | 2 +- doc/wbctl edit volume.md | 2 +- doc/wbctl edit workbench.md | 2 +- doc/wbctl get image-builds.md | 2 +- doc/wbctl get images.md | 2 +- doc/wbctl get job-submissions.md | 2 +- doc/wbctl get jobs.md | 2 +- doc/wbctl get snapshots.md | 2 +- doc/wbctl get volumes.md | 2 +- doc/wbctl get workbenches.md | 2 +- doc/wbctl logs image-builds.md | 2 +- doc/wbctl logs job-submissions.md | 2 +- doc/wbctl rebuild images.md | 2 +- doc/wbctl start jobs.md | 2 +- doc/wbctl start workbenches.md | 2 +- doc/wbctl stop job-submission.md | 2 +- doc/wbctl stop workbenches.md | 2 +- doc/wbctl update images.md | 2 +- doc/wbctl update jobs.md | 2 +- doc/wbctl update snapshots.md | 2 +- doc/wbctl update volumes.md | 2 +- doc/wbctl update workbenches.md | 2 +- internal/cmd/describe/command.go | 14 +++++++------- internal/cmd/get/command.go | 14 +++++++------- internal/i18n/i18n_messages_workbench_cli.go | 18 ++++++++++-------- internal/i18n/logmsg.go | 2 ++ 42 files changed, 64 insertions(+), 60 deletions(-) diff --git a/doc/wbctl cancel job-submission.md b/doc/wbctl cancel job-submission.md index a16ebc1..2ce00b2 100644 --- a/doc/wbctl cancel job-submission.md +++ b/doc/wbctl cancel job-submission.md @@ -1,5 +1,5 @@
-Cancels the Job-Submission resources identified by a list of resource ids. At least one id is required.
+Cancels the Job-Submission resources identified by a list of resource ids or names. At least one id or name is required.
 
 Usage:
   wbctl cancel job-submission [flags]
diff --git a/doc/wbctl delete images.md b/doc/wbctl delete images.md
index 3506a91..639f992 100644
--- a/doc/wbctl delete images.md	
+++ b/doc/wbctl delete images.md	
@@ -1,5 +1,5 @@
 
-Deletes the Image resources identified by a list of resource ids. At least one id is required.
+Deletes the Image resources identified by a list of resource ids or names. At least one id or name is required.
 
 Usage:
   wbctl delete images [flags]
diff --git a/doc/wbctl delete job-submissions.md b/doc/wbctl delete job-submissions.md
index f57efa2..24266fd 100644
--- a/doc/wbctl delete job-submissions.md	
+++ b/doc/wbctl delete job-submissions.md	
@@ -1,5 +1,5 @@
 
-Deletes the Job-Submission resources identified by a list of resource ids. At least one id is required.
+Deletes the Job-Submission resources identified by a list of resource ids or names. At least one id or name is required.
 
 Usage:
   wbctl delete job-submissions [flags]
diff --git a/doc/wbctl delete jobs.md b/doc/wbctl delete jobs.md
index e650edd..7a93307 100644
--- a/doc/wbctl delete jobs.md	
+++ b/doc/wbctl delete jobs.md	
@@ -1,5 +1,5 @@
 
-Deletes the Job resources identified by a list of resource ids. At least one id is required.
+Deletes the Job resources identified by a list of resource ids or names. At least one id or name is required.
 
 Usage:
   wbctl delete jobs [flags]
diff --git a/doc/wbctl delete snapshots.md b/doc/wbctl delete snapshots.md
index e637b8d..8026375 100644
--- a/doc/wbctl delete snapshots.md	
+++ b/doc/wbctl delete snapshots.md	
@@ -1,5 +1,5 @@
 
-Deletes the Snapshot resources identified by a list of resource ids. At least one id is required.
+Deletes the Snapshot resources identified by a list of resource ids or names. At least one id or name is required.
 
 Usage:
   wbctl delete snapshots [flags]
diff --git a/doc/wbctl delete volumes.md b/doc/wbctl delete volumes.md
index 87b8cbe..64da9d7 100644
--- a/doc/wbctl delete volumes.md	
+++ b/doc/wbctl delete volumes.md	
@@ -1,5 +1,5 @@
 
-Deletes the Volume resources identified by a list of resource ids. At least one id is required.
+Deletes the Volume resources identified by a list of resource ids or names. At least one id or name is required.
 
 Usage:
   wbctl delete volumes [flags]
diff --git a/doc/wbctl delete workbenches.md b/doc/wbctl delete workbenches.md
index 7537480..87ec2ad 100644
--- a/doc/wbctl delete workbenches.md	
+++ b/doc/wbctl delete workbenches.md	
@@ -1,5 +1,5 @@
 
-Deletes the Workbench resources identified by a list of resource ids. At least one id is required.
+Deletes the Workbench resources identified by a list of resource ids or names. At least one id or name is required.
 
 Usage:
   wbctl delete workbenches [flags]
diff --git a/doc/wbctl describe image-builds.md b/doc/wbctl describe image-builds.md
index 55de127..a6ff774 100644
--- a/doc/wbctl describe image-builds.md	
+++ b/doc/wbctl describe image-builds.md	
@@ -1,5 +1,5 @@
 
-Print detailed information about the Image-Build resources identified by an optional list of resource ids. All resources printed if no ids provided.
+Print detailed information about the Image-Build resources identified by an optional list of resource ids or names. All resources printed if no ids or names provided.
 
 Usage:
   wbctl describe image-builds [flags]
diff --git a/doc/wbctl describe images.md b/doc/wbctl describe images.md
index 0389906..8694a01 100644
--- a/doc/wbctl describe images.md	
+++ b/doc/wbctl describe images.md	
@@ -1,5 +1,5 @@
 
-Print detailed information about the Image resources identified by an optional list of resource ids. All resources printed if no ids provided.
+Print detailed information about the Image resources identified by an optional list of resource ids or names. All resources printed if no ids or names provided.
 
 Usage:
   wbctl describe images [flags]
diff --git a/doc/wbctl describe job-submissions.md b/doc/wbctl describe job-submissions.md
index 8fa346d..9b38fdb 100644
--- a/doc/wbctl describe job-submissions.md	
+++ b/doc/wbctl describe job-submissions.md	
@@ -1,5 +1,5 @@
 
-Print detailed information about the Job-Submission resources identified by an optional list of resource ids. All resources printed if no ids provided.
+Print detailed information about the Job-Submission resources identified by an optional list of resource ids or names. All resources printed if no ids or names provided.
 
 Usage:
   wbctl describe job-submissions [flags]
diff --git a/doc/wbctl describe jobs.md b/doc/wbctl describe jobs.md
index 1e9a544..938d26f 100644
--- a/doc/wbctl describe jobs.md	
+++ b/doc/wbctl describe jobs.md	
@@ -1,5 +1,5 @@
 
-Print detailed information about the Job resources identified by an optional list of resource ids. All resources printed if no ids provided.
+Print detailed information about the Job resources identified by an optional list of resource ids or names. All resources printed if no ids or names provided.
 
 Usage:
   wbctl describe jobs [flags]
diff --git a/doc/wbctl describe snapshots.md b/doc/wbctl describe snapshots.md
index d2a2d63..26f35b7 100644
--- a/doc/wbctl describe snapshots.md	
+++ b/doc/wbctl describe snapshots.md	
@@ -1,5 +1,5 @@
 
-Print detailed information about the Snapshot resources identified by an optional list of resource ids. All resources printed if no ids provided.
+Print detailed information about the Snapshot resources identified by an optional list of resource ids or names. All resources printed if no ids or names provided.
 
 Usage:
   wbctl describe snapshots [flags]
diff --git a/doc/wbctl describe volumes.md b/doc/wbctl describe volumes.md
index 33aaf4a..e5237a4 100644
--- a/doc/wbctl describe volumes.md	
+++ b/doc/wbctl describe volumes.md	
@@ -1,5 +1,5 @@
 
-Print detailed information about the Volume resources identified by an optional list of resource ids. All resources printed if no ids provided.
+Print detailed information about the Volume resources identified by an optional list of resource ids or names. All resources printed if no ids or names provided.
 
 Usage:
   wbctl describe volumes [flags]
diff --git a/doc/wbctl describe workbenches.md b/doc/wbctl describe workbenches.md
index c5a0411..b491c68 100644
--- a/doc/wbctl describe workbenches.md	
+++ b/doc/wbctl describe workbenches.md	
@@ -1,5 +1,5 @@
 
-Print detailed information about the Workbench resources identified by an optional list of resource ids. All resources printed if no ids provided.
+Print detailed information about the Workbench resources identified by an optional list of resource ids or names. All resources printed if no ids or names provided.
 
 Usage:
   wbctl describe workbenches [flags]
diff --git a/doc/wbctl edit image.md b/doc/wbctl edit image.md
index d904465..8b863f5 100644
--- a/doc/wbctl edit image.md	
+++ b/doc/wbctl edit image.md	
@@ -1,5 +1,5 @@
 
-Edit one Image resource identified by id. One and only one id is required.
+Edit one Image resource identified by id or name. One and only one id or name is required.
 
 Usage:
   wbctl edit image [flags]
diff --git a/doc/wbctl edit job.md b/doc/wbctl edit job.md
index 14e8dc6..d241bf5 100644
--- a/doc/wbctl edit job.md	
+++ b/doc/wbctl edit job.md	
@@ -1,5 +1,5 @@
 
-Edit one Job resource identified by id. One and only one id is required.
+Edit one Job resource identified by id or name. One and only one id or name is required.
 
 Usage:
   wbctl edit job [flags]
diff --git a/doc/wbctl edit snapshot.md b/doc/wbctl edit snapshot.md
index 3f9ac81..b037c54 100644
--- a/doc/wbctl edit snapshot.md	
+++ b/doc/wbctl edit snapshot.md	
@@ -1,5 +1,5 @@
 
-Edit one Snapshot resource identified by id. One and only one id is required.
+Edit one Snapshot resource identified by id or name. One and only one id or name is required.
 
 Usage:
   wbctl edit snapshot [flags]
diff --git a/doc/wbctl edit volume.md b/doc/wbctl edit volume.md
index 7f4eab1..2a99317 100644
--- a/doc/wbctl edit volume.md	
+++ b/doc/wbctl edit volume.md	
@@ -1,5 +1,5 @@
 
-Edit one Volume resource identified by id. One and only one id is required.
+Edit one Volume resource identified by id or name. One and only one id or name is required.
 
 Usage:
   wbctl edit volume [flags]
diff --git a/doc/wbctl edit workbench.md b/doc/wbctl edit workbench.md
index 6ee3a9b..3766394 100644
--- a/doc/wbctl edit workbench.md	
+++ b/doc/wbctl edit workbench.md	
@@ -1,5 +1,5 @@
 
-Edit one Workbench resource identified by id. One and only one id is required.
+Edit one Workbench resource identified by id or name. One and only one id or name is required.
 
 Usage:
   wbctl edit workbench [flags]
diff --git a/doc/wbctl get image-builds.md b/doc/wbctl get image-builds.md
index fd389c2..2f71298 100644
--- a/doc/wbctl get image-builds.md	
+++ b/doc/wbctl get image-builds.md	
@@ -1,5 +1,5 @@
 
-Print a table of basic information about the Image-Build resources identified by an optional list of resource ids. All resources printed if no ids provided.
+Print a table of basic information about the Image-Build resources identified by an optional list of resource ids or names. All resources printed if no ids or names provided.
 
 Usage:
   wbctl get image-builds [flags]
diff --git a/doc/wbctl get images.md b/doc/wbctl get images.md
index a3baec0..515b2b4 100644
--- a/doc/wbctl get images.md	
+++ b/doc/wbctl get images.md	
@@ -1,5 +1,5 @@
 
-Print a table of basic information about the Image resources identified by an optional list of resource ids. All resources printed if no ids provided.
+Print a table of basic information about the Image resources identified by an optional list of resource ids or names. All resources printed if no ids or names provided.
 
 Usage:
   wbctl get images [flags]
diff --git a/doc/wbctl get job-submissions.md b/doc/wbctl get job-submissions.md
index 0c159a0..c3b0920 100644
--- a/doc/wbctl get job-submissions.md	
+++ b/doc/wbctl get job-submissions.md	
@@ -1,5 +1,5 @@
 
-Print a table of basic information about the Job-Submission resources identified by an optional list of resource ids. All resources printed if no ids provided.
+Print a table of basic information about the Job-Submission resources identified by an optional list of resource ids or names. All resources printed if no ids or names provided.
 
 Usage:
   wbctl get job-submissions [flags]
diff --git a/doc/wbctl get jobs.md b/doc/wbctl get jobs.md
index 638d897..e057edc 100644
--- a/doc/wbctl get jobs.md	
+++ b/doc/wbctl get jobs.md	
@@ -1,5 +1,5 @@
 
-Print a table of basic information about the Job resources identified by an optional list of resource ids. All resources printed if no ids provided.
+Print a table of basic information about the Job resources identified by an optional list of resource ids or names. All resources printed if no ids or names provided.
 
 Usage:
   wbctl get jobs [flags]
diff --git a/doc/wbctl get snapshots.md b/doc/wbctl get snapshots.md
index cdf77a6..a7b2df7 100644
--- a/doc/wbctl get snapshots.md	
+++ b/doc/wbctl get snapshots.md	
@@ -1,5 +1,5 @@
 
-Print a table of basic information about the Snapshot resources identified by an optional list of resource ids. All resources printed if no ids provided.
+Print a table of basic information about the Snapshot resources identified by an optional list of resource ids or names. All resources printed if no ids or names provided.
 
 Usage:
   wbctl get snapshots [flags]
diff --git a/doc/wbctl get volumes.md b/doc/wbctl get volumes.md
index 452178c..534c876 100644
--- a/doc/wbctl get volumes.md	
+++ b/doc/wbctl get volumes.md	
@@ -1,5 +1,5 @@
 
-Print a table of basic information about the Volume resources identified by an optional list of resource ids. All resources printed if no ids provided.
+Print a table of basic information about the Volume resources identified by an optional list of resource ids or names. All resources printed if no ids or names provided.
 
 Usage:
   wbctl get volumes [flags]
diff --git a/doc/wbctl get workbenches.md b/doc/wbctl get workbenches.md
index c85855d..42d144c 100644
--- a/doc/wbctl get workbenches.md	
+++ b/doc/wbctl get workbenches.md	
@@ -1,5 +1,5 @@
 
-Print a table of basic information about the Workbench resources identified by an optional list of resource ids. All resources printed if no ids provided.
+Print a table of basic information about the Workbench resources identified by an optional list of resource ids or names. All resources printed if no ids or names provided.
 
 Usage:
   wbctl get workbenches [flags]
diff --git a/doc/wbctl logs image-builds.md b/doc/wbctl logs image-builds.md
index 5b7c30b..5190eae 100644
--- a/doc/wbctl logs image-builds.md	
+++ b/doc/wbctl logs image-builds.md	
@@ -1,5 +1,5 @@
 
-Print the log of the Image-Build resources identified a list of resource ids. At least one id is required.
+Print the log of the Image-Build resources identified by a list of resource ids or names. At least one id or name is required.
 
 Usage:
   wbctl logs image-builds [flags]
diff --git a/doc/wbctl logs job-submissions.md b/doc/wbctl logs job-submissions.md
index efdae80..a977797 100644
--- a/doc/wbctl logs job-submissions.md	
+++ b/doc/wbctl logs job-submissions.md	
@@ -1,5 +1,5 @@
 
-Print the log of the Job-Submission resources identified a list of resource ids. At least one id is required.
+Print the log of the Job-Submission resources identified by a list of resource ids or names. At least one id or name is required.
 
 Usage:
   wbctl logs job-submissions [flags]
diff --git a/doc/wbctl rebuild images.md b/doc/wbctl rebuild images.md
index 23e839b..cfd74b5 100644
--- a/doc/wbctl rebuild images.md	
+++ b/doc/wbctl rebuild images.md	
@@ -1,5 +1,5 @@
 
-Rebuild the Image resources identified by a list of resource ids. At least one id is required.
+Rebuild the Image resources identified by a list of resource ids or names. At least one id or name is required.
 
 Usage:
   wbctl rebuild images [flags]
diff --git a/doc/wbctl start jobs.md b/doc/wbctl start jobs.md
index 395cbf8..086e201 100644
--- a/doc/wbctl start jobs.md	
+++ b/doc/wbctl start jobs.md	
@@ -1,5 +1,5 @@
 
-Start the Job-Submission resources identified by a list of resource ids. At least one id is required.
+Start the Job-Submission resources identified by a list of resource ids or names. At least one id or name is required.
 
 Usage:
   wbctl start jobs [flags]
diff --git a/doc/wbctl start workbenches.md b/doc/wbctl start workbenches.md
index 695437b..510d296 100644
--- a/doc/wbctl start workbenches.md	
+++ b/doc/wbctl start workbenches.md	
@@ -1,5 +1,5 @@
 
-Start the Workbench resources identified by a list of resource ids. At least one id is required.
+Start the Workbench resources identified by a list of resource ids or names. At least one id or name is required.
 
 Usage:
   wbctl start workbenches [flags]
diff --git a/doc/wbctl stop job-submission.md b/doc/wbctl stop job-submission.md
index 6960c95..b4d0f33 100644
--- a/doc/wbctl stop job-submission.md	
+++ b/doc/wbctl stop job-submission.md	
@@ -1,5 +1,5 @@
 
-Cancels the Job-Submission resources identified by a list of resource ids. At least one id is required.
+Cancels the Job-Submission resources identified by a list of resource ids or names. At least one id or name is required.
 
 Usage:
   wbctl stop job-submission [flags]
diff --git a/doc/wbctl stop workbenches.md b/doc/wbctl stop workbenches.md
index 351f827..0a23abe 100644
--- a/doc/wbctl stop workbenches.md	
+++ b/doc/wbctl stop workbenches.md	
@@ -1,5 +1,5 @@
 
-Stop the Workbench resources identified by a list of resource ids. At least one id is required.
+Stop the Workbench resources identified by a list of resource ids or names. At least one id or name is required.
 
 Usage:
   wbctl stop workbenches [flags]
diff --git a/doc/wbctl update images.md b/doc/wbctl update images.md
index e9c198b..d6e54ab 100644
--- a/doc/wbctl update images.md	
+++ b/doc/wbctl update images.md	
@@ -1,5 +1,5 @@
 
-Updates all the Image resources identified by a list of ids with the options provided.
+Updates all the Image resources identified by a list of ids or names with the options provided.
 
 Usage:
   wbctl update images [flags]
diff --git a/doc/wbctl update jobs.md b/doc/wbctl update jobs.md
index ba75d2e..27074be 100644
--- a/doc/wbctl update jobs.md	
+++ b/doc/wbctl update jobs.md	
@@ -1,5 +1,5 @@
 
-Updates all the Job resources identified by a list of ids with the options provided.
+Updates all the Job resources identified by a list of ids or names with the options provided.
 
 Usage:
   wbctl update jobs [flags]
diff --git a/doc/wbctl update snapshots.md b/doc/wbctl update snapshots.md
index 0247ef4..146a972 100644
--- a/doc/wbctl update snapshots.md	
+++ b/doc/wbctl update snapshots.md	
@@ -1,5 +1,5 @@
 
-Updates all the Snapshot resources identified by a list of ids with the options provided.
+Updates all the Snapshot resources identified by a list of ids or names with the options provided.
 
 Usage:
   wbctl update snapshots [flags]
diff --git a/doc/wbctl update volumes.md b/doc/wbctl update volumes.md
index 773668f..1e7b8ea 100644
--- a/doc/wbctl update volumes.md	
+++ b/doc/wbctl update volumes.md	
@@ -1,5 +1,5 @@
 
-Updates all the Volume resources identified by a list of ids with the options provided.
+Updates all the Volume resources identified by a list of ids or names with the options provided.
 
 Usage:
   wbctl update volumes [flags]
diff --git a/doc/wbctl update workbenches.md b/doc/wbctl update workbenches.md
index a0e4b5b..0c6f7a5 100644
--- a/doc/wbctl update workbenches.md	
+++ b/doc/wbctl update workbenches.md	
@@ -1,5 +1,5 @@
 
-Updates all the Workbench resources identified by a list of ids with the options provided.
+Updates all the Workbench resources identified by a list of ids or names with the options provided.
 
 Usage:
   wbctl update workbenches [flags]
diff --git a/internal/cmd/describe/command.go b/internal/cmd/describe/command.go
index 6e8fb7d..a905d5d 100644
--- a/internal/cmd/describe/command.go
+++ b/internal/cmd/describe/command.go
@@ -61,7 +61,7 @@ func getSubcommands(
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.ImageType)},
 			),
 			Long: i18n.Format(
-				i18n.CmdDescribeTypeDescription,
+				i18n.CmdDescribeTypeDescriptionWithName,
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.ImageType)},
 			),
 		},
@@ -75,7 +75,7 @@ func getSubcommands(
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.ImageBuildType)},
 			),
 			Long: i18n.Format(
-				i18n.CmdDescribeTypeDescription,
+				i18n.CmdDescribeTypeDescriptionWithName,
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.ImageBuildType)},
 			),
 		},
@@ -89,7 +89,7 @@ func getSubcommands(
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.JobType)},
 			),
 			Long: i18n.Format(
-				i18n.CmdDescribeTypeDescription,
+				i18n.CmdDescribeTypeDescriptionWithName,
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.JobType)},
 			),
 		},
@@ -103,7 +103,7 @@ func getSubcommands(
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.JobSubmissionType)},
 			),
 			Long: i18n.Format(
-				i18n.CmdDescribeTypeDescription,
+				i18n.CmdDescribeTypeDescriptionWithName,
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.JobSubmissionType)},
 			),
 		},
@@ -117,7 +117,7 @@ func getSubcommands(
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.SnapshotType)},
 			),
 			Long: i18n.Format(
-				i18n.CmdDescribeTypeDescription,
+				i18n.CmdDescribeTypeDescriptionWithName,
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.SnapshotType)},
 			),
 		},
@@ -145,7 +145,7 @@ func getSubcommands(
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.WorkbenchType)},
 			),
 			Long: i18n.Format(
-				i18n.CmdDescribeTypeDescription,
+				i18n.CmdDescribeTypeDescriptionWithName,
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.WorkbenchType)},
 			),
 		},
@@ -181,7 +181,7 @@ func getSubcommands(
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.VolumeType)},
 			),
 			Long: i18n.Format(
-				i18n.CmdDescribeTypeDescription,
+				i18n.CmdDescribeTypeDescriptionWithName,
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.VolumeType)},
 			),
 		},
diff --git a/internal/cmd/get/command.go b/internal/cmd/get/command.go
index 21c5817..2937278 100644
--- a/internal/cmd/get/command.go
+++ b/internal/cmd/get/command.go
@@ -90,7 +90,7 @@ func getSubcommands(
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.ImageType)},
 			),
 			Long: i18n.Format(
-				i18n.CmdGetTypeDescription,
+				i18n.CmdGetTypeDescriptionWithName,
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.ImageType)},
 			),
 		},
@@ -104,7 +104,7 @@ func getSubcommands(
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.ImageBuildType)},
 			),
 			Long: i18n.Format(
-				i18n.CmdGetTypeDescription,
+				i18n.CmdGetTypeDescriptionWithName,
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.ImageBuildType)},
 			),
 		},
@@ -132,7 +132,7 @@ func getSubcommands(
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.JobType)},
 			),
 			Long: i18n.Format(
-				i18n.CmdGetTypeDescription,
+				i18n.CmdGetTypeDescriptionWithName,
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.JobType)},
 			),
 		},
@@ -146,7 +146,7 @@ func getSubcommands(
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.JobSubmissionType)},
 			),
 			Long: i18n.Format(
-				i18n.CmdGetTypeDescription,
+				i18n.CmdGetTypeDescriptionWithName,
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.JobSubmissionType)},
 			),
 		},
@@ -202,7 +202,7 @@ func getSubcommands(
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.SnapshotType)},
 			),
 			Long: i18n.Format(
-				i18n.CmdGetTypeDescription,
+				i18n.CmdGetTypeDescriptionWithName,
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.SnapshotType)},
 			),
 		},
@@ -244,7 +244,7 @@ func getSubcommands(
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.WorkbenchType)},
 			),
 			Long: i18n.Format(
-				i18n.CmdGetTypeDescription,
+				i18n.CmdGetTypeDescriptionWithName,
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.WorkbenchType)},
 			),
 		},
@@ -280,7 +280,7 @@ func getSubcommands(
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.VolumeType)},
 			),
 			Long: i18n.Format(
-				i18n.CmdGetTypeDescription,
+				i18n.CmdGetTypeDescriptionWithName,
 				i18n.A{constants.I18nArgTypeKey: types.Titler.String(constants.VolumeType)},
 			),
 		},
diff --git a/internal/i18n/i18n_messages_workbench_cli.go b/internal/i18n/i18n_messages_workbench_cli.go
index 6f80a4b..f67256d 100644
--- a/internal/i18n/i18n_messages_workbench_cli.go
+++ b/internal/i18n/i18n_messages_workbench_cli.go
@@ -15,7 +15,7 @@ var wbctlMessages = "\n" +
 	"wbctl-cli-icu.client.id.must.be.specified.msg=--client-id must be specified\n" +
 	"wbctl-cli-icu.cmd.api.usage.msg=Directly call Workbench API endpoints\n" +
 	"wbctl-cli-icu.cmd.cancel.desc=Cancels one or many Workbench resources\n" +
-	"wbctl-cli-icu.cmd.cancel.type.description.msg=Cancels the {type} resources identified by a list of resource ids. At least one id is required.\n" +
+	"wbctl-cli-icu.cmd.cancel.type.description.msg=Cancels the {type} resources identified by a list of resource ids or names. At least one id or name is required.\n" +
 	"wbctl-cli-icu.cmd.cancel.type.usage.msg=Cancel one or many {type} resources\n" +
 	"wbctl-cli-icu.cmd.cancel.usage.msg=Cancel running Workbench resources\n" +
 	"wbctl-cli-icu.cmd.create.desc=Creates Workbench resources.\n" +
@@ -23,23 +23,25 @@ var wbctlMessages = "\n" +
 	"wbctl-cli-icu.cmd.create.type.usage.msg=Create a new {type} resource\n" +
 	"wbctl-cli-icu.cmd.create.usage.msg=Create Workbench resources\n" +
 	"wbctl-cli-icu.cmd.delete.desc=Deletes one or many Workbench resources.\n" +
-	"wbctl-cli-icu.cmd.delete.type.description.msg=Deletes the {type} resources identified by a list of resource ids. At least one id is required.\n" +
+	"wbctl-cli-icu.cmd.delete.type.description.msg=Deletes the {type} resources identified by a list of resource ids or names. At least one id or name is required.\n" +
 	"wbctl-cli-icu.cmd.delete.type.usage.msg=Delete one or many {type} resources\n" +
 	"wbctl-cli-icu.cmd.delete.usage.msg=Delete Workbench resources\n" +
 	"wbctl-cli-icu.cmd.describe.desc=Print a detailed description of one or many Workbench resources.\n" +
 	"wbctl-cli-icu.cmd.describe.type.description.msg=Print detailed information about the {type} resources identified by an optional list of resource ids. All resources printed if no ids provided.\n" +
+	"wbctl-cli-icu.cmd.describe.type.description.with.name.msg=Print detailed information about the {type} resources identified by an optional list of resource ids or names. All resources printed if no ids or names provided.\n" +
 	"wbctl-cli-icu.cmd.describe.type.usage.msg=Describe one or many {type} resources\n" +
 	"wbctl-cli-icu.cmd.describe.usage.msg=Describe Workbench resources in detail\n" +
 	"wbctl-cli-icu.cmd.edit.description.msg=Edit a Workbench resource using the default editor.\n" +
-	"wbctl-cli-icu.cmd.edit.type.description.msg=Edit one {type} resource identified by id. One and only one id is required.\n" +
+	"wbctl-cli-icu.cmd.edit.type.description.msg=Edit one {type} resource identified by id or name. One and only one id or name is required.\n" +
 	"wbctl-cli-icu.cmd.edit.type.usage.msg=Edit a {type} resource\n" +
 	"wbctl-cli-icu.cmd.edit.usage.msg=Edit Workbench resources\n" +
 	"wbctl-cli-icu.cmd.get.desc=Print a table of basic information about one or many Workbench resources.\n" +
 	"wbctl-cli-icu.cmd.get.type.description.msg=Print a table of basic information about the {type} resources identified by an optional list of resource ids. All resources printed if no ids provided.\n" +
+	"wbctl-cli-icu.cmd.get.type.description.with.name.msg=Print a table of basic information about the {type} resources identified by an optional list of resource ids or names. All resources printed if no ids or names provided.\n" +
 	"wbctl-cli-icu.cmd.get.type.usage.msg=Get one or many {type} resources\n" +
 	"wbctl-cli-icu.cmd.get.usage.msg=Print Workbench resources\n" +
 	"wbctl-cli-icu.cmd.log.desc=Fetches the logs of one or many Workbench resources\n" +
-	"wbctl-cli-icu.cmd.log.type.description.msg=Print the log of the {type} resources identified a list of resource ids. At least one id is required.\n" +
+	"wbctl-cli-icu.cmd.log.type.description.msg=Print the log of the {type} resources identified by a list of resource ids or names. At least one id or name is required.\n" +
 	"wbctl-cli-icu.cmd.log.type.usage.msg=Print the log of one or many {type} resources\n" +
 	"wbctl-cli-icu.cmd.log.usage.msg=Fetch the logs of Workbench resources\n" +
 	"wbctl-cli-icu.cmd.login.desc=Authenticates with the Workbench API and stores the resulting token in ~/.wbctl/token.json so that subsequent commands do not require repeated authorization.\n" +
@@ -55,21 +57,21 @@ var wbctlMessages = "\n" +
 	"wbctl-cli-icu.cmd.profile.show.usage.msg=Show current profile\n" +
 	"wbctl-cli-icu.cmd.profile.usage.msg=Manage profiles\n" +
 	"wbctl-cli-icu.cmd.rebuild.desc=Rebuilds one or many Workbench resources\n" +
-	"wbctl-cli-icu.cmd.rebuild.type.description.msg=Rebuild the {type} resources identified by a list of resource ids. At least one id is required.\n" +
+	"wbctl-cli-icu.cmd.rebuild.type.description.msg=Rebuild the {type} resources identified by a list of resource ids or names. At least one id or name is required.\n" +
 	"wbctl-cli-icu.cmd.rebuild.type.usage.msg=Rebuild one or many {type} resources\n" +
 	"wbctl-cli-icu.cmd.rebuild.usage.msg=Rebuild Workbench image\n" +
 	"wbctl-cli-icu.cmd.settings.description.msg=Displays or sets user settings.\n" +
 	"wbctl-cli-icu.cmd.settings.usage.msg=Manage user settings\n" +
 	"wbctl-cli-icu.cmd.start.desc=Starts one or many Workbench resources\n" +
-	"wbctl-cli-icu.cmd.start.type.description.msg=Start the {type} resources identified by a list of resource ids. At least one id is required.\n" +
+	"wbctl-cli-icu.cmd.start.type.description.msg=Start the {type} resources identified by a list of resource ids or names. At least one id or name is required.\n" +
 	"wbctl-cli-icu.cmd.start.type.usage.msg=Start one or many {type} resources\n" +
 	"wbctl-cli-icu.cmd.start.usage.msg=Start stopped Workbench resources\n" +
 	"wbctl-cli-icu.cmd.stop.desc=Stops one or many Workbench resources\n" +
-	"wbctl-cli-icu.cmd.stop.type.description.msg=Stop the {type} resources identified by a list of resource ids. At least one id is required.\n" +
+	"wbctl-cli-icu.cmd.stop.type.description.msg=Stop the {type} resources identified by a list of resource ids or names. At least one id or name is required.\n" +
 	"wbctl-cli-icu.cmd.stop.type.usage.msg=Stop one or many {type} resources\n" +
 	"wbctl-cli-icu.cmd.stop.usage.msg=Stop running Workbench resources\n" +
 	"wbctl-cli-icu.cmd.update.desc=Updates one or many Workbench resources.\n" +
-	"wbctl-cli-icu.cmd.update.type.description.msg=Updates all the {type} resources identified by a list of ids with the options provided.\n" +
+	"wbctl-cli-icu.cmd.update.type.description.msg=Updates all the {type} resources identified by a list of ids or names with the options provided.\n" +
 	"wbctl-cli-icu.cmd.update.type.usage.msg=Update one or many {type} resources\n" +
 	"wbctl-cli-icu.cmd.update.usage.msg=Update Workbench resources\n" +
 	"wbctl-cli-icu.cmd.version.description.msg=Print version information of the wbctl binary.\n" +
diff --git a/internal/i18n/logmsg.go b/internal/i18n/logmsg.go
index cd62a74..dc53005 100644
--- a/internal/i18n/logmsg.go
+++ b/internal/i18n/logmsg.go
@@ -40,6 +40,7 @@ const (
 	CmdDeleteUsage                       = BundleID + ".cmd.delete.usage.msg"
 	CmdDescribeDescription               = BundleID + ".cmd.describe.desc"
 	CmdDescribeTypeDescription           = BundleID + ".cmd.describe.type.description.msg"
+	CmdDescribeTypeDescriptionWithName   = BundleID + ".cmd.describe.type.description.with.name.msg"
 	CmdDescribeTypeUsage                 = BundleID + ".cmd.describe.type.usage.msg"
 	CmdDescribeUsage                     = BundleID + ".cmd.describe.usage.msg"
 	CmdEditDescription                   = BundleID + ".cmd.edit.description.msg"
@@ -48,6 +49,7 @@ const (
 	CmdEditUsage                         = BundleID + ".cmd.edit.usage.msg"
 	CmdGetDescription                    = BundleID + ".cmd.get.desc"
 	CmdGetTypeDescription                = BundleID + ".cmd.get.type.description.msg"
+	CmdGetTypeDescriptionWithName        = BundleID + ".cmd.get.type.description.with.name.msg"
 	CmdGetTypeUsage                      = BundleID + ".cmd.get.type.usage.msg"
 	CmdGetUsage                          = BundleID + ".cmd.get.usage.msg"
 	CmdLogDescription                    = BundleID + ".cmd.log.desc"

From 5b369ca42f87b92ef84b1158218d12d5950077b1 Mon Sep 17 00:00:00 2001
From: Luke Duan 
Date: Tue, 3 Nov 2026 11:36:08 -0700
Subject: [PATCH 04/11] chore: fix lint

---
 .../services/workbench/image_builds_test.go   | 10 ++++-
 internal/services/workbench/images_test.go    | 12 ++++--
 .../workbench/job_submissions_test.go         |  4 +-
 internal/services/workbench/jobs_test.go      |  3 +-
 internal/services/workbench/snapshots_test.go |  4 +-
 internal/services/workbench/util.go           | 42 ++++++++++++-------
 internal/services/workbench/util_test.go      | 20 ++++++---
 .../services/workbench/workbenches_test.go    | 24 ++++++++++-
 .../services/workbench/workspaces_test.go     | 24 ++++++++++-
 9 files changed, 111 insertions(+), 32 deletions(-)

diff --git a/internal/services/workbench/image_builds_test.go b/internal/services/workbench/image_builds_test.go
index 6a07a6a..1dd3888 100644
--- a/internal/services/workbench/image_builds_test.go
+++ b/internal/services/workbench/image_builds_test.go
@@ -244,7 +244,9 @@ func TestGetImageBuilds(t *testing.T) {
 				sort: "id.asc",
 			},
 			mockEndpoints: getImageBuildsGetEndpoints(
-				"or=(id.in.(123e4567-e89b-12d3-a456-426614174000),controller_id.in.(controller-build-2,build-name),name.in.(123e4567-e89b-12d3-a456-426614174000,controller-build-2,build-name))&order=id.asc",
+				"or=(id.in.(123e4567-e89b-12d3-a456-426614174000),"+
+					"controller_id.in.(controller-build-2,build-name),"+
+					"name.in.(123e4567-e89b-12d3-a456-426614174000,controller-build-2,build-name))&order=id.asc",
 				http.StatusOK,
 				imageBuildsGetMixedIDsResponseJSON,
 			),
@@ -323,7 +325,11 @@ func TestGetImageBuilds(t *testing.T) {
 			request: getRequest{
 				ids: []string{"id-a"},
 			},
-			mockEndpoints: getImageBuildsGetEndpoints("or=(controller_id.in.(id-a),name.in.(id-a))", http.StatusUnauthorized, `unauthorized`),
+			mockEndpoints: getImageBuildsGetEndpoints(
+				"or=(controller_id.in.(id-a),name.in.(id-a))",
+				http.StatusUnauthorized,
+				`unauthorized`,
+			),
 			expected: getExpected[types.ImageBuilds]{
 				err: "GET ${URL}/postgrest/image_builds?or=%28controller_id.in.%28id-a%29%2Cname.in.%28id-a%29%29 failed\n" +
 					" Caused by:\n" +
diff --git a/internal/services/workbench/images_test.go b/internal/services/workbench/images_test.go
index 7c41822..6022146 100644
--- a/internal/services/workbench/images_test.go
+++ b/internal/services/workbench/images_test.go
@@ -695,7 +695,9 @@ func TestGetImages(t *testing.T) {
 				sort: "id.asc",
 			},
 			mockEndpoints: getImagesGetEndpoints(
-				"or=(id.in.(123e4567-e89b-12d3-a456-426614174000),controller_id.in.(controller-app-2,image-name),name.in.(123e4567-e89b-12d3-a456-426614174000,controller-app-2,image-name))&order=id.asc",
+				"or=(id.in.(123e4567-e89b-12d3-a456-426614174000),"+
+					"controller_id.in.(controller-app-2,image-name),"+
+					"name.in.(123e4567-e89b-12d3-a456-426614174000,controller-app-2,image-name))&order=id.asc",
 				http.StatusOK,
 				imagesGetMixedResponseJSON,
 			),
@@ -787,9 +789,11 @@ func TestGetImages(t *testing.T) {
 					"\t* unexpected EOF"},
 		},
 		{
-			name:          "get images non-200 from server",
-			request:       getRequest{ids: []string{"id-a"}},
-			mockEndpoints: getImagesGetEndpoints("or=(controller_id.in.(id-a),name.in.(id-a))", http.StatusUnauthorized, "unauthorized"),
+			name:    "get images non-200 from server",
+			request: getRequest{ids: []string{"id-a"}},
+			mockEndpoints: getImagesGetEndpoints(
+				"or=(controller_id.in.(id-a),name.in.(id-a))", http.StatusUnauthorized, "unauthorized",
+			),
 			expected: getExpected[types.Images]{
 				err: "GET ${URL}/postgrest/images?or=%28controller_id.in.%28id-a%29%2Cname.in.%28id-a%29%29 failed\n" +
 					" Caused by:\n" +
diff --git a/internal/services/workbench/job_submissions_test.go b/internal/services/workbench/job_submissions_test.go
index 46b2d01..6f2dafc 100644
--- a/internal/services/workbench/job_submissions_test.go
+++ b/internal/services/workbench/job_submissions_test.go
@@ -531,7 +531,9 @@ func TestGetJobSubmissions(t *testing.T) {
 				ids: []string{"123e4567-e89b-12d3-a456-426614174000", "controller-job-2", "job-name-job"},
 			},
 			mockEndpoints: getJobSubmissionsGetEndpoints(
-				"or=(id.in.(123e4567-e89b-12d3-a456-426614174000),controller_id.in.(controller-job-2,job-name-job),job_name.in.(123e4567-e89b-12d3-a456-426614174000,controller-job-2,job-name-job))",
+				"or=(id.in.(123e4567-e89b-12d3-a456-426614174000),"+
+					"controller_id.in.(controller-job-2,job-name-job),"+
+					"job_name.in.(123e4567-e89b-12d3-a456-426614174000,controller-job-2,job-name-job))",
 				http.StatusOK,
 				jobSubmissionsGetMixedIDsResponseJSON,
 			),
diff --git a/internal/services/workbench/jobs_test.go b/internal/services/workbench/jobs_test.go
index bd81e49..9c3c128 100644
--- a/internal/services/workbench/jobs_test.go
+++ b/internal/services/workbench/jobs_test.go
@@ -586,7 +586,8 @@ func TestGetJobs(t *testing.T) {
 				sort: "id.asc",
 			},
 			mockEndpoints: getJobsGetEndpoints(
-				"or=(id.in.(123e4567-e89b-12d3-a456-426614174000),name.in.(123e4567-e89b-12d3-a456-426614174000,job-name))&order=id.asc",
+				"or=(id.in.(123e4567-e89b-12d3-a456-426614174000),"+
+					"name.in.(123e4567-e89b-12d3-a456-426614174000,job-name))&order=id.asc",
 				http.StatusOK,
 				jobsGetMixedResponseJSON,
 			),
diff --git a/internal/services/workbench/snapshots_test.go b/internal/services/workbench/snapshots_test.go
index 7f64e6a..53927fa 100644
--- a/internal/services/workbench/snapshots_test.go
+++ b/internal/services/workbench/snapshots_test.go
@@ -596,7 +596,9 @@ func TestGetSnapshots(t *testing.T) {
 				sort: "id.asc",
 			},
 			mockEndpoints: getSnapshotsGetEndpoints(
-				"or=(id.in.(123e4567-e89b-12d3-a456-426614174000),controller_id.in.(controller-snapshot-2,snapshot-name),name.in.(123e4567-e89b-12d3-a456-426614174000,controller-snapshot-2,snapshot-name))&order=id.asc",
+				"or=(id.in.(123e4567-e89b-12d3-a456-426614174000),"+
+					"controller_id.in.(controller-snapshot-2,snapshot-name),"+
+					"name.in.(123e4567-e89b-12d3-a456-426614174000,controller-snapshot-2,snapshot-name))&order=id.asc",
 				http.StatusOK,
 				snapshotsGetMixedIDsResponseJSON,
 			),
diff --git a/internal/services/workbench/util.go b/internal/services/workbench/util.go
index 5e7a57f..94dd9ca 100644
--- a/internal/services/workbench/util.go
+++ b/internal/services/workbench/util.go
@@ -105,21 +105,7 @@ func selectByIDThenName[T any, TS ~[]T](
 		if arg == "" {
 			continue
 		}
-		var byID, byName []int
-		for i, resource := range resources {
-			switch {
-			case getIDFunc(resource) == arg ||
-				(getControllerIDFunc != nil && getControllerIDFunc(resource) == arg):
-				byID = append(byID, i)
-			case getNameFunc(resource) == arg:
-				byName = append(byName, i)
-			}
-		}
-		matches := byID
-		if len(matches) == 0 {
-			matches = byName
-		}
-		for _, i := range matches {
+		for _, i := range matchIndexesByIDThenName(arg, resources, getIDFunc, getControllerIDFunc, getNameFunc) {
 			selected[i] = true
 		}
 	}
@@ -132,6 +118,32 @@ func selectByIDThenName[T any, TS ~[]T](
 	return filtered
 }
 
+// matchIndexesByIDThenName returns the indexes of the resources matching args.
+// Resources matching by ID or controller ID take precedence over those
+// matching by name.
+func matchIndexesByIDThenName[T any, TS ~[]T](
+	arg string,
+	resources TS,
+	getIDFunc func(T) string,
+	getControllerIDFunc func(T) string,
+	getNameFunc func(T) string,
+) []int {
+	var byID, byName []int
+	for i, resource := range resources {
+		switch {
+		case getIDFunc(resource) == arg ||
+			(getControllerIDFunc != nil && getControllerIDFunc(resource) == arg):
+			byID = append(byID, i)
+		case getNameFunc(resource) == arg:
+			byName = append(byName, i)
+		}
+	}
+	if len(byID) > 0 {
+		return byID
+	}
+	return byName
+}
+
 // resolveIDs converts the given args, which may be ids, controller_ids, or names,
 // into the database ids of the matching resources.
 func resolveIDs[T any, TS ~[]T](
diff --git a/internal/services/workbench/util_test.go b/internal/services/workbench/util_test.go
index 87538e0..dfb97f8 100644
--- a/internal/services/workbench/util_test.go
+++ b/internal/services/workbench/util_test.go
@@ -61,7 +61,8 @@ func TestIDsControllerIDsOrNamesQuery(t *testing.T) {
 			sort: "id.asc",
 			expected: url.Values{
 				"or": []string{
-					"(id.in.(123e4567-e89b-12d3-a456-426614174000,123e4567-e89b-12d3-a456-426614174001),name.in.(123e4567-e89b-12d3-a456-426614174000,123e4567-e89b-12d3-a456-426614174001))",
+					"(id.in.(123e4567-e89b-12d3-a456-426614174000,123e4567-e89b-12d3-a456-426614174001)," +
+						"name.in.(123e4567-e89b-12d3-a456-426614174000,123e4567-e89b-12d3-a456-426614174001))",
 				},
 				"order": []string{"id.asc"},
 			},
@@ -80,7 +81,9 @@ func TestIDsControllerIDsOrNamesQuery(t *testing.T) {
 			ids:  []string{"123e4567-e89b-12d3-a456-426614174000", "controller-a", "name-a"},
 			expected: url.Values{
 				"or": []string{
-					"(id.in.(123e4567-e89b-12d3-a456-426614174000),controller_id.in.(controller-a,name-a),name.in.(123e4567-e89b-12d3-a456-426614174000,controller-a,name-a))",
+					"(id.in.(123e4567-e89b-12d3-a456-426614174000)," +
+						"controller_id.in.(controller-a,name-a)," +
+						"name.in.(123e4567-e89b-12d3-a456-426614174000,controller-a,name-a))",
 				},
 			},
 		},
@@ -112,7 +115,8 @@ func TestIDsOrNamesQuery(t *testing.T) {
 			sort: "id.asc",
 			expected: url.Values{
 				"or": []string{
-					"(id.in.(123e4567-e89b-12d3-a456-426614174000,123e4567-e89b-12d3-a456-426614174001),name.in.(123e4567-e89b-12d3-a456-426614174000,123e4567-e89b-12d3-a456-426614174001))",
+					"(id.in.(123e4567-e89b-12d3-a456-426614174000,123e4567-e89b-12d3-a456-426614174001)," +
+						"name.in.(123e4567-e89b-12d3-a456-426614174000,123e4567-e89b-12d3-a456-426614174001))",
 				},
 				"order": []string{"id.asc"},
 			},
@@ -129,7 +133,8 @@ func TestIDsOrNamesQuery(t *testing.T) {
 			ids:  []string{"123e4567-e89b-12d3-a456-426614174000", "name-a"},
 			expected: url.Values{
 				"or": []string{
-					"(id.in.(123e4567-e89b-12d3-a456-426614174000),name.in.(123e4567-e89b-12d3-a456-426614174000,name-a))",
+					"(id.in.(123e4567-e89b-12d3-a456-426614174000)," +
+						"name.in.(123e4567-e89b-12d3-a456-426614174000,name-a))",
 				},
 			},
 		},
@@ -161,7 +166,8 @@ func TestIDsControllerIDsOrJobNamesQuery(t *testing.T) {
 			sort: "id.asc",
 			expected: url.Values{
 				"or": []string{
-					"(id.in.(123e4567-e89b-12d3-a456-426614174000,123e4567-e89b-12d3-a456-426614174001),job_name.in.(123e4567-e89b-12d3-a456-426614174000,123e4567-e89b-12d3-a456-426614174001))",
+					"(id.in.(123e4567-e89b-12d3-a456-426614174000,123e4567-e89b-12d3-a456-426614174001)," +
+						"job_name.in.(123e4567-e89b-12d3-a456-426614174000,123e4567-e89b-12d3-a456-426614174001))",
 				},
 				"order": []string{"id.asc"},
 			},
@@ -180,7 +186,9 @@ func TestIDsControllerIDsOrJobNamesQuery(t *testing.T) {
 			ids:  []string{"123e4567-e89b-12d3-a456-426614174000", "controller-a", "job-a"},
 			expected: url.Values{
 				"or": []string{
-					"(id.in.(123e4567-e89b-12d3-a456-426614174000),controller_id.in.(controller-a,job-a),job_name.in.(123e4567-e89b-12d3-a456-426614174000,controller-a,job-a))",
+					"(id.in.(123e4567-e89b-12d3-a456-426614174000)," +
+						"controller_id.in.(controller-a,job-a)," +
+						"job_name.in.(123e4567-e89b-12d3-a456-426614174000,controller-a,job-a))",
 				},
 			},
 		},
diff --git a/internal/services/workbench/workbenches_test.go b/internal/services/workbench/workbenches_test.go
index a261bbc..532405c 100644
--- a/internal/services/workbench/workbenches_test.go
+++ b/internal/services/workbench/workbenches_test.go
@@ -590,6 +590,26 @@ func TestBulkUpdateWorkbenches(t *testing.T) {
 					i18n.A{constants.I18nArgKeyType: constants.WorkbenchesType}),
 			},
 		},
+		{
+			name: "bulk update workbenches with no matching resources returns not found",
+			request: bulkupdateRequest[types.Workbench]{
+				ids:  []string{"missing"},
+				body: types.Workbench{DesiredState: "STOPPED"},
+			},
+			mockEndpoints: getWorkbenchesBulkUpdateEndpoints(
+				`or=(controller_id.in.(missing),name.in.(missing))`,
+				`[]`,
+				"",
+				http.StatusOK,
+				"",
+			),
+			expected: bulkupdateExpected[types.Workbenches]{
+				err: i18n.Format(i18n.ResourceNotFoundWithID, i18n.A{
+					constants.I18nArgTypeKey: constants.WorkbenchType,
+					constants.I18nArgKeyID:   "missing",
+				}),
+			},
+		},
 		{
 			name: "update single workbench name invalid response",
 			request: bulkupdateRequest[types.Workbench]{
@@ -646,7 +666,9 @@ func TestGetWorkbenches(t *testing.T) {
 				sort: "id.asc",
 			},
 			mockEndpoints: getWorkbenchesGetEndpoints(
-				"or=(id.in.(123e4567-e89b-12d3-a456-426614174000),controller_id.in.(controller-wb-2,workbench-name),name.in.(123e4567-e89b-12d3-a456-426614174000,controller-wb-2,workbench-name))&order=id.asc",
+				"or=(id.in.(123e4567-e89b-12d3-a456-426614174000),"+
+					"controller_id.in.(controller-wb-2,workbench-name),"+
+					"name.in.(123e4567-e89b-12d3-a456-426614174000,controller-wb-2,workbench-name))&order=id.asc",
 				http.StatusOK,
 				workbenchesGetMixedIDsResponseJSON,
 			),
diff --git a/internal/services/workbench/workspaces_test.go b/internal/services/workbench/workspaces_test.go
index 941233f..9b9dbc7 100644
--- a/internal/services/workbench/workspaces_test.go
+++ b/internal/services/workbench/workspaces_test.go
@@ -564,6 +564,26 @@ func TestBulkUpdateWorkspaces(t *testing.T) {
 					i18n.A{constants.I18nArgKeyType: constants.VolumesType}),
 			},
 		},
+		{
+			name: "bulk update workspaces with no matching resources returns not found",
+			request: bulkupdateRequest[types.Workspace]{
+				ids:  []string{"missing"},
+				body: types.Workspace{Description: "updated-description"},
+			},
+			mockEndpoints: getWorkspaceBulkUpdateEndpoints(
+				`or=(controller_id.in.(missing),name.in.(missing))`,
+				`[]`,
+				"",
+				http.StatusOK,
+				"",
+			),
+			expected: bulkupdateExpected[types.Workspaces]{
+				err: i18n.Format(i18n.ResourceNotFoundWithID, i18n.A{
+					constants.I18nArgTypeKey: constants.VolumeType,
+					constants.I18nArgKeyID:   "missing",
+				}),
+			},
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
@@ -604,7 +624,9 @@ func TestGetWorkspaces(t *testing.T) {
 				sort: "id.asc",
 			},
 			mockEndpoints: getWorkspaceGetEndpoints(
-				"or=(id.in.(123e4567-e89b-12d3-a456-426614174000),controller_id.in.(controller-ws-2,ws-name),name.in.(123e4567-e89b-12d3-a456-426614174000,controller-ws-2,ws-name))&order=id.asc",
+				"or=(id.in.(123e4567-e89b-12d3-a456-426614174000),"+
+					"controller_id.in.(controller-ws-2,ws-name),"+
+					"name.in.(123e4567-e89b-12d3-a456-426614174000,controller-ws-2,ws-name))&order=id.asc",
 				http.StatusOK,
 				workspacesGetMixedIDsResponseJSON,
 			),

From a94884117aa3c058d7425d25ee96f2efeaf2e6c6 Mon Sep 17 00:00:00 2001
From: Luke Duan 
Date: Tue, 3 Nov 2026 12:27:40 -0700
Subject: [PATCH 05/11] chore: add nolint's

---
 internal/services/workbench/job_submissions.go | 1 +
 internal/services/workbench/snapshots.go       | 1 +
 internal/services/workbench/workbenches.go     | 1 +
 internal/services/workbench/workspaces.go      | 1 +
 4 files changed, 4 insertions(+)

diff --git a/internal/services/workbench/job_submissions.go b/internal/services/workbench/job_submissions.go
index bffffa7..00f8151 100644
--- a/internal/services/workbench/job_submissions.go
+++ b/internal/services/workbench/job_submissions.go
@@ -23,6 +23,7 @@ import (
 // GetJobSubmissions  gets the job submissions in the given WorkbenchService. If
 // given any ids/names, only the matching resources will be returned. Otherwise, all
 // resources are returned.
+// nolint:dupl
 func GetJobSubmissions(
 	ctx context.Context,
 	s *WorkbenchService,
diff --git a/internal/services/workbench/snapshots.go b/internal/services/workbench/snapshots.go
index bc9ee9d..e4d3c22 100644
--- a/internal/services/workbench/snapshots.go
+++ b/internal/services/workbench/snapshots.go
@@ -24,6 +24,7 @@ import (
 // GetSnapshots gets the snapshots in the given WorkbenchService. If given any
 // ids/names, only the matching resources will be returned. Otherwise, all resources
 // are returned.
+// nolint:dupl
 func GetSnapshots(
 	ctx context.Context,
 	s *WorkbenchService,
diff --git a/internal/services/workbench/workbenches.go b/internal/services/workbench/workbenches.go
index af01a2d..0fb435e 100644
--- a/internal/services/workbench/workbenches.go
+++ b/internal/services/workbench/workbenches.go
@@ -22,6 +22,7 @@ import (
 // GetWorkbenches gets the workbenches in the given WorkbenchService. If given
 // any ids/names, only the matching resources will be returned. Otherwise, all
 // resources are returned.
+// nolint:dupl
 func GetWorkbenches(
 	ctx context.Context,
 	s *WorkbenchService,
diff --git a/internal/services/workbench/workspaces.go b/internal/services/workbench/workspaces.go
index c290147..8a3e056 100644
--- a/internal/services/workbench/workspaces.go
+++ b/internal/services/workbench/workspaces.go
@@ -23,6 +23,7 @@ import (
 // GetWorkspaces gets the workspaces in the given WorkbenchService. If given any
 // ids/names, only the matching resources will be returned. Otherwise, all resources
 // are returned.
+// nolint:dupl
 func GetWorkspaces(
 	ctx context.Context,
 	s *WorkbenchService,

From 240c478ce12feb66a2919f059b4db9ee759d21b7 Mon Sep 17 00:00:00 2001
From: Luke Duan 
Date: Tue, 3 Nov 2026 15:09:07 -0700
Subject: [PATCH 06/11] test: additional test adjustments

---
 internal/cmd/cancel/cancel_test.go            |   6 +-
 internal/cmd/create/image/image_test.go       |  30 ++++-
 internal/cmd/create/job/job_test.go           |  51 ++++++--
 .../job_submission/job_submission_test.go     |  17 +--
 internal/cmd/create/snapshot/snapshot_test.go |  59 ++++++++--
 .../cmd/create/workbench/workbench_test.go    |  71 ++++++++---
 .../cmd/create/workspace/workspace_test.go    |  57 +++++++--
 internal/cmd/delete/delete_test.go            | 111 +++++++++++++++++-
 internal/cmd/describe/describe_test.go        |  14 +--
 internal/cmd/edit/edit_test.go                |  24 ++--
 internal/services/workbench/images_test.go    |  52 ++++----
 .../workbench/job_submissions_test.go         |  26 ++--
 internal/services/workbench/jobs_test.go      |  52 ++++----
 internal/services/workbench/snapshots_test.go |  52 ++++----
 internal/services/workbench/workbench_test.go |  11 +-
 .../services/workbench/workbenches_test.go    |  78 ++++++------
 .../services/workbench/workspaces_test.go     |  78 ++++++------
 internal/stubs/common_endpoints.go            |   4 +-
 18 files changed, 541 insertions(+), 252 deletions(-)

diff --git a/internal/cmd/cancel/cancel_test.go b/internal/cmd/cancel/cancel_test.go
index d97c9fd..4bd2047 100644
--- a/internal/cmd/cancel/cancel_test.go
+++ b/internal/cmd/cancel/cancel_test.go
@@ -35,7 +35,8 @@ const (
 	cancelTestControllerID1        = "controller-1"
 	cancelTestControllerID2        = "controller-2"
 	cancelTestGetJobSubmissionsErr = "" +
-		"GET ${URL}/postgrest/job_runs?controller_id=in.%28controller-1%2Ccontroller-2%29 failed" +
+		"GET ${URL}/postgrest/job_runs?or=%28controller_id.in.%28controller-1%2Ccontroller-2%29" +
+		"%2Cjob_name.in.%28controller-1%2Ccontroller-2%29%29 failed" +
 		"\n Caused by:" +
 		"\n\t* unexpected EOF"
 	cancelTestBulkCancelErr = "" +
@@ -51,7 +52,8 @@ const (
 	expectedInvalidEndpointErr              = "failed to get device token" +
 		"\n Caused by:" +
 		"\n\t* parse \"a\\nb\": net/url: invalid control character in URL"
-	cancelExpectedGetQuery = "controller_id=in.(controller-1,controller-2)"
+	cancelExpectedGetQuery = "or=(controller_id.in.(controller-1,controller-2)," +
+		"job_name.in.(controller-1,controller-2))"
 )
 
 var cancelExpectedDisplaysStoppedIDs = []string{
diff --git a/internal/cmd/create/image/image_test.go b/internal/cmd/create/image/image_test.go
index 0509696..dc9fe36 100644
--- a/internal/cmd/create/image/image_test.go
+++ b/internal/cmd/create/image/image_test.go
@@ -56,6 +56,8 @@ const (
 	expectedCreateImageServerErr         = "POST ${URL}/postgrest/rpc/image_create failed\n" +
 		" Caused by:\n" +
 		"\t* 401 unauthorized"
+	expectedResolveImagesQueryOne   = `or=(controller_id.in.(img-cli-1),name.in.(img-cli-1))`
+	expectedResolveImagesQueryTwo   = `or=(controller_id.in.(img-cli-1,img-cli-2),name.in.(img-cli-1,img-cli-2))`
 	expectedUpdateImageDryRunOutput = `{
     "ids": [
         "img-cli-1",
@@ -120,7 +122,7 @@ const (
 	expectedInvalidEndpointErr = "failed to get device token" +
 		"\n Caused by:" +
 		"\n\t* parse \"a\\nb\": net/url: invalid control character in URL"
-	expectedInvlidDataplaneErr = "GET ${URL}/postgrest/dataplanes?id=in.%28invalid%29 failed" +
+	expectedInvalidDataplaneErr = "GET ${URL}/postgrest/dataplanes?id=in.%28invalid%29 failed" +
 		"\n Caused by:" +
 		"\n\t* 400 error"
 	expectedInvalidSpecErr            = "invalid character 'i' looking for beginning of value"
@@ -374,7 +376,7 @@ func TestCreateImageCommand(t *testing.T) {
 				dataplanesResponseBody:   `error`,
 			}),
 			expected: createExpected{
-				err:            expectedInvlidDataplaneErr,
+				err:            expectedInvalidDataplaneErr,
 				displays:       []string{stubs.ExpectedDeviceAuthDisplay},
 				dataplaneCalls: 1,
 			},
@@ -546,6 +548,8 @@ func TestUpdateImageCommand(t *testing.T) {
 			name:  "update success prints IDs only",
 			input: updateInput{args: []string{"img-cli-1"}},
 			mockEndpoints: getUpdateImageEndpoints(
+				expectedResolveImagesQueryOne,
+				`[{"id":"img-cli-1"}]`,
 				expectedUpdateImageRequestBodyIDsOnly,
 				http.StatusOK,
 				`[{"id":"img-a"},{"id":"img-b"}]`,
@@ -559,6 +563,8 @@ func TestUpdateImageCommand(t *testing.T) {
 			name:  "server error",
 			input: updateInput{args: []string{"img-cli-1"}},
 			mockEndpoints: getUpdateImageEndpoints(
+				expectedResolveImagesQueryOne,
+				`[{"id":"img-cli-1"}]`,
 				expectedUpdateImageRequestBodyIDsOnly,
 				http.StatusUnauthorized,
 				`unauthorized`,
@@ -594,6 +600,8 @@ func TestUpdateImageCommand(t *testing.T) {
 			name:  "ids only with empty body accepted",
 			input: updateInput{args: []string{"img-cli-1"}},
 			mockEndpoints: getUpdateImageEndpoints(
+				expectedResolveImagesQueryOne,
+				`[{"id":"img-cli-1"}]`,
 				expectedUpdateImageRequestBodyIDsOnly,
 				http.StatusOK,
 				`[{"id":"img-cli-1"}]`,
@@ -614,6 +622,8 @@ func TestUpdateImageCommand(t *testing.T) {
 				args:        []string{"img-cli-1", "img-cli-2"},
 			},
 			mockEndpoints: getUpdateImageEndpoints(
+				expectedResolveImagesQueryTwo,
+				`[{"id":"img-cli-1"},{"id":"img-cli-2"}]`,
 				expectedUpdateImageRequestBodyMapping,
 				http.StatusOK,
 				`[{"id":"img-cli-1"},{"id":"img-cli-2"}]`,
@@ -636,6 +646,8 @@ func TestUpdateImageCommand(t *testing.T) {
 				args:        []string{"img-cli-1"},
 			},
 			mockEndpoints: getUpdateImageEndpoints(
+				expectedResolveImagesQueryOne,
+				`[{"id":"img-cli-1"}]`,
 				expectedUpdateImageRequestBodyAllFlags,
 				http.StatusOK,
 				`[{"id":"img-cli-1"}]`,
@@ -806,12 +818,26 @@ func getUpdateArgs(
 }
 
 func getUpdateImageEndpoints(
+	expectedResolveQuery string,
+	resolveResponseBody string,
 	expectedRequestBody string,
 	responseStatus int,
 	responseBody string,
 ) []stubs.StubHTTPEndpoint {
 	return append(stubs.GetStubAuthEndpoints(),
 		[]stubs.StubHTTPEndpoint{
+			{
+				Request: stubs.StubHTTPRequest{
+					Method:          http.MethodGet,
+					URL:             postgrest.Endpoint(constants.WorkbenchEndpointImages),
+					ExpectedHeaders: stubs.StubAuthHeaders,
+					ExpectedQuery:   expectedResolveQuery,
+				},
+				Response: stubs.StubHTTPResponse{
+					Status: http.StatusOK,
+					Body:   resolveResponseBody,
+				},
+			},
 			{
 				Request: stubs.StubHTTPRequest{
 					Method:          http.MethodPost,
diff --git a/internal/cmd/create/job/job_test.go b/internal/cmd/create/job/job_test.go
index 07aea10..252e10a 100644
--- a/internal/cmd/create/job/job_test.go
+++ b/internal/cmd/create/job/job_test.go
@@ -54,6 +54,8 @@ const (
 		`"description":"R app","services":["svc-a","svc-b"]}]`
 	expectedServicesResponseBody = `[{"id":"service-1","name":"service 1"},` +
 		`{"id":"service-2","name":"service 2"}]`
+	expectedResolveImagesQueryOne           = `or=(name.in.(job-cli-1))`
+	expectedResolveImagesQueryTwo           = `or=(name.in.(job-cli-1,job-cli-2))`
 	expectedCreateJobRequestBodyWithContext = `
 {
   "param": {
@@ -388,13 +390,15 @@ const (
 	expectedInvalidEndpointErr = "failed to get device token" +
 		"\n Caused by:" +
 		"\n\t* parse \"a\\nb\": net/url: invalid control character in URL"
-	expectedInvlidDataplaneErr = "GET ${URL}/postgrest/dataplanes?id=in.%28invalid%29 failed" +
+	expectedInvalidDataplaneErr = "GET ${URL}/postgrest/dataplanes?id=in.%28invalid%29 failed" +
 		"\n Caused by:" +
 		"\n\t* 400 error"
-	expectedInvlidWorkspaceErr = "GET ${URL}/postgrest/workspaces?controller_id=in.%28invalid%29 failed" +
+	expectedInvalidWorkspaceErr = "GET ${URL}/postgrest/workspaces" +
+		"?or=%28controller_id.in.%28invalid%29%2Cname.in.%28invalid%29%29 failed" +
 		"\n Caused by:" +
 		"\n\t* 400 error"
-	expectedInvlidApplicationErr = "GET ${URL}/postgrest/dataplane_workbench_applications?id=in.%28invalid%29 failed" +
+	expectedInvalidApplicationErr = "GET ${URL}/postgrest/dataplane_workbench_applications" +
+		"?id=in.%28invalid%29 failed" +
 		"\n Caused by:" +
 		"\n\t* 400 error"
 	expectedCreateImageInvalidFileErr = "unexpected end of JSON input"
@@ -763,7 +767,7 @@ func TestCreateJobCommand(t *testing.T) {
 			}),
 			expected: createExpected{
 				displays:        []string{stubs.ExpectedDeviceAuthDisplay},
-				err:             expectedInvlidDataplaneErr,
+				err:             expectedInvalidDataplaneErr,
 				dataplanesCalls: 1,
 			},
 		},
@@ -775,7 +779,7 @@ func TestCreateJobCommand(t *testing.T) {
 			},
 			mockEndpoints: getCreateJobEndpoints(jobEndpointsConfig{
 				workspaceConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(invalid)",
+					ExpectedQuery:  "or=(controller_id.in.(invalid),name.in.(invalid))",
 					ResponseBody:   `error`,
 					ResponseStatus: http.StatusBadRequest,
 				},
@@ -783,7 +787,7 @@ func TestCreateJobCommand(t *testing.T) {
 			expected: createExpected{
 				displays:        []string{stubs.ExpectedDeviceAuthDisplay},
 				workspacesCalls: 1,
-				err:             expectedInvlidWorkspaceErr,
+				err:             expectedInvalidWorkspaceErr,
 			},
 		},
 		{
@@ -802,7 +806,7 @@ func TestCreateJobCommand(t *testing.T) {
 			expected: createExpected{
 				displays:          []string{stubs.ExpectedDeviceAuthDisplay},
 				applicationsCalls: 1,
-				err:               expectedInvlidApplicationErr,
+				err:               expectedInvalidApplicationErr,
 			},
 		},
 	}
@@ -1033,6 +1037,11 @@ func TestUpdateJobCommand(t *testing.T) {
 				args: []string{"job-cli-1"},
 			},
 			mockEndpoints: getUpdateJobEndpoints(jobEndpointsConfig{
+				jobsConfig: stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedResolveImagesQueryOne,
+					ResponseBody:   `[{"id":"job-cli-1"}]`,
+					ResponseStatus: http.StatusOK,
+				},
 				jobConfig: stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: expectedUpdateJobRequestBodyIDsOnly,
 					ResponseStatus:      http.StatusOK,
@@ -1050,6 +1059,11 @@ func TestUpdateJobCommand(t *testing.T) {
 				args: []string{"job-cli-1"},
 			},
 			mockEndpoints: getUpdateJobEndpoints(jobEndpointsConfig{
+				jobsConfig: stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedResolveImagesQueryOne,
+					ResponseBody:   `[{"id":"job-cli-1"}]`,
+					ResponseStatus: http.StatusOK,
+				},
 				jobConfig: stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: expectedUpdateJobRequestBodyIDsOnly,
 					ResponseStatus:      http.StatusUnauthorized,
@@ -1096,6 +1110,11 @@ func TestUpdateJobCommand(t *testing.T) {
 				args:        []string{"job-cli-1", "job-cli-2"},
 			},
 			mockEndpoints: getUpdateJobEndpoints(jobEndpointsConfig{
+				jobsConfig: stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedResolveImagesQueryTwo,
+					ResponseBody:   `[{"id":"job-cli-1"},{"id":"job-cli-2"}]`,
+					ResponseStatus: http.StatusOK,
+				},
 				jobConfig: stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: expectedUpdateJobRequestBodyMapping,
 					ResponseStatus:      http.StatusOK,
@@ -1125,6 +1144,11 @@ func TestUpdateJobCommand(t *testing.T) {
 				args:        []string{"job-cli-1"},
 			},
 			mockEndpoints: getUpdateJobEndpoints(jobEndpointsConfig{
+				jobsConfig: stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedResolveImagesQueryOne,
+					ResponseBody:   `[{"id":"job-cli-1"}]`,
+					ResponseStatus: http.StatusOK,
+				},
 				jobConfig: stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: expectedUpdateJobRequestBodyAllFlags,
 					ResponseStatus:      http.StatusOK,
@@ -1341,6 +1365,7 @@ type jobEndpointsConfig struct {
 	servicesConfig     stubs.StubHTTPPartialGetConfig
 	nodepoolsConfig    stubs.StubHTTPPartialGetConfig
 	workspaceConfig    stubs.StubHTTPPartialGetConfig
+	jobsConfig         stubs.StubHTTPPartialGetConfig
 	jobConfig          stubs.StubHTTPPartialPostConfig
 }
 
@@ -1353,6 +1378,18 @@ func getJobEndpoints(
 	endpoints = append(endpoints, stubs.GetStubApplicationsEndpoints(config.applicationsConfig)...)
 	endpoints = append(endpoints, stubs.GetStubServicesEndpoints(config.servicesConfig)...)
 	endpoints = append(endpoints, stubs.GetStubNodepoolsEndpoints(config.nodepoolsConfig)...)
+	endpoints = append(endpoints, stubs.StubHTTPEndpoint{
+		Request: stubs.StubHTTPRequest{
+			Method:          http.MethodGet,
+			URL:             postgrest.Endpoint(constants.WorkbenchEndpointJobs),
+			ExpectedHeaders: stubs.StubAuthHeaders,
+			ExpectedQuery:   config.jobsConfig.ExpectedQuery,
+		},
+		Response: stubs.StubHTTPResponse{
+			Status: config.jobsConfig.ResponseStatus,
+			Body:   config.jobsConfig.ResponseBody,
+		},
+	})
 	return append(endpoints,
 		[]stubs.StubHTTPEndpoint{
 			{
diff --git a/internal/cmd/create/job_submission/job_submission_test.go b/internal/cmd/create/job_submission/job_submission_test.go
index ede4a00..3c75d52 100644
--- a/internal/cmd/create/job_submission/job_submission_test.go
+++ b/internal/cmd/create/job_submission/job_submission_test.go
@@ -44,10 +44,11 @@ const (
 	expectedCreateJobInvalidEndpointErr            = "failed to get device token" +
 		"\n Caused by:" +
 		"\n\t* parse \"a\\nb\": net/url: invalid control character in URL"
-	expectedInvlidDataplaneErr = "GET ${URL}/postgrest/dataplanes?id=in.%28invalid%29 failed" +
+	expectedInvalidDataplaneErr = "GET ${URL}/postgrest/dataplanes?id=in.%28invalid%29 failed" +
 		"\n Caused by:" +
 		"\n\t* 400 error"
-	expectedInvlidWorkspaceErr = "GET ${URL}/postgrest/workspaces?controller_id=in.%28invalid%29 failed" +
+	expectedInvalidWorkspaceErr = "GET ${URL}/postgrest/workspaces" +
+		"?or=%28controller_id.in.%28invalid%29%2Cname.in.%28invalid%29%29 failed" +
 		"\n Caused by:" +
 		"\n\t* 400 error"
 )
@@ -236,7 +237,7 @@ func TestCreateJobSubmissionCommand(t *testing.T) {
 			}),
 			expected: createExpected{
 				displays:       []string{stubs.ExpectedDeviceAuthDisplay},
-				err:            expectedInvlidDataplaneErr,
+				err:            expectedInvalidDataplaneErr,
 				dataplaneCalls: 1,
 			},
 		},
@@ -254,7 +255,7 @@ func TestCreateJobSubmissionCommand(t *testing.T) {
 					ResponseStatus: http.StatusOK,
 				},
 				workspaceConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(invalid)",
+					ExpectedQuery:  "or=(controller_id.in.(invalid),name.in.(invalid))",
 					ResponseStatus: http.StatusBadRequest,
 					ResponseBody:   `error`,
 				},
@@ -263,7 +264,7 @@ func TestCreateJobSubmissionCommand(t *testing.T) {
 				displays:       []string{stubs.ExpectedDeviceAuthDisplay},
 				dataplaneCalls: 1,
 				workspaceCalls: 1,
-				err:            expectedInvlidWorkspaceErr,
+				err:            expectedInvalidWorkspaceErr,
 			},
 		},
 	}
@@ -465,7 +466,7 @@ func TestStartJobCommand(t *testing.T) {
 			}),
 			expected: createExpected{
 				displays:       []string{stubs.ExpectedDeviceAuthDisplay},
-				err:            expectedInvlidDataplaneErr,
+				err:            expectedInvalidDataplaneErr,
 				dataplaneCalls: 1,
 			},
 		},
@@ -484,7 +485,7 @@ func TestStartJobCommand(t *testing.T) {
 					ResponseStatus: http.StatusOK,
 				},
 				workspaceConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(invalid)",
+					ExpectedQuery:  "or=(controller_id.in.(invalid),name.in.(invalid))",
 					ResponseStatus: http.StatusBadRequest,
 					ResponseBody:   `error`,
 				},
@@ -493,7 +494,7 @@ func TestStartJobCommand(t *testing.T) {
 				displays:       []string{stubs.ExpectedDeviceAuthDisplay},
 				dataplaneCalls: 1,
 				workspaceCalls: 1,
-				err:            expectedInvlidWorkspaceErr,
+				err:            expectedInvalidWorkspaceErr,
 			},
 		},
 	}
diff --git a/internal/cmd/create/snapshot/snapshot_test.go b/internal/cmd/create/snapshot/snapshot_test.go
index bc765c8..8a7a1ec 100644
--- a/internal/cmd/create/snapshot/snapshot_test.go
+++ b/internal/cmd/create/snapshot/snapshot_test.go
@@ -65,8 +65,9 @@ const (
         }
     }
 }`
-	snapshotUpdateMissingIDErr = "at least one resource ID must be provided for update"
-	snapshotUpdateServerErr    = "POST ${URL}/postgrest/rpc/snapshot_bulk_update failed\n" +
+	expectedResolveSnapshotsQuery = `or=(controller_id.in.(snap-cli-1),name.in.(snap-cli-1))`
+	snapshotUpdateMissingIDErr    = "at least one resource ID must be provided for update"
+	snapshotUpdateServerErr       = "POST ${URL}/postgrest/rpc/snapshot_bulk_update failed\n" +
 		" Caused by:\n" +
 		"\t* 401 unauthorized"
 	snapshotUpdateLongFlagsDryRunOutput = `{
@@ -86,11 +87,12 @@ const (
 	expectedCreateSnapshotInvalidEndpointErr = "failed to get device token" +
 		"\n Caused by:" +
 		"\n\t* parse \"a\\nb\": net/url: invalid control character in URL"
-	expectedInvlidDataplaneErr = "GET ${URL}/postgrest/dataplanes?id=in.%28invalid%29 failed" +
+	expectedInvalidDataplaneErr = "GET ${URL}/postgrest/dataplanes?id=in.%28invalid%29 failed" +
 		"\n Caused by:" +
 		"\n\t* 400 error"
 	expectedInvalidVolumeErr = "" +
-		"failed to get volume invalid: GET ${URL}/postgrest/workspaces?controller_id=in.%28invalid%29 failed" +
+		"failed to get volume invalid: GET ${URL}/postgrest/workspaces" +
+		"?or=%28controller_id.in.%28invalid%29%2Cname.in.%28invalid%29%29 failed" +
 		"\n Caused by:" + "\n\t* 400 error"
 	expectedCreateImageInvalidFileErr = "unexpected end of JSON input"
 )
@@ -157,7 +159,7 @@ func TestCreateSnapshotCommand(t *testing.T) {
 					ResponseStatus: http.StatusOK,
 				},
 				workspaceConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  `controller_id=in.(ws-1)`,
+					ExpectedQuery:  `or=(controller_id.in.(ws-1),name.in.(ws-1))`,
 					ResponseBody:   `[{"id":"ws-1","controller_id":"ctrl-1"}]`,
 					ResponseStatus: http.StatusOK,
 				},
@@ -189,7 +191,7 @@ func TestCreateSnapshotCommand(t *testing.T) {
 					ResponseStatus: http.StatusOK,
 				},
 				workspaceConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  `controller_id=in.(ws-1)`,
+					ExpectedQuery:  `or=(controller_id.in.(ws-1),name.in.(ws-1))`,
 					ResponseBody:   `[{"id":"ws-1","controller_id":"ctrl-1"}]`,
 					ResponseStatus: http.StatusOK,
 				},
@@ -220,7 +222,7 @@ func TestCreateSnapshotCommand(t *testing.T) {
 					ResponseStatus: http.StatusOK,
 				},
 				workspaceConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  `controller_id=in.(ws-1)`,
+					ExpectedQuery:  `or=(controller_id.in.(ws-1),name.in.(ws-1))`,
 					ResponseBody:   `[{"id":"ws-1","controller_id":"ctrl-1"}]`,
 					ResponseStatus: http.StatusOK,
 				},
@@ -259,7 +261,7 @@ func TestCreateSnapshotCommand(t *testing.T) {
 			},
 			mockEndpoints: getCreateSnapshotEndpoints(snapshotEndpointsConfig{
 				workspaceConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  `controller_id=in.(missing)`,
+					ExpectedQuery:  `or=(controller_id.in.(missing),name.in.(missing))`,
 					ResponseBody:   `[]`,
 					ResponseStatus: http.StatusOK,
 				},
@@ -280,7 +282,7 @@ func TestCreateSnapshotCommand(t *testing.T) {
 			},
 			mockEndpoints: getCreateSnapshotEndpoints(snapshotEndpointsConfig{
 				workspaceConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  `controller_id=in.(invalid)`,
+					ExpectedQuery:  `or=(controller_id.in.(invalid),name.in.(invalid))`,
 					ResponseBody:   `error`,
 					ResponseStatus: http.StatusBadRequest,
 				},
@@ -315,7 +317,7 @@ func TestCreateSnapshotCommand(t *testing.T) {
 			}),
 			expected: createExpected{
 				displays:       []string{stubs.ExpectedDeviceAuthDisplay},
-				err:            expectedInvlidDataplaneErr,
+				err:            expectedInvalidDataplaneErr,
 				dataplaneCalls: 1,
 			},
 		},
@@ -443,7 +445,7 @@ func TestUpdateSnapshotCommand(t *testing.T) {
 			},
 			mockEndpoints: getUpdateSnapshotEndpoints(snapshotEndpointsConfig{
 				workspaceConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  `controller_id=in.(ws-1)`,
+					ExpectedQuery:  `or=(controller_id.in.(ws-1),name.in.(ws-1))`,
 					ResponseBody:   `[{"id":"ws-1","controller_id":"ctrl-1"}]`,
 					ResponseStatus: http.StatusOK,
 				},
@@ -471,6 +473,11 @@ func TestUpdateSnapshotCommand(t *testing.T) {
 				args: []string{"snap-cli-1"},
 			},
 			mockEndpoints: getUpdateSnapshotEndpoints(snapshotEndpointsConfig{
+				snapshotsConfig: stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedResolveSnapshotsQuery,
+					ResponseBody:   `[{"id":"snap-cli-1"}]`,
+					ResponseStatus: http.StatusOK,
+				},
 				snapshotConfig: stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: snapshotUpdateRequestBodyIDsOnly,
 					ResponseStatus:      http.StatusOK,
@@ -488,6 +495,11 @@ func TestUpdateSnapshotCommand(t *testing.T) {
 				args: []string{"snap-cli-1"},
 			},
 			mockEndpoints: getUpdateSnapshotEndpoints(snapshotEndpointsConfig{
+				snapshotsConfig: stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedResolveSnapshotsQuery,
+					ResponseBody:   `[{"id":"snap-cli-1"}]`,
+					ResponseStatus: http.StatusOK,
+				},
 				snapshotConfig: stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: snapshotUpdateRequestBodyIDsOnly,
 					ResponseStatus:      http.StatusUnauthorized,
@@ -529,6 +541,11 @@ func TestUpdateSnapshotCommand(t *testing.T) {
 				args: []string{"snap-cli-1"},
 			},
 			mockEndpoints: getUpdateSnapshotEndpoints(snapshotEndpointsConfig{
+				snapshotsConfig: stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedResolveSnapshotsQuery,
+					ResponseBody:   `[{"id":"snap-cli-1"}]`,
+					ResponseStatus: http.StatusOK,
+				},
 				snapshotConfig: stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: snapshotUpdateRequestBodyIDsOnly,
 					ResponseStatus:      http.StatusOK,
@@ -550,10 +567,15 @@ func TestUpdateSnapshotCommand(t *testing.T) {
 			},
 			mockEndpoints: getUpdateSnapshotEndpoints(snapshotEndpointsConfig{
 				workspaceConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  `controller_id=in.(ws-1)`,
+					ExpectedQuery:  `or=(controller_id.in.(ws-1),name.in.(ws-1))`,
 					ResponseBody:   `[{"id":"ws-1","controller_id":"ctrl-1"}]`,
 					ResponseStatus: http.StatusOK,
 				},
+				snapshotsConfig: stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedResolveSnapshotsQuery,
+					ResponseBody:   `[{"id":"snap-cli-1"}]`,
+					ResponseStatus: http.StatusOK,
+				},
 				snapshotConfig: stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: snapshotUpdateRequestBodyMapping,
 					ResponseStatus:      http.StatusOK,
@@ -703,6 +725,7 @@ func getUpdateArgs(
 type snapshotEndpointsConfig struct {
 	dataplaneConfig stubs.StubHTTPPartialGetConfig
 	workspaceConfig stubs.StubHTTPPartialGetConfig
+	snapshotsConfig stubs.StubHTTPPartialGetConfig
 	snapshotConfig  stubs.StubHTTPPartialPostConfig
 }
 
@@ -712,6 +735,18 @@ func getSnapshotEndpoints(
 ) []stubs.StubHTTPEndpoint {
 	endpoints := append(stubs.GetStubAuthEndpoints(), stubs.GetStubWorkspaceEndpoints(config.workspaceConfig)...)
 	endpoints = append(endpoints, stubs.GetStubDataplaneEndpoints(config.dataplaneConfig)...)
+	endpoints = append(endpoints, stubs.StubHTTPEndpoint{
+		Request: stubs.StubHTTPRequest{
+			Method:          http.MethodGet,
+			URL:             postgrest.Endpoint(constants.WorkbenchEndpointSnapshots),
+			ExpectedHeaders: stubs.StubAuthHeaders,
+			ExpectedQuery:   config.snapshotsConfig.ExpectedQuery,
+		},
+		Response: stubs.StubHTTPResponse{
+			Status: config.snapshotsConfig.ResponseStatus,
+			Body:   config.snapshotsConfig.ResponseBody,
+		},
+	})
 	return append(endpoints,
 		[]stubs.StubHTTPEndpoint{
 			{
diff --git a/internal/cmd/create/workbench/workbench_test.go b/internal/cmd/create/workbench/workbench_test.go
index b0cc081..94d4ab4 100644
--- a/internal/cmd/create/workbench/workbench_test.go
+++ b/internal/cmd/create/workbench/workbench_test.go
@@ -112,20 +112,24 @@ const (
 	expectedCreateSnapshotInvalidEndpointErr = "failed to get device token" +
 		"\n Caused by:" +
 		"\n\t* parse \"a\\nb\": net/url: invalid control character in URL"
-	expectedInvlidDataplaneErr = "GET ${URL}/postgrest/dataplanes?id=in.%28invalid%29 failed" +
+	expectedInvalidDataplaneErr = "GET ${URL}/postgrest/dataplanes?id=in.%28invalid%29 failed" +
 		"\n Caused by:" +
 		"\n\t* 400 error"
-	expectedInvlidWorkspaceErr = "GET ${URL}/postgrest/workspaces?controller_id=in.%28invalid%29 failed" +
+	expectedInvalidWorkspaceErr = "GET ${URL}/postgrest/workspaces" +
+		"?or=%28controller_id.in.%28invalid%29%2Cname.in.%28invalid%29%29 failed" +
 		"\n Caused by:" +
 		"\n\t* 400 error"
-	expectedInvlidApplicationErr = "GET ${URL}/postgrest/dataplane_workbench_applications?id=in.%28invalid%29 failed" +
+	expectedInvalidApplicationErr = "GET ${URL}/postgrest/dataplane_workbench_applications" +
+		"?id=in.%28invalid%29 failed" +
 		"\n Caused by:" +
 		"\n\t* 400 error"
 	expectedApplicationQuery = "id=in.(app-1)"
 	applicationResponseBody  = `[{"id":"app-1","services":["svc-a","svc-b"]}]`
 	servicesResponseBody     = `[{"id":"service-1","name":"service 1"},` +
 		`{"id":"service-2","name":"service 2"}]`
-	expectedCreateImageInvalidFileErr = "unexpected end of JSON input"
+	expectedResolveWorkbenchesQueryOne = `or=(controller_id.in.(wb-cli-1),name.in.(wb-cli-1))`
+	expectedResolveWorkbenchesQueryTwo = `or=(controller_id.in.(wb-cli-1,wb-cli-2),name.in.(wb-cli-1,wb-cli-2))`
+	expectedCreateImageInvalidFileErr  = "unexpected end of JSON input"
 )
 
 type createInput struct {
@@ -454,7 +458,7 @@ func TestCreateWorkbenchCommand(t *testing.T) {
 			}),
 			expected: createExpected{
 				displays:       []string{stubs.ExpectedDeviceAuthDisplay},
-				err:            expectedInvlidDataplaneErr,
+				err:            expectedInvalidDataplaneErr,
 				dataplaneCalls: 1,
 			},
 		},
@@ -466,7 +470,7 @@ func TestCreateWorkbenchCommand(t *testing.T) {
 			},
 			mockEndpoints: getCreateWorkbenchEndpoints(workbenchEndpointsConfig{
 				workspacesConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  `controller_id=in.(invalid)`,
+					ExpectedQuery:  `or=(controller_id.in.(invalid),name.in.(invalid))`,
 					ResponseStatus: http.StatusBadRequest,
 					ResponseBody:   `error`,
 				},
@@ -474,7 +478,7 @@ func TestCreateWorkbenchCommand(t *testing.T) {
 			expected: createExpected{
 				displays:       []string{stubs.ExpectedDeviceAuthDisplay},
 				workspaceCalls: 1,
-				err:            expectedInvlidWorkspaceErr,
+				err:            expectedInvalidWorkspaceErr,
 			},
 		},
 		{
@@ -493,7 +497,7 @@ func TestCreateWorkbenchCommand(t *testing.T) {
 			expected: createExpected{
 				displays:          []string{stubs.ExpectedDeviceAuthDisplay},
 				applicationsCalls: 1,
-				err:               expectedInvlidApplicationErr,
+				err:               expectedInvalidApplicationErr,
 			},
 		},
 	}
@@ -733,6 +737,11 @@ func TestUpdateWorkbenchCommand(t *testing.T) {
 				args: []string{"wb-cli-1"},
 			},
 			mockEndpoints: getBulkUpdateWorkbenchEndpoints(workbenchEndpointsConfig{
+				workbenchesResolveConfig: stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedResolveWorkbenchesQueryOne,
+					ResponseBody:   `[{"id":"wb-cli-1"}]`,
+					ResponseStatus: http.StatusOK,
+				},
 				workbenchesConfig: stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: workbenchUpdateRequestBodyIDsOnly,
 					ResponseStatus:      http.StatusUnauthorized,
@@ -774,6 +783,11 @@ func TestUpdateWorkbenchCommand(t *testing.T) {
 				args: []string{"wb-cli-1"},
 			},
 			mockEndpoints: getBulkUpdateWorkbenchEndpoints(workbenchEndpointsConfig{
+				workbenchesResolveConfig: stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedResolveWorkbenchesQueryOne,
+					ResponseBody:   `[{"id":"wb-cli-1"}]`,
+					ResponseStatus: http.StatusOK,
+				},
 				workbenchesConfig: stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: workbenchUpdateRequestBodyIDsOnly,
 					ResponseStatus:      http.StatusOK,
@@ -799,6 +813,11 @@ func TestUpdateWorkbenchCommand(t *testing.T) {
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   applicationResponseBody,
 				},
+				workbenchesResolveConfig: stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedResolveWorkbenchesQueryTwo,
+					ResponseBody:   `[{"id":"wb-cli-1"},{"id":"wb-cli-2"}]`,
+					ResponseStatus: http.StatusOK,
+				},
 				workbenchesConfig: stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: workbenchUpdateRequestBodyMapping,
 					ResponseStatus:      http.StatusOK,
@@ -818,6 +837,11 @@ func TestUpdateWorkbenchCommand(t *testing.T) {
 				args:       []string{"wb-cli-1"},
 			},
 			mockEndpoints: getBulkUpdateWorkbenchEndpoints(workbenchEndpointsConfig{
+				workbenchesResolveConfig: stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedResolveWorkbenchesQueryOne,
+					ResponseBody:   `[{"id":"wb-cli-1"}]`,
+					ResponseStatus: http.StatusOK,
+				},
 				workbenchesConfig: stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: workbenchUpdateRequestBodyWorkspace,
 					ResponseStatus:      http.StatusOK,
@@ -838,6 +862,11 @@ func TestUpdateWorkbenchCommand(t *testing.T) {
 				args:   []string{"wb-cli-1"},
 			},
 			mockEndpoints: getUpdateWorkbenchEndpoints(workbenchEndpointsConfig{
+				workbenchesResolveConfig: stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedResolveWorkbenchesQueryOne,
+					ResponseBody:   `[{"id":"wb-cli-1"}]`,
+					ResponseStatus: http.StatusOK,
+				},
 				workbenchesConfig: stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: workbenchUpdateRequestBodyNameLabels,
 					ResponseStatus:      http.StatusOK,
@@ -1030,12 +1059,13 @@ func getUpdateArgs(
 }
 
 type workbenchEndpointsConfig struct {
-	dataplaneConfig    stubs.StubHTTPPartialGetConfig
-	applicationsConfig stubs.StubHTTPPartialGetConfig
-	servicesConfig     stubs.StubHTTPPartialGetConfig
-	nodepoolsConfig    stubs.StubHTTPPartialGetConfig
-	workspacesConfig   stubs.StubHTTPPartialGetConfig
-	workbenchesConfig  stubs.StubHTTPPartialPostConfig
+	dataplaneConfig          stubs.StubHTTPPartialGetConfig
+	applicationsConfig       stubs.StubHTTPPartialGetConfig
+	servicesConfig           stubs.StubHTTPPartialGetConfig
+	nodepoolsConfig          stubs.StubHTTPPartialGetConfig
+	workspacesConfig         stubs.StubHTTPPartialGetConfig
+	workbenchesResolveConfig stubs.StubHTTPPartialGetConfig
+	workbenchesConfig        stubs.StubHTTPPartialPostConfig
 }
 
 func getWorkbenchEndpoints(
@@ -1047,6 +1077,18 @@ func getWorkbenchEndpoints(
 	endpoints = append(endpoints, stubs.GetStubApplicationsEndpoints(config.applicationsConfig)...)
 	endpoints = append(endpoints, stubs.GetStubServicesEndpoints(config.servicesConfig)...)
 	endpoints = append(endpoints, stubs.GetStubNodepoolsEndpoints(config.nodepoolsConfig)...)
+	endpoints = append(endpoints, stubs.StubHTTPEndpoint{
+		Request: stubs.StubHTTPRequest{
+			Method:          http.MethodGet,
+			URL:             postgrest.Endpoint(constants.WorkbenchEndpointWorkbenches),
+			ExpectedHeaders: stubs.StubAuthHeaders,
+			ExpectedQuery:   config.workbenchesResolveConfig.ExpectedQuery,
+		},
+		Response: stubs.StubHTTPResponse{
+			Status: config.workbenchesResolveConfig.ResponseStatus,
+			Body:   config.workbenchesResolveConfig.ResponseBody,
+		},
+	})
 	return append(endpoints,
 		[]stubs.StubHTTPEndpoint{
 			{
@@ -1071,6 +1113,7 @@ func getCreateWorkbenchEndpoints(config workbenchEndpointsConfig) []stubs.StubHT
 func getUpdateWorkbenchEndpoints(config workbenchEndpointsConfig) []stubs.StubHTTPEndpoint {
 	return getWorkbenchEndpoints(postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkbenchUpdate), config)
 }
+
 func getBulkUpdateWorkbenchEndpoints(config workbenchEndpointsConfig) []stubs.StubHTTPEndpoint {
 	return getWorkbenchEndpoints(postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkbenchBulkUpdate), config)
 }
diff --git a/internal/cmd/create/workspace/workspace_test.go b/internal/cmd/create/workspace/workspace_test.go
index e2fe87c..128ef8c 100644
--- a/internal/cmd/create/workspace/workspace_test.go
+++ b/internal/cmd/create/workspace/workspace_test.go
@@ -86,6 +86,7 @@ const (
         }
     }
 }`
+	expectedResolveWorkspacesQueryOne = `or=(controller_id.in.(ws-cli-1),name.in.(ws-cli-1))`
 	workspaceUpdateRequestBodyIDsOnly = `{"ids":["ws-cli-1"],"workspace":{"pvc_status":{"status":{}}}}`
 	workspaceUpdateFileContent        = `{"id":"ws-file","description":"from file"}`
 	workspaceUpdateFileDryRunOutput   = `{
@@ -114,7 +115,7 @@ const (
 	expectedCreateSnapshotInvalidEndpointErr = "failed to get device token" +
 		"\n Caused by:" +
 		"\n\t* parse \"a\\nb\": net/url: invalid control character in URL"
-	expectedInvlidDataplaneErr = "GET ${URL}/postgrest/dataplanes?id=in.%28invalid%29 failed" +
+	expectedInvalidDataplaneErr = "GET ${URL}/postgrest/dataplanes?id=in.%28invalid%29 failed" +
 		"\n Caused by:" +
 		"\n\t* 400 error"
 	expectedInvalidInitalizersErr = "" +
@@ -128,7 +129,8 @@ const (
 		"\n Caused by:" +
 		"\n\t* 400 error"
 	expectedInvalidSnapshotErr = "" +
-		"GET ${URL}/postgrest/snapshots?controller_id=in.%28snap-1%29 failed" +
+		"GET ${URL}/postgrest/snapshots?" +
+		"or=%28controller_id.in.%28snap-1%29%2Cname.in.%28snap-1%29%29 failed" +
 		"\n Caused by:" +
 		"\n\t* invalid character 'i' looking for beginning of value"
 )
@@ -271,7 +273,7 @@ func TestCreateWorkspaceCommand(t *testing.T) {
 					ResponseStatus: http.StatusOK,
 				},
 				snapshotsConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  `controller_id=in.(snap-1)`,
+					ExpectedQuery:  `or=(controller_id.in.(snap-1),name.in.(snap-1))`,
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   workspaceSnapshotLookupBody,
 				},
@@ -403,7 +405,7 @@ func TestCreateWorkspaceCommand(t *testing.T) {
 			},
 			mockEndpoints: getCreateWorkspaceEndpoints(workspaceEndpointsConfig{
 				snapshotsConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  `controller_id=in.(invalid-snap)`,
+					ExpectedQuery:  `or=(controller_id.in.(invalid-snap),name.in.(invalid-snap))`,
 					ResponseBody:   `[]`,
 					ResponseStatus: http.StatusOK,
 				},
@@ -423,7 +425,7 @@ func TestCreateWorkspaceCommand(t *testing.T) {
 			},
 			mockEndpoints: getCreateWorkspaceEndpoints(workspaceEndpointsConfig{
 				snapshotsConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery: `controller_id=in.(snap-not-ready)`,
+					ExpectedQuery: `or=(controller_id.in.(snap-not-ready),name.in.(snap-not-ready))`,
 					ResponseBody: `[{"id":"snap-not-ready","controller_id":"","volume_status":{"status":` +
 						`{"readyToUse":false}}}]`,
 					ResponseStatus: http.StatusOK,
@@ -445,7 +447,7 @@ func TestCreateWorkspaceCommand(t *testing.T) {
 			},
 			mockEndpoints: getCreateWorkspaceEndpoints(workspaceEndpointsConfig{
 				snapshotsConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery: `controller_id=in.(snap-no-size)`,
+					ExpectedQuery: `or=(controller_id.in.(snap-no-size),name.in.(snap-no-size))`,
 					ResponseBody: `[{"id":"snap-no-size","controller_id":"ctrl-snap","workspace_source":{` +
 						`"storage_spec":{"accessModes":["ReadWriteOnce"],"storageClassName":"snap-sc"}},` +
 						`"volume_status":{"status":{"readyToUse":true}}}]`,
@@ -468,7 +470,7 @@ func TestCreateWorkspaceCommand(t *testing.T) {
 			},
 			mockEndpoints: getCreateWorkspaceEndpoints(workspaceEndpointsConfig{
 				snapshotsConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery: `controller_id=in.(snap-1)`,
+					ExpectedQuery: `or=(controller_id.in.(snap-1),name.in.(snap-1))`,
 					ResponseBody: `[{"id":"snap-1","controller_id":"ctrl-snap","volume_status":` +
 						`{"status":{"readyToUse":true}}}]`,
 					ResponseStatus: http.StatusOK,
@@ -490,7 +492,7 @@ func TestCreateWorkspaceCommand(t *testing.T) {
 			},
 			mockEndpoints: getCreateWorkspaceEndpoints(workspaceEndpointsConfig{
 				snapshotsConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery: `controller_id=in.(snap-1)`,
+					ExpectedQuery: `or=(controller_id.in.(snap-1),name.in.(snap-1))`,
 					ResponseBody: `[{"id":"snap-1","controller_id":"ctrl-snap","workspace_source":{"storage_spec":` +
 						`{"accessModes":[],"storageClassName":"snap-sc"}},"volume_status":{"status":` +
 						`{"readyToUse":true,"restoreSize":"1Gi"}}}]`,
@@ -528,14 +530,14 @@ func TestCreateWorkspaceCommand(t *testing.T) {
 					ResponseBody:   `error`,
 				},
 				snapshotsConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  `controller_id=in.(snap-1)`,
+					ExpectedQuery:  `or=(controller_id.in.(snap-1),name.in.(snap-1))`,
 					ResponseBody:   workspaceSnapshotLookupBody,
 					ResponseStatus: http.StatusOK,
 				},
 			}),
 			expected: createExpected{
 				displays:       []string{stubs.ExpectedDeviceAuthDisplay},
-				err:            expectedInvlidDataplaneErr,
+				err:            expectedInvalidDataplaneErr,
 				dataplaneCalls: 1,
 				snapshotCalls:  1,
 			},
@@ -591,7 +593,7 @@ func TestCreateWorkspaceCommand(t *testing.T) {
 			},
 			mockEndpoints: getCreateWorkspaceEndpoints(workspaceEndpointsConfig{
 				snapshotsConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  `controller_id=in.(snap-1)`,
+					ExpectedQuery:  `or=(controller_id.in.(snap-1),name.in.(snap-1))`,
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   `[invalid`,
 				},
@@ -771,6 +773,11 @@ func TestUpdateWorkspaceCommand(t *testing.T) {
 				args: []string{"ws-cli-1"},
 			},
 			mockEndpoints: getBulkUpdateWorkspaceEndpoints(workspaceUpdateEndpointsConfig{
+				workspacesConfig: stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedResolveWorkspacesQueryOne,
+					ResponseBody:   `[{"id":"ws-cli-1"}]`,
+					ResponseStatus: http.StatusOK,
+				},
 				workspaceConfig: stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: workspaceUpdateRequestBodyIDsOnly,
 					ResponseStatus:      http.StatusOK,
@@ -788,6 +795,11 @@ func TestUpdateWorkspaceCommand(t *testing.T) {
 				args: []string{"ws-cli-1"},
 			},
 			mockEndpoints: getBulkUpdateWorkspaceEndpoints(workspaceUpdateEndpointsConfig{
+				workspacesConfig: stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedResolveWorkspacesQueryOne,
+					ResponseBody:   `[{"id":"ws-cli-1"}]`,
+					ResponseStatus: http.StatusOK,
+				},
 				workspaceConfig: stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: workspaceUpdateRequestBodyIDsOnly,
 					ResponseStatus:      http.StatusUnauthorized,
@@ -829,6 +841,11 @@ func TestUpdateWorkspaceCommand(t *testing.T) {
 				args: []string{"ws-cli-1"},
 			},
 			mockEndpoints: getBulkUpdateWorkspaceEndpoints(workspaceUpdateEndpointsConfig{
+				workspacesConfig: stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedResolveWorkspacesQueryOne,
+					ResponseBody:   `[{"id":"ws-cli-1"}]`,
+					ResponseStatus: http.StatusOK,
+				},
 				workspaceConfig: stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: workspaceUpdateRequestBodyIDsOnly,
 					ResponseStatus:      http.StatusOK,
@@ -848,6 +865,11 @@ func TestUpdateWorkspaceCommand(t *testing.T) {
 				args:        []string{"ws-cli-1"},
 			},
 			mockEndpoints: getSingleUpdateWorkspaceEndpoints(workspaceUpdateEndpointsConfig{
+				workspacesConfig: stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedResolveWorkspacesQueryOne,
+					ResponseBody:   `[{"id":"ws-cli-1"}]`,
+					ResponseStatus: http.StatusOK,
+				},
 				workspaceConfig: stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: workspaceUpdateRequestBodyNameDesc,
 					ResponseStatus:      http.StatusOK,
@@ -1045,6 +1067,7 @@ type workspaceEndpointsConfig struct {
 
 type workspaceUpdateEndpointsConfig struct {
 	initializerConfig stubs.StubHTTPPartialGetConfig
+	workspacesConfig  stubs.StubHTTPPartialGetConfig
 	workspaceConfig   stubs.StubHTTPPartialPostConfig
 }
 
@@ -1126,6 +1149,18 @@ func getUpdateWorkspaceEndpoints(
 ) []stubs.StubHTTPEndpoint {
 	endpoints := append(stubs.GetStubAuthEndpoints(),
 		getInitializerEndpoints(config.initializerConfig)...)
+	endpoints = append(endpoints, stubs.StubHTTPEndpoint{
+		Request: stubs.StubHTTPRequest{
+			Method:          http.MethodGet,
+			URL:             postgrest.Endpoint(constants.WorkbenchEndpointWorkspaces),
+			ExpectedHeaders: stubs.StubAuthHeaders,
+			ExpectedQuery:   config.workspacesConfig.ExpectedQuery,
+		},
+		Response: stubs.StubHTTPResponse{
+			Status: config.workspacesConfig.ResponseStatus,
+			Body:   config.workspacesConfig.ResponseBody,
+		},
+	})
 	return append(endpoints, stubs.StubHTTPEndpoint{
 		Request: stubs.StubHTTPRequest{
 			Method:          http.MethodPost,
diff --git a/internal/cmd/delete/delete_test.go b/internal/cmd/delete/delete_test.go
index 0a10fd4..fda2d92 100644
--- a/internal/cmd/delete/delete_test.go
+++ b/internal/cmd/delete/delete_test.go
@@ -38,6 +38,24 @@ const (
 		"\n\t* parse \"a\\nb\": net/url: invalid control character in URL"
 )
 
+const (
+	expectedImageResolveQuery      = `or=(controller_id.in.(image-1,image-2),name.in.(image-1,image-2))`
+	expectedJobResolveQuery        = `or=(name.in.(job-1,job-2))`
+	expectedSnapshotResolveQuery   = `or=(controller_id.in.(snapshot-1,snapshot-2),name.in.(snapshot-1,snapshot-2))`
+	expectedSubmissionResolveQuery = `or=(controller_id.in.(submission-1,submission-2),` +
+		`job_name.in.(submission-1,submission-2))`
+	expectedWorkbenchResolveQuery    = `or=(controller_id.in.(workbench-1,workbench-2),name.in.(workbench-1,workbench-2))`
+	expectedWorkspaceResolveQueryOne = `or=(controller_id.in.(workspace-1),name.in.(workspace-1))`
+	expectedWorkspaceResolveQueryTwo = `or=(controller_id.in.(workspace-1,workspace-2),name.in.(workspace-1,workspace-2))`
+	imageResolveResponseBody         = `[{"id":"image-1"},{"id":"image-2"}]`
+	jobResolveResponseBody           = `[{"id":"job-1"},{"id":"job-2"}]`
+	snapshotResolveResponseBody      = `[{"id":"snapshot-1"},{"id":"snapshot-2"}]`
+	submissionResolveResponseBody    = `[{"id":"submission-1"},{"id":"submission-2"}]`
+	workbenchResolveResponseBody     = `[{"id":"workbench-1"},{"id":"workbench-2"}]`
+	workspaceResolveResponseBodyOne  = `[{"id":"workspace-1"}]`
+	workspaceResolveResponseBodyTwo  = `[{"id":"workspace-1"},{"id":"workspace-2"}]`
+)
+
 var (
 	expectedImageDisplay          = strings.Join([]string{image1ID, image2ID}, "\n")
 	expectedJobDisplay            = strings.Join([]string{job1ID, job2ID}, "\n")
@@ -99,6 +117,12 @@ func TestDeleteCommand(t *testing.T) {
 				resourceIDs:  []string{workspace1ID},
 			},
 			mockEndpoints: getDeleteEndpoints(
+				postgrest.Endpoint(constants.WorkbenchEndpointWorkspaces),
+				stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedWorkspaceResolveQueryOne,
+					ResponseStatus: http.StatusOK,
+					ResponseBody:   workspaceResolveResponseBodyOne,
+				},
 				postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkspaceBulkDelete),
 				stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: `{"ids":["workspace-1"]}`,
@@ -121,6 +145,12 @@ func TestDeleteCommand(t *testing.T) {
 				resourceIDs:  []string{image1ID, image2ID},
 			},
 			mockEndpoints: getDeleteEndpoints(
+				postgrest.Endpoint(constants.WorkbenchEndpointImages),
+				stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedImageResolveQuery,
+					ResponseStatus: http.StatusOK,
+					ResponseBody:   imageResolveResponseBody,
+				},
 				postgrest.Endpoint(constants.WorkbenchEndpointRPCImageBulkDelete),
 				stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: expectedImageRequestBody,
@@ -140,6 +170,12 @@ func TestDeleteCommand(t *testing.T) {
 				resourceIDs:  []string{image1ID, image2ID},
 			},
 			mockEndpoints: getDeleteEndpoints(
+				postgrest.Endpoint(constants.WorkbenchEndpointImages),
+				stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedImageResolveQuery,
+					ResponseStatus: http.StatusOK,
+					ResponseBody:   imageResolveResponseBody,
+				},
 				postgrest.Endpoint(constants.WorkbenchEndpointRPCImageBulkDelete),
 				stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: expectedImageRequestBody,
@@ -159,6 +195,12 @@ func TestDeleteCommand(t *testing.T) {
 				resourceIDs:  []string{submission1ID, submission2ID},
 			},
 			mockEndpoints: getDeleteEndpoints(
+				postgrest.Endpoint(constants.WorkbenchEndpointJobRuns),
+				stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedSubmissionResolveQuery,
+					ResponseStatus: http.StatusOK,
+					ResponseBody:   submissionResolveResponseBody,
+				},
 				postgrest.Endpoint(constants.WorkbenchEndpointRPCJobRunBulkDelete),
 				stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: expectedSubmissionRequestBody,
@@ -178,6 +220,12 @@ func TestDeleteCommand(t *testing.T) {
 				resourceIDs:  []string{submission1ID, submission2ID},
 			},
 			mockEndpoints: getDeleteEndpoints(
+				postgrest.Endpoint(constants.WorkbenchEndpointJobRuns),
+				stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedSubmissionResolveQuery,
+					ResponseStatus: http.StatusOK,
+					ResponseBody:   submissionResolveResponseBody,
+				},
 				postgrest.Endpoint(constants.WorkbenchEndpointRPCJobRunBulkDelete),
 				stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: expectedSubmissionRequestBody,
@@ -197,6 +245,12 @@ func TestDeleteCommand(t *testing.T) {
 				resourceIDs:  []string{job1ID, job2ID},
 			},
 			mockEndpoints: getDeleteEndpoints(
+				postgrest.Endpoint(constants.WorkbenchEndpointJobs),
+				stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedJobResolveQuery,
+					ResponseStatus: http.StatusOK,
+					ResponseBody:   jobResolveResponseBody,
+				},
 				postgrest.Endpoint(constants.WorkbenchEndpointRPCJobBulkDelete),
 				stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: expectedJobRequestBody,
@@ -216,6 +270,12 @@ func TestDeleteCommand(t *testing.T) {
 				resourceIDs:  []string{job1ID, job2ID},
 			},
 			mockEndpoints: getDeleteEndpoints(
+				postgrest.Endpoint(constants.WorkbenchEndpointJobs),
+				stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedJobResolveQuery,
+					ResponseStatus: http.StatusOK,
+					ResponseBody:   jobResolveResponseBody,
+				},
 				postgrest.Endpoint(constants.WorkbenchEndpointRPCJobBulkDelete),
 				stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: expectedJobRequestBody,
@@ -235,6 +295,12 @@ func TestDeleteCommand(t *testing.T) {
 				resourceIDs:  []string{snapshot1ID, snapshot2ID},
 			},
 			mockEndpoints: getDeleteEndpoints(
+				postgrest.Endpoint(constants.WorkbenchEndpointSnapshots),
+				stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedSnapshotResolveQuery,
+					ResponseStatus: http.StatusOK,
+					ResponseBody:   snapshotResolveResponseBody,
+				},
 				postgrest.Endpoint(constants.WorkbenchEndpointRPCSnapshotBulkDelete),
 				stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: expectedSnapshotRequestBody,
@@ -254,6 +320,12 @@ func TestDeleteCommand(t *testing.T) {
 				resourceIDs:  []string{snapshot1ID, snapshot2ID},
 			},
 			mockEndpoints: getDeleteEndpoints(
+				postgrest.Endpoint(constants.WorkbenchEndpointSnapshots),
+				stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedSnapshotResolveQuery,
+					ResponseStatus: http.StatusOK,
+					ResponseBody:   snapshotResolveResponseBody,
+				},
 				postgrest.Endpoint(constants.WorkbenchEndpointRPCSnapshotBulkDelete),
 				stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: expectedSnapshotRequestBody,
@@ -273,6 +345,12 @@ func TestDeleteCommand(t *testing.T) {
 				resourceIDs:  []string{workbench1ID, workbench2ID},
 			},
 			mockEndpoints: getDeleteEndpoints(
+				postgrest.Endpoint(constants.WorkbenchEndpointWorkbenches),
+				stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedWorkbenchResolveQuery,
+					ResponseStatus: http.StatusOK,
+					ResponseBody:   workbenchResolveResponseBody,
+				},
 				postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkbenchBulkDelete),
 				stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: expectedWorkbenchRequestBody,
@@ -292,6 +370,12 @@ func TestDeleteCommand(t *testing.T) {
 				resourceIDs:  []string{workbench1ID, workbench2ID},
 			},
 			mockEndpoints: getDeleteEndpoints(
+				postgrest.Endpoint(constants.WorkbenchEndpointWorkbenches),
+				stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedWorkbenchResolveQuery,
+					ResponseStatus: http.StatusOK,
+					ResponseBody:   workbenchResolveResponseBody,
+				},
 				postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkbenchBulkDelete),
 				stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: expectedWorkbenchRequestBody,
@@ -311,6 +395,12 @@ func TestDeleteCommand(t *testing.T) {
 				resourceIDs:  []string{workspace1ID, workspace2ID},
 			},
 			mockEndpoints: getDeleteEndpoints(
+				postgrest.Endpoint(constants.WorkbenchEndpointWorkspaces),
+				stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedWorkspaceResolveQueryTwo,
+					ResponseStatus: http.StatusOK,
+					ResponseBody:   workspaceResolveResponseBodyTwo,
+				},
 				postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkspaceBulkDelete),
 				stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: expectedWorkspaceRequestBody,
@@ -330,6 +420,12 @@ func TestDeleteCommand(t *testing.T) {
 				resourceIDs:  []string{workspace1ID, workspace2ID},
 			},
 			mockEndpoints: getDeleteEndpoints(
+				postgrest.Endpoint(constants.WorkbenchEndpointWorkspaces),
+				stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  expectedWorkspaceResolveQueryTwo,
+					ResponseStatus: http.StatusOK,
+					ResponseBody:   workspaceResolveResponseBodyTwo,
+				},
 				postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkspaceBulkDelete),
 				stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: expectedWorkspaceRequestBody,
@@ -432,10 +528,23 @@ func getDeleteArgs(
 }
 
 func getDeleteEndpoints(
+	resolveEndpoint string,
+	resolveConfig stubs.StubHTTPPartialGetConfig,
 	urlUnderTest string,
 	config ...stubs.StubHTTPPartialPostConfig,
 ) []stubs.StubHTTPEndpoint {
-	endpoints := stubs.GetStubAuthEndpoints()
+	endpoints := append(stubs.GetStubAuthEndpoints(), stubs.StubHTTPEndpoint{
+		Request: stubs.StubHTTPRequest{
+			Method:          http.MethodGet,
+			URL:             resolveEndpoint,
+			ExpectedHeaders: stubs.StubAuthHeaders,
+			ExpectedQuery:   resolveConfig.ExpectedQuery,
+		},
+		Response: stubs.StubHTTPResponse{
+			Status: resolveConfig.ResponseStatus,
+			Body:   resolveConfig.ResponseBody,
+		},
+	})
 	for _, cfg := range config {
 		endpoints = append(endpoints,
 			stubs.StubHTTPEndpoint{
diff --git a/internal/cmd/describe/describe_test.go b/internal/cmd/describe/describe_test.go
index f9fb455..98cb88b 100644
--- a/internal/cmd/describe/describe_test.go
+++ b/internal/cmd/describe/describe_test.go
@@ -589,7 +589,7 @@ func TestDescribeCommand(t *testing.T) {
 			mockEndpoints: getDescribeEndpoints(
 				postgrest.Endpoint(constants.WorkbenchEndpointImageBuilds),
 				stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(resource-1)",
+					ExpectedQuery:  "or=(controller_id.in.(resource-1),name.in.(resource-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   describeGenericResourceBySingularTypeExpectedResponseBody,
 				},
@@ -608,7 +608,7 @@ func TestDescribeCommand(t *testing.T) {
 			mockEndpoints: getDescribeEndpoints(
 				postgrest.Endpoint(constants.WorkbenchEndpointImages),
 				stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(resource-1)",
+					ExpectedQuery:  "or=(controller_id.in.(resource-1),name.in.(resource-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   describeGenericResourceBySingularTypeExpectedResponseBody,
 				},
@@ -627,7 +627,7 @@ func TestDescribeCommand(t *testing.T) {
 			mockEndpoints: getDescribeEndpoints(
 				postgrest.Endpoint(constants.WorkbenchEndpointJobRuns),
 				stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(resource-1)",
+					ExpectedQuery:  "or=(controller_id.in.(resource-1),job_name.in.(resource-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   describeGenericResourceBySingularTypeExpectedResponseBody,
 				},
@@ -646,7 +646,7 @@ func TestDescribeCommand(t *testing.T) {
 			mockEndpoints: getDescribeEndpoints(
 				postgrest.Endpoint(constants.WorkbenchEndpointJobs),
 				stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "id=in.(resource-1)",
+					ExpectedQuery:  "or=(name.in.(resource-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   describeGenericResourceBySingularTypeExpectedResponseBody,
 				},
@@ -665,7 +665,7 @@ func TestDescribeCommand(t *testing.T) {
 			mockEndpoints: getDescribeEndpoints(
 				postgrest.Endpoint(constants.WorkbenchEndpointSnapshots),
 				stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(resource-1)",
+					ExpectedQuery:  "or=(controller_id.in.(resource-1),name.in.(resource-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   describeGenericResourceBySingularTypeExpectedResponseBody,
 				},
@@ -703,7 +703,7 @@ func TestDescribeCommand(t *testing.T) {
 			mockEndpoints: getDescribeEndpoints(
 				postgrest.Endpoint(constants.WorkbenchEndpointWorkbenches),
 				stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(resource-1)",
+					ExpectedQuery:  "or=(controller_id.in.(resource-1),name.in.(resource-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   describeGenericResourceBySingularTypeExpectedResponseBody,
 				},
@@ -722,7 +722,7 @@ func TestDescribeCommand(t *testing.T) {
 			mockEndpoints: getDescribeEndpoints(
 				postgrest.Endpoint(constants.WorkbenchEndpointWorkspaces),
 				stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(resource-1)",
+					ExpectedQuery:  "or=(controller_id.in.(resource-1),name.in.(resource-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   describeGenericResourceBySingularTypeExpectedResponseBody,
 				},
diff --git a/internal/cmd/edit/edit_test.go b/internal/cmd/edit/edit_test.go
index 220e455..93a5678 100644
--- a/internal/cmd/edit/edit_test.go
+++ b/internal/cmd/edit/edit_test.go
@@ -275,7 +275,7 @@ func TestEditCommand(t *testing.T) {
 			mockEndpoints: getEditEndpoints(editConfig{
 				getURL: postgrest.Endpoint(constants.WorkbenchEndpointImages),
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(resource-1)",
+					ExpectedQuery:  "or=(controller_id.in.(resource-1),name.in.(resource-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   "[]",
 				},
@@ -292,7 +292,7 @@ func TestEditCommand(t *testing.T) {
 			mockEndpoints: getEditEndpoints(editConfig{
 				getURL: postgrest.Endpoint(constants.WorkbenchEndpointImages),
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(resource-1)",
+					ExpectedQuery:  "or=(controller_id.in.(resource-1),name.in.(resource-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   twoImagesJSON,
 				},
@@ -315,7 +315,7 @@ func TestEditCommand(t *testing.T) {
 			mockEndpoints: getEditEndpoints(editConfig{
 				getURL: postgrest.Endpoint(constants.WorkbenchEndpointImages),
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(resource-1)",
+					ExpectedQuery:  "or=(controller_id.in.(resource-1),name.in.(resource-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   oneImageJSON,
 				},
@@ -338,7 +338,7 @@ func TestEditCommand(t *testing.T) {
 			mockEndpoints: getEditEndpoints(editConfig{
 				getURL: postgrest.Endpoint(constants.WorkbenchEndpointImages),
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(resource-1)",
+					ExpectedQuery:  "or=(controller_id.in.(resource-1),name.in.(resource-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   oneImageJSON,
 				},
@@ -362,7 +362,7 @@ func TestEditCommand(t *testing.T) {
 			mockEndpoints: getEditEndpoints(editConfig{
 				getURL: postgrest.Endpoint(constants.WorkbenchEndpointImages),
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(resource-1)",
+					ExpectedQuery:  "or=(controller_id.in.(resource-1),name.in.(resource-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   oneImageJSON,
 				},
@@ -389,7 +389,7 @@ func TestEditCommand(t *testing.T) {
 			mockEndpoints: getEditEndpoints(editConfig{
 				getURL: postgrest.Endpoint(constants.WorkbenchEndpointImages),
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(resource-1)",
+					ExpectedQuery:  "or=(controller_id.in.(resource-1),name.in.(resource-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   oneImageJSON,
 				},
@@ -410,7 +410,7 @@ func TestEditCommand(t *testing.T) {
 			mockEndpoints: getEditEndpoints(editConfig{
 				getURL: postgrest.Endpoint(constants.WorkbenchEndpointImages),
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(resource-1)",
+					ExpectedQuery:  "or=(controller_id.in.(resource-1),name.in.(resource-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   oneImageJSON,
 				},
@@ -433,7 +433,7 @@ func TestEditCommand(t *testing.T) {
 			mockEndpoints: getEditEndpoints(editConfig{
 				getURL: postgrest.Endpoint(constants.WorkbenchEndpointImages),
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(resource-1)",
+					ExpectedQuery:  "or=(controller_id.in.(resource-1),name.in.(resource-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   oneImageJSON,
 				},
@@ -456,7 +456,7 @@ func TestEditCommand(t *testing.T) {
 			mockEndpoints: getEditEndpoints(editConfig{
 				getURL: postgrest.Endpoint(constants.WorkbenchEndpointImages),
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(resource-1)",
+					ExpectedQuery:  "or=(controller_id.in.(resource-1),name.in.(resource-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   oneImageJSON,
 				},
@@ -483,7 +483,7 @@ func TestEditCommand(t *testing.T) {
 			mockEndpoints: getEditEndpoints(editConfig{
 				getURL: postgrest.Endpoint(constants.WorkbenchEndpointImages),
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(resource-1)",
+					ExpectedQuery:  "or=(controller_id.in.(resource-1),name.in.(resource-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   oneImageJSON,
 				},
@@ -510,7 +510,7 @@ func TestEditCommand(t *testing.T) {
 			mockEndpoints: getEditEndpoints(editConfig{
 				getURL: postgrest.Endpoint(constants.WorkbenchEndpointImages),
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(resource-1)",
+					ExpectedQuery:  "or=(controller_id.in.(resource-1),name.in.(resource-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   oneImageJSON,
 				},
@@ -531,7 +531,7 @@ func TestEditCommand(t *testing.T) {
 			mockEndpoints: getEditEndpoints(editConfig{
 				getURL: postgrest.Endpoint(constants.WorkbenchEndpointWorkbenches),
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(resource-1)",
+					ExpectedQuery:  "or=(controller_id.in.(resource-1),name.in.(resource-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   oneWorkbenchJSON,
 				},
diff --git a/internal/services/workbench/images_test.go b/internal/services/workbench/images_test.go
index 6022146..7b57070 100644
--- a/internal/services/workbench/images_test.go
+++ b/internal/services/workbench/images_test.go
@@ -921,19 +921,19 @@ func getImagesDeleteEndpoints(
 	responseStatus int,
 	responseBody string,
 ) []stubs.StubHTTPEndpoint {
-	endpoints := getImagesGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody)
-	return append(endpoints, stubs.StubHTTPEndpoint{
-		Request: stubs.StubHTTPRequest{
-			Method:          http.MethodPost,
-			URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCImageBulkDelete),
-			ExpectedHeaders: authHeaders,
-			ExpectedBody:    expectedBody,
-		},
-		Response: stubs.StubHTTPResponse{
-			Status: responseStatus,
-			Body:   responseBody,
-		},
-	})
+	return append(getImagesGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody),
+		stubs.StubHTTPEndpoint{
+			Request: stubs.StubHTTPRequest{
+				Method:          http.MethodPost,
+				URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCImageBulkDelete),
+				ExpectedHeaders: authHeaders,
+				ExpectedBody:    expectedBody,
+			},
+			Response: stubs.StubHTTPResponse{
+				Status: responseStatus,
+				Body:   responseBody,
+			},
+		})
 }
 
 func getImagesCreateEndpoints(
@@ -964,19 +964,19 @@ func getImagesBulkUpdateEndpoints(
 	responseStatus int,
 	responseBody string,
 ) []stubs.StubHTTPEndpoint {
-	endpoints := getImagesGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody)
-	return append(endpoints, stubs.StubHTTPEndpoint{
-		Request: stubs.StubHTTPRequest{
-			Method:          http.MethodPost,
-			URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCImageBulkUpdate),
-			ExpectedHeaders: authHeaders,
-			ExpectedBody:    expectedBody,
-		},
-		Response: stubs.StubHTTPResponse{
-			Status: responseStatus,
-			Body:   responseBody,
-		},
-	})
+	return append(getImagesGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody),
+		stubs.StubHTTPEndpoint{
+			Request: stubs.StubHTTPRequest{
+				Method:          http.MethodPost,
+				URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCImageBulkUpdate),
+				ExpectedHeaders: authHeaders,
+				ExpectedBody:    expectedBody,
+			},
+			Response: stubs.StubHTTPResponse{
+				Status: responseStatus,
+				Body:   responseBody,
+			},
+		})
 }
 
 func getImagesGetEndpoints(
diff --git a/internal/services/workbench/job_submissions_test.go b/internal/services/workbench/job_submissions_test.go
index 6f2dafc..2d6617e 100644
--- a/internal/services/workbench/job_submissions_test.go
+++ b/internal/services/workbench/job_submissions_test.go
@@ -885,19 +885,19 @@ func getJobSubmissionDeleteEndpoints(
 	responseStatus int,
 	responseBody string,
 ) []stubs.StubHTTPEndpoint {
-	endpoints := getJobSubmissionsGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody)
-	return append(endpoints, stubs.StubHTTPEndpoint{
-		Request: stubs.StubHTTPRequest{
-			Method:          http.MethodPost,
-			URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCJobRunBulkDelete),
-			ExpectedHeaders: authHeaders,
-			ExpectedBody:    expectedBody,
-		},
-		Response: stubs.StubHTTPResponse{
-			Body:   responseBody,
-			Status: responseStatus,
-		},
-	})
+	return append(getJobSubmissionsGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody),
+		stubs.StubHTTPEndpoint{
+			Request: stubs.StubHTTPRequest{
+				Method:          http.MethodPost,
+				URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCJobRunBulkDelete),
+				ExpectedHeaders: authHeaders,
+				ExpectedBody:    expectedBody,
+			},
+			Response: stubs.StubHTTPResponse{
+				Body:   responseBody,
+				Status: responseStatus,
+			},
+		})
 }
 
 func getJobSubmissionCreateEndpoints(
diff --git a/internal/services/workbench/jobs_test.go b/internal/services/workbench/jobs_test.go
index 9c3c128..ad7336c 100644
--- a/internal/services/workbench/jobs_test.go
+++ b/internal/services/workbench/jobs_test.go
@@ -875,19 +875,19 @@ func getJobsDeleteEndpoints(
 	responseStatus int,
 	responseBody string,
 ) []stubs.StubHTTPEndpoint {
-	endpoints := getJobsGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody)
-	return append(endpoints, stubs.StubHTTPEndpoint{
-		Request: stubs.StubHTTPRequest{
-			Method:          http.MethodPost,
-			URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCJobBulkDelete),
-			ExpectedHeaders: authHeaders,
-			ExpectedBody:    expectedBody,
-		},
-		Response: stubs.StubHTTPResponse{
-			Status: responseStatus,
-			Body:   responseBody,
-		},
-	})
+	return append(getJobsGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody),
+		stubs.StubHTTPEndpoint{
+			Request: stubs.StubHTTPRequest{
+				Method:          http.MethodPost,
+				URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCJobBulkDelete),
+				ExpectedHeaders: authHeaders,
+				ExpectedBody:    expectedBody,
+			},
+			Response: stubs.StubHTTPResponse{
+				Status: responseStatus,
+				Body:   responseBody,
+			},
+		})
 }
 
 func getJobsCreateEndpoints(
@@ -918,19 +918,19 @@ func getJobsBulkUpdateEndpoints(
 	responseStatus int,
 	responseBody string,
 ) []stubs.StubHTTPEndpoint {
-	endpoints := getJobsGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody)
-	return append(endpoints, stubs.StubHTTPEndpoint{
-		Request: stubs.StubHTTPRequest{
-			Method:          http.MethodPost,
-			URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCJobBulkUpdate),
-			ExpectedHeaders: authHeaders,
-			ExpectedBody:    expectedBody,
-		},
-		Response: stubs.StubHTTPResponse{
-			Status: responseStatus,
-			Body:   responseBody,
-		},
-	})
+	return append(getJobsGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody),
+		stubs.StubHTTPEndpoint{
+			Request: stubs.StubHTTPRequest{
+				Method:          http.MethodPost,
+				URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCJobBulkUpdate),
+				ExpectedHeaders: authHeaders,
+				ExpectedBody:    expectedBody,
+			},
+			Response: stubs.StubHTTPResponse{
+				Status: responseStatus,
+				Body:   responseBody,
+			},
+		})
 }
 
 func getJobsGetEndpoints(
diff --git a/internal/services/workbench/snapshots_test.go b/internal/services/workbench/snapshots_test.go
index 53927fa..84b1017 100644
--- a/internal/services/workbench/snapshots_test.go
+++ b/internal/services/workbench/snapshots_test.go
@@ -905,19 +905,19 @@ func getSnapshotDeleteEndpoints(
 	responseStatus int,
 	responseBody string,
 ) []stubs.StubHTTPEndpoint {
-	endpoints := getSnapshotsGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody)
-	return append(endpoints, stubs.StubHTTPEndpoint{
-		Request: stubs.StubHTTPRequest{
-			Method:          http.MethodPost,
-			URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCSnapshotBulkDelete),
-			ExpectedHeaders: authHeaders,
-			ExpectedBody:    expectedBody,
-		},
-		Response: stubs.StubHTTPResponse{
-			Body:   responseBody,
-			Status: responseStatus,
-		},
-	})
+	return append(getSnapshotsGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody),
+		stubs.StubHTTPEndpoint{
+			Request: stubs.StubHTTPRequest{
+				Method:          http.MethodPost,
+				URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCSnapshotBulkDelete),
+				ExpectedHeaders: authHeaders,
+				ExpectedBody:    expectedBody,
+			},
+			Response: stubs.StubHTTPResponse{
+				Body:   responseBody,
+				Status: responseStatus,
+			},
+		})
 }
 
 func getSnapshotCreateEndpoints(
@@ -948,19 +948,19 @@ func getSnapshotBulkUpdateEndpoints(
 	responseStatus int,
 	responseBody string,
 ) []stubs.StubHTTPEndpoint {
-	endpoints := getSnapshotsGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody)
-	return append(endpoints, stubs.StubHTTPEndpoint{
-		Request: stubs.StubHTTPRequest{
-			Method:          http.MethodPost,
-			URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCSnapshotBulkUpdate),
-			ExpectedHeaders: authHeaders,
-			ExpectedBody:    expectedBody,
-		},
-		Response: stubs.StubHTTPResponse{
-			Status: responseStatus,
-			Body:   responseBody,
-		},
-	})
+	return append(getSnapshotsGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody),
+		stubs.StubHTTPEndpoint{
+			Request: stubs.StubHTTPRequest{
+				Method:          http.MethodPost,
+				URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCSnapshotBulkUpdate),
+				ExpectedHeaders: authHeaders,
+				ExpectedBody:    expectedBody,
+			},
+			Response: stubs.StubHTTPResponse{
+				Status: responseStatus,
+				Body:   responseBody,
+			},
+		})
 }
 
 func getSnapshotsGetEndpoints(
diff --git a/internal/services/workbench/workbench_test.go b/internal/services/workbench/workbench_test.go
index 0fd8e33..5886056 100644
--- a/internal/services/workbench/workbench_test.go
+++ b/internal/services/workbench/workbench_test.go
@@ -378,7 +378,7 @@ func TestGetWorkspaceRequests(t *testing.T) {
 			request: request{workspaceIDs: []string{"ws-1"}},
 			mockEndpoints: getEndpoints(
 				postgrest.Endpoint(constants.WorkbenchEndpointWorkspaces),
-				"controller_id=in.(ws-1)",
+				"or=(controller_id.in.(ws-1),name.in.(ws-1))",
 				http.StatusOK,
 				`[ {"id":"ws-1","controller_id":"ctrl-1","name":"myws","properties":{"HOME_DIRECTORY":"true"}} ]`,
 			),
@@ -393,7 +393,7 @@ func TestGetWorkspaceRequests(t *testing.T) {
 			request: request{workspaceIDs: []string{"00000000-0000-0000-0000-000000000001"}},
 			mockEndpoints: getEndpoints(
 				postgrest.Endpoint(constants.WorkbenchEndpointWorkspaces),
-				"id=in.(00000000-0000-0000-0000-000000000001)",
+				"or=(id.in.(00000000-0000-0000-0000-000000000001),name.in.(00000000-0000-0000-0000-000000000001))",
 				http.StatusOK,
 				`[ {"id":"00000000-0000-0000-0000-000000000001","controller_id":"","name":"wsname","properties":{}} ]`,
 			),
@@ -408,7 +408,7 @@ func TestGetWorkspaceRequests(t *testing.T) {
 			request: request{workspaceIDs: []string{"missing"}},
 			mockEndpoints: getEndpoints(
 				postgrest.Endpoint(constants.WorkbenchEndpointWorkspaces),
-				"controller_id=in.(missing)",
+				"or=(controller_id.in.(missing),name.in.(missing))",
 				http.StatusOK,
 				`[]`,
 			),
@@ -419,12 +419,13 @@ func TestGetWorkspaceRequests(t *testing.T) {
 			request: request{workspaceIDs: []string{"ws-1"}},
 			mockEndpoints: getEndpoints(
 				postgrest.Endpoint(constants.WorkbenchEndpointWorkspaces),
-				"controller_id=in.(ws-1)",
+				"or=(controller_id.in.(ws-1),name.in.(ws-1))",
 				http.StatusOK,
 				`[invalid`,
 			),
 			expected: expected{
-				err: "GET ${URL}/postgrest/workspaces?controller_id=in.%28ws-1%29 failed" +
+				err: "GET ${URL}/postgrest/workspaces?" +
+					"or=%28controller_id.in.%28ws-1%29%2Cname.in.%28ws-1%29%29 failed" +
 					"\n Caused by:" +
 					"\n\t* invalid character 'i' looking for beginning of value",
 			},
diff --git a/internal/services/workbench/workbenches_test.go b/internal/services/workbench/workbenches_test.go
index 532405c..bbfdec7 100644
--- a/internal/services/workbench/workbenches_test.go
+++ b/internal/services/workbench/workbenches_test.go
@@ -987,19 +987,19 @@ func getWorkbenchesDeleteEndpoints(
 	responseStatus int,
 	responseBody string,
 ) []stubs.StubHTTPEndpoint {
-	endpoints := getWorkbenchesGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody)
-	return append(endpoints, stubs.StubHTTPEndpoint{
-		Request: stubs.StubHTTPRequest{
-			Method:          http.MethodPost,
-			URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkbenchBulkDelete),
-			ExpectedHeaders: authHeaders,
-			ExpectedBody:    expectedBody,
-		},
-		Response: stubs.StubHTTPResponse{
-			Status: responseStatus,
-			Body:   responseBody,
-		},
-	})
+	return append(getWorkbenchesGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody),
+		stubs.StubHTTPEndpoint{
+			Request: stubs.StubHTTPRequest{
+				Method:          http.MethodPost,
+				URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkbenchBulkDelete),
+				ExpectedHeaders: authHeaders,
+				ExpectedBody:    expectedBody,
+			},
+			Response: stubs.StubHTTPResponse{
+				Status: responseStatus,
+				Body:   responseBody,
+			},
+		})
 }
 
 func getWorkbenchesCreateEndpoints(
@@ -1030,19 +1030,19 @@ func getWorkbenchesBulkUpdateEndpoints(
 	responseStatus int,
 	responseBody string,
 ) []stubs.StubHTTPEndpoint {
-	endpoints := getWorkbenchesGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody)
-	return append(endpoints, stubs.StubHTTPEndpoint{
-		Request: stubs.StubHTTPRequest{
-			Method:          http.MethodPost,
-			URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkbenchBulkUpdate),
-			ExpectedHeaders: authHeaders,
-			ExpectedBody:    expectedBody,
-		},
-		Response: stubs.StubHTTPResponse{
-			Status: responseStatus,
-			Body:   responseBody,
-		},
-	})
+	return append(getWorkbenchesGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody),
+		stubs.StubHTTPEndpoint{
+			Request: stubs.StubHTTPRequest{
+				Method:          http.MethodPost,
+				URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkbenchBulkUpdate),
+				ExpectedHeaders: authHeaders,
+				ExpectedBody:    expectedBody,
+			},
+			Response: stubs.StubHTTPResponse{
+				Status: responseStatus,
+				Body:   responseBody,
+			},
+		})
 }
 
 func getWorkbenchesUpdateEndpoints(
@@ -1052,19 +1052,19 @@ func getWorkbenchesUpdateEndpoints(
 	responseStatus int,
 	responseBody string,
 ) []stubs.StubHTTPEndpoint {
-	endpoints := getWorkbenchesGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody)
-	return append(endpoints, stubs.StubHTTPEndpoint{
-		Request: stubs.StubHTTPRequest{
-			Method:          http.MethodPost,
-			URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkbenchUpdate),
-			ExpectedHeaders: authHeaders,
-			ExpectedBody:    expectedBody,
-		},
-		Response: stubs.StubHTTPResponse{
-			Status: responseStatus,
-			Body:   responseBody,
-		},
-	})
+	return append(getWorkbenchesGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody),
+		stubs.StubHTTPEndpoint{
+			Request: stubs.StubHTTPRequest{
+				Method:          http.MethodPost,
+				URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkbenchUpdate),
+				ExpectedHeaders: authHeaders,
+				ExpectedBody:    expectedBody,
+			},
+			Response: stubs.StubHTTPResponse{
+				Status: responseStatus,
+				Body:   responseBody,
+			},
+		})
 }
 
 func getWorkbenchesGetEndpoints(
diff --git a/internal/services/workbench/workspaces_test.go b/internal/services/workbench/workspaces_test.go
index 9b9dbc7..67ac1b8 100644
--- a/internal/services/workbench/workspaces_test.go
+++ b/internal/services/workbench/workspaces_test.go
@@ -953,19 +953,19 @@ func getWorkspaceUpdateEndpoints(
 	responseStatus int,
 	responseBody string,
 ) []stubs.StubHTTPEndpoint {
-	endpoints := getWorkspaceGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody)
-	return append(endpoints, stubs.StubHTTPEndpoint{
-		Request: stubs.StubHTTPRequest{
-			Method:          http.MethodPost,
-			URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkspaceUpdate),
-			ExpectedHeaders: authHeaders,
-			ExpectedBody:    expectedBody,
-		},
-		Response: stubs.StubHTTPResponse{
-			Status: responseStatus,
-			Body:   responseBody,
-		},
-	})
+	return append(getWorkspaceGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody),
+		stubs.StubHTTPEndpoint{
+			Request: stubs.StubHTTPRequest{
+				Method:          http.MethodPost,
+				URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkspaceUpdate),
+				ExpectedHeaders: authHeaders,
+				ExpectedBody:    expectedBody,
+			},
+			Response: stubs.StubHTTPResponse{
+				Status: responseStatus,
+				Body:   responseBody,
+			},
+		})
 }
 
 func getWorkspaceBulkUpdateEndpoints(
@@ -975,19 +975,19 @@ func getWorkspaceBulkUpdateEndpoints(
 	responseStatus int,
 	responseBody string,
 ) []stubs.StubHTTPEndpoint {
-	endpoints := getWorkspaceGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody)
-	return append(endpoints, stubs.StubHTTPEndpoint{
-		Request: stubs.StubHTTPRequest{
-			Method:          http.MethodPost,
-			URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkspaceBulkUpdate),
-			ExpectedHeaders: authHeaders,
-			ExpectedBody:    expectedBody,
-		},
-		Response: stubs.StubHTTPResponse{
-			Status: responseStatus,
-			Body:   responseBody,
-		},
-	})
+	return append(getWorkspaceGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody),
+		stubs.StubHTTPEndpoint{
+			Request: stubs.StubHTTPRequest{
+				Method:          http.MethodPost,
+				URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkspaceBulkUpdate),
+				ExpectedHeaders: authHeaders,
+				ExpectedBody:    expectedBody,
+			},
+			Response: stubs.StubHTTPResponse{
+				Status: responseStatus,
+				Body:   responseBody,
+			},
+		})
 }
 
 func getWorkspaceDeleteEndpoints(
@@ -997,19 +997,19 @@ func getWorkspaceDeleteEndpoints(
 	responseStatus int,
 	responseBody string,
 ) []stubs.StubHTTPEndpoint {
-	endpoints := getWorkspaceGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody)
-	return append(endpoints, stubs.StubHTTPEndpoint{
-		Request: stubs.StubHTTPRequest{
-			Method:          http.MethodPost,
-			URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkspaceBulkDelete),
-			ExpectedHeaders: authHeaders,
-			ExpectedBody:    expectedBody,
-		},
-		Response: stubs.StubHTTPResponse{
-			Body:   responseBody,
-			Status: responseStatus,
-		},
-	})
+	return append(getWorkspaceGetEndpoints(expectedQuery, http.StatusOK, resolveResponseBody),
+		stubs.StubHTTPEndpoint{
+			Request: stubs.StubHTTPRequest{
+				Method:          http.MethodPost,
+				URL:             postgrest.Endpoint(constants.WorkbenchEndpointRPCWorkspaceBulkDelete),
+				ExpectedHeaders: authHeaders,
+				ExpectedBody:    expectedBody,
+			},
+			Response: stubs.StubHTTPResponse{
+				Body:   responseBody,
+				Status: responseStatus,
+			},
+		})
 }
 
 func getWorkspaceCreateEndpoints(
diff --git a/internal/stubs/common_endpoints.go b/internal/stubs/common_endpoints.go
index 2e0fa89..372cf5e 100644
--- a/internal/stubs/common_endpoints.go
+++ b/internal/stubs/common_endpoints.go
@@ -12,8 +12,8 @@ import (
 )
 
 const (
-	expectedWorkspaceQueryHome        = "controller_id=in.(ws-home)"
-	expectedWorkspaceQueryData        = "controller_id=in.(ws-data)"
+	expectedWorkspaceQueryHome        = "or=(controller_id.in.(ws-home),name.in.(ws-home))"
+	expectedWorkspaceQueryData        = "or=(controller_id.in.(ws-data),name.in.(ws-data))"
 	expectedWorkspaceResponseBodyHome = `[{"id":"ws-home","controller_id":"ctrl-home","name":"home",` +
 		`"properties":{"HOME_DIRECTORY":"true"}}]`
 	expectedWorkspaceResponseBodyData = `[{"id":"ws-data","controller_id":"ctrl-data","name":"data",` +

From 6bb2ef0b932905d8aaad76a03647580a5f47a2eb Mon Sep 17 00:00:00 2001
From: Luke Duan 
Date: Tue, 3 Nov 2026 16:51:32 -0700
Subject: [PATCH 07/11] fix: don't make redundant get

---
 internal/cmd/state/state.go      |  7 +------
 internal/cmd/state/state_test.go | 11 ++++++-----
 2 files changed, 7 insertions(+), 11 deletions(-)

diff --git a/internal/cmd/state/state.go b/internal/cmd/state/state.go
index 53d65a1..a82f140 100644
--- a/internal/cmd/state/state.go
+++ b/internal/cmd/state/state.go
@@ -105,12 +105,7 @@ func setWorkbenchDesiredState(
 	desiredState string,
 	svc *workbench.WorkbenchService,
 ) error {
-	workbenches, err := workbench.GetWorkbenches(ctx, svc, ids, "")
-	if err != nil {
-		return err
-	}
-	resolvedIDs := getIDs(workbenches)
-	changedIDs, err := bulkUpdateState(ctx, svc, desiredState, resolvedIDs)
+	changedIDs, err := bulkUpdateState(ctx, svc, desiredState, ids)
 	if err != nil {
 		return err
 	}
diff --git a/internal/cmd/state/state_test.go b/internal/cmd/state/state_test.go
index 149983e..1018c1e 100644
--- a/internal/cmd/state/state_test.go
+++ b/internal/cmd/state/state_test.go
@@ -51,7 +51,8 @@ const (
 		"\n Caused by:" +
 		"\n\t* parse \"a\\nb\": net/url: invalid control character in URL"
 	expectedInvalidResourceErr = "" +
-		"GET ${URL}/postgrest/workbenches?controller_id=in.%28controller-1%29 failed" +
+		"GET ${URL}/postgrest/workbenches?" +
+		"or=%28controller_id.in.%28controller-1%29%2Cname.in.%28controller-1%29%29 failed" +
 		"\n Caused by:" +
 		"\n\t* invalid character 'i' looking for beginning of value"
 	expectedInvalidUpdateResponseErr = "" +
@@ -112,7 +113,7 @@ func TestStateCommands(t *testing.T) {
 			},
 			mockEndpoints: getStateEndpoints(stateConfig{
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(controller-1,controller-2)",
+					ExpectedQuery:  "or=(controller_id.in.(controller-1,controller-2),name.in.(controller-1,controller-2))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   stateGetResponseBodySingular,
 				},
@@ -136,7 +137,7 @@ func TestStateCommands(t *testing.T) {
 			},
 			mockEndpoints: getStateEndpoints(stateConfig{
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(controller-3)",
+					ExpectedQuery:  "or=(controller_id.in.(controller-3),name.in.(controller-3))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   stateGetResponseBodyPlural,
 				},
@@ -171,7 +172,7 @@ func TestStateCommands(t *testing.T) {
 			},
 			mockEndpoints: getStateEndpoints(stateConfig{
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(controller-1)",
+					ExpectedQuery:  "or=(controller_id.in.(controller-1),name.in.(controller-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   `[invalid`,
 				},
@@ -190,7 +191,7 @@ func TestStateCommands(t *testing.T) {
 			},
 			mockEndpoints: getStateEndpoints(stateConfig{
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(controller-3)",
+					ExpectedQuery:  "or=(controller_id.in.(controller-3),name.in.(controller-3))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   stateGetResponseBodyPlural,
 				},

From 6f828bcb8a887b62f05b0a803cea5f299189dc40 Mon Sep 17 00:00:00 2001
From: Luke Duan 
Date: Tue, 3 Nov 2026 17:28:44 -0700
Subject: [PATCH 08/11] test: test fixes

---
 internal/cmd/create/image/image_test.go |  4 +-
 internal/cmd/edit/edit_test.go          | 98 ++++++++++++++++++-------
 internal/cmd/log/log_test.go            | 38 +++++-----
 internal/cmd/rebuild/rebuild_test.go    | 21 +++---
 4 files changed, 105 insertions(+), 56 deletions(-)

diff --git a/internal/cmd/create/image/image_test.go b/internal/cmd/create/image/image_test.go
index dc9fe36..c493c2e 100644
--- a/internal/cmd/create/image/image_test.go
+++ b/internal/cmd/create/image/image_test.go
@@ -818,7 +818,7 @@ func getUpdateArgs(
 }
 
 func getUpdateImageEndpoints(
-	expectedResolveQuery string,
+	expectedQuery string,
 	resolveResponseBody string,
 	expectedRequestBody string,
 	responseStatus int,
@@ -831,7 +831,7 @@ func getUpdateImageEndpoints(
 					Method:          http.MethodGet,
 					URL:             postgrest.Endpoint(constants.WorkbenchEndpointImages),
 					ExpectedHeaders: stubs.StubAuthHeaders,
-					ExpectedQuery:   expectedResolveQuery,
+					ExpectedQuery:   expectedQuery,
 				},
 				Response: stubs.StubHTTPResponse{
 					Status: http.StatusOK,
diff --git a/internal/cmd/edit/edit_test.go b/internal/cmd/edit/edit_test.go
index 93a5678..8349f55 100644
--- a/internal/cmd/edit/edit_test.go
+++ b/internal/cmd/edit/edit_test.go
@@ -27,6 +27,7 @@ const (
 	testResourceID1 = "resource-1"
 	oneImageJSON    = `[
     {
+        "id": "resource-1",
         "image_name": "test-image",
         "image_registry": "example.com",
         "image_tag": "latest",
@@ -44,11 +45,20 @@ const (
         "image_name": "test-image",
         "image_registry": "example.com",
         "image_tag": "latest",
-        "name": "resource-2"
+        "name": "resource-1"
     }
 ]`
 	oneUpdatedImageJSON = `{"ids":["resource-1"],"param":{"image_name":"test-image",` +
 		`"image_registry":"example.com","image_tag":"latest","name":"UPDATED"}}`
+	oneUpdatedImageResponseJSON = `[
+    {
+        "id": "resource-1",
+        "image_name": "test-image",
+        "image_registry": "example.com",
+        "image_tag": "latest",
+        "name": "UPDATED"
+    }
+]`
 	oneWorkbenchJSON = `[
     {
         "id": "resource-1",
@@ -129,7 +139,7 @@ const (
         "workspaces": [
             {
                 "id": "sas-workbench-v0fq4c4ff9xqlv70j76amcqe",
-                "mountName": "replace-me"
+                "mountName": "UPDATED"
             }
         ]
     }`
@@ -145,9 +155,10 @@ const (
 		`"workspaces":[{"id":"sas-workbench-v0fq4c4ff9xqlv70j76amcqe","mountName":"UPDATED"}]` +
 		`}}`
 	oneJobJSON = `[{
+    "id": "resource-1",
     "application_id": "workbench-environment-builder",
     "dataplane_id": "ae0d1d17-c239-443e-ab80-70731b9e15de",
-    "name": "Job-2",
+    "name": "resource-1",
     "nodepool": "C0M0",
     "services": [
         {
@@ -178,17 +189,49 @@ const (
 	oneUpdatedJobJSON = `{"ids":["resource-1"],` +
 		`"param":{"application_id":"workbench-environment-builder",` +
 		`"dataplane_id":"ae0d1d17-c239-443e-ab80-70731b9e15de",` +
-		`"name":"Job-2",` +
+		`"name":"resource-1",` +
 		`"nodepool":"C0M0",` +
 		`"services":[{"id":"workbench-environment-builder",` +
 		`"env":{"DEFAULT_MOUNTNAME":"containers","WB_LOCALE":"en-US,en"}}],` +
 		`"spec":{"tasks":{"default":{"cmds":["echo \"test123\""]}},"version":"3"},` +
 		`"workspaces":[{"id":"sas-workbench-5tcc2a32w0wu293go4nu8ldvj","mountName":"UPDATED"}]}}`
+	oneUpdatedJobResponseJSON = `[{
+    "id": "resource-1",
+    "application_id": "workbench-environment-builder",
+    "dataplane_id": "ae0d1d17-c239-443e-ab80-70731b9e15de",
+    "name": "resource-1",
+    "nodepool": "C0M0",
+    "services": [
+        {
+            "id": "workbench-environment-builder",
+            "env": {
+                "DEFAULT_MOUNTNAME": "containers",
+                "WB_LOCALE": "en-US,en"
+            }
+        }
+    ],
+    "spec": {
+        "tasks": {
+            "default": {
+                "cmds": [
+                    "echo \"test123\""
+                ]
+            }
+        },
+        "version": "3"
+    },
+    "workspaces": [
+        {
+            "id": "sas-workbench-5tcc2a32w0wu293go4nu8ldvj",
+            "mountName": "UPDATED"
+        }
+    ]
+}]`
 	expectedInvalidEndpointErr = "failed to get device token" +
 		"\n Caused by:" +
 		"\n\t* parse \"a\\nb\": net/url: invalid control character in URL"
 	expectedInvalidResourceErr = "" +
-		"GET ${URL}/postgrest/jobs?id=in.%28resource-1%29 failed" +
+		"GET ${URL}/postgrest/jobs?or=%28name.in.%28resource-1%29%29 failed" +
 		"\n Caused by:" +
 		"\n\t* invalid character 'i' looking for beginning of value"
 	expectedEditErr = "" +
@@ -464,11 +507,11 @@ func TestEditCommand(t *testing.T) {
 				postConfig: stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: oneUpdatedImageJSON,
 					ResponseStatus:      http.StatusOK,
-					ResponseBody:        oneImageJSON,
+					ResponseBody:        oneUpdatedImageResponseJSON,
 				},
 			}),
 			expected: editExpected{
-				getCalls:  1,
+				getCalls:  2,
 				postCalls: 1,
 				displays:  []string{stubs.ExpectedDeviceAuthDisplay, "resource-1"},
 			},
@@ -543,7 +586,7 @@ func TestEditCommand(t *testing.T) {
 				},
 			}),
 			expected: editExpected{
-				getCalls:  1,
+				getCalls:  2,
 				postCalls: 1,
 				displays:  []string{stubs.ExpectedDeviceAuthDisplay, "resource-1"},
 			},
@@ -558,7 +601,7 @@ func TestEditCommand(t *testing.T) {
 			mockEndpoints: getEditEndpoints(editConfig{
 				getURL: postgrest.Endpoint(constants.WorkbenchEndpointJobs),
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "id=in.(resource-1)",
+					ExpectedQuery:  "or=(name.in.(resource-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   oneJobJSON,
 				},
@@ -566,11 +609,11 @@ func TestEditCommand(t *testing.T) {
 				postConfig: stubs.StubHTTPPartialPostConfig{
 					ExpectedRequestBody: oneUpdatedJobJSON,
 					ResponseStatus:      http.StatusOK,
-					ResponseBody:        oneJobJSON,
+					ResponseBody:        oneUpdatedJobResponseJSON,
 				},
 			}),
 			expected: editExpected{
-				getCalls:  1,
+				getCalls:  2,
 				postCalls: 1,
 				displays:  []string{stubs.ExpectedDeviceAuthDisplay, "resource-1"},
 			},
@@ -595,7 +638,7 @@ func TestEditCommand(t *testing.T) {
 			mockEndpoints: getEditEndpoints(editConfig{
 				getURL: postgrest.Endpoint(constants.WorkbenchEndpointJobs),
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "id=in.(resource-1)",
+					ExpectedQuery:  "or=(name.in.(resource-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   `[invalid`,
 				},
@@ -616,7 +659,7 @@ func TestEditCommand(t *testing.T) {
 			mockEndpoints: getEditEndpoints(editConfig{
 				getURL: postgrest.Endpoint(constants.WorkbenchEndpointJobs),
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "id=in.(resource-1)",
+					ExpectedQuery:  "or=(name.in.(resource-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   oneJobJSON,
 				},
@@ -629,7 +672,7 @@ func TestEditCommand(t *testing.T) {
 			}),
 			expected: editExpected{
 				displays:  []string{stubs.ExpectedDeviceAuthDisplay},
-				getCalls:  1,
+				getCalls:  2,
 				postCalls: 1,
 				err:       expectedEditErr,
 			},
@@ -734,20 +777,23 @@ type editConfig struct {
 func getEditEndpoints(
 	config editConfig,
 ) []stubs.StubHTTPEndpoint {
+	getEndpoint := stubs.StubHTTPEndpoint{
+		Request: stubs.StubHTTPRequest{
+			Method:          http.MethodGet,
+			URL:             config.getURL,
+			ExpectedHeaders: stubs.StubAuthHeaders,
+			ExpectedQuery:   config.getConfig.ExpectedQuery,
+		},
+		Response: stubs.StubHTTPResponse{
+			Status: config.getConfig.ResponseStatus,
+			Body:   config.getConfig.ResponseBody,
+		},
+	}
 	endpoints := stubs.GetStubAuthEndpoints()
+	endpoints = append(endpoints, getEndpoint)
+	// ID resolution repeats the GET before bulk updates.
+	endpoints = append(endpoints, getEndpoint)
 	endpoints = append(endpoints,
-		stubs.StubHTTPEndpoint{
-			Request: stubs.StubHTTPRequest{
-				Method:          http.MethodGet,
-				URL:             config.getURL,
-				ExpectedHeaders: stubs.StubAuthHeaders,
-				ExpectedQuery:   config.getConfig.ExpectedQuery,
-			},
-			Response: stubs.StubHTTPResponse{
-				Status: config.getConfig.ResponseStatus,
-				Body:   config.getConfig.ResponseBody,
-			},
-		},
 		stubs.StubHTTPEndpoint{
 			Request: stubs.StubHTTPRequest{
 				Method:          http.MethodPost,
diff --git a/internal/cmd/log/log_test.go b/internal/cmd/log/log_test.go
index fe000f5..9b95d05 100644
--- a/internal/cmd/log/log_test.go
+++ b/internal/cmd/log/log_test.go
@@ -61,19 +61,20 @@ func TestLogCommand(t *testing.T) {
 			name: "new log function image build list non-200 from server",
 			input: logInput{
 				resourceType: constants.ImageBuildsType,
-				resourceIDs:  []string{"controller-1"},
+				resourceIDs:  []string{"image-build-1"},
 			},
 			mockEndpoints: getLogEndpoints(logConfig{
 				resourceURL: postgrest.Endpoint(constants.WorkbenchEndpointImageBuilds),
 				getResourceConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(controller-1)",
+					ExpectedQuery:  "or=(controller_id.in.(image-build-1),name.in.(image-build-1))",
 					ResponseStatus: http.StatusInternalServerError,
 					ResponseBody:   `resource-error`,
 				},
 			}),
 			expected: logExpected{
 				displays: []string{stubs.ExpectedDeviceAuthDisplay},
-				err: "GET ${URL}/postgrest/image_builds?controller_id=in.%28controller-1%29 failed\n" +
+				err: "GET ${URL}/postgrest/image_builds" +
+					"?or=%28controller_id.in.%28image-build-1%29%2Cname.in.%28image-build-1%29%29 failed\n" +
 					" Caused by:\n" +
 					"\t* 500 resource-error",
 				resourceCalls: 1},
@@ -82,12 +83,12 @@ func TestLogCommand(t *testing.T) {
 			name: "new log function image build raw log non-200 from server",
 			input: logInput{
 				resourceType: constants.ImageBuildsType,
-				resourceIDs:  []string{"controller-1"},
+				resourceIDs:  []string{"image-build-1"},
 			},
 			mockEndpoints: getLogEndpoints(logConfig{
 				resourceURL: postgrest.Endpoint(constants.WorkbenchEndpointImageBuilds),
 				getResourceConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(controller-1)",
+					ExpectedQuery:  "or=(controller_id.in.(image-build-1),name.in.(image-build-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   logImageBuildsResponseBody,
 				},
@@ -108,19 +109,20 @@ func TestLogCommand(t *testing.T) {
 			name: "new log function job submission list non-200 from server",
 			input: logInput{
 				resourceType: constants.JobSubmissionsType,
-				resourceIDs:  []string{"controller-1"},
+				resourceIDs:  []string{"job-submission-1"},
 			},
 			mockEndpoints: getLogEndpoints(logConfig{
 				resourceURL: postgrest.Endpoint(constants.WorkbenchEndpointJobRuns),
 				getResourceConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(controller-1)",
+					ExpectedQuery:  "or=(controller_id.in.(job-submission-1),job_name.in.(job-submission-1))",
 					ResponseStatus: http.StatusInternalServerError,
 					ResponseBody:   "resource-error",
 				},
 			}),
 			expected: logExpected{
 				displays: []string{stubs.ExpectedDeviceAuthDisplay},
-				err: "GET ${URL}/postgrest/job_runs?controller_id=in.%28controller-1%29 failed\n" +
+				err: "GET ${URL}/postgrest/job_runs" +
+					"?or=%28controller_id.in.%28job-submission-1%29%2Cjob_name.in.%28job-submission-1%29%29 failed\n" +
 					" Caused by:\n" +
 					"\t* 500 resource-error",
 				resourceCalls: 1},
@@ -129,12 +131,12 @@ func TestLogCommand(t *testing.T) {
 			name: "new log function job submission raw log non-200 from server",
 			input: logInput{
 				resourceType: constants.JobSubmissionsType,
-				resourceIDs:  []string{"controller-1"},
+				resourceIDs:  []string{"job-submission-1"},
 			},
 			mockEndpoints: getLogEndpoints(logConfig{
 				resourceURL: postgrest.Endpoint(constants.WorkbenchEndpointJobRuns),
 				getResourceConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(controller-1)",
+					ExpectedQuery:  "or=(controller_id.in.(job-submission-1),job_name.in.(job-submission-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   logJobSubmissionsResponseBody,
 				},
@@ -153,11 +155,11 @@ func TestLogCommand(t *testing.T) {
 		},
 		{
 			name:  "new log function prints image build logs by singular type",
-			input: logInput{resourceType: constants.ImageBuildType, resourceIDs: []string{"controller-1"}},
+			input: logInput{resourceType: constants.ImageBuildType, resourceIDs: []string{"image-build-1"}},
 			mockEndpoints: getLogEndpoints(logConfig{
 				resourceURL: postgrest.Endpoint(constants.WorkbenchEndpointImageBuilds),
 				getResourceConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(controller-1)",
+					ExpectedQuery:  "or=(controller_id.in.(image-build-1),name.in.(image-build-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   logImageBuildsResponseBody,
 				},
@@ -177,12 +179,12 @@ func TestLogCommand(t *testing.T) {
 			name: "new log function prints image build logs by plural type",
 			input: logInput{
 				resourceType: constants.ImageBuildsType,
-				resourceIDs:  []string{"controller-1"},
+				resourceIDs:  []string{"image-build-1"},
 			},
 			mockEndpoints: getLogEndpoints(logConfig{
 				resourceURL: postgrest.Endpoint(constants.WorkbenchEndpointImageBuilds),
 				getResourceConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(controller-1)",
+					ExpectedQuery:  "or=(controller_id.in.(image-build-1),name.in.(image-build-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   logImageBuildsResponseBody,
 				},
@@ -200,11 +202,11 @@ func TestLogCommand(t *testing.T) {
 		},
 		{
 			name:  "new log function prints job submission logs by singular type",
-			input: logInput{resourceType: constants.JobSubmissionType, resourceIDs: []string{"controller-2"}},
+			input: logInput{resourceType: constants.JobSubmissionType, resourceIDs: []string{"job-submission-1"}},
 			mockEndpoints: getLogEndpoints(logConfig{
 				resourceURL: postgrest.Endpoint(constants.WorkbenchEndpointJobRuns),
 				getResourceConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(controller-2)",
+					ExpectedQuery:  "or=(controller_id.in.(job-submission-1),job_name.in.(job-submission-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   logJobSubmissionsResponseBody,
 				},
@@ -222,11 +224,11 @@ func TestLogCommand(t *testing.T) {
 		},
 		{
 			name:  "new log function prints job submission logs by plural type",
-			input: logInput{resourceType: constants.JobSubmissionsType, resourceIDs: []string{"controller-2"}},
+			input: logInput{resourceType: constants.JobSubmissionsType, resourceIDs: []string{"job-submission-1"}},
 			mockEndpoints: getLogEndpoints(logConfig{
 				resourceURL: postgrest.Endpoint(constants.WorkbenchEndpointJobRuns),
 				getResourceConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(controller-2)",
+					ExpectedQuery:  "or=(controller_id.in.(job-submission-1),job_name.in.(job-submission-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   logJobSubmissionsResponseBody,
 				},
diff --git a/internal/cmd/rebuild/rebuild_test.go b/internal/cmd/rebuild/rebuild_test.go
index 169c4fb..33d20de 100644
--- a/internal/cmd/rebuild/rebuild_test.go
+++ b/internal/cmd/rebuild/rebuild_test.go
@@ -29,7 +29,8 @@ const (
 		"\n Caused by:" +
 		"\n\t* parse \"a\\nb\": net/url: invalid control character in URL"
 	expectedInvalidResourceErr = "" +
-		"GET ${URL}/postgrest/images?controller_id=in.%28controller-1%29 failed" +
+		"GET ${URL}/postgrest/images?" +
+		"or=%28controller_id.in.%28image-1%29%2Cname.in.%28image-1%29%29 failed" +
 		"\n Caused by:" +
 		"\n\t* invalid character 'i' looking for beginning of value"
 )
@@ -59,10 +60,10 @@ func TestRebuildCommand(t *testing.T) {
 	tests := []rebuildTestCase{
 		{
 			name:  "rebuild images by singular type",
-			input: rebuildInput{resourceType: constants.ImageType, resourceIDs: []string{"controller-1"}},
+			input: rebuildInput{resourceType: constants.ImageType, resourceIDs: []string{"image-1", "image-2"}},
 			mockEndpoints: getRebuildEndpoints(rebuildConfig{
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(controller-1)",
+					ExpectedQuery:  "or=(controller_id.in.(image-1,image-2),name.in.(image-1,image-2))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   rebuildImagesResponseBody,
 				},
@@ -79,10 +80,10 @@ func TestRebuildCommand(t *testing.T) {
 		},
 		{
 			name:  "rebuild images by plural type",
-			input: rebuildInput{resourceType: constants.ImagesType, resourceIDs: []string{"controller-2"}},
+			input: rebuildInput{resourceType: constants.ImagesType, resourceIDs: []string{"image-1", "image-2"}},
 			mockEndpoints: getRebuildEndpoints(rebuildConfig{
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(controller-2)",
+					ExpectedQuery:  "or=(controller_id.in.(image-1,image-2),name.in.(image-1,image-2))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   rebuildImagesResponseBody,
 				},
@@ -99,10 +100,10 @@ func TestRebuildCommand(t *testing.T) {
 		},
 		{
 			name:  "returns error when rebuild call fails",
-			input: rebuildInput{resourceType: constants.ImagesType, resourceIDs: []string{"controller-2"}},
+			input: rebuildInput{resourceType: constants.ImagesType, resourceIDs: []string{"image-1"}},
 			mockEndpoints: getRebuildEndpoints(rebuildConfig{
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(controller-2)",
+					ExpectedQuery:  "or=(controller_id.in.(image-1),name.in.(image-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   rebuildImagesResponseBody,
 				},
@@ -122,7 +123,7 @@ func TestRebuildCommand(t *testing.T) {
 			input: rebuildInput{
 				endpoint:     "a\nb",
 				resourceType: constants.ImageType,
-				resourceIDs:  []string{"controller-1"},
+				resourceIDs:  []string{"image-1"},
 			},
 			expected: rebuildExpected{
 				err: expectedInvalidEndpointErr,
@@ -132,11 +133,11 @@ func TestRebuildCommand(t *testing.T) {
 			name: "invalid image response",
 			input: rebuildInput{
 				resourceType: constants.ImageType,
-				resourceIDs:  []string{"controller-1"},
+				resourceIDs:  []string{"image-1"},
 			},
 			mockEndpoints: getRebuildEndpoints(rebuildConfig{
 				getConfig: stubs.StubHTTPPartialGetConfig{
-					ExpectedQuery:  "controller_id=in.(controller-1)",
+					ExpectedQuery:  "or=(controller_id.in.(image-1),name.in.(image-1))",
 					ResponseStatus: http.StatusOK,
 					ResponseBody:   `[invalid`,
 				},

From 2f886c709096fefe17329e91a59c93aca83c48e9 Mon Sep 17 00:00:00 2001
From: Luke Duan 
Date: Wed, 4 Nov 2026 11:31:38 -0700
Subject: [PATCH 09/11] test: improve log_test coverage

---
 internal/cmd/log/log_test.go | 32 ++++++++++++++++++++++++++++++++
 1 file changed, 32 insertions(+)

diff --git a/internal/cmd/log/log_test.go b/internal/cmd/log/log_test.go
index 9b95d05..452fc1c 100644
--- a/internal/cmd/log/log_test.go
+++ b/internal/cmd/log/log_test.go
@@ -255,6 +255,38 @@ func TestLogCommand(t *testing.T) {
 				err: expectedInvalidEndpointErr,
 			},
 		},
+		{
+			name:  "empty job submission log url",
+			input: logInput{resourceType: constants.JobSubmissionsType, resourceIDs: []string{"job-submission-2"}},
+			mockEndpoints: getLogEndpoints(logConfig{
+				resourceURL: postgrest.Endpoint(constants.WorkbenchEndpointJobRuns),
+				getResourceConfig: stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  "or=(controller_id.in.(job-submission-2),job_name.in.(job-submission-2))",
+					ResponseStatus: http.StatusOK,
+					ResponseBody:   logJobSubmissionsResponseBody,
+				},
+			}),
+			expected: logExpected{
+				displays:      []string{stubs.ExpectedDeviceAuthDisplay},
+				resourceCalls: 1,
+			},
+		},
+		{
+			name:  "empty build log url",
+			input: logInput{resourceType: constants.ImageBuildType, resourceIDs: []string{"image-build-2"}},
+			mockEndpoints: getLogEndpoints(logConfig{
+				resourceURL: postgrest.Endpoint(constants.WorkbenchEndpointImageBuilds),
+				getResourceConfig: stubs.StubHTTPPartialGetConfig{
+					ExpectedQuery:  "or=(controller_id.in.(image-build-2),name.in.(image-build-2))",
+					ResponseStatus: http.StatusOK,
+					ResponseBody:   logImageBuildsResponseBody,
+				},
+			}),
+			expected: logExpected{
+				displays:      []string{stubs.ExpectedDeviceAuthDisplay},
+				resourceCalls: 1,
+			},
+		},
 	}
 
 	for _, tt := range tests {

From 091b0b5fa5e2bae224489b5708293459974e8498 Mon Sep 17 00:00:00 2001
From: Luke Duan 
Date: Wed, 4 Nov 2026 12:19:20 -0700
Subject: [PATCH 10/11] fix: use interface for identifiable fields (darren
 patch)

---
 internal/services/workbench/image_builds.go   |   3 -
 internal/services/workbench/images.go         |   5 +-
 .../services/workbench/job_submissions.go     |   5 +-
 internal/services/workbench/jobs.go           |   5 +-
 internal/services/workbench/snapshots.go      |   5 +-
 internal/services/workbench/util.go           |  17 +--
 .../services/workbench/util_internal_test.go  |  11 +-
 internal/services/workbench/workbenches.go    |   5 +-
 internal/services/workbench/workspaces.go     |   5 +-
 internal/types/types.go                       | 128 ++++++++++++++++++
 internal/types/types_test.go                  | 102 ++++++++++++++
 11 files changed, 245 insertions(+), 46 deletions(-)

diff --git a/internal/services/workbench/image_builds.go b/internal/services/workbench/image_builds.go
index 503a6c4..4585b23 100644
--- a/internal/services/workbench/image_builds.go
+++ b/internal/services/workbench/image_builds.go
@@ -40,9 +40,6 @@ func GetImageBuilds(
 	return selectByIDThenName(
 		args,
 		imageBuilds,
-		func(b types.ImageBuild) string { return b.ID },
-		func(b types.ImageBuild) string { return b.ControllerID },
-		func(b types.ImageBuild) string { return b.Name },
 	), nil
 }
 
diff --git a/internal/services/workbench/images.go b/internal/services/workbench/images.go
index b7b4cb6..bf10ffc 100644
--- a/internal/services/workbench/images.go
+++ b/internal/services/workbench/images.go
@@ -48,15 +48,12 @@ func GetImages(
 	return selectByIDThenName(
 		args,
 		images,
-		func(i types.Image) string { return i.ID },
-		func(i types.Image) string { return i.ControllerID },
-		func(i types.Image) string { return i.Name },
 	), nil
 }
 
 func resolveImageIDs(ctx context.Context, s *WorkbenchService, args []string) ([]string, error) {
 	return resolveIDs(
-		ctx, s, args, GetImages, func(i types.Image) string { return i.ID }, constants.ImageType,
+		ctx, s, args, GetImages, types.Image.GetID, constants.ImageType,
 	)
 }
 
diff --git a/internal/services/workbench/job_submissions.go b/internal/services/workbench/job_submissions.go
index 00f8151..bcb661d 100644
--- a/internal/services/workbench/job_submissions.go
+++ b/internal/services/workbench/job_submissions.go
@@ -44,15 +44,12 @@ func GetJobSubmissions(
 	return selectByIDThenName(
 		args,
 		jobSubmissions,
-		func(j types.JobSubmission) string { return j.ID },
-		func(j types.JobSubmission) string { return j.ControllerID },
-		func(j types.JobSubmission) string { return j.JobName },
 	), nil
 }
 
 func resolveJobSubmissionIDs(ctx context.Context, s *WorkbenchService, ids []string) ([]string, error) {
 	return resolveIDs(
-		ctx, s, ids, GetJobSubmissions, func(j types.JobSubmission) string { return j.ID }, constants.JobSubmissionType,
+		ctx, s, ids, GetJobSubmissions, types.JobSubmission.GetID, constants.JobSubmissionType,
 	)
 }
 
diff --git a/internal/services/workbench/jobs.go b/internal/services/workbench/jobs.go
index d2a0812..9230363 100644
--- a/internal/services/workbench/jobs.go
+++ b/internal/services/workbench/jobs.go
@@ -41,15 +41,12 @@ func GetJobs(
 	return selectByIDThenName(
 		args,
 		jobs,
-		func(j types.Job) string { return j.ID },
-		nil,
-		func(j types.Job) string { return j.Name },
 	), nil
 }
 
 func resolveJobIDs(ctx context.Context, s *WorkbenchService, args []string) ([]string, error) {
 	return resolveIDs(
-		ctx, s, args, GetJobs, func(j types.Job) string { return j.ID }, constants.JobType,
+		ctx, s, args, GetJobs, types.Job.GetID, constants.JobType,
 	)
 }
 
diff --git a/internal/services/workbench/snapshots.go b/internal/services/workbench/snapshots.go
index e4d3c22..1debda5 100644
--- a/internal/services/workbench/snapshots.go
+++ b/internal/services/workbench/snapshots.go
@@ -47,15 +47,12 @@ func GetSnapshots(
 	return selectByIDThenName(
 		args,
 		snapshots,
-		func(s types.Snapshot) string { return s.ID },
-		func(s types.Snapshot) string { return s.ControllerID },
-		func(s types.Snapshot) string { return s.Name },
 	), nil
 }
 
 func resolveSnapshotIDs(ctx context.Context, s *WorkbenchService, args []string) ([]string, error) {
 	return resolveIDs(
-		ctx, s, args, GetSnapshots, func(s types.Snapshot) string { return s.ID }, constants.SnapshotType,
+		ctx, s, args, GetSnapshots, types.Snapshot.GetID, constants.SnapshotType,
 	)
 }
 
diff --git a/internal/services/workbench/util.go b/internal/services/workbench/util.go
index 94dd9ca..497a548 100644
--- a/internal/services/workbench/util.go
+++ b/internal/services/workbench/util.go
@@ -90,12 +90,9 @@ func buildFilterQuery(args []string, controllerIDColumn, nameColumn, sortSpec st
 // selectByIDThenName narrows resources to those requested by args. It provides precedence
 // for selecting by ID > Name. Each arg is resolved independently and no resource is
 // returned twice.
-func selectByIDThenName[T any, TS ~[]T](
+func selectByIDThenName[T types.IdentifiableResource, TS ~[]T](
 	args []string,
 	resources TS,
-	getIDFunc func(T) string,
-	getControllerIDFunc func(T) string,
-	getNameFunc func(T) string,
 ) TS {
 	if len(args) == 0 {
 		return resources
@@ -105,7 +102,7 @@ func selectByIDThenName[T any, TS ~[]T](
 		if arg == "" {
 			continue
 		}
-		for _, i := range matchIndexesByIDThenName(arg, resources, getIDFunc, getControllerIDFunc, getNameFunc) {
+		for _, i := range matchIndexesByIDThenName(arg, resources) {
 			selected[i] = true
 		}
 	}
@@ -121,20 +118,16 @@ func selectByIDThenName[T any, TS ~[]T](
 // matchIndexesByIDThenName returns the indexes of the resources matching args.
 // Resources matching by ID or controller ID take precedence over those
 // matching by name.
-func matchIndexesByIDThenName[T any, TS ~[]T](
+func matchIndexesByIDThenName[T types.IdentifiableResource, TS ~[]T](
 	arg string,
 	resources TS,
-	getIDFunc func(T) string,
-	getControllerIDFunc func(T) string,
-	getNameFunc func(T) string,
 ) []int {
 	var byID, byName []int
 	for i, resource := range resources {
 		switch {
-		case getIDFunc(resource) == arg ||
-			(getControllerIDFunc != nil && getControllerIDFunc(resource) == arg):
+		case resource.GetID() == arg || resource.GetControllerID() == arg:
 			byID = append(byID, i)
-		case getNameFunc(resource) == arg:
+		case resource.GetName() == arg:
 			byName = append(byName, i)
 		}
 	}
diff --git a/internal/services/workbench/util_internal_test.go b/internal/services/workbench/util_internal_test.go
index e913a1a..aefc3c1 100644
--- a/internal/services/workbench/util_internal_test.go
+++ b/internal/services/workbench/util_internal_test.go
@@ -21,9 +21,9 @@ type testResource struct {
 
 type testResources []testResource
 
-func (r testResource) id() string           { return r.ID }
-func (r testResource) controllerID() string { return r.ControllerID }
-func (r testResource) name() string         { return r.Name }
+func (r testResource) GetID() string           { return r.ID }
+func (r testResource) GetControllerID() string { return r.ControllerID }
+func (r testResource) GetName() string         { return r.Name }
 
 func TestSelectByIDThenName(t *testing.T) {
 	a := testResource{ID: "id-a", ControllerID: "ctrl-a", Name: "name-a"}
@@ -100,9 +100,6 @@ func TestSelectByIDThenName(t *testing.T) {
 			actual := selectByIDThenName(
 				tt.args,
 				tt.resources,
-				testResource.id,
-				testResource.controllerID,
-				testResource.name,
 			)
 			require.Equal(t, tt.expected, actual)
 		})
@@ -163,7 +160,7 @@ func TestResolveIDs(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			actual, err := resolveIDs(
-				context.Background(), nil, tt.args, tt.getter, testResource.id, constants.VolumeType,
+				context.Background(), nil, tt.args, tt.getter, testResource.GetID, constants.VolumeType,
 			)
 			if tt.expectedErr != "" {
 				require.EqualError(t, err, tt.expectedErr)
diff --git a/internal/services/workbench/workbenches.go b/internal/services/workbench/workbenches.go
index 0fb435e..d232af2 100644
--- a/internal/services/workbench/workbenches.go
+++ b/internal/services/workbench/workbenches.go
@@ -43,15 +43,12 @@ func GetWorkbenches(
 	return selectByIDThenName(
 		args,
 		workbenches,
-		func(w types.Workbench) string { return w.ID },
-		func(w types.Workbench) string { return w.ControllerID },
-		func(w types.Workbench) string { return w.Name },
 	), nil
 }
 
 func resolveWorkbenchIDs(ctx context.Context, s *WorkbenchService, args []string) ([]string, error) {
 	return resolveIDs(
-		ctx, s, args, GetWorkbenches, func(w types.Workbench) string { return w.ID }, constants.WorkbenchType,
+		ctx, s, args, GetWorkbenches, types.Workbench.GetID, constants.WorkbenchType,
 	)
 }
 
diff --git a/internal/services/workbench/workspaces.go b/internal/services/workbench/workspaces.go
index 8a3e056..4941448 100644
--- a/internal/services/workbench/workspaces.go
+++ b/internal/services/workbench/workspaces.go
@@ -46,15 +46,12 @@ func GetWorkspaces(
 	return selectByIDThenName(
 		args,
 		workspaces,
-		func(w types.Workspace) string { return w.ID },
-		func(w types.Workspace) string { return w.ControllerID },
-		func(w types.Workspace) string { return w.Name },
 	), nil
 }
 
 func resolveWorkspaceIDs(ctx context.Context, s *WorkbenchService, args []string) ([]string, error) {
 	return resolveIDs(
-		ctx, s, args, GetWorkspaces, func(w types.Workspace) string { return w.ID }, constants.VolumeType,
+		ctx, s, args, GetWorkspaces, types.Workspace.GetID, constants.VolumeType,
 	)
 }
 
diff --git a/internal/types/types.go b/internal/types/types.go
index f670999..b05a6b9 100644
--- a/internal/types/types.go
+++ b/internal/types/types.go
@@ -13,6 +13,14 @@ import (
 	corev1 "k8s.io/api/core/v1"
 )
 
+// IdentifiableResource is implemented by resources that can be identified by
+// an ID, (optionally) a controller ID, and a name.
+type IdentifiableResource interface {
+	GetID() string
+	GetControllerID() string
+	GetName() string
+}
+
 // Config holds CLI configuration values.
 type Config struct {
 	Endpoint string `json:"endpoint,omitempty"`
@@ -93,6 +101,24 @@ type Job struct {
 	Mounts                    []MountRequest         `json:"mounts,omitempty" wbctl:"alias:volumes,editable"`
 }
 
+// GetID returns the ID of the job.
+func (r Job) GetID() string {
+	return r.ID
+}
+
+// GetControllerID returns the controller ID of the job. Jobs do not have a
+// controller ID, so this always returns an empty string.
+func (r Job) GetControllerID() string {
+	return ""
+}
+
+// GetName returns the name of the job.
+func (r Job) GetName() string {
+	return r.Name
+}
+
+var _ IdentifiableResource = (*Job)(nil)
+
 // Jobs is a slice of Job values.
 type Jobs []Job
 
@@ -131,6 +157,23 @@ type JobSubmission struct {
 	LogURL       string                      `json:"log_url,omitempty"`
 }
 
+// GetID returns the ID of the job submission.
+func (r JobSubmission) GetID() string {
+	return r.ID
+}
+
+// GetControllerID returns the controller ID of the job submission.
+func (r JobSubmission) GetControllerID() string {
+	return r.ControllerID
+}
+
+// GetName returns the job name of the job submission.
+func (r JobSubmission) GetName() string {
+	return r.JobName
+}
+
+var _ IdentifiableResource = (*JobSubmission)(nil)
+
 // JobSubmissions is a slice of JobSubmission values.
 type JobSubmissions []JobSubmission
 
@@ -186,6 +229,23 @@ type Workspace struct {
 	Username     string                            `json:"username,omitempty"`
 }
 
+// GetID returns the ID of the workspace.
+func (r Workspace) GetID() string {
+	return r.ID
+}
+
+// GetControllerID returns the controller ID of the workspace.
+func (r Workspace) GetControllerID() string {
+	return r.ControllerID
+}
+
+// GetName returns the name of the workspace.
+func (r Workspace) GetName() string {
+	return r.Name
+}
+
+var _ IdentifiableResource = (*Workspace)(nil)
+
 // Workspaces is a slice of Workspace values.
 type Workspaces []Workspace
 
@@ -223,6 +283,23 @@ type Workbench struct {
 	Mounts         []MountRequest              `json:"mounts,omitempty" wbctl:"alias:volumes,editable"`
 }
 
+// GetID returns the ID of the workbench.
+func (r Workbench) GetID() string {
+	return r.ID
+}
+
+// GetControllerID returns the controller ID of the workbench.
+func (r Workbench) GetControllerID() string {
+	return r.ControllerID
+}
+
+// GetName returns the name of the workbench.
+func (r Workbench) GetName() string {
+	return r.Name
+}
+
+var _ IdentifiableResource = (*Workbench)(nil)
+
 // Workbenches is a slice of Workbench values.
 type Workbenches []Workbench
 
@@ -312,6 +389,23 @@ type ImageBuild struct {
 	PodStatus    []ExecuteableResourceStatus `json:"pod_status,omitempty"`
 }
 
+// GetID returns the ID of the image build.
+func (r ImageBuild) GetID() string {
+	return r.ID
+}
+
+// GetControllerID returns the controller ID of the image build.
+func (r ImageBuild) GetControllerID() string {
+	return r.ControllerID
+}
+
+// GetName returns the name of the image build.
+func (r ImageBuild) GetName() string {
+	return r.Name
+}
+
+var _ IdentifiableResource = (*ImageBuild)(nil)
+
 // ImageBuilds is a slice of ImageBuild values.
 type ImageBuilds []ImageBuild
 
@@ -348,6 +442,23 @@ type Image struct {
 	Username                     string                      `json:"username,omitempty"`
 }
 
+// GetID returns the ID of the image.
+func (r Image) GetID() string {
+	return r.ID
+}
+
+// GetControllerID returns the controller ID of the image.
+func (r Image) GetControllerID() string {
+	return r.ControllerID
+}
+
+// GetName returns the name of the image.
+func (r Image) GetName() string {
+	return r.Name
+}
+
+var _ IdentifiableResource = (*Image)(nil)
+
 // Images is a slice of Image values.
 type Images []Image
 
@@ -417,6 +528,23 @@ type Snapshot struct {
 	WorkspaceSource *Workspace                    `json:"workspace_source,omitempty" wbctl:"alias:volume_source"`
 }
 
+// GetID returns the ID of the snapshot.
+func (r Snapshot) GetID() string {
+	return r.ID
+}
+
+// GetControllerID returns the controller ID of the snapshot.
+func (r Snapshot) GetControllerID() string {
+	return r.ControllerID
+}
+
+// GetName returns the name of the snapshot.
+func (r Snapshot) GetName() string {
+	return r.Name
+}
+
+var _ IdentifiableResource = (*Snapshot)(nil)
+
 // VolumeStatus describes a snapshot volume status.
 type VolumeStatus struct {
 	Name    string                         `json:"name,omitempty"`
diff --git a/internal/types/types_test.go b/internal/types/types_test.go
index 7bd7cac..72d3fbf 100644
--- a/internal/types/types_test.go
+++ b/internal/types/types_test.go
@@ -470,6 +470,108 @@ func TestAsWorkspaceSlice(t *testing.T) {
 	}
 }
 
+func TestIdentifiableResourceGetters(t *testing.T) {
+	cases := []struct {
+		name                 string
+		resource             types.IdentifiableResource
+		expectedID           string
+		expectedControllerID string
+		expectedName         string
+	}{
+		{
+			name:                 "zero value job returns empty strings",
+			resource:             types.Job{},
+			expectedID:           "",
+			expectedControllerID: "",
+			expectedName:         "",
+		},
+		{
+			name: "image build returns ID, controller ID, and name",
+			resource: types.ImageBuild{
+				ID:           "image-build-1",
+				ControllerID: "controller-1",
+				Name:         "image-build-name",
+			},
+			expectedID:           "image-build-1",
+			expectedControllerID: "controller-1",
+			expectedName:         "image-build-name",
+		},
+		{
+			name: "image returns ID, controller ID, and name",
+			resource: types.Image{
+				ID:           "image-1",
+				ControllerID: "controller-1",
+				Name:         "image-name",
+			},
+			expectedID:           "image-1",
+			expectedControllerID: "controller-1",
+			expectedName:         "image-name",
+		},
+		{
+			name: "job submission returns job name as name",
+			resource: types.JobSubmission{
+				ID:           "job-submission-1",
+				ControllerID: "controller-1",
+				JobName:      "job-name",
+			},
+			expectedID:           "job-submission-1",
+			expectedControllerID: "controller-1",
+			expectedName:         "job-name",
+		},
+		{
+			name: "job returns ID and name with empty controller ID",
+			resource: types.Job{
+				ID:   "job-1",
+				Name: "job-name",
+			},
+			expectedID:           "job-1",
+			expectedControllerID: "",
+			expectedName:         "job-name",
+		},
+		{
+			name: "snapshot returns ID, controller ID, and name",
+			resource: types.Snapshot{
+				ID:           "snapshot-1",
+				ControllerID: "controller-1",
+				Name:         "snapshot-name",
+			},
+			expectedID:           "snapshot-1",
+			expectedControllerID: "controller-1",
+			expectedName:         "snapshot-name",
+		},
+		{
+			name: "workspace returns ID, controller ID, and name",
+			resource: types.Workspace{
+				ID:           "workspace-1",
+				ControllerID: "controller-1",
+				Name:         "workspace-name",
+			},
+			expectedID:           "workspace-1",
+			expectedControllerID: "controller-1",
+			expectedName:         "workspace-name",
+		},
+		{
+			name: "workbench returns ID, controller ID, and name",
+			resource: types.Workbench{
+				ID:           "workbench-1",
+				ControllerID: "controller-1",
+				Name:         "workbench-name",
+			},
+			expectedID:           "workbench-1",
+			expectedControllerID: "controller-1",
+			expectedName:         "workbench-name",
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			require.Equal(t, tc.expectedID, tc.resource.GetID())
+			require.Equal(t, tc.expectedControllerID, tc.resource.GetControllerID())
+			require.Equal(t, tc.expectedName, tc.resource.GetName())
+		})
+	}
+}
+
 func TestGetFunction(t *testing.T) {
 	metadata := []types.FunctionMetadata[string]{
 		{

From 197e5e726e460be2de8d92dd93a731081a28964b Mon Sep 17 00:00:00 2001
From: Luke Duan 
Date: Wed, 4 Nov 2026 13:36:36 -0700
Subject: [PATCH 11/11] test: more test coverage

---
 internal/services/workbench/images_test.go    | 37 +++++++++++++++++
 .../workbench/job_submissions_test.go         | 20 ++++++++++
 internal/services/workbench/jobs_test.go      | 40 +++++++++++++++++++
 internal/services/workbench/snapshots_test.go | 40 +++++++++++++++++++
 .../services/workbench/util_internal_test.go  |  6 +++
 internal/services/workbench/workbench_test.go | 13 ++++++
 .../services/workbench/workbenches_test.go    | 28 ++++++++++---
 .../services/workbench/workspaces_test.go     | 19 +++++++++
 8 files changed, 198 insertions(+), 5 deletions(-)

diff --git a/internal/services/workbench/images_test.go b/internal/services/workbench/images_test.go
index 7b57070..d287b08 100644
--- a/internal/services/workbench/images_test.go
+++ b/internal/services/workbench/images_test.go
@@ -13,6 +13,7 @@ import (
 	"time"
 
 	"github.com/sassoftware/wbctl/internal/constants"
+	"github.com/sassoftware/wbctl/internal/i18n"
 	"github.com/sassoftware/wbctl/internal/postgrest"
 	"github.com/sassoftware/wbctl/internal/services/workbench"
 	"github.com/sassoftware/wbctl/internal/stubs"
@@ -434,6 +435,25 @@ func TestDeleteImage(t *testing.T) {
 					"\t* 401 unauthorized",
 			},
 		},
+		{
+			name: "delete image not found",
+			request: deleteRequest{
+				ids: []string{"missing"},
+			},
+			mockEndpoints: getImagesDeleteEndpoints(
+				`or=(controller_id.in.(missing),name.in.(missing))`,
+				`[]`,
+				"",
+				http.StatusOK,
+				"",
+			),
+			expected: deleteExpected{
+				err: i18n.Format(i18n.ResourceNotFoundWithID, i18n.A{
+					constants.I18nArgTypeKey: constants.ImageType,
+					constants.I18nArgKeyID:   "missing",
+				}),
+			},
+		},
 	}
 
 	for _, tt := range tests {
@@ -559,6 +579,23 @@ func TestRebuildImage(t *testing.T) {
 
 func TestBulkUpdateImages(t *testing.T) {
 	tests := []bulkupdateTestCase[types.Image, types.Images]{
+		{
+			name:    "bulk update images not found",
+			request: bulkupdateRequest[types.Image]{ids: []string{"missing"}, body: types.Image{Name: "updated-name"}},
+			mockEndpoints: getImagesBulkUpdateEndpoints(
+				`or=(controller_id.in.(missing),name.in.(missing))`,
+				`[]`,
+				"",
+				http.StatusOK,
+				"",
+			),
+			expected: bulkupdateExpected[types.Images]{
+				err: i18n.Format(i18n.ResourceNotFoundWithID, i18n.A{
+					constants.I18nArgTypeKey: constants.ImageType,
+					constants.I18nArgKeyID:   "missing",
+				}),
+			},
+		},
 		{
 			name: "bulk update images success",
 			request: bulkupdateRequest[types.Image]{
diff --git a/internal/services/workbench/job_submissions_test.go b/internal/services/workbench/job_submissions_test.go
index 2d6617e..2b0461d 100644
--- a/internal/services/workbench/job_submissions_test.go
+++ b/internal/services/workbench/job_submissions_test.go
@@ -12,6 +12,7 @@ import (
 	"time"
 
 	"github.com/sassoftware/wbctl/internal/constants"
+	"github.com/sassoftware/wbctl/internal/i18n"
 	"github.com/sassoftware/wbctl/internal/postgrest"
 	"github.com/sassoftware/wbctl/internal/services/workbench"
 	"github.com/sassoftware/wbctl/internal/stubs"
@@ -432,6 +433,25 @@ func TestDeleteJobSubmission(t *testing.T) {
 					"\t* 401 unauthorized",
 			},
 		},
+		{
+			name: "delete job-submission not found",
+			request: deleteRequest{
+				ids: []string{"missing"},
+			},
+			mockEndpoints: getJobSubmissionDeleteEndpoints(
+				`or=(controller_id.in.(missing),job_name.in.(missing))`,
+				`[]`,
+				"",
+				http.StatusOK,
+				"",
+			),
+			expected: deleteExpected{
+				err: i18n.Format(i18n.ResourceNotFoundWithID, i18n.A{
+					constants.I18nArgTypeKey: constants.JobSubmissionType,
+					constants.I18nArgKeyID:   "missing",
+				}),
+			},
+		},
 	}
 
 	for _, tt := range tests {
diff --git a/internal/services/workbench/jobs_test.go b/internal/services/workbench/jobs_test.go
index ad7336c..dcf4a26 100644
--- a/internal/services/workbench/jobs_test.go
+++ b/internal/services/workbench/jobs_test.go
@@ -12,6 +12,7 @@ import (
 	"time"
 
 	"github.com/sassoftware/wbctl/internal/constants"
+	"github.com/sassoftware/wbctl/internal/i18n"
 	"github.com/sassoftware/wbctl/internal/postgrest"
 	"github.com/sassoftware/wbctl/internal/services/workbench"
 	"github.com/sassoftware/wbctl/internal/stubs"
@@ -408,6 +409,25 @@ func TestDeleteJob(t *testing.T) {
 					"\t* 401 unauthorized",
 			},
 		},
+		{
+			name: "delete jobs not found",
+			request: deleteRequest{
+				ids: []string{"missing"},
+			},
+			mockEndpoints: getJobsDeleteEndpoints(
+				`or=(name.in.(missing))`,
+				`[]`,
+				"",
+				http.StatusOK,
+				"",
+			),
+			expected: deleteExpected{
+				err: i18n.Format(i18n.ResourceNotFoundWithID, i18n.A{
+					constants.I18nArgTypeKey: constants.JobType,
+					constants.I18nArgKeyID:   "missing",
+				}),
+			},
+		},
 	}
 
 	for _, tt := range tests {
@@ -547,6 +567,26 @@ func TestBulkUpdateJobs(t *testing.T) {
 					"\t* unexpected EOF",
 			},
 		},
+		{
+			name: "bulk update jobs not found",
+			request: bulkupdateRequest[types.Job]{
+				ids:  []string{"missing"},
+				body: types.Job{Name: "updated-name"},
+			},
+			mockEndpoints: getJobsBulkUpdateEndpoints(
+				`or=(name.in.(missing))`,
+				`[]`,
+				"",
+				http.StatusOK,
+				"",
+			),
+			expected: bulkupdateExpected[types.Jobs]{
+				err: i18n.Format(i18n.ResourceNotFoundWithID, i18n.A{
+					constants.I18nArgTypeKey: constants.JobType,
+					constants.I18nArgKeyID:   "missing",
+				}),
+			},
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
diff --git a/internal/services/workbench/snapshots_test.go b/internal/services/workbench/snapshots_test.go
index 84b1017..e032127 100644
--- a/internal/services/workbench/snapshots_test.go
+++ b/internal/services/workbench/snapshots_test.go
@@ -12,6 +12,7 @@ import (
 	"time"
 
 	"github.com/sassoftware/wbctl/internal/constants"
+	"github.com/sassoftware/wbctl/internal/i18n"
 	"github.com/sassoftware/wbctl/internal/postgrest"
 	"github.com/sassoftware/wbctl/internal/services/workbench"
 	"github.com/sassoftware/wbctl/internal/stubs"
@@ -420,6 +421,25 @@ func TestDeleteSnapshot(t *testing.T) {
 					"\t* 401 unauthorized",
 			},
 		},
+		{
+			name: "delete snapshot not found",
+			request: deleteRequest{
+				ids: []string{"missing"},
+			},
+			mockEndpoints: getSnapshotDeleteEndpoints(
+				`or=(controller_id.in.(missing),name.in.(missing))`,
+				`[]`,
+				"",
+				http.StatusOK,
+				"",
+			),
+			expected: deleteExpected{
+				err: i18n.Format(i18n.ResourceNotFoundWithID, i18n.A{
+					constants.I18nArgTypeKey: constants.SnapshotType,
+					constants.I18nArgKeyID:   "missing",
+				}),
+			},
+		},
 	}
 
 	for _, tt := range tests {
@@ -557,6 +577,26 @@ func TestBulkUpdateSnapshots(t *testing.T) {
 					"\t* unexpected EOF",
 			},
 		},
+		{
+			name: "bulk update snapshots not found",
+			request: bulkupdateRequest[types.Snapshot]{
+				ids:  []string{"missing"},
+				body: types.Snapshot{Name: "updated-name"},
+			},
+			mockEndpoints: getSnapshotBulkUpdateEndpoints(
+				`or=(controller_id.in.(missing),name.in.(missing))`,
+				`[]`,
+				"",
+				http.StatusOK,
+				"",
+			),
+			expected: bulkupdateExpected[types.Snapshots]{
+				err: i18n.Format(i18n.ResourceNotFoundWithID, i18n.A{
+					constants.I18nArgTypeKey: constants.SnapshotType,
+					constants.I18nArgKeyID:   "missing",
+				}),
+			},
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
diff --git a/internal/services/workbench/util_internal_test.go b/internal/services/workbench/util_internal_test.go
index aefc3c1..7643536 100644
--- a/internal/services/workbench/util_internal_test.go
+++ b/internal/services/workbench/util_internal_test.go
@@ -45,6 +45,12 @@ func TestSelectByIDThenName(t *testing.T) {
 			resources: testResources{a, b},
 			expected:  testResources{a, b},
 		},
+		{
+			name:      "empty string args are skipped",
+			args:      []string{"", "id-a"},
+			resources: testResources{a, b},
+			expected:  testResources{a},
+		},
 		{
 			name:      "match by id",
 			args:      []string{"id-a"},
diff --git a/internal/services/workbench/workbench_test.go b/internal/services/workbench/workbench_test.go
index 5886056..6dc414d 100644
--- a/internal/services/workbench/workbench_test.go
+++ b/internal/services/workbench/workbench_test.go
@@ -10,6 +10,7 @@ import (
 	"testing"
 
 	"github.com/sassoftware/wbctl/internal/constants"
+	"github.com/sassoftware/wbctl/internal/i18n"
 	"github.com/sassoftware/wbctl/internal/postgrest"
 	"github.com/sassoftware/wbctl/internal/services/workbench"
 	"github.com/sassoftware/wbctl/internal/stubs"
@@ -414,6 +415,18 @@ func TestGetWorkspaceRequests(t *testing.T) {
 			),
 			expected: expected{err: "volume not found: missing"},
 		},
+		{
+			name:    "multiple workspaces match one name",
+			request: request{workspaceIDs: []string{"ws-name"}},
+			mockEndpoints: getEndpoints(
+				postgrest.Endpoint(constants.WorkbenchEndpointWorkspaces),
+				"or=(controller_id.in.(ws-name),name.in.(ws-name))",
+				http.StatusOK,
+				`[ {"id":"ws-1","controller_id":"ctrl-a","name":"ws-name","properties":{}},`+
+					`{"id":"ws-2","controller_id":"ctrl-b","name":"ws-name","properties":{}} ]`,
+			),
+			expected: expected{err: i18n.Format(i18n.MultipleElementsFound)},
+		},
 		{
 			name:    "invalid workspace response",
 			request: request{workspaceIDs: []string{"ws-1"}},
diff --git a/internal/services/workbench/workbenches_test.go b/internal/services/workbench/workbenches_test.go
index bbfdec7..5be7103 100644
--- a/internal/services/workbench/workbenches_test.go
+++ b/internal/services/workbench/workbenches_test.go
@@ -6,7 +6,6 @@ package workbench_test
 import (
 	"context"
 	"encoding/json"
-	"fmt"
 	"net/http"
 	"strings"
 	"testing"
@@ -410,11 +409,30 @@ func TestDeleteWorkbench(t *testing.T) {
 				"unauthorized",
 			),
 			expected: deleteExpected{
-				err: "POST %s/postgrest/rpc/workbench_bulk_delete failed\n" +
+				err: "POST ${URL}/postgrest/rpc/workbench_bulk_delete failed\n" +
 					" Caused by:\n" +
 					"\t* 401 unauthorized",
 			},
 		},
+		{
+			name: "delete workbench not found",
+			request: deleteRequest{
+				ids: []string{"missing"},
+			},
+			mockEndpoints: getWorkbenchesDeleteEndpoints(
+				`or=(controller_id.in.(missing),name.in.(missing))`,
+				`[]`,
+				"",
+				http.StatusOK,
+				"",
+			),
+			expected: deleteExpected{
+				err: i18n.Format(i18n.ResourceNotFoundWithID, i18n.A{
+					constants.I18nArgTypeKey: constants.WorkbenchType,
+					constants.I18nArgKeyID:   "missing",
+				}),
+			},
+		},
 	}
 
 	for _, tt := range tests {
@@ -424,7 +442,7 @@ func TestDeleteWorkbench(t *testing.T) {
 			svc := workbench.NewWorkbenchService(srv.URL, "access-token", "csrf-token")
 			actual, err := workbench.BulkDeleteWorkbenches(context.Background(), svc, tt.request.ids)
 			if tt.expected.err != "" {
-				require.EqualError(t, err, fmt.Sprintf(tt.expected.err, srv.URL))
+				require.EqualError(t, err, strings.ReplaceAll(tt.expected.err, "${URL}", srv.URL))
 				return
 			}
 			require.NoError(t, err)
@@ -471,7 +489,7 @@ func TestCreateWorkbench(t *testing.T) {
 				"unauthorized",
 			),
 			expected: createExpected[types.Workbench]{
-				err: "POST %s/postgrest/rpc/workbench_create failed\n" +
+				err: "POST ${URL}/postgrest/rpc/workbench_create failed\n" +
 					" Caused by:\n" +
 					"\t* 401 unauthorized",
 			},
@@ -486,7 +504,7 @@ func TestCreateWorkbench(t *testing.T) {
 
 			actual, err := workbench.CreateWorkbench(context.Background(), svc, tt.request.body)
 			if tt.expected.err != "" {
-				require.EqualError(t, err, fmt.Sprintf(tt.expected.err, srv.URL))
+				require.EqualError(t, err, strings.ReplaceAll(tt.expected.err, "${URL}", srv.URL))
 				return
 			}
 			require.NoError(t, err)
diff --git a/internal/services/workbench/workspaces_test.go b/internal/services/workbench/workspaces_test.go
index 67ac1b8..b0490fe 100644
--- a/internal/services/workbench/workspaces_test.go
+++ b/internal/services/workbench/workspaces_test.go
@@ -387,6 +387,25 @@ func TestDeleteWorkspace(t *testing.T) {
 					"\t* 401 unauthorized",
 			},
 		},
+		{
+			name: "delete workspace not found",
+			request: deleteRequest{
+				ids: []string{"missing"},
+			},
+			mockEndpoints: getWorkspaceDeleteEndpoints(
+				`or=(controller_id.in.(missing),name.in.(missing))`,
+				`[]`,
+				"",
+				http.StatusOK,
+				"",
+			),
+			expected: deleteExpected{
+				err: i18n.Format(i18n.ResourceNotFoundWithID, i18n.A{
+					constants.I18nArgTypeKey: constants.VolumeType,
+					constants.I18nArgKeyID:   "missing",
+				}),
+			},
+		},
 	}
 
 	for _, tt := range tests {