Organize backend into product executable and shared Go modules

This commit is contained in:
Stephan D committed 2026-09-12 14:14:08 +02:00
1 parent 981e84a4b4
commit f0720ec66b
382 files changed
+1944 -6989

No files matched your search

@@ -0,0 +1,143 @@
package repositoryimp
import (
"context"
"fmt"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
"git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func (r *MongoRepository) CreateIndex(def *ri.Definition) error {
if r.collection == nil {
return merrors.NoData("data collection is not set")
}
if len(def.Keys) == 0 {
return merrors.InvalidArgument("Index definition has no keys", "index.keys")
}
// ----- build BSON keys --------------------------------------------------
keys := bson.D{}
for _, k := range def.Keys {
var value any
switch {
case k.Type != "":
value = k.Type // text, 2dsphere, …
case k.Sort == ri.Desc:
value = int8(-1)
default:
value = int8(1) // default to Asc
}
keys = append(keys, bson.E{Key: k.Field, Value: value})
}
opts := options.Index().
SetUnique(def.Unique)
if def.TTL != nil {
opts.SetExpireAfterSeconds(*def.TTL)
}
if def.Name != "" {
opts.SetName(def.Name)
}
if def.PartialFilter != nil {
opts.SetPartialFilterExpression(def.PartialFilter.BuildQuery())
}
if def.Sparse {
opts.SetSparse(def.Sparse)
}
_, err := r.collection.Indexes().CreateOne(
context.Background(),
mongo.IndexModel{Keys: keys, Options: opts},
)
return err
}
func (r *MongoRepository) DropIndex(ctx context.Context, name string) error {
if r.collection == nil {
return merrors.NoData("data collection is not set")
}
if name == "" {
return merrors.InvalidArgument("index name is required", "index.name")
}
return r.collection.Indexes().DropOne(ctx, name)
}
func (r *MongoRepository) ListIndexDefinitions(ctx context.Context) ([]*ri.Definition, error) {
if r.collection == nil {
return nil, merrors.NoData("data collection is not set")
}
cursor, err := r.collection.Indexes().List(ctx)
if err != nil {
return nil, err
}
defer func() { _ = cursor.Close(ctx) }()
defs := make([]*ri.Definition, 0)
for cursor.Next(ctx) {
var doc mongoIndexDocument
if err := cursor.Decode(&doc); err != nil {
return nil, err
}
defs = append(defs, indexDefinitionFromDocument(doc))
}
if err := cursor.Err(); err != nil {
return nil, err
}
return defs, nil
}
type mongoIndexDocument struct {
Name string `bson:"name"`
Key bson.D `bson:"key"`
Unique bool `bson:"unique"`
Sparse bool `bson:"sparse"`
ExpireAfterSeconds *int32 `bson:"expireAfterSeconds,omitempty"`
}
func indexDefinitionFromDocument(doc mongoIndexDocument) *ri.Definition {
keys := make([]ri.Key, 0, len(doc.Key))
for _, key := range doc.Key {
keys = append(keys, indexKeyFromBSON(key))
}
return &ri.Definition{
Keys: keys,
Unique: doc.Unique,
Sparse: doc.Sparse,
TTL: doc.ExpireAfterSeconds,
Name: doc.Name,
}
}
func indexKeyFromBSON(key bson.E) ri.Key {
out := ri.Key{Field: key.Key}
switch typed := key.Value.(type) {
case string:
out.Type = ri.IndexType(typed)
case int:
out.Sort = indexSort(typed)
case int8:
out.Sort = indexSort(typed)
case int16:
out.Sort = indexSort(typed)
case int32:
out.Sort = indexSort(typed)
case int64:
out.Sort = indexSort(typed)
case float64:
out.Sort = indexSort(typed)
default:
out.Type = ri.IndexType(fmt.Sprint(typed))
}
return out
}
func indexSort[T ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~float64](value T) ri.Sort {
if value < 0 {
return ri.Desc
}
return ri.Asc
}
@@ -0,0 +1,95 @@
//go:build integration
// +build integration
package repositoryimp_test
import (
"context"
"testing"
"time"
"git.sendico.io/tech/sxchnge/shared/database/repository"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func TestCreateIndex_WithPartialFilter(t *testing.T) {
startCtx, startCancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer startCancel()
mongoContainer, err := mongodb.Run(startCtx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForListeningPort("27017/tcp").WithStartupTimeout(2*time.Minute)),
)
require.NoError(t, err)
mongoURI, err := mongoContainer.ConnectionString(startCtx)
require.NoError(t, err)
client, err := mongo.Connect(options.Client().ApplyURI(mongoURI))
require.NoError(t, err)
defer client.Disconnect(context.Background())
database := client.Database("test_partial_index_" + t.Name())
defer database.Drop(context.Background())
repo := repository.CreateMongoRepository(database, "partial_index_items")
def := &ri.Definition{
Keys: []ri.Key{
{Field: "field", Sort: ri.Asc},
},
Unique: true,
Name: "partial_unique_field_true",
PartialFilter: repository.Filter("flag", true),
}
require.NoError(t, repo.CreateIndex(def))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
defs, err := repo.ListIndexDefinitions(ctx)
require.NoError(t, err)
listed := false
for _, listedDef := range defs {
if listedDef.Name == def.Name {
listed = true
assert.True(t, listedDef.Unique)
assert.Equal(t, []ri.Key{{Field: "field", Sort: ri.Asc}}, listedDef.Keys)
}
}
assert.True(t, listed, "created index was not returned by ListIndexDefinitions")
cursor, err := database.Collection(repo.Collection()).Indexes().List(ctx)
require.NoError(t, err)
defer cursor.Close(ctx)
found := false
for cursor.Next(ctx) {
var idx bson.M
require.NoError(t, cursor.Decode(&idx))
if idx["name"] == def.Name {
found = true
assert.Equal(t, true, idx["unique"])
assert.Equal(t, bson.M{"field": int32(1)}, idx["key"])
partial, ok := idx["partialFilterExpression"].(bson.M)
require.True(t, ok)
assert.Equal(t, bson.M{"flag": true}, partial)
}
}
assert.True(t, found, "partial unique index was not created")
termCtx, termCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer termCancel()
_ = mongoContainer.Terminate(termCtx)
}
@@ -0,0 +1,52 @@
package repositoryimp
import (
"testing"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestIndexDefinitionFromDocumentMapsIndexMetadata(t *testing.T) {
ttl := int32(3600)
def := indexDefinitionFromDocument(mongoIndexDocument{
Name: "account_ref_unique",
Key: bson.D{{Key: "accountRef", Value: int32(1)}, {Key: "version", Value: int64(-1)}},
Unique: true,
Sparse: true,
ExpireAfterSeconds: &ttl,
})
if def.Name != "account_ref_unique" || !def.Unique || !def.Sparse {
t.Fatalf("unexpected definition metadata: %#v", def)
}
if def.TTL == nil || *def.TTL != ttl {
t.Fatalf("TTL = %v, want %d", def.TTL, ttl)
}
wantKeys := []ri.Key{
{Field: "accountRef", Sort: ri.Asc},
{Field: "version", Sort: ri.Desc},
}
if len(def.Keys) != len(wantKeys) {
t.Fatalf("keys = %#v, want %#v", def.Keys, wantKeys)
}
for i := range wantKeys {
if def.Keys[i] != wantKeys[i] {
t.Fatalf("keys[%d] = %#v, want %#v", i, def.Keys[i], wantKeys[i])
}
}
}
func TestIndexDefinitionFromDocumentMapsTypedIndex(t *testing.T) {
def := indexDefinitionFromDocument(mongoIndexDocument{
Name: "text_index",
Key: bson.D{{Key: "description", Value: "text"}},
})
if len(def.Keys) != 1 {
t.Fatalf("keys = %#v", def.Keys)
}
if def.Keys[0] != (ri.Key{Field: "description", Type: ri.IndexTypeText}) {
t.Fatalf("key = %#v", def.Keys[0])
}
}
@@ -0,0 +1,253 @@
package repositoryimp
import (
"context"
"errors"
"fmt"
"git.sendico.io/tech/sxchnge/shared/database/record"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
rd "git.sendico.io/tech/sxchnge/shared/database/repository/decoder"
"git.sendico.io/tech/sxchnge/shared/database/transaction"
"git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
type MongoRepository struct {
collectionName string
collection *mongo.Collection
}
func idFilter(id bson.ObjectID) bson.D {
return bson.D{
{Key: storable.IDField, Value: id},
}
}
func NewMongoRepository(db *mongo.Database, collection string) *MongoRepository {
return &MongoRepository{
collectionName: collection,
collection: db.Collection(collection),
}
}
func (r *MongoRepository) Collection() string {
return r.collectionName
}
func prepareForWrite(obj storable.Storable) bson.ObjectID {
id := obj.GetID()
if id == nil || id.IsZero() {
obj.SetID(bson.NewObjectID())
id = obj.GetID()
}
obj.Update()
return *id
}
func (r *MongoRepository) Insert(ctx context.Context, obj storable.Storable, getFilter builder.Query) error {
prepareForWrite(obj)
_, err := r.collection.InsertOne(ctx, obj)
if mongo.IsDuplicateKeyError(err) {
if getFilter != nil {
if err = r.FindOneByFilter(ctx, getFilter, obj); err != nil {
return err
}
}
return merrors.DataConflict("duplicate_key")
}
return err
}
func (r *MongoRepository) InsertMany(ctx context.Context, objects []storable.Storable) error {
if len(objects) == 0 {
return nil
}
docs := make([]interface{}, len(objects))
for i, obj := range objects {
prepareForWrite(obj)
docs[i] = obj
}
_, err := r.collection.InsertMany(ctx, docs)
return err
}
func (r *MongoRepository) findOneByFilterImp(ctx context.Context, filter bson.D, errMessage string, result storable.Storable) error {
err := r.collection.FindOne(ctx, filter).Decode(result)
if errors.Is(err, mongo.ErrNoDocuments) {
return merrors.NoData(errMessage)
}
return err
}
func (r *MongoRepository) Get(ctx context.Context, id bson.ObjectID, result storable.Storable) error {
if id.IsZero() {
return merrors.InvalidArgument("zero id provided while fetching "+result.Collection(), "id")
}
return r.findOneByFilterImp(ctx, idFilter(id), fmt.Sprintf("%s with ID = %s not found", result.Collection(), id.Hex()), result)
}
type QueryFunc func(ctx context.Context, collection *mongo.Collection) (*mongo.Cursor, error)
func (r *MongoRepository) executeQuery(ctx context.Context, queryFunc QueryFunc, decoder rd.DecodingFunc) error {
cursor, err := queryFunc(ctx, r.collection)
if errors.Is(err, mongo.ErrNoDocuments) {
return merrors.NoData("no_items_in_array")
}
if err != nil {
return err
}
defer func() {
_ = cursor.Close(ctx)
}()
for cursor.Next(ctx) {
if err = decoder(cursor); err != nil {
return err
}
}
return nil
}
func (r *MongoRepository) Aggregate(ctx context.Context, pipeline builder.Pipeline, decoder rd.DecodingFunc) error {
queryFunc := func(ctx context.Context, collection *mongo.Collection) (*mongo.Cursor, error) {
return collection.Aggregate(ctx, pipeline.Build())
}
return r.executeQuery(ctx, queryFunc, decoder)
}
func (r *MongoRepository) FindManyByFilter(ctx context.Context, query builder.Query, decoder rd.DecodingFunc) error {
queryFunc := func(ctx context.Context, collection *mongo.Collection) (*mongo.Cursor, error) {
return collection.Find(ctx, query.BuildQuery(), query.BuildOptions())
}
return r.executeQuery(ctx, queryFunc, decoder)
}
func (r *MongoRepository) FindAllByFilter(ctx context.Context, query builder.Query, result any) error {
if query == nil {
cursor, err := r.collection.Find(ctx, bson.D{})
if err != nil {
return err
}
defer func() {
_ = cursor.Close(ctx)
}()
return cursor.All(ctx, result)
}
cursor, err := r.collection.Find(ctx, query.BuildQuery(), query.BuildOptions())
if err != nil {
return err
}
defer func() {
_ = cursor.Close(ctx)
}()
return cursor.All(ctx, result)
}
func (r *MongoRepository) FindOneByFilter(ctx context.Context, query builder.Query, result storable.Storable) error {
return r.findOneByFilterImp(ctx, query.BuildQuery(), result.Collection()+" not found by filter", result)
}
func (r *MongoRepository) Update(ctx context.Context, obj storable.Storable) error {
obj.Update()
return r.collection.FindOneAndReplace(ctx, idFilter(*obj.GetID()), obj).Err()
}
func (r *MongoRepository) ReplaceOneByFilter(ctx context.Context, query builder.Query, obj storable.Storable) (bool, error) {
if query == nil {
return false, merrors.InvalidArgument("filter is required", "filter")
}
if obj == nil {
return false, merrors.InvalidArgument("object is required", "obj")
}
result, err := r.collection.ReplaceOne(ctx, query.BuildQuery(), obj)
if mongo.IsDuplicateKeyError(err) {
return false, merrors.DataConflict("duplicate_key")
}
if err != nil {
return false, err
}
return result.MatchedCount > 0, nil
}
func (r *MongoRepository) Upsert(ctx context.Context, obj storable.Storable) error {
id := prepareForWrite(obj)
_, err := r.collection.ReplaceOne(ctx, idFilter(id), obj, options.Replace().SetUpsert(true))
return err
}
func (r *MongoRepository) Patch(ctx context.Context, id bson.ObjectID, patch builder.Patch) error {
if id.IsZero() {
return merrors.InvalidArgument("zero id provided while patching", "id")
}
_, err := r.collection.UpdateByID(ctx, id, patch.Build())
return err
}
func (r *MongoRepository) PatchMany(ctx context.Context, query builder.Query, patch builder.Patch) (int, error) {
result, err := r.collection.UpdateMany(ctx, query.BuildQuery(), patch.Build())
if err != nil {
return 0, err
}
return int(result.ModifiedCount), nil
}
func (r *MongoRepository) ListIDs(ctx context.Context, query builder.Query) ([]bson.ObjectID, error) {
filter := query.BuildQuery()
findOptions := query.BuildOptions().SetProjection(bson.M{storable.IDField: 1})
cursor, err := r.collection.Find(ctx, filter, findOptions)
if err != nil {
return nil, err
}
defer func() {
_ = cursor.Close(ctx)
}()
var ids []bson.ObjectID
for cursor.Next(ctx) {
var doc struct {
ID bson.ObjectID `bson:"_id"`
}
if err := cursor.Decode(&doc); err != nil {
return nil, err
}
ids = append(ids, doc.ID)
}
if err := cursor.Err(); err != nil {
return nil, err
}
return ids, nil
}
func (r *MongoRepository) Delete(ctx context.Context, id bson.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, idFilter(id))
return err
}
func (r *MongoRepository) DeleteMany(ctx context.Context, query builder.Query) error {
_, err := r.collection.DeleteMany(ctx, query.BuildQuery())
return err
}
func (r *MongoRepository) Name() string {
return r.collection.Name()
}
func (r *MongoRepository) WithTransaction(ctx context.Context, cb transaction.Callback) (any, error) {
if cb == nil {
return nil, merrors.InvalidArgument("transaction callback is required", "callback")
}
session, err := r.collection.Database().Client().StartSession()
if err != nil {
return nil, err
}
defer session.EndSession(ctx)
return session.WithTransaction(ctx, cb)
}
@@ -0,0 +1,510 @@
//go:build integration
// +build integration
package repositoryimp_test
import (
"context"
"errors"
"testing"
"time"
"git.sendico.io/tech/sxchnge/shared/database/internal/builder"
"git.sendico.io/tech/sxchnge/shared/database/internal/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
"git.sendico.io/tech/sxchnge/shared/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func TestMongoRepository_Insert(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("Insert_WithoutID", func(t *testing.T) {
testObj := &TestObject{Name: "testInsert"}
// ID should be nil/zero initially
assert.True(t, testObj.GetID().IsZero())
err := repository.Insert(ctx, testObj, nil)
require.NoError(t, err)
// ID should be assigned after insert
assert.False(t, testObj.GetID().IsZero())
assert.NotEmpty(t, testObj.CreatedAt)
assert.NotEmpty(t, testObj.UpdatedAt)
})
t.Run("Insert_WithExistingID", func(t *testing.T) {
existingID := bson.NewObjectID()
testObj := &TestObject{Name: "testInsertWithID"}
testObj.SetID(existingID)
err := repository.Insert(ctx, testObj, nil)
require.NoError(t, err)
// ID should remain the same
assert.Equal(t, existingID, *testObj.GetID())
})
t.Run("Insert_DuplicateKey", func(t *testing.T) {
// Insert first object
testObj1 := &TestObject{Name: "duplicate"}
err := repository.Insert(ctx, testObj1, nil)
require.NoError(t, err)
// Try to insert object with same ID
testObj2 := &TestObject{Name: "duplicate2"}
testObj2.SetID(*testObj1.GetID())
err = repository.Insert(ctx, testObj2, nil)
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrDataConflict))
})
t.Run("Insert_DuplicateKeyWithGetFilter", func(t *testing.T) {
// Insert first object
testObj1 := &TestObject{Name: "duplicateWithFilter"}
err := repository.Insert(ctx, testObj1, nil)
require.NoError(t, err)
// Try to insert object with same ID, but with getFilter
testObj2 := &TestObject{Name: "duplicateWithFilter2"}
testObj2.SetID(*testObj1.GetID())
getFilter := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("_id"), builder.Eq, *testObj1.GetID())
err = repository.Insert(ctx, testObj2, getFilter)
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrDataConflict))
// But testObj2 should be populated with the existing object data
assert.Equal(t, testObj1.Name, testObj2.Name)
})
}
func TestMongoRepository_Update(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("Update_ExistingObject", func(t *testing.T) {
// Insert object first
testObj := &TestObject{Name: "originalName"}
err := repository.Insert(ctx, testObj, nil)
require.NoError(t, err)
originalUpdatedAt := testObj.UpdatedAt
// Update the object
testObj.Name = "updatedName"
time.Sleep(10 * time.Millisecond) // Ensure time difference
err = repository.Update(ctx, testObj)
require.NoError(t, err)
// Verify the object was updated
result := &TestObject{}
err = repository.Get(ctx, *testObj.GetID(), result)
require.NoError(t, err)
assert.Equal(t, "updatedName", result.Name)
assert.True(t, result.UpdatedAt.After(originalUpdatedAt))
})
t.Run("Update_NonExistentObject", func(t *testing.T) {
nonExistentID := bson.NewObjectID()
testObj := &TestObject{Name: "nonExistent"}
testObj.SetID(nonExistentID)
err := repository.Update(ctx, testObj)
assert.Error(t, err)
assert.True(t, errors.Is(err, mongo.ErrNoDocuments))
})
}
func TestMongoRepository_Delete(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("Delete_ExistingObject", func(t *testing.T) {
// Insert object first
testObj := &TestObject{Name: "toDelete"}
err := repository.Insert(ctx, testObj, nil)
require.NoError(t, err)
// Delete the object
err = repository.Delete(ctx, *testObj.GetID())
require.NoError(t, err)
// Verify the object was deleted
result := &TestObject{}
err = repository.Get(ctx, *testObj.GetID(), result)
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrNoData))
})
t.Run("Delete_NonExistentObject", func(t *testing.T) {
nonExistentID := bson.NewObjectID()
err := repository.Delete(ctx, nonExistentID)
// Delete should not return error even if object doesn't exist
assert.NoError(t, err)
})
}
func TestMongoRepository_FindOneByFilter(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("FindOneByFilter_MatchingFilter", func(t *testing.T) {
// Insert test objects
testObjs := []*TestObject{
{Name: "findMe"},
{Name: "dontFindMe"},
{Name: "findMeToo"},
}
for _, obj := range testObjs {
err := repository.Insert(ctx, obj, nil)
require.NoError(t, err)
}
// Find by filter
query := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("name"), builder.Eq, "findMe")
result := &TestObject{}
err := repository.FindOneByFilter(ctx, query, result)
require.NoError(t, err)
assert.Equal(t, "findMe", result.Name)
})
t.Run("FindOneByFilter_NoMatch", func(t *testing.T) {
query := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("name"), builder.Eq, "nonExistentName")
result := &TestObject{}
err := repository.FindOneByFilter(ctx, query, result)
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrNoData))
})
}
func TestMongoRepository_FindManyByFilter(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("FindManyByFilter_MultipleResults", func(t *testing.T) {
// Insert test objects
testObjs := []*TestObject{
{Name: "findMany1"},
{Name: "findMany2"},
{Name: "dontFind"},
}
for _, obj := range testObjs {
err := repository.Insert(ctx, obj, nil)
require.NoError(t, err)
}
// Find objects with names starting with "findMany"
query := builderimp.NewQueryImp().RegEx(builderimp.NewFieldImp("name"), "^findMany", "")
var results []*TestObject
decoder := func(cursor *mongo.Cursor) error {
var obj TestObject
if err := cursor.Decode(&obj); err != nil {
return err
}
results = append(results, &obj)
return nil
}
err := repository.FindManyByFilter(ctx, query, decoder)
require.NoError(t, err)
assert.Len(t, results, 2)
names := make([]string, len(results))
for i, obj := range results {
names[i] = obj.Name
}
assert.Contains(t, names, "findMany1")
assert.Contains(t, names, "findMany2")
})
t.Run("FindManyByFilter_NoResults", func(t *testing.T) {
query := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("name"), builder.Eq, "nonExistentPattern")
var results []*TestObject
decoder := func(cursor *mongo.Cursor) error {
var obj TestObject
if err := cursor.Decode(&obj); err != nil {
return err
}
results = append(results, &obj)
return nil
}
err := repository.FindManyByFilter(ctx, query, decoder)
require.NoError(t, err)
assert.Empty(t, results)
})
}
func TestMongoRepository_DeleteMany(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("DeleteMany_MultipleDocuments", func(t *testing.T) {
// Insert test objects
testObjs := []*TestObject{
{Name: "deleteMany1"},
{Name: "deleteMany2"},
{Name: "keepMe"},
}
for _, obj := range testObjs {
err := repository.Insert(ctx, obj, nil)
require.NoError(t, err)
}
// Delete objects with names starting with "deleteMany"
query := builderimp.NewQueryImp().RegEx(builderimp.NewFieldImp("name"), "^deleteMany", "")
err := repository.DeleteMany(ctx, query)
require.NoError(t, err)
// Verify deletions
queryAll := builderimp.NewQueryImp()
var results []*TestObject
decoder := func(cursor *mongo.Cursor) error {
var obj TestObject
if err := cursor.Decode(&obj); err != nil {
return err
}
results = append(results, &obj)
return nil
}
err = repository.FindManyByFilter(ctx, queryAll, decoder)
require.NoError(t, err)
assert.Len(t, results, 1)
assert.Equal(t, "keepMe", results[0].Name)
})
}
func TestMongoRepository_Name(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "mycollection")
t.Run("Name_ReturnsCollectionName", func(t *testing.T) {
name := repository.Name()
assert.Equal(t, "mycollection", name)
})
}
func TestMongoRepository_UpdateTimestamp(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("Update_Should_Update_Timestamp", func(t *testing.T) {
// Create test object
obj := &TestObject{
Name: "Test Object",
}
// Set ID and initial timestamps
obj.SetID(bson.NewObjectID())
originalCreatedAt := obj.CreatedAt
originalUpdatedAt := obj.UpdatedAt
// Insert the object
err := repository.Insert(ctx, obj, nil)
require.NoError(t, err)
// Wait a moment to ensure timestamp difference
time.Sleep(10 * time.Millisecond)
// Update the object
obj.Name = "Updated Object"
err = repository.Update(ctx, obj)
require.NoError(t, err)
// Verify timestamps
assert.Equal(t, originalCreatedAt, obj.CreatedAt, "CreatedAt should not change")
assert.True(t, obj.UpdatedAt.After(originalUpdatedAt), "UpdatedAt should be updated")
// Verify the object was actually updated in the database
var retrieved TestObject
err = repository.Get(ctx, *obj.GetID(), &retrieved)
require.NoError(t, err)
assert.Equal(t, "Updated Object", retrieved.Name, "Name should be updated")
assert.WithinDuration(t, originalCreatedAt, retrieved.CreatedAt, time.Second, "CreatedAt should not change in DB")
assert.True(t, retrieved.UpdatedAt.After(originalUpdatedAt), "UpdatedAt should be updated in DB")
assert.WithinDuration(t, obj.UpdatedAt, retrieved.UpdatedAt, time.Second, "UpdatedAt should match between object and DB")
})
}
@@ -0,0 +1,59 @@
package repositoryimp
import (
"context"
"errors"
"git.sendico.io/tech/sxchnge/shared/database/record"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
"git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func (r *MongoRepository) FindFirstByFilter(ctx context.Context, query builder.Query, result storable.Storable) error {
opts := query.BuildOptions().SetLimit(1)
cursor, err := r.collection.Find(ctx, query.BuildQuery(), opts)
if err != nil {
return err
}
defer func() {
_ = cursor.Close(ctx)
}()
if !cursor.Next(ctx) {
if err := cursor.Err(); err != nil {
return err
}
return merrors.NoData(result.Collection() + " not found by filter")
}
return cursor.Decode(result)
}
func (r *MongoRepository) CountByFilter(ctx context.Context, query builder.Query) (int64, error) {
return r.collection.CountDocuments(ctx, query.BuildQuery())
}
func (r *MongoRepository) PatchOneByFilter(ctx context.Context, query builder.Query, patch builder.Patch, result storable.Storable) (bool, error) {
if query == nil {
return false, merrors.InvalidArgument("filter is required", "filter")
}
if patch == nil {
return false, merrors.InvalidArgument("patch is required", "patch")
}
if result == nil {
return false, merrors.InvalidArgument("result is required", "result")
}
err := r.collection.FindOneAndUpdate(
ctx,
query.BuildQuery(),
patch.Build(),
options.FindOneAndUpdate().SetReturnDocument(options.After),
).Decode(result)
if errors.Is(err, mongo.ErrNoDocuments) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
@@ -0,0 +1,153 @@
//go:build integration
// +build integration
package repositoryimp_test
import (
"context"
"testing"
"time"
"git.sendico.io/tech/sxchnge/shared/database/internal/repository"
"git.sendico.io/tech/sxchnge/shared/database/record"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func TestMongoRepository_InsertMany(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("InsertMany_Success", func(t *testing.T) {
objects := []storable.Storable{
&TestObject{Name: "test1"},
&TestObject{Name: "test2"},
&TestObject{Name: "test3"},
}
err := repository.InsertMany(ctx, objects)
require.NoError(t, err)
// Verify all objects were inserted and have IDs
for _, obj := range objects {
assert.NotNil(t, obj.GetID())
assert.False(t, obj.GetID().IsZero())
// Verify we can retrieve each object
result := &TestObject{}
err := repository.Get(ctx, *obj.GetID(), result)
require.NoError(t, err)
assert.Equal(t, obj.(*TestObject).Name, result.Name)
}
})
t.Run("InsertMany_EmptySlice", func(t *testing.T) {
objects := []storable.Storable{}
err := repository.InsertMany(ctx, objects)
require.NoError(t, err)
})
t.Run("InsertMany_WithExistingIDs", func(t *testing.T) {
id1 := bson.NewObjectID()
id2 := bson.NewObjectID()
objects := []storable.Storable{
&TestObject{Base: storable.Base{ID: id1}, Name: "preassigned1"},
&TestObject{Base: storable.Base{ID: id2}, Name: "preassigned2"},
}
err := repository.InsertMany(ctx, objects)
require.NoError(t, err)
// Verify objects were inserted with pre-assigned IDs
result1 := &TestObject{}
err = repository.Get(ctx, id1, result1)
require.NoError(t, err)
assert.Equal(t, "preassigned1", result1.Name)
result2 := &TestObject{}
err = repository.Get(ctx, id2, result2)
require.NoError(t, err)
assert.Equal(t, "preassigned2", result2.Name)
})
t.Run("InsertMany_MixedTypes", func(t *testing.T) {
objects := []storable.Storable{
&TestObject{Name: "test1"},
&AnotherObject{Description: "desc1"},
&TestObject{Name: "test2"},
}
err := repository.InsertMany(ctx, objects)
require.NoError(t, err)
// Verify all objects were inserted
for _, obj := range objects {
assert.NotNil(t, obj.GetID())
assert.False(t, obj.GetID().IsZero())
}
})
t.Run("InsertMany_DuplicateKey", func(t *testing.T) {
id := bson.NewObjectID()
// Insert first object
obj1 := &TestObject{Base: storable.Base{ID: id}, Name: "original"}
err := repository.Insert(ctx, obj1, nil)
require.NoError(t, err)
// Try to insert multiple objects including one with duplicate ID
objects := []storable.Storable{
&TestObject{Name: "test1"},
&TestObject{Base: storable.Base{ID: id}, Name: "duplicate"},
}
err = repository.InsertMany(ctx, objects)
assert.Error(t, err)
assert.True(t, mongo.IsDuplicateKeyError(err))
})
t.Run("InsertMany_UpdateTimestamps", func(t *testing.T) {
objects := []storable.Storable{
&TestObject{Name: "test1"},
&TestObject{Name: "test2"},
}
err := repository.InsertMany(ctx, objects)
require.NoError(t, err)
// Verify timestamps were set
for _, obj := range objects {
testObj := obj.(*TestObject)
assert.NotZero(t, testObj.CreatedAt)
assert.NotZero(t, testObj.UpdatedAt)
}
})
}
@@ -0,0 +1,288 @@
//go:build integration
// +build integration
package repositoryimp_test
import (
"context"
"errors"
"testing"
"time"
"git.sendico.io/tech/sxchnge/shared/database/internal/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
"git.sendico.io/tech/sxchnge/shared/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func TestMongoRepository_PatchOperations(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repo := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("Patch_SingleDocument", func(t *testing.T) {
obj := &TestObject{Name: "old"}
err := repo.Insert(ctx, obj, nil)
require.NoError(t, err)
original := obj.UpdatedAt
patch := repository.Patch().Set(repository.Field("name"), "new")
err = repo.Patch(ctx, *obj.GetID(), patch)
require.NoError(t, err)
var result TestObject
err = repo.Get(ctx, *obj.GetID(), &result)
require.NoError(t, err)
assert.Equal(t, "new", result.Name)
assert.True(t, result.UpdatedAt.After(original))
})
t.Run("PatchMany_MultipleDocuments", func(t *testing.T) {
objs := []*TestObject{{Name: "match"}, {Name: "match"}, {Name: "other"}}
for _, o := range objs {
err := repo.Insert(ctx, o, nil)
require.NoError(t, err)
}
query := repository.Query().Comparison(repository.Field("name"), builder.Eq, "match")
patch := repository.Patch().Set(repository.Field("name"), "patched")
modified, err := repo.PatchMany(ctx, query, patch)
require.NoError(t, err)
assert.Equal(t, 2, modified)
verify := repository.Query().Comparison(repository.Field("name"), builder.Eq, "patched")
var results []TestObject
decoder := func(cursor *mongo.Cursor) error {
var obj TestObject
if err := cursor.Decode(&obj); err != nil {
return err
}
results = append(results, obj)
return nil
}
err = repo.FindManyByFilter(ctx, verify, decoder)
require.NoError(t, err)
assert.Len(t, results, 2)
})
t.Run("PatchOneByFilter_MatchesAndReturnsUpdatedDocument", func(t *testing.T) {
obj := &TestObject{Name: "patch-one-match", Tags: []string{"before"}}
err := repo.Insert(ctx, obj, nil)
require.NoError(t, err)
originalUpdatedAt := obj.UpdatedAt
query := repository.Query().Filter(repository.Field("name"), "patch-one-match")
patch := repository.Patch().
Set(repository.Field("name"), "patch-one-updated").
Push(repository.Field("tags"), "after")
var result TestObject
matched, err := repo.PatchOneByFilter(ctx, query, patch, &result)
require.NoError(t, err)
require.True(t, matched)
require.Equal(t, *obj.GetID(), *result.GetID())
require.Equal(t, "patch-one-updated", result.Name)
require.Equal(t, []string{"before", "after"}, result.Tags)
require.True(t, result.UpdatedAt.After(originalUpdatedAt))
var persisted TestObject
err = repo.Get(ctx, *obj.GetID(), &persisted)
require.NoError(t, err)
require.Equal(t, result.Name, persisted.Name)
require.Equal(t, result.Tags, persisted.Tags)
})
t.Run("PatchOneByFilter_NoMatch", func(t *testing.T) {
patch := repository.Patch().Set(repository.Field("name"), "not-applied")
var result TestObject
matched, err := repo.PatchOneByFilter(ctx, repository.Query().Filter(repository.Field("name"), "missing-patch-one"), patch, &result)
require.NoError(t, err)
require.False(t, matched)
require.Empty(t, result.Name)
})
t.Run("PatchOneByFilter_InvalidArguments", func(t *testing.T) {
var result TestObject
_, err := repo.PatchOneByFilter(ctx, nil, repository.Patch().Set(repository.Field("name"), "x"), &result)
require.Error(t, err)
require.True(t, errors.Is(err, merrors.ErrInvalidArg))
_, err = repo.PatchOneByFilter(ctx, repository.Query().Filter(repository.Field("name"), "x"), nil, &result)
require.Error(t, err)
require.True(t, errors.Is(err, merrors.ErrInvalidArg))
_, err = repo.PatchOneByFilter(ctx, repository.Query().Filter(repository.Field("name"), "x"), repository.Patch().Set(repository.Field("name"), "y"), nil)
require.Error(t, err)
require.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("Patch_PushArray", func(t *testing.T) {
obj := &TestObject{Name: "test", Tags: []string{"tag1"}}
err := repo.Insert(ctx, obj, nil)
require.NoError(t, err)
patch := repository.Patch().Push(repository.Field("tags"), "tag2")
err = repo.Patch(ctx, *obj.GetID(), patch)
require.NoError(t, err)
var result TestObject
err = repo.Get(ctx, *obj.GetID(), &result)
require.NoError(t, err)
assert.Equal(t, []string{"tag1", "tag2"}, result.Tags)
})
t.Run("Patch_PullArray", func(t *testing.T) {
obj := &TestObject{Name: "test", Tags: []string{"tag1", "tag2", "tag3"}}
err := repo.Insert(ctx, obj, nil)
require.NoError(t, err)
patch := repository.Patch().Pull(repository.Field("tags"), "tag2")
err = repo.Patch(ctx, *obj.GetID(), patch)
require.NoError(t, err)
var result TestObject
err = repo.Get(ctx, *obj.GetID(), &result)
require.NoError(t, err)
assert.Equal(t, []string{"tag1", "tag3"}, result.Tags)
})
t.Run("Patch_AddToSetArray", func(t *testing.T) {
obj := &TestObject{Name: "test", Tags: []string{"tag1"}}
err := repo.Insert(ctx, obj, nil)
require.NoError(t, err)
// Add new tag
patch := repository.Patch().AddToSet(repository.Field("tags"), "tag2")
err = repo.Patch(ctx, *obj.GetID(), patch)
require.NoError(t, err)
var result TestObject
err = repo.Get(ctx, *obj.GetID(), &result)
require.NoError(t, err)
assert.Equal(t, []string{"tag1", "tag2"}, result.Tags)
// Try to add duplicate tag - should not add
patch = repository.Patch().AddToSet(repository.Field("tags"), "tag1")
err = repo.Patch(ctx, *obj.GetID(), patch)
require.NoError(t, err)
err = repo.Get(ctx, *obj.GetID(), &result)
require.NoError(t, err)
assert.Equal(t, []string{"tag1", "tag2"}, result.Tags)
})
t.Run("Patch_PushToEmptyArray", func(t *testing.T) {
obj := &TestObject{Name: "test", Tags: []string{}}
err := repo.Insert(ctx, obj, nil)
require.NoError(t, err)
patch := repository.Patch().Push(repository.Field("tags"), "tag1")
err = repo.Patch(ctx, *obj.GetID(), patch)
require.NoError(t, err)
var result TestObject
err = repo.Get(ctx, *obj.GetID(), &result)
require.NoError(t, err)
assert.Equal(t, []string{"tag1"}, result.Tags)
})
t.Run("Patch_PullFromEmptyArray", func(t *testing.T) {
obj := &TestObject{Name: "test", Tags: []string{}}
err := repo.Insert(ctx, obj, nil)
require.NoError(t, err)
patch := repository.Patch().Pull(repository.Field("tags"), "nonexistent")
err = repo.Patch(ctx, *obj.GetID(), patch)
require.NoError(t, err)
var result TestObject
err = repo.Get(ctx, *obj.GetID(), &result)
require.NoError(t, err)
assert.Equal(t, []string{}, result.Tags)
})
t.Run("Patch_PullNonExistentElement", func(t *testing.T) {
obj := &TestObject{Name: "test", Tags: []string{"tag1", "tag2"}}
err := repo.Insert(ctx, obj, nil)
require.NoError(t, err)
patch := repository.Patch().Pull(repository.Field("tags"), "nonexistent")
err = repo.Patch(ctx, *obj.GetID(), patch)
require.NoError(t, err)
var result TestObject
err = repo.Get(ctx, *obj.GetID(), &result)
require.NoError(t, err)
assert.Equal(t, []string{"tag1", "tag2"}, result.Tags)
})
t.Run("Patch_ChainedArrayOperations", func(t *testing.T) {
obj := &TestObject{Name: "test", Tags: []string{"tag1"}}
err := repo.Insert(ctx, obj, nil)
require.NoError(t, err)
// Note: MongoDB doesn't allow multiple operations on the same array field in a single update
// This test demonstrates that chained array operations on the same field will fail
patch := repository.Patch().
Push(repository.Field("tags"), "tag2").
AddToSet(repository.Field("tags"), "tag3").
Pull(repository.Field("tags"), "tag1")
err = repo.Patch(ctx, *obj.GetID(), patch)
require.Error(t, err) // This should fail due to MongoDB's limitation
assert.Contains(t, err.Error(), "conflict")
})
t.Run("PatchMany_ArrayOperations", func(t *testing.T) {
objs := []*TestObject{
{Name: "obj1", Tags: []string{"tag1"}},
{Name: "obj2", Tags: []string{"tag2"}},
{Name: "obj3", Tags: []string{"tag3"}},
}
for _, o := range objs {
err := repo.Insert(ctx, o, nil)
require.NoError(t, err)
}
query := repository.Query().Comparison(repository.Field("name"), builder.In, []string{"obj1", "obj2"})
patch := repository.Patch().Push(repository.Field("tags"), "common")
modified, err := repo.PatchMany(ctx, query, patch)
require.NoError(t, err)
assert.Equal(t, 2, modified)
// Verify the changes
for _, name := range []string{"obj1", "obj2"} {
var result TestObject
err = repo.FindOneByFilter(ctx, repository.Query().Comparison(repository.Field("name"), builder.Eq, name), &result)
require.NoError(t, err)
assert.Contains(t, result.Tags, "common")
}
})
}
@@ -0,0 +1,188 @@
//go:build integration
// +build integration
package repositoryimp_test
import (
"context"
"errors"
"testing"
"time"
"git.sendico.io/tech/sxchnge/shared/database/internal/builder"
"git.sendico.io/tech/sxchnge/shared/database/internal/repository"
"git.sendico.io/tech/sxchnge/shared/database/record"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
"git.sendico.io/tech/sxchnge/shared/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
type TestObject struct {
storable.Base `bson:",inline" json:",inline"`
Name string `bson:"name"`
Tags []string `bson:"tags"`
}
func (t *TestObject) Collection() string {
return "testObject"
}
type AnotherObject struct {
storable.Base `bson:",inline" json:",inline"`
Description string `bson:"description"`
}
func (a *AnotherObject) Collection() string {
return "anotherObject"
}
func terminate(ctx context.Context, t *testing.T, container *mongodb.MongoDBContainer) {
err := container.Terminate(ctx)
require.NoError(t, err, "failed to terminate MongoDB container")
}
func disconnect(ctx context.Context, t *testing.T, client *mongo.Client) {
err := client.Disconnect(ctx)
require.NoError(t, err, "failed to disconnect from MongoDB")
}
func TestMongoRepository_Get(t *testing.T) {
// Use a context with timeout, so if container spinning or DB ops hang,
// the test won't run indefinitely.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("Get_Success", func(t *testing.T) {
testObj := &TestObject{Name: "testName"}
err := repository.Insert(ctx, testObj, nil)
require.NoError(t, err)
result := &TestObject{}
err = repository.Get(ctx, testObj.ID, result)
require.NoError(t, err)
assert.Equal(t, testObj.Name, result.Name)
assert.Equal(t, testObj.ID, result.ID)
})
t.Run("Get_NotFound", func(t *testing.T) {
nonExistentID := bson.NewObjectID()
result := &TestObject{}
err := repository.Get(ctx, nonExistentID, result)
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrNoData))
})
t.Run("Get_InvalidID", func(t *testing.T) {
invalidID := bson.ObjectID{} // zero value
result := &TestObject{}
err := repository.Get(ctx, invalidID, result)
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("Get_DifferentTypes", func(t *testing.T) {
anotherObj := &AnotherObject{Description: "testDescription"}
err := repository.Insert(ctx, anotherObj, nil)
require.NoError(t, err)
result := &AnotherObject{}
err = repository.Get(ctx, anotherObj.ID, result)
require.NoError(t, err)
assert.Equal(t, anotherObj.Description, result.Description)
assert.Equal(t, anotherObj.ID, result.ID)
})
}
func TestMongoRepository_ListIDs(t *testing.T) {
// Use a context with timeout, so if container spinning or DB ops hang,
// the test won't run indefinitely.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repository := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("ListIDs_Success", func(t *testing.T) {
// Insert test data
testObjs := []*TestObject{
{Name: "testName1"},
{Name: "testName2"},
{Name: "testName3"},
}
for _, obj := range testObjs {
err := repository.Insert(ctx, obj, nil)
require.NoError(t, err)
}
// Define a query to match all objects
query := builderimp.NewQueryImp()
// Call ListIDs
ids, err := repository.ListIDs(ctx, query)
require.NoError(t, err)
// Assert the IDs are correct
require.Len(t, ids, len(testObjs))
for _, obj := range testObjs {
assert.Contains(t, ids, obj.ID)
}
})
t.Run("ListIDs_EmptyResult", func(t *testing.T) {
// Define a query that matches no objects
query := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("name"), builder.Eq, "nonExistentName")
// Call ListIDs
ids, err := repository.ListIDs(ctx, query)
require.NoError(t, err)
// Assert no IDs are returned
assert.Empty(t, ids)
})
}
@@ -0,0 +1,99 @@
//go:build integration
// +build integration
package repositoryimp_test
import (
"context"
"testing"
"time"
"git.sendico.io/tech/sxchnge/shared/database/internal/repository"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func TestMongoRepository_Upsert(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
defer terminate(ctx, t, mongoContainer)
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer disconnect(ctx, t, client)
db := client.Database("testdb")
repo := repositoryimp.NewMongoRepository(db, "testcollection")
t.Run("Upsert_InsertsWhenIDIsZero", func(t *testing.T) {
obj := &TestObject{Name: "inserted-via-upsert"}
require.True(t, obj.GetID().IsZero())
err := repo.Upsert(ctx, obj)
require.NoError(t, err)
assert.False(t, obj.GetID().IsZero())
assert.NotZero(t, obj.CreatedAt)
assert.NotZero(t, obj.UpdatedAt)
var stored TestObject
err = repo.Get(ctx, *obj.GetID(), &stored)
require.NoError(t, err)
assert.Equal(t, "inserted-via-upsert", stored.Name)
})
t.Run("Upsert_UpdatesExistingDocument", func(t *testing.T) {
obj := &TestObject{Name: "before-upsert"}
err := repo.Insert(ctx, obj, nil)
require.NoError(t, err)
createdAt := obj.CreatedAt
updatedAt := obj.UpdatedAt
time.Sleep(10 * time.Millisecond)
obj.Name = "after-upsert"
err = repo.Upsert(ctx, obj)
require.NoError(t, err)
assert.Equal(t, createdAt, obj.CreatedAt)
assert.True(t, obj.UpdatedAt.After(updatedAt))
var stored TestObject
err = repo.Get(ctx, *obj.GetID(), &stored)
require.NoError(t, err)
assert.Equal(t, "after-upsert", stored.Name)
assert.WithinDuration(t, createdAt, stored.CreatedAt, time.Second)
assert.True(t, stored.UpdatedAt.After(updatedAt))
})
t.Run("Upsert_InsertsWhenIDDoesNotExist", func(t *testing.T) {
obj := &TestObject{Name: "upsert-with-preassigned-id"}
obj.SetID(bson.NewObjectID())
err := repo.Upsert(ctx, obj)
require.NoError(t, err)
var stored TestObject
err = repo.Get(ctx, *obj.GetID(), &stored)
require.NoError(t, err)
assert.Equal(t, "upsert-with-preassigned-id", stored.Name)
assert.Equal(t, *obj.GetID(), stored.ID)
})
}