feat(group): group service to create, update, and delete groups (#10455)

# Which Problems Are Solved

This PR adds API definition and backend implementation for GroupService
to manage user groups.

# How the Problems Are Solved
* API definition to create, update, retrieve, and delete groups is added
* Command-side implementation to create, update, and delete user groups
as part of the GroupV2 API is added

# Additional Changes
N/A

# Additional Context
- Related to #10089, #9702 (parent ticket)
- User contribution: https://github.com/zitadel/zitadel/pull/9428/files
- Additional functionalities to list/search user groups, add
permissions, manage users in groups, group scopes will be added in
subsequent PRs.
- Also needs documentation, which will be added once the entire feature
is available

---------

Co-authored-by: Livio Spring <[email protected]>
This commit is contained in:
Gayathri Vijayan
2025-10-06 11:23:15 +02:00
committed by GitHub
co-authored by Livio Spring
parent ee3654fe6f
commit b81dedcaea
18 changed files with 2349 additions and 0 deletions
+160
View File
@@ -0,0 +1,160 @@
package command
import (
"context"
"strings"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
repo "github.com/zitadel/zitadel/internal/repository/group"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
)
type CreateGroup struct {
models.ObjectRoot
Name string
Description string
}
func (g *CreateGroup) IsValid() error {
if strings.TrimSpace(g.Name) == "" {
return zerrors.ThrowInvalidArgument(nil, "GROUP-m177lN", "Errors.Group.InvalidName")
}
return nil
}
// CreateGroup creates a new user group in an organization
func (c *Commands) CreateGroup(ctx context.Context, group *CreateGroup) (details *domain.ObjectDetails, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
// todo: check permissions
if err = group.IsValid(); err != nil {
return nil, err
}
if group.ResourceOwner == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "CMDGRP-msc0Tt", "Errors.Group.MissingOrganizationID")
}
// check whether the organization where the group should be created exists
err = c.checkOrgExists(ctx, group.ResourceOwner)
if err != nil {
return nil, zerrors.ThrowPreconditionFailed(nil, "CMDGRP-j1mH8l", "Errors.Org.NotFound")
}
// create a unique group ID if not provided
if group.AggregateID == "" {
group.AggregateID, err = c.idGenerator.Next()
if err != nil {
return nil, err
}
}
// check if a group with the same ID already exists
groupWriteModel, err := c.getGroupWriteModelByID(ctx, group.AggregateID, group.ResourceOwner)
if err != nil {
return nil, err
}
if groupWriteModel.State.Exists() {
return nil, zerrors.ThrowAlreadyExists(nil, "CMDGRP-shRut3", "Errors.Group.AlreadyExists")
}
err = c.pushAppendAndReduce(ctx,
groupWriteModel,
repo.NewGroupAddedEvent(ctx,
GroupAggregateFromWriteModel(ctx, &groupWriteModel.WriteModel),
group.Name,
group.Description,
))
if err != nil {
return nil, err
}
return writeModelToObjectDetails(&groupWriteModel.WriteModel), nil
}
type UpdateGroup struct {
models.ObjectRoot
Name *string
Description *string
}
func (g *UpdateGroup) IsValid() error {
if g.Name != nil && strings.TrimSpace(*g.Name) == "" {
return zerrors.ThrowInvalidArgument(nil, "GROUP-dUNd3r", "Errors.Group.InvalidName")
}
return nil
}
// UpdateGroup updates a user group in an organization
func (c *Commands) UpdateGroup(ctx context.Context, groupUpdate *UpdateGroup) (details *domain.ObjectDetails, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
if err = groupUpdate.IsValid(); err != nil {
return nil, err
}
// todo: check permissions
existingGroup, err := c.getGroupWriteModelByID(ctx, groupUpdate.AggregateID, groupUpdate.ResourceOwner)
if err != nil {
return nil, err
}
if !existingGroup.State.Exists() {
return nil, zerrors.ThrowNotFound(nil, "CMDGRP-b33zly", "Errors.Group.NotFound")
}
changedEvent := existingGroup.NewChangedEvent(
ctx,
GroupAggregateFromWriteModel(ctx, &existingGroup.WriteModel),
groupUpdate.Name,
groupUpdate.Description,
)
if changedEvent == nil {
return writeModelToObjectDetails(&existingGroup.WriteModel), nil
}
err = c.pushAppendAndReduce(ctx,
existingGroup,
changedEvent)
if err != nil {
return nil, err
}
return writeModelToObjectDetails(&existingGroup.WriteModel), nil
}
// DeleteGroup deletes a user group from an organization
func (c *Commands) DeleteGroup(ctx context.Context, groupID string) (details *domain.ObjectDetails, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
existingGroup, err := c.getGroupWriteModelByID(ctx, groupID, "")
if err != nil {
return nil, err
}
if !existingGroup.State.Exists() {
return writeModelToObjectDetails(&existingGroup.WriteModel), nil
}
err = c.pushAppendAndReduce(ctx,
existingGroup,
repo.NewGroupRemovedEvent(ctx,
GroupAggregateFromWriteModel(ctx, &existingGroup.WriteModel),
existingGroup.Name,
))
if err != nil {
return nil, err
}
return writeModelToObjectDetails(&existingGroup.WriteModel), nil
}
func (c *Commands) getGroupWriteModelByID(ctx context.Context, id, orgID string) (*GroupWriteModel, error) {
groupWriteModel := NewGroupWriteModel(id, orgID)
err := c.eventstore.FilterToQueryReducer(ctx, groupWriteModel)
if err != nil {
return nil, err
}
return groupWriteModel, nil
}
+87
View File
@@ -0,0 +1,87 @@
package command
import (
"context"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/group"
)
// GroupWriteModel represents the write-model for a group.
type GroupWriteModel struct {
eventstore.WriteModel
Name string
Description string
State domain.GroupState
}
// NewGroupWriteModel initializes a new instance of GroupWriteModel from the given Group.
func NewGroupWriteModel(id, orgID string) *GroupWriteModel {
return &GroupWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: id,
ResourceOwner: orgID,
},
}
}
// Query constructs a search query for retrieving group-related events based on the GroupWriteModel attributes.
func (g *GroupWriteModel) Query() *eventstore.SearchQueryBuilder {
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
ResourceOwner(g.ResourceOwner).
AddQuery().
AggregateTypes(group.AggregateType).
AggregateIDs(g.AggregateID).
EventTypes(
group.GroupAddedEventType,
group.GroupChangedEventType,
group.GroupRemovedEventType).Builder()
}
func (g *GroupWriteModel) Reduce() error {
for _, event := range g.Events {
switch e := event.(type) {
case *group.GroupAddedEvent:
g.AggregateID = e.ID
g.Name = e.Name
g.Description = e.Description
g.State = domain.GroupStateActive
case *group.GroupChangedEvent:
if e.Name != nil {
g.Name = *e.Name
}
if e.Description != nil {
g.Description = *e.Description
}
case *group.GroupRemovedEvent:
g.State = domain.GroupStateRemoved
}
}
return g.WriteModel.Reduce()
}
func (g *GroupWriteModel) NewChangedEvent(ctx context.Context, agg *eventstore.Aggregate, name, description *string) *group.GroupChangedEvent {
changes := make([]group.GroupChanges, 0)
oldName := ""
if name != nil && g.Name != *name {
oldName = g.Name
changes = append(changes, group.ChangeName(name))
}
if description != nil && g.Description != *description {
changes = append(changes, group.ChangeDescription(description))
}
if len(changes) == 0 {
return nil
}
return group.NewGroupChangedEvent(ctx, agg, oldName, changes)
}
// GroupAggregateFromWriteModel maps a WriteModel to a group-specific Aggregate using its type and version.
func GroupAggregateFromWriteModel(ctx context.Context, wm *eventstore.WriteModel) *eventstore.Aggregate {
return eventstore.AggregateFromWriteModelCtx(ctx, wm, group.AggregateType, group.AggregateVersion)
}
+770
View File
@@ -0,0 +1,770 @@
package command
import (
"context"
"errors"
"testing"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
"github.com/zitadel/zitadel/internal/id"
"github.com/zitadel/zitadel/internal/id/mock"
"github.com/zitadel/zitadel/internal/repository/group"
"github.com/zitadel/zitadel/internal/repository/org"
"github.com/zitadel/zitadel/internal/zerrors"
)
func TestCommands_CreateGroup(t *testing.T) {
t.Parallel()
pushErr := errors.New("push error")
filterErr := errors.New("filter error")
idGeneratorErr := errors.New("id generator error")
type fields struct {
eventstore func(t *testing.T) *eventstore.Eventstore
idGenerator id.Generator
}
type args struct {
ctx context.Context
group *CreateGroup
aggregateID func() (string, error)
}
tests := []struct {
name string
fields fields
args args
want *domain.ObjectDetails
wantErr func(error) bool
}{
{
name: "invalid group name, error",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: context.Background(),
group: &CreateGroup{
ObjectRoot: models.ObjectRoot{
ResourceOwner: "org1",
},
Name: " ",
Description: "example group",
},
},
wantErr: zerrors.IsErrorInvalidArgument,
},
{
name: "missing organization id, error",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: context.Background(),
group: &CreateGroup{
Name: "example",
Description: "example group",
},
},
wantErr: zerrors.IsErrorInvalidArgument,
},
{
name: "org not found, precondition error",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
},
args: args{
ctx: context.Background(),
group: &CreateGroup{
ObjectRoot: models.ObjectRoot{
ResourceOwner: "org1",
},
Name: "example",
Description: "example group",
},
},
wantErr: zerrors.IsPreconditionFailed,
},
{
name: "failed to generate group id, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
org.NewOrgAddedEvent(context.Background(),
&org.NewAggregate("org1").Aggregate,
"org1",
),
),
),
),
},
args: args{
ctx: context.Background(),
group: &CreateGroup{
ObjectRoot: models.ObjectRoot{
ResourceOwner: "org1",
},
Name: "example",
Description: "example group",
},
aggregateID: func() (string, error) {
return "", idGeneratorErr
},
},
wantErr: func(err error) bool {
return errors.Is(err, idGeneratorErr)
},
},
{
name: "group already exists, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
org.NewOrgAddedEvent(context.Background(),
&org.NewAggregate("org1").Aggregate,
"org1",
),
),
),
expectFilter(
eventFromEventPusher(
group.NewGroupAddedEvent(context.Background(),
&group.NewAggregate("1234", "org1").Aggregate,
"example",
"example group",
),
),
),
),
},
args: args{
ctx: context.Background(),
group: &CreateGroup{
ObjectRoot: models.ObjectRoot{
AggregateID: "1234",
ResourceOwner: "org1",
},
Name: "example",
Description: "example group",
},
},
wantErr: zerrors.IsErrorAlreadyExists,
},
{
name: "failed to get org write model, error",
fields: fields{
eventstore: expectEventstore(
expectFilterError(filterErr),
),
},
args: args{
ctx: context.Background(),
group: &CreateGroup{
ObjectRoot: models.ObjectRoot{
ResourceOwner: "org1",
},
Name: "example",
Description: "example group",
},
},
wantErr: func(err error) bool {
return errors.Is(err, zerrors.ThrowPreconditionFailed(nil, "CMDGRP-j1mH8l", "Errors.Org.NotFound"))
},
},
{
name: "failed to get group write model, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
org.NewOrgAddedEvent(context.Background(),
&org.NewAggregate("org1").Aggregate,
"org1",
),
),
),
expectFilterError(filterErr),
),
},
args: args{
ctx: context.Background(),
group: &CreateGroup{
ObjectRoot: models.ObjectRoot{
ResourceOwner: "org1",
},
Name: "example",
Description: "example group",
},
aggregateID: func() (string, error) {
return "12345", nil
},
},
wantErr: func(err error) bool {
return errors.Is(err, filterErr)
},
},
{
name: "failed to push group added event, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
org.NewOrgAddedEvent(context.Background(),
&org.NewAggregate("org1").Aggregate,
"org1",
),
),
),
expectFilter(),
expectPushFailed(
pushErr,
group.NewGroupAddedEvent(context.Background(),
&group.NewAggregate("12345", "org1").Aggregate,
"example",
"example group",
),
),
),
},
args: args{
ctx: context.Background(),
group: &CreateGroup{
ObjectRoot: models.ObjectRoot{
ResourceOwner: "org1",
},
Name: "example",
Description: "example group",
},
aggregateID: func() (string, error) {
return "12345", nil
},
},
wantErr: func(err error) bool {
return errors.Is(err, pushErr)
},
},
{
name: "group without user provided id, ok",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
org.NewOrgAddedEvent(context.Background(),
&org.NewAggregate("org1").Aggregate,
"org1",
),
),
),
expectFilter(),
expectPush(
group.NewGroupAddedEvent(context.Background(),
&group.NewAggregate("12345", "org1").Aggregate,
"example",
"example group",
),
),
),
},
args: args{
ctx: context.Background(),
group: &CreateGroup{
ObjectRoot: models.ObjectRoot{
ResourceOwner: "org1",
},
Name: "example",
Description: "example group",
},
aggregateID: func() (string, error) {
return "12345", nil
}, // mock value generated by the id generator
},
want: &domain.ObjectDetails{
ID: "12345",
ResourceOwner: "org1",
},
},
{
name: "group with user provided id, ok",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
org.NewOrgAddedEvent(context.Background(),
&org.NewAggregate("org1").Aggregate,
"org1",
),
),
),
expectFilter(),
expectPush(
group.NewGroupAddedEvent(context.Background(),
&group.NewAggregate("9090", "org1").Aggregate,
"example",
"example group",
),
),
),
},
args: args{
ctx: context.Background(),
group: &CreateGroup{
ObjectRoot: models.ObjectRoot{
AggregateID: "9090",
ResourceOwner: "org1",
},
Name: "example",
Description: "example group",
},
},
want: &domain.ObjectDetails{
ID: "9090",
ResourceOwner: "org1",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// setup mock id generator
// it's not defined directly in the tests because,
// when run individually, it affects other tests that don't need this mock.
if tt.args.aggregateID != nil {
aggregateID, err := tt.args.aggregateID()
if err != nil {
tt.fields.idGenerator = mock.NewIDGeneratorExpectError(t, err)
} else {
tt.fields.idGenerator = mock.ExpectID(t, aggregateID)
}
}
c := &Commands{
eventstore: tt.fields.eventstore(t),
idGenerator: tt.fields.idGenerator,
}
got, err := c.CreateGroup(tt.args.ctx, tt.args.group)
if tt.wantErr == nil {
require.NoError(t, err)
require.NotEmpty(t, got.ID)
assertObjectDetails(t, tt.want, got)
return
}
require.True(t, tt.wantErr(err))
})
}
}
func TestCommands_UpdateGroup(t *testing.T) {
t.Parallel()
filterErr := errors.New("filter error")
pushErr := errors.New("push error")
type fields struct {
eventstore func(t *testing.T) *eventstore.Eventstore
}
type args struct {
ctx context.Context
group *UpdateGroup
}
tests := []struct {
name string
fields fields
args args
want *domain.ObjectDetails
wantErr func(error) bool
}{
{
name: "invalid group name, error",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: context.Background(),
group: &UpdateGroup{
Name: gu.Ptr(" "),
Description: gu.Ptr("example group"),
},
},
wantErr: zerrors.IsErrorInvalidArgument,
},
{
name: "group not found, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
},
args: args{
ctx: context.Background(),
group: &UpdateGroup{
ObjectRoot: models.ObjectRoot{
AggregateID: "1234",
},
Name: gu.Ptr("updated name"),
Description: gu.Ptr("updated description"),
},
},
wantErr: zerrors.IsNotFound,
},
{
name: "failed to get group write model, error",
fields: fields{
eventstore: expectEventstore(
expectFilterError(filterErr),
),
},
args: args{
ctx: context.Background(),
group: &UpdateGroup{
Name: gu.Ptr("updated group name"),
Description: gu.Ptr("updated group description"),
},
},
wantErr: func(err error) bool {
return errors.Is(err, filterErr)
},
},
{
name: "failed to push group changed event, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
group.NewGroupAddedEvent(context.Background(),
&group.NewAggregate("1234", "org1").Aggregate,
"group1",
"group1 description",
),
),
),
expectPushFailed(
pushErr,
group.NewGroupChangedEvent(context.Background(),
&group.NewAggregate("1234", "org1").Aggregate,
"group1",
[]group.GroupChanges{
group.ChangeName(gu.Ptr("updated group name")),
group.ChangeDescription(gu.Ptr("updated group description")),
},
),
),
),
},
args: args{
ctx: context.Background(),
group: &UpdateGroup{
Name: gu.Ptr("updated group name"),
Description: gu.Ptr("updated group description"),
},
},
wantErr: func(err error) bool {
return errors.Is(err, pushErr)
},
},
{
name: "no change, ok",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
group.NewGroupAddedEvent(context.Background(),
&group.NewAggregate("1234", "org1").Aggregate,
"group1",
"group1 description",
),
),
),
),
},
args: args{
ctx: context.Background(),
group: &UpdateGroup{
ObjectRoot: models.ObjectRoot{
AggregateID: "1234",
},
Name: gu.Ptr("group1"),
Description: gu.Ptr("group1 description"),
},
},
want: &domain.ObjectDetails{
ID: "1234",
ResourceOwner: "org1",
},
},
{
name: "update group name, ok",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
group.NewGroupAddedEvent(context.Background(),
&group.NewAggregate("1234", "org1").Aggregate,
"group1",
"group1 description",
),
),
),
expectPush(
group.NewGroupChangedEvent(context.Background(),
&group.NewAggregate("1234", "org1").Aggregate,
"group1",
[]group.GroupChanges{
group.ChangeName(gu.Ptr("groupXX")),
},
),
),
),
},
args: args{
ctx: context.Background(),
group: &UpdateGroup{
ObjectRoot: models.ObjectRoot{
AggregateID: "1234",
},
Name: gu.Ptr("groupXX"),
},
},
want: &domain.ObjectDetails{
ID: "1234",
ResourceOwner: "org1",
},
},
{
name: "update group description, ok",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
group.NewGroupAddedEvent(context.Background(),
&group.NewAggregate("1234", "org1").Aggregate,
"group1",
"group1 description",
),
),
),
expectPush(
group.NewGroupChangedEvent(context.Background(),
&group.NewAggregate("1234", "org1").Aggregate,
"group1",
[]group.GroupChanges{
group.ChangeDescription(gu.Ptr("updated group description")),
},
),
),
),
},
args: args{
ctx: context.Background(),
group: &UpdateGroup{
ObjectRoot: models.ObjectRoot{
AggregateID: "1234",
},
Description: gu.Ptr("updated group description"),
},
},
want: &domain.ObjectDetails{
ID: "1234",
ResourceOwner: "org1",
},
},
{
name: "full update (group name and description), ok",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
group.NewGroupAddedEvent(context.Background(),
&group.NewAggregate("1234", "org1").Aggregate,
"group1",
"group1 description",
),
),
),
expectPush(
group.NewGroupChangedEvent(context.Background(),
&group.NewAggregate("1234", "org1").Aggregate,
"group1",
[]group.GroupChanges{
group.ChangeName(gu.Ptr("groupXX")),
group.ChangeDescription(gu.Ptr("updated group description")),
},
),
),
),
},
args: args{
ctx: context.Background(),
group: &UpdateGroup{
ObjectRoot: models.ObjectRoot{
AggregateID: "1234",
},
Name: gu.Ptr("groupXX"),
Description: gu.Ptr("updated group description"),
},
},
want: &domain.ObjectDetails{
ID: "1234",
ResourceOwner: "org1",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
c := &Commands{
eventstore: tt.fields.eventstore(t),
}
got, err := c.UpdateGroup(tt.args.ctx, tt.args.group)
if tt.wantErr == nil {
require.NoError(t, err)
require.NotEmpty(t, got.ID)
assertObjectDetails(t, tt.want, got)
return
}
require.True(t, tt.wantErr(err))
})
}
}
func TestCommands_DeleteGroup(t *testing.T) {
t.Parallel()
filterErr := errors.New("filter error")
pushErr := errors.New("push error")
type fields struct {
eventstore func(t *testing.T) *eventstore.Eventstore
}
type args struct {
ctx context.Context
groupID string
}
tests := []struct {
name string
fields fields
args args
want *domain.ObjectDetails
wantErr func(error) bool
}{
{
name: "failed to get group write model, error",
fields: fields{
eventstore: expectEventstore(
expectFilterError(filterErr),
),
},
args: args{
ctx: context.Background(),
groupID: "1234",
},
wantErr: func(err error) bool {
return errors.Is(err, filterErr)
},
},
{
name: "group not found, ok",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
},
args: args{
ctx: context.Background(),
groupID: "1234",
},
want: &domain.ObjectDetails{
ID: "1234",
},
},
{
name: "failed to push group delete event, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
group.NewGroupAddedEvent(context.Background(),
&group.NewAggregate("1234", "").Aggregate,
"group1",
"group1 description",
),
),
),
expectPushFailed(
pushErr,
group.NewGroupRemovedEvent(context.Background(),
&group.NewAggregate("1234", "").Aggregate,
"group1",
),
),
),
},
args: args{
ctx: context.Background(),
groupID: "1234",
},
wantErr: func(err error) bool {
return errors.Is(err, pushErr)
},
},
{
name: "delete group, ok",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
group.NewGroupAddedEvent(context.Background(),
&group.NewAggregate("1234", "").Aggregate,
"group1",
"group1 description",
),
),
),
expectPush(
eventFromEventPusher(
group.NewGroupRemovedEvent(context.Background(),
&group.NewAggregate("1234", "").Aggregate,
"group1",
),
),
),
),
},
args: args{
ctx: context.Background(),
groupID: "1234",
},
want: &domain.ObjectDetails{
ID: "1234",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
c := &Commands{
eventstore: tt.fields.eventstore(t),
}
got, err := c.DeleteGroup(tt.args.ctx, tt.args.groupID)
if tt.wantErr == nil {
require.NoError(t, err)
require.NotEmpty(t, got.ID)
assertObjectDetails(t, tt.want, got)
return
}
require.True(t, tt.wantErr(err))
})
}
}