Files
zitadel/backend/v3/domain/invoker_event_test.go
T
c4221a9c0f refactor: invoker implementations and clean up code (#10962)
This pull request refactors the command execution and transaction
handling logic within the `domain` package. The primary goal is to
simplify the invoker chain, clarify transaction management, and improve
the overall structure for better maintainability and readability.

### Key Changes:

- **Simplified Invoker Chain**: The previous chain of invokers has been
refactored. A new `transactionInvoker` is introduced to explicitly
manage database transactions for commands that require them. This
responsibility is no longer spread across different parts of the
invocation flow.
- **`Transactional` Interface**: A new `Transactional` interface has
been introduced. Commands that need to run within a database transaction
now implement this interface. This provides a clear and explicit
contract for transactional operations.
- **Consolidated Transaction Logic**: The logic for starting,
committing, and rolling back transactions is now centralized within the
`transactionInvoker` and the `eventStoreInvoker`. The `EnsureTx` method
on `InvokeOpts` has been removed, and transaction handling is now
managed automatically by the invocation framework.
- **Improved Event Handling**: The `eventStoreInvoker` and its
`eventCollector` have been rewritten to be more robust and to correctly
handle nested and batched commands, ensuring that all events are
collected and published within the same transaction.
- **Code Cleanup**: A significant amount of now-unused code, including
the old `invoker_test.go`, has been removed. Various comments and
variable declarations have also been cleaned up.
- **Updated Command Implementations**: All commands in the `domain`
package (e.g., `ActivateOrgCommand`, `DeactivateOrgCommand`,
`DeleteOrgCommand`, `UpdateOrgCommand`) have been updated to use the new
`Transactional` interface where appropriate, removing manual transaction
management.
- **Enhanced Testing**: Tests have been added and updated to cover the
new transaction and event handling logic, ensuring the refactoring is
sound.
- **Use `PrimaryKeyCondition`**: Use the
`repository.PrimaryKeyCondition(...)` method instead of using
`database.And(...)` where possible.

These changes lead to a cleaner, more decoupled architecture for command
handling in ZITADEL.

---------

Co-authored-by: Marco A. <[email protected]>
2025-10-27 14:04:06 +00:00

377 lines
14 KiB
Go

package domain_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"github.com/zitadel/zitadel/backend/v3/domain"
domainmock "github.com/zitadel/zitadel/backend/v3/domain/mock"
"github.com/zitadel/zitadel/backend/v3/storage/database"
"github.com/zitadel/zitadel/backend/v3/storage/database/dbmock"
"github.com/zitadel/zitadel/backend/v3/storage/eventstore"
legacy_es "github.com/zitadel/zitadel/internal/eventstore"
)
type testLegacyEventstore struct {
commands []legacy_es.Command
}
func (es *testLegacyEventstore) PushWithNewClient(ctx context.Context, client database.QueryExecutor, commands ...legacy_es.Command) ([]legacy_es.Event, error) {
es.commands = append(es.commands, commands...)
return nil, nil
}
var _ eventstore.LegacyEventstore = (*testLegacyEventstore)(nil)
type invokeTestEvent struct {
id string
}
// Aggregate implements [legacy_es.Command].
func (i *invokeTestEvent) Aggregate() *legacy_es.Aggregate {
return &legacy_es.Aggregate{
ID: i.id,
Type: "test",
ResourceOwner: "test",
InstanceID: "test",
Version: legacy_es.Version("v1"),
}
}
// Creator implements [legacy_es.Command].
func (i *invokeTestEvent) Creator() string {
return "test"
}
// Fields implements [legacy_es.Command].
func (i *invokeTestEvent) Fields() []*legacy_es.FieldOperation {
return nil
}
// Payload implements [legacy_es.Command].
func (i *invokeTestEvent) Payload() any {
return nil
}
// Revision implements [legacy_es.Command].
func (i *invokeTestEvent) Revision() uint16 {
return 1
}
// Type implements [legacy_es.Command].
func (i *invokeTestEvent) Type() legacy_es.EventType {
return "test"
}
// UniqueConstraints implements [legacy_es.Command].
func (i *invokeTestEvent) UniqueConstraints() []*legacy_es.UniqueConstraint {
return nil
}
var _ legacy_es.Command = (*invokeTestEvent)(nil)
func Test_eventCollector_Invoke(t *testing.T) {
tests := []struct {
name string
command func(ctrl *gomock.Controller) domain.Executor
queryExecutor func(ctrl *gomock.Controller) database.QueryExecutor
expectedErr error
assertCollectedEvents func(t *testing.T, events []legacy_es.Command)
}{
{
name: "simple command without events",
expectedErr: nil,
command: func(ctrl *gomock.Controller) domain.Executor {
cmd := domainmock.NewMockCommander(ctrl)
gomock.InOrder(
cmd.EXPECT().Execute(gomock.Any(), gomock.Any()).Return(nil),
cmd.EXPECT().Events(gomock.Any(), gomock.Any()).Return(nil, nil),
)
return cmd
},
assertCollectedEvents: func(t *testing.T, events []legacy_es.Command) {
assert.Len(t, events, 0)
},
},
{
name: "simple command with events",
expectedErr: nil,
command: func(ctrl *gomock.Controller) domain.Executor {
cmd := domainmock.NewMockCommander(ctrl)
gomock.InOrder(
cmd.EXPECT().Execute(gomock.Any(), gomock.Any()).Return(nil),
cmd.EXPECT().Events(gomock.Any(), gomock.Any()).Return([]legacy_es.Command{&invokeTestEvent{id: "1"}}, nil),
)
return cmd
},
assertCollectedEvents: func(t *testing.T, events []legacy_es.Command) {
require.Len(t, events, 1)
assert.Equal(t, "1", events[0].Aggregate().ID)
},
},
{
name: "command with sub commands",
expectedErr: nil,
command: func(ctrl *gomock.Controller) domain.Executor {
mainCmd := domainmock.NewMockCommander(ctrl)
subCmd := domainmock.NewMockCommander(ctrl)
gomock.InOrder(
mainCmd.EXPECT().Execute(gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, opts *domain.InvokeOpts) error {
return opts.Invoke(ctx, subCmd)
},
),
subCmd.EXPECT().Execute(gomock.Any(), gomock.Any()).Return(nil),
subCmd.EXPECT().Events(gomock.Any(), gomock.Any()).Return([]legacy_es.Command{&invokeTestEvent{id: "2"}}, nil),
mainCmd.EXPECT().Events(gomock.Any(), gomock.Any()).Return([]legacy_es.Command{&invokeTestEvent{id: "1"}}, nil),
)
return mainCmd
},
assertCollectedEvents: func(t *testing.T, events []legacy_es.Command) {
require.Len(t, events, 2)
assert.Equal(t, "1", events[0].Aggregate().ID)
assert.Equal(t, "2", events[1].Aggregate().ID)
},
},
{
name: "command with multiple sub commands",
expectedErr: nil,
command: func(ctrl *gomock.Controller) domain.Executor {
mainCmd := domainmock.NewMockCommander(ctrl)
firstSubCmd := domainmock.NewMockCommander(ctrl)
secondSubCommand := domainmock.NewMockCommander(ctrl)
gomock.InOrder(
mainCmd.EXPECT().Execute(gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, opts *domain.InvokeOpts) error {
return opts.Invoke(ctx, firstSubCmd)
},
),
firstSubCmd.EXPECT().Execute(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, io *domain.InvokeOpts) error {
return io.Invoke(ctx, secondSubCommand)
}),
secondSubCommand.EXPECT().Execute(gomock.Any(), gomock.Any()).Return(nil),
secondSubCommand.EXPECT().Events(gomock.Any(), gomock.Any()).Return([]legacy_es.Command{&invokeTestEvent{id: "3"}}, nil),
firstSubCmd.EXPECT().Events(gomock.Any(), gomock.Any()).Return([]legacy_es.Command{&invokeTestEvent{id: "2"}}, nil),
mainCmd.EXPECT().Events(gomock.Any(), gomock.Any()).Return([]legacy_es.Command{&invokeTestEvent{id: "1"}}, nil),
)
return mainCmd
},
assertCollectedEvents: func(t *testing.T, events []legacy_es.Command) {
require.Len(t, events, 3)
assert.Equal(t, "1", events[0].Aggregate().ID)
assert.Equal(t, "2", events[1].Aggregate().ID)
assert.Equal(t, "3", events[2].Aggregate().ID)
},
},
{
name: "only sub commands with events",
expectedErr: nil,
command: func(ctrl *gomock.Controller) domain.Executor {
mainCmd := domainmock.NewMockCommander(ctrl)
subCmd := domainmock.NewMockCommander(ctrl)
gomock.InOrder(
mainCmd.EXPECT().Execute(gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, opts *domain.InvokeOpts) error {
return opts.Invoke(ctx, subCmd)
},
),
subCmd.EXPECT().Execute(gomock.Any(), gomock.Any()).Return(nil),
subCmd.EXPECT().Events(gomock.Any(), gomock.Any()).Return([]legacy_es.Command{&invokeTestEvent{id: "2"}}, nil),
mainCmd.EXPECT().Events(gomock.Any(), gomock.Any()).Return(nil, nil),
)
return mainCmd
},
assertCollectedEvents: func(t *testing.T, events []legacy_es.Command) {
require.Len(t, events, 1)
assert.Equal(t, "2", events[0].Aggregate().ID)
},
},
{
name: "executor batch all commands",
expectedErr: nil,
command: func(ctrl *gomock.Controller) domain.Executor {
mainCommand := domainmock.NewMockCommander(ctrl)
firstSubCommand := domainmock.NewMockCommander(ctrl)
secondSubCommand := domainmock.NewMockCommander(ctrl)
gomock.InOrder(
mainCommand.EXPECT().Execute(gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, opts *domain.InvokeOpts) error {
return opts.Invoke(ctx,
domain.BatchExecutors(
firstSubCommand,
secondSubCommand,
),
)
},
),
firstSubCommand.EXPECT().Execute(gomock.Any(), gomock.Any()).Return(nil),
firstSubCommand.EXPECT().Events(gomock.Any(), gomock.Any()).Return([]legacy_es.Command{&invokeTestEvent{id: "2"}}, nil),
secondSubCommand.EXPECT().Execute(gomock.Any(), gomock.Any()).Return(nil),
secondSubCommand.EXPECT().Events(gomock.Any(), gomock.Any()).Return([]legacy_es.Command{&invokeTestEvent{id: "3"}}, nil),
mainCommand.EXPECT().Events(gomock.Any(), gomock.Any()).Return([]legacy_es.Command{&invokeTestEvent{id: "1"}}, nil),
)
return mainCommand
},
assertCollectedEvents: func(t *testing.T, events []legacy_es.Command) {
require.Len(t, events, 3)
assert.Equal(t, "1", events[0].Aggregate().ID)
assert.Equal(t, "2", events[1].Aggregate().ID)
assert.Equal(t, "3", events[2].Aggregate().ID)
},
},
{
name: "executor batch with sub batch",
expectedErr: nil,
command: func(ctrl *gomock.Controller) domain.Executor {
mainCommand := domainmock.NewMockCommander(ctrl)
firstSubCommand := domainmock.NewMockCommander(ctrl)
secondSubCommand := domainmock.NewMockCommander(ctrl)
thirdSubCommand := domainmock.NewMockCommander(ctrl)
fourthSubCommand := domainmock.NewMockCommander(ctrl)
fifthSubCommand := domainmock.NewMockCommander(ctrl)
gomock.InOrder(
mainCommand.EXPECT().Execute(gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, opts *domain.InvokeOpts) error {
return opts.Invoke(ctx,
domain.BatchExecutors(
firstSubCommand,
domain.BatchExecutors(
secondSubCommand,
thirdSubCommand,
),
),
)
},
),
firstSubCommand.EXPECT().Execute(gomock.Any(), gomock.Any()).Return(nil),
firstSubCommand.EXPECT().Events(gomock.Any(), gomock.Any()).Return([]legacy_es.Command{&invokeTestEvent{id: "2"}}, nil),
secondSubCommand.EXPECT().Execute(gomock.Any(), gomock.Any()).Return(nil),
secondSubCommand.EXPECT().Events(gomock.Any(), gomock.Any()).Return([]legacy_es.Command{&invokeTestEvent{id: "3"}}, nil),
thirdSubCommand.EXPECT().Execute(gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, opts *domain.InvokeOpts) error {
return opts.Invoke(ctx,
domain.BatchExecutors(
domain.BatchExecutors(
fourthSubCommand,
),
domain.BatchExecutors(
fifthSubCommand,
),
),
)
},
),
fourthSubCommand.EXPECT().Execute(gomock.Any(), gomock.Any()).Return(nil),
fourthSubCommand.EXPECT().Events(gomock.Any(), gomock.Any()).Return([]legacy_es.Command{&invokeTestEvent{id: "5"}}, nil),
fifthSubCommand.EXPECT().Execute(gomock.Any(), gomock.Any()).Return(nil),
fifthSubCommand.EXPECT().Events(gomock.Any(), gomock.Any()).Return([]legacy_es.Command{&invokeTestEvent{id: "6"}}, nil),
thirdSubCommand.EXPECT().Events(gomock.Any(), gomock.Any()).Return([]legacy_es.Command{&invokeTestEvent{id: "4"}}, nil),
mainCommand.EXPECT().Events(gomock.Any(), gomock.Any()).Return([]legacy_es.Command{&invokeTestEvent{id: "1"}}, nil),
)
return mainCommand
},
assertCollectedEvents: func(t *testing.T, events []legacy_es.Command) {
require.Len(t, events, 6)
assert.Equal(t, "1", events[0].Aggregate().ID)
assert.Equal(t, "2", events[1].Aggregate().ID)
assert.Equal(t, "3", events[2].Aggregate().ID)
assert.Equal(t, "4", events[3].Aggregate().ID)
assert.Equal(t, "5", events[4].Aggregate().ID)
assert.Equal(t, "6", events[5].Aggregate().ID)
},
},
{
name: "command with sub query",
expectedErr: nil,
command: func(ctrl *gomock.Controller) domain.Executor {
mainCmd := domainmock.NewMockCommander(ctrl)
subQuery := domainmock.NewMockQuerier[string](ctrl)
gomock.InOrder(
mainCmd.EXPECT().Execute(gomock.Any(), gomock.Any()).DoAndReturn(
func(ctx context.Context, opts *domain.InvokeOpts) error {
return opts.Invoke(ctx, subQuery)
},
),
subQuery.EXPECT().Execute(gomock.Any(), gomock.Any()).Return(nil),
mainCmd.EXPECT().Events(gomock.Any(), gomock.Any()).Return([]legacy_es.Command{&invokeTestEvent{id: "1"}}, nil),
)
return mainCmd
},
assertCollectedEvents: func(t *testing.T, events []legacy_es.Command) {
require.Len(t, events, 1)
assert.Equal(t, "1", events[0].Aggregate().ID)
},
},
{
name: "begin fails",
expectedErr: assert.AnError,
command: func(ctrl *gomock.Controller) domain.Executor {
return domainmock.NewMockCommander(ctrl)
},
queryExecutor: func(ctrl *gomock.Controller) database.QueryExecutor {
pool := dbmock.NewMockPool(ctrl)
pool.EXPECT().Begin(gomock.Any(), gomock.Any()).Return(nil, assert.AnError).Times(1)
return pool
},
assertCollectedEvents: func(t *testing.T, events []legacy_es.Command) {
require.Len(t, events, 0)
},
},
{
name: "execute fails",
expectedErr: assert.AnError,
command: func(ctrl *gomock.Controller) domain.Executor {
cmd := domainmock.NewMockCommander(ctrl)
cmd.EXPECT().Execute(gomock.Any(), gomock.Any()).Return(assert.AnError)
return cmd
},
queryExecutor: func(ctrl *gomock.Controller) database.QueryExecutor {
pool := dbmock.NewMockPool(ctrl)
tx := dbmock.NewMockTransaction(ctrl)
gomock.InOrder(
pool.EXPECT().Begin(gomock.Any(), gomock.Any()).Return(tx, nil).Times(1),
tx.EXPECT().End(gomock.Any(), assert.AnError).Return(assert.AnError).Times(1),
)
return pool
},
assertCollectedEvents: func(t *testing.T, events []legacy_es.Command) {
require.Len(t, events, 0)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
i := domain.NewEventStoreInvoker(nil)
opts := domain.InvokeOpts{
Invoker: i,
}
if tt.queryExecutor != nil {
domain.WithQueryExecutor(tt.queryExecutor(ctrl))(&opts)
} else {
pool := dbmock.NewMockPool(ctrl)
tx := dbmock.NewMockTransaction(ctrl)
tx.EXPECT().End(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
pool.EXPECT().Begin(gomock.Any(), gomock.Any()).Return(tx, nil).Times(1)
domain.WithQueryExecutor(pool)(&opts)
}
eventStore := new(testLegacyEventstore)
domain.WithLegacyEventstore(eventStore)(&opts)
err := opts.Invoke(t.Context(), tt.command(ctrl))
require.ErrorIs(t, err, tt.expectedErr)
tt.assertCollectedEvents(t, eventStore.commands)
})
}
}