diff --git a/pkg/database/database.go b/pkg/database/database.go index 0b255cb..e414e7d 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -39,6 +39,7 @@ type DatabaseInterface interface { Ping(context.Context) error Find(ctx context.Context, db string, collection string, filter any, opts ...any) FindResultInterface FindOne(ctx context.Context, db string, collection string, filter any, opts ...any) SingleResultInterface + FindOneAndUpdate(ctx context.Context, db string, collection string, filter any, update any, opts ...any) SingleResultInterface UpdateOne(ctx context.Context, db string, collection string, filter any, update any, opts ...any) (UpdateResultInterface, error) DeleteOne(ctx context.Context, db string, collection string, filter any, opts ...any) (DeleteResultInterface, error) DeleteMany(ctx context.Context, db string, collection string, filter any, opts ...any) (DeleteResultInterface, error) diff --git a/pkg/database/mock.go b/pkg/database/mock.go index 2d0b8e4..4d13ff4 100644 --- a/pkg/database/mock.go +++ b/pkg/database/mock.go @@ -20,6 +20,9 @@ type MockDatabase struct { // FindOneFunc allows customizing FindOne behavior - returns a SingleResultInterface FindOneFunc func(ctx context.Context, db string, collection string, filter any, opts ...any) SingleResultInterface + // FindOneAndUpdateFunc allows customizing atomic find-and-update behavior. + FindOneAndUpdateFunc func(ctx context.Context, db string, collection string, filter any, update any, opts ...any) SingleResultInterface + // UpdateOneFunc allows customizing UpdateOne behavior UpdateOneFunc func(ctx context.Context, db string, collection string, filter any, update any, opts ...any) (UpdateResultInterface, error) @@ -33,24 +36,28 @@ type MockDatabase struct { CountFunc func(ctx context.Context, db string, collection string, filter any, opts ...any) (int64, error) // Sequential response queues for multiple calls - PingQueue []PingResponse - FindQueue []FindResponse - FindOneQueue []FindOneResponse - UpdateOneQueue []UpdateOneResponse - DeleteOneQueue []DeleteResponse - DeleteManyQueue []DeleteResponse - CountQueue []CountResponse + PingQueue []PingResponse + FindQueue []FindResponse + FindOneQueue []FindOneResponse + FindOneAndUpdateQueue []FindOneResponse + UpdateOneQueue []UpdateOneResponse + DeleteOneQueue []DeleteResponse + DeleteManyQueue []DeleteResponse + CountQueue []CountResponse // Call tracking - PingCalls []PingCall - FindCalls []FindCall - FindOneCalls []FindOneCall - UpdateOneCalls []UpdateOneCall - DeleteOneCalls []DeleteCall - DeleteManyCalls []DeleteCall - CountCalls []CountCall + PingCalls []PingCall + FindCalls []FindCall + FindOneCalls []FindOneCall + FindOneAndUpdateCalls []FindOneAndUpdateCall + UpdateOneCalls []UpdateOneCall + DeleteOneCalls []DeleteCall + DeleteManyCalls []DeleteCall + CountCalls []CountCall } +var _ DatabaseInterface = (*MockDatabase)(nil) + // MockSingleResult implements SingleResultInterface for testing type MockSingleResult struct { result any @@ -193,6 +200,16 @@ type FindOneCall struct { Opts []any } +// FindOneAndUpdateCall records an atomic find-and-update call. +type FindOneAndUpdateCall struct { + Ctx context.Context + Db string + Collection string + Filter any + Update any + Opts []any +} + // UpdateOneCall records a call to UpdateOne type UpdateOneCall struct { Ctx context.Context @@ -239,6 +256,9 @@ func NewMockDatabase() *MockDatabase { FindOneFunc: func(ctx context.Context, db string, collection string, filter any, opts ...any) SingleResultInterface { return &MockSingleResult{result: nil, err: fmt.Errorf("no document found")} }, + FindOneAndUpdateFunc: func(ctx context.Context, db string, collection string, filter any, update any, opts ...any) SingleResultInterface { + return &MockSingleResult{result: nil, err: fmt.Errorf("no document found")} + }, UpdateOneFunc: func(ctx context.Context, db string, collection string, filter any, update any, opts ...any) (UpdateResultInterface, error) { return &MockUpdateResult{matchedCount: 1, modifiedCount: 1}, nil }, @@ -251,20 +271,22 @@ func NewMockDatabase() *MockDatabase { CountFunc: func(ctx context.Context, db string, collection string, filter any, opts ...any) (int64, error) { return 0, nil }, - PingCalls: []PingCall{}, - FindCalls: []FindCall{}, - FindOneCalls: []FindOneCall{}, - UpdateOneCalls: []UpdateOneCall{}, - DeleteOneCalls: []DeleteCall{}, - DeleteManyCalls: []DeleteCall{}, - CountCalls: []CountCall{}, - PingQueue: []PingResponse{}, - FindQueue: []FindResponse{}, - FindOneQueue: []FindOneResponse{}, - UpdateOneQueue: []UpdateOneResponse{}, - DeleteOneQueue: []DeleteResponse{}, - DeleteManyQueue: []DeleteResponse{}, - CountQueue: []CountResponse{}, + PingCalls: []PingCall{}, + FindCalls: []FindCall{}, + FindOneCalls: []FindOneCall{}, + FindOneAndUpdateCalls: []FindOneAndUpdateCall{}, + UpdateOneCalls: []UpdateOneCall{}, + DeleteOneCalls: []DeleteCall{}, + DeleteManyCalls: []DeleteCall{}, + CountCalls: []CountCall{}, + PingQueue: []PingResponse{}, + FindQueue: []FindResponse{}, + FindOneQueue: []FindOneResponse{}, + FindOneAndUpdateQueue: []FindOneResponse{}, + UpdateOneQueue: []UpdateOneResponse{}, + DeleteOneQueue: []DeleteResponse{}, + DeleteManyQueue: []DeleteResponse{}, + CountQueue: []CountResponse{}, } } @@ -377,6 +399,28 @@ func (m *MockDatabase) FindOne(ctx context.Context, db string, collection string return &MockSingleResult{result: result, err: err} } +// FindOneAndUpdate implements DatabaseInterface. +func (m *MockDatabase) FindOneAndUpdate(ctx context.Context, db string, collection string, filter any, update any, opts ...any) SingleResultInterface { + m.FindOneAndUpdateCalls = append(m.FindOneAndUpdateCalls, FindOneAndUpdateCall{ + Ctx: ctx, + Db: db, + Collection: collection, + Filter: filter, + Update: update, + Opts: opts, + }) + + if len(m.FindOneAndUpdateQueue) > 0 { + response := m.FindOneAndUpdateQueue[0] + m.FindOneAndUpdateQueue = m.FindOneAndUpdateQueue[1:] + return &MockSingleResult{result: response.Result, err: response.Err} + } + if m.FindOneAndUpdateFunc != nil { + return m.FindOneAndUpdateFunc(ctx, db, collection, filter, update, opts...) + } + return &MockSingleResult{result: nil, err: fmt.Errorf("no document found")} +} + // copyResult copies src into dest using BSON marshaling (for mock testing) func copyResult(src any, dest any) error { bytes, err := bson.Marshal(src) @@ -590,6 +634,7 @@ func (m *MockDatabase) Reset() { m.PingCalls = []PingCall{} m.FindCalls = []FindCall{} m.FindOneCalls = []FindOneCall{} + m.FindOneAndUpdateCalls = []FindOneAndUpdateCall{} m.UpdateOneCalls = []UpdateOneCall{} m.DeleteOneCalls = []DeleteCall{} m.DeleteManyCalls = []DeleteCall{} @@ -597,6 +642,7 @@ func (m *MockDatabase) Reset() { m.PingQueue = []PingResponse{} m.FindQueue = []FindResponse{} m.FindOneQueue = []FindOneResponse{} + m.FindOneAndUpdateQueue = []FindOneResponse{} m.UpdateOneQueue = []UpdateOneResponse{} m.DeleteOneQueue = []DeleteResponse{} m.DeleteManyQueue = []DeleteResponse{} @@ -627,6 +673,14 @@ func (m *MockDatabase) ExpectFindOne(result any, err error) *MockDatabase { return m } +// ExpectFindOneAndUpdate sets up an atomic find-and-update response. +func (m *MockDatabase) ExpectFindOneAndUpdate(result any, err error) *MockDatabase { + m.FindOneAndUpdateFunc = func(ctx context.Context, db string, collection string, filter any, update any, opts ...any) SingleResultInterface { + return &MockSingleResult{result: result, err: err} + } + return m +} + // ExpectUpdateOne sets up an expectation for UpdateOne func (m *MockDatabase) ExpectUpdateOne(matchedCount, modifiedCount, upsertedCount int64, upsertedID any, err error) *MockDatabase { m.UpdateOneFunc = func(ctx context.Context, db string, collection string, filter any, update any, opts ...any) (UpdateResultInterface, error) { @@ -683,6 +737,12 @@ func (m *MockDatabase) QueueFindOne(result any, err error) *MockDatabase { return m } +// QueueFindOneAndUpdate adds an atomic find-and-update response to the queue. +func (m *MockDatabase) QueueFindOneAndUpdate(result any, err error) *MockDatabase { + m.FindOneAndUpdateQueue = append(m.FindOneAndUpdateQueue, FindOneResponse{Result: result, Err: err}) + return m +} + // QueueUpdateOne adds an UpdateOne response to the queue for sequential calls func (m *MockDatabase) QueueUpdateOne(matchedCount, modifiedCount, upsertedCount int64, upsertedID any, err error) *MockDatabase { m.UpdateOneQueue = append(m.UpdateOneQueue, UpdateOneResponse{ diff --git a/pkg/database/mock_test.go b/pkg/database/mock_test.go index 4088265..435e922 100644 --- a/pkg/database/mock_test.go +++ b/pkg/database/mock_test.go @@ -130,6 +130,47 @@ func TestMockDatabase(t *testing.T) { } }) + t.Run("ExpectFindOneAndUpdateWithResult", func(t *testing.T) { + mock := NewMockDatabase() + expected := map[string]any{"id": 1, "name": "Updated"} + filter := map[string]any{"id": 1} + update := map[string]any{"$set": map[string]any{"name": "Updated"}} + mock.ExpectFindOneAndUpdate(expected, nil) + + var result map[string]any + err := mock.FindOneAndUpdate(context.Background(), "testdb", "users", filter, update).Into(&result) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if result["name"] != "Updated" { + t.Fatalf("expected updated document, got %#v", result) + } + if len(mock.FindOneAndUpdateCalls) != 1 { + t.Fatalf("expected 1 findOneAndUpdate call, got %d", len(mock.FindOneAndUpdateCalls)) + } + call := mock.FindOneAndUpdateCalls[0] + if call.Db != "testdb" || call.Collection != "users" { + t.Fatalf("unexpected call target %s.%s", call.Db, call.Collection) + } + }) + + t.Run("QueueFindOneAndUpdateErrorAndReset", func(t *testing.T) { + mock := NewMockDatabase().QueueFindOneAndUpdate(nil, errors.New("update failed")) + + err := mock.FindOneAndUpdate(context.Background(), "testdb", "users", map[string]any{"id": 1}, map[string]any{"$set": map[string]any{"name": "Updated"}}).Err() + if err == nil || err.Error() != "update failed" { + t.Fatalf("expected update failure, got %v", err) + } + if len(mock.FindOneAndUpdateCalls) != 1 { + t.Fatalf("expected 1 findOneAndUpdate call, got %d", len(mock.FindOneAndUpdateCalls)) + } + + mock.Reset() + if len(mock.FindOneAndUpdateCalls) != 0 || len(mock.FindOneAndUpdateQueue) != 0 { + t.Fatal("expected findOneAndUpdate state to be cleared") + } + }) + t.Run("CustomFindFunction", func(t *testing.T) { mock := NewMockDatabase() diff --git a/pkg/database/mongodb.go b/pkg/database/mongodb.go index cc48681..6a9e7a2 100644 --- a/pkg/database/mongodb.go +++ b/pkg/database/mongodb.go @@ -229,6 +229,8 @@ type MongoClient struct { Options *MongoOptions } +var _ DatabaseInterface = (*MongoClient)(nil) + // NewMongoClient creates a new MongoClient with the provided MongoDB settings func NewMongoClient(options *MongoOptions) (DatabaseInterface, error) { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(options.Timeout)*time.Millisecond) @@ -421,6 +423,23 @@ func (m *MongoClient) FindOne(ctx context.Context, db string, collection string, return &SingleResult{result: coll.FindOne(ctx, filter, findOneOpts...)} } +// FindOneAndUpdate atomically updates one matching document and returns the +// document selected by the supplied options. Callers that need the updated +// document should pass options.FindOneAndUpdate().SetReturnDocument(options.After). +// Supports *moptions.FindOneAndUpdateOptions in opts. +func (m *MongoClient) FindOneAndUpdate(ctx context.Context, db string, collection string, filter any, update any, opts ...any) SingleResultInterface { + coll := m.Client.Database(db).Collection(collection) + + var findOneAndUpdateOpts []*moptions.FindOneAndUpdateOptions + for _, opt := range opts { + if fo, ok := opt.(*moptions.FindOneAndUpdateOptions); ok { + findOneAndUpdateOpts = append(findOneAndUpdateOpts, fo) + } + } + + return &SingleResult{result: coll.FindOneAndUpdate(ctx, filter, update, findOneAndUpdateOpts...)} +} + // InsertOne inserts a single document into the specified database and collection func (m *MongoClient) InsertOne(ctx context.Context, db string, collection string, document any, opts ...any) (any, error) { coll := m.Client.Database(db).Collection(collection)