Files
zitadel/backend/v3/domain/invoke_test.go
T
SilvanandGitHub 22ef817d5c fix(eventstore): Make Eventstore Compatible with Relational Table Package (#10687)
Improves compatibility of eventstore and related database components
with the new relational table package.

## Which problems are solved

1. **Incompatible Database Interfaces**: The existing eventstore was
tightly coupled to the database package, which is incompatible with the
new, more abstract relational table package in v3. This prevented the
new command-side logic from pushing events to the legacy eventstore.
2. **Missing Health Checks**: The database interfaces in the new package
lacked a Ping method, making it impossible to perform health checks on
database connections.
3. **Event Publishing Logic**: The command handling logic in domain
needed a way to collect and push events to the legacy eventstore after a
command was successfully executed.

## How the problems are solved

1. **`LegacyEventstore` Interface**:
* A new `LegacyEventstore` interface is introduced in the new
`database/eventstore` . This interface exposes a `PushWithNewClient`
method that accepts the new `database.QueryExecutor` interface,
decoupling the v3 domain from the legacy implementation.
* The `internal/eventstore.Eventstore` now implements this interface. A
wrapper, PushWithClient, is added to convert the old database client
types (`*sql.DB`, `*sql.Tx`) into the new `QueryExecutor` types before
calling `PushWithNewClient`.
2. **Database Interface Updates**:
* The `database.Pool` and `database.Client` interfaces in
`storage/eventstore` have been updated to include a Ping method,
allowing for consistent health checks across different database
dialects.
* The `postgres` and `sql` dialect implementations have been updated to
support this new method.
3. **Command and Invoker Refactoring**:
* The `Commander` interface in domain now includes an `Events()
[]legacy_es.Command` method. This allows commands to declare which
events they will generate.
* The `eventCollector` in the invoker logic has been redesigned. It now
ensures a database transaction is started before executing a command.
After successful execution, it calls the `Events()` method on the
command to collect the generated events and appends them to a list.
* The `eventStoreInvoker` then pushes all collected events to the legacy
eventstore using the new `LegacyEventstore` interface, ensuring that
events are only pushed if the entire command (and any sub-commands)
executes successfully within the transaction.
4. **Testing**:
* New unit tests have been added for the invoker to verify that events
are correctly collected from single commands, batched commands, and
nested commands.

These changes create a clean bridge between the new v3 command-side
logic and the existing v1 eventstore, allowing for incremental adoption
of the new architecture while maintaining full functionality.

## Additional Information

closes https://github.com/zitadel/zitadel/issues/10442
2025-09-16 18:58:49 +02:00

181 lines
4.7 KiB
Go

package domain
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"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{}
func (es *testLegacyEventstore) PushWithNewClient(ctx context.Context, client database.QueryExecutor, commands ...legacy_es.Command) ([]legacy_es.Event, error) {
return nil, nil
}
var _ eventstore.LegacyEventstore = (*testLegacyEventstore)(nil)
type invokeTestCommand struct {
events []legacy_es.Command
execute func(ctx context.Context, opts *CommandOpts) error
}
// Events implements Commander.
func (i *invokeTestCommand) Events(ctx context.Context) []legacy_es.Command {
return i.events
}
// Execute implements Commander.
func (i *invokeTestCommand) Execute(ctx context.Context, opts *CommandOpts) (err error) {
if i.execute == nil {
return nil
}
return i.execute(ctx, opts)
}
// String implements Commander.
func (i *invokeTestCommand) String() string {
return "invokeTestCommand"
}
var _ Commander = (*invokeTestCommand)(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 Commander
expectedErr error
assertCollectedEvents func(t *testing.T, events []legacy_es.Command)
}{
{
name: "simple command with events",
expectedErr: nil,
command: &invokeTestCommand{
events: []legacy_es.Command{&invokeTestEvent{id: "1"}},
},
assertCollectedEvents: func(t *testing.T, events []legacy_es.Command) {
require.Len(t, events, 1)
assert.Equal(t, "1", events[0].Aggregate().ID)
},
},
{
name: "simple command without events",
expectedErr: nil,
command: &invokeTestCommand{},
assertCollectedEvents: func(t *testing.T, events []legacy_es.Command) {
assert.Len(t, events, 0)
},
},
{
name: "command with sub commands",
expectedErr: nil,
command: &invokeTestCommand{
events: []legacy_es.Command{&invokeTestEvent{id: "1"}},
execute: func(ctx context.Context, opts *CommandOpts) error {
return opts.Invoke(ctx, &invokeTestCommand{
events: []legacy_es.Command{&invokeTestEvent{id: "2"}},
})
},
},
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: "only sub commands with events",
expectedErr: nil,
command: &invokeTestCommand{
execute: func(ctx context.Context, opts *CommandOpts) error {
return opts.Invoke(ctx, &invokeTestCommand{
events: []legacy_es.Command{&invokeTestEvent{id: "2"}},
})
},
},
assertCollectedEvents: func(t *testing.T, events []legacy_es.Command) {
require.Len(t, events, 1)
assert.Equal(t, "2", events[0].Aggregate().ID)
},
},
}
SetLegacyEventstore(new(testLegacyEventstore))
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
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).AnyTimes()
i := newEventStoreInvoker(nil)
opts := CommandOpts{
Invoker: i,
DB: pool,
}
err := opts.Invoke(t.Context(), tt.command)
require.ErrorIs(t, err, tt.expectedErr)
tt.assertCollectedEvents(t, i.collector.events)
})
}
}