diff --git a/internal/infrastructure/persistence/mongodb/transaction.go b/internal/infrastructure/persistence/mongodb/transaction.go new file mode 100644 index 0000000..dbb2efc --- /dev/null +++ b/internal/infrastructure/persistence/mongodb/transaction.go @@ -0,0 +1,44 @@ +package mongodb + +import ( + "context" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + + "github.com/Financial-Partner/server/internal/entities" + transaction_repository "github.com/Financial-Partner/server/internal/module/transaction/repository" +) + +type MongoTransactionRepository struct { + collection *mongo.Collection +} + +func NewTransactionRepository(db MongoClient) transaction_repository.Repository { + return &MongoTransactionRepository{ + collection: db.Collection("transactions"), + } +} + +func (r *MongoTransactionRepository) Create(ctx context.Context, entity *entities.Transaction) (*entities.Transaction, error) { + _, err := r.collection.InsertOne(ctx, entity) + if err != nil { + return nil, err + } + return entity, nil +} + +func (r *MongoTransactionRepository) FindByUserId(ctx context.Context, userID string) ([]entities.Transaction, error) { + var transactions []entities.Transaction + cursor, err := r.collection.Find(ctx, bson.M{"user_id": userID}) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + if err := cursor.All(ctx, &transactions); err != nil { + return nil, err + } + + return transactions, nil +} diff --git a/internal/infrastructure/persistence/mongodb/transaction_test.go b/internal/infrastructure/persistence/mongodb/transaction_test.go new file mode 100644 index 0000000..e5b1395 --- /dev/null +++ b/internal/infrastructure/persistence/mongodb/transaction_test.go @@ -0,0 +1,126 @@ +package mongodb_test + +import ( + "context" + "testing" + "time" + + "github.com/Financial-Partner/server/internal/entities" + "github.com/Financial-Partner/server/internal/infrastructure/persistence/mongodb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/integration/mtest" +) + +func TestMongoTransactionRepository(t *testing.T) { + mt := mtest.New(t, mtest.NewOptions().ClientType(mtest.Mock)) + + testUserID := primitive.NewObjectID().Hex() + testTransactions := []entities.Transaction{ + { + ID: primitive.NewObjectID(), + UserID: testUserID, + Amount: 100, + Description: "Dinner", + Date: time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC), + Category: "Food", + Type: "expense", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + { + ID: primitive.NewObjectID(), + UserID: testUserID, + Amount: 200, + Description: "Rent", + Date: time.Date(2023, time.January, 2, 0, 0, 0, 0, time.UTC), + Category: "Housing", + Type: "expense", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + } + + // Convert transactions to BSON documents + var testTransactionDocs []bson.D + for _, transaction := range testTransactions { + transactionBSON, err := bson.Marshal(transaction) + require.NoError(t, err) + var transactionDoc bson.D + err = bson.Unmarshal(transactionBSON, &transactionDoc) + require.NoError(t, err) + testTransactionDocs = append(testTransactionDocs, transactionDoc) + } + + t.Run("FindByUserId", func(t *testing.T) { + mt.Run("success", func(mt *mtest.T) { + mt.AddMockResponses( + mtest.CreateCursorResponse(1, "foo.bar", mtest.FirstBatch, testTransactionDocs...), + mtest.CreateCursorResponse(0, "foo.bar", mtest.NextBatch), // Simulate end of cursor + ) + repo := mongodb.NewTransactionRepository(mt.Client.Database("testdb")) + result, err := repo.FindByUserId(context.Background(), testUserID) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Len(t, result, len(testTransactions)) + + // Validate each transaction + for i, transaction := range result { + assert.Equal(t, testTransactions[i].ID, transaction.ID) + assert.Equal(t, testTransactions[i].UserID, transaction.UserID) + assert.Equal(t, testTransactions[i].Amount, transaction.Amount) + assert.Equal(t, testTransactions[i].Description, transaction.Description) + _, err := time.Parse(time.DateOnly, transaction.Date.Format(time.DateOnly)) + require.NoError(t, err) + assert.Equal(t, testTransactions[i].Category, transaction.Category) + assert.Equal(t, testTransactions[i].Type, transaction.Type) + } + }) + mt.Run("not found", func(mt *mtest.T) { + mt.AddMockResponses(mtest.CreateCursorResponse(0, "foo.bar", mtest.FirstBatch)) + repo := mongodb.NewTransactionRepository(mt.DB) + result, err := repo.FindByUserId(context.Background(), testUserID) + assert.Nil(t, err) + assert.Nil(t, result) + }) + mt.Run("database error", func(mt *mtest.T) { + mt.AddMockResponses(mtest.CreateCommandErrorResponse(mtest.CommandError{ + Code: 11000, + Message: "database error", + })) + repo := mongodb.NewTransactionRepository(mt.DB) + result, err := repo.FindByUserId(context.Background(), testUserID) + assert.Error(t, err) + assert.Nil(t, result) + }) + }) + + t.Run("Create", func(t *testing.T) { + mt.Run("success", func(mt *mtest.T) { + mt.AddMockResponses(mtest.CreateSuccessResponse()) + repo := mongodb.NewTransactionRepository(mt.DB) + result, err := repo.Create(context.Background(), &(testTransactions[0])) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, testTransactions[0].ID, result.ID) + assert.Equal(t, testTransactions[0].UserID, result.UserID) + assert.Equal(t, testTransactions[0].Amount, result.Amount) + assert.Equal(t, testTransactions[0].Description, result.Description) + assert.Equal(t, testTransactions[0].Date, result.Date) + assert.Equal(t, testTransactions[0].Category, result.Category) + assert.Equal(t, testTransactions[0].Type, result.Type) + }) + mt.Run("error", func(mt *mtest.T) { + mt.AddMockResponses(mtest.CreateCommandErrorResponse(mtest.CommandError{ + Code: 11000, + Message: "duplicate key error", + })) + repo := mongodb.NewTransactionRepository(mt.DB) + result, err := repo.Create(context.Background(), &(testTransactions[0])) + assert.Error(t, err) + assert.Nil(t, result) + }) + }) +} diff --git a/internal/infrastructure/persistence/redis/transaction.go b/internal/infrastructure/persistence/redis/transaction.go new file mode 100644 index 0000000..75c9ca0 --- /dev/null +++ b/internal/infrastructure/persistence/redis/transaction.go @@ -0,0 +1,45 @@ +package redis + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/Financial-Partner/server/internal/entities" +) + +const ( + transactionCacheKey = "user:%s:transactions" + transactionCacheTTL = time.Hour * 24 +) + +type TransactionStore struct { + cacheClient RedisClient +} + +func NewTransactionStore(cacheClient RedisClient) *TransactionStore { + return &TransactionStore{cacheClient: cacheClient} +} + +func (s *TransactionStore) GetByUserId(ctx context.Context, userID string) ([]entities.Transaction, error) { + var transactions []entities.Transaction + err := s.cacheClient.Get(ctx, fmt.Sprintf(transactionCacheKey, userID), &transactions) + if err != nil { + return nil, err + } + return transactions, nil +} + +func (s *TransactionStore) SetByUserId(ctx context.Context, userID string, transactions []entities.Transaction) error { + data, err := json.Marshal(transactions) + if err != nil { + return err + } + + return s.cacheClient.Set(ctx, fmt.Sprintf(transactionCacheKey, userID), data, transactionCacheTTL) +} + +func (s *TransactionStore) DeleteByUserId(ctx context.Context, userID string) error { + return s.cacheClient.Delete(ctx, fmt.Sprintf(transactionCacheKey, userID)) +} diff --git a/internal/infrastructure/persistence/redis/transaction_test.go b/internal/infrastructure/persistence/redis/transaction_test.go new file mode 100644 index 0000000..430dfe6 --- /dev/null +++ b/internal/infrastructure/persistence/redis/transaction_test.go @@ -0,0 +1,127 @@ +package redis_test + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/Financial-Partner/server/internal/entities" + "github.com/Financial-Partner/server/internal/infrastructure/persistence/redis" + goredis "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/mock/gomock" +) + +func TestTransactionStore(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + t.Run("GetByUserIdSuccess", func(t *testing.T) { + mockRedisClient := redis.NewMockRedisClient(ctrl) + transactionStore := redis.NewTransactionStore(mockRedisClient) + + // Mock data + userID := primitive.NewObjectID().Hex() + + mockTransactions := []entities.Transaction{ + { + ID: primitive.NewObjectID(), + UserID: userID, + Amount: 100, + Description: "Groceries", + Date: time.Now(), + Category: "Food", + Type: "expense", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + { + ID: primitive.NewObjectID(), + UserID: userID, + Amount: 200, + Description: "Rent", + Date: time.Now(), + Category: "Housing", + Type: "expense", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + } + + // Serialize mockTransactions to JSON + mockData, _ := json.Marshal(mockTransactions) + + // Mock the Get method to return the serialized JSON data + mockRedisClient.EXPECT().Get(gomock.Any(), fmt.Sprintf("user:%s:transactions", userID), gomock.Any()).DoAndReturn( + func(_ context.Context, _ string, dest interface{}) error { + return json.Unmarshal(mockData, dest) + }, + ) + transactions, err := transactionStore.GetByUserId(context.Background(), userID) + require.NoError(t, err) + assert.NotNil(t, transactions) + }) + + t.Run("GetByUserIdNotFound", func(t *testing.T) { + mockRedisClient := redis.NewMockRedisClient(ctrl) + transactionStore := redis.NewTransactionStore(mockRedisClient) + + userID := primitive.NewObjectID().Hex() + mockRedisClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(goredis.Nil) + + transactions, err := transactionStore.GetByUserId(context.Background(), userID) + require.Error(t, err) + assert.Nil(t, transactions) + }) + + t.Run("SetByUserIdSuccess", func(t *testing.T) { + mockRedisClient := redis.NewMockRedisClient(ctrl) + transactionStore := redis.NewTransactionStore(mockRedisClient) + + // mock data + userID := primitive.NewObjectID().Hex() + mockTransactions := []entities.Transaction{ + { + ID: primitive.NewObjectID(), + UserID: userID, + Amount: 100, + Description: "Groceries", + Date: time.Now(), + Category: "Food", + Type: "expense", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + { + ID: primitive.NewObjectID(), + UserID: userID, + Amount: 200, + Description: "Rent", + Date: time.Now(), + Category: "Housing", + Type: "expense", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + } + mockRedisClient.EXPECT().Set(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + + err := transactionStore.SetByUserId(context.Background(), userID, mockTransactions) + require.NoError(t, err) + }) + + t.Run("DeleteTransactionSuccess", func(t *testing.T) { + mockRedisClient := redis.NewMockRedisClient(ctrl) + transactionStore := redis.NewTransactionStore(mockRedisClient) + + userID := primitive.NewObjectID().Hex() + mockRedisClient.EXPECT().Delete(gomock.Any(), fmt.Sprintf("user:%s:transactions", userID)).Return(nil) + + err := transactionStore.DeleteByUserId(context.Background(), userID) + require.NoError(t, err) + }) +} diff --git a/internal/interfaces/http/transaction.go b/internal/interfaces/http/transaction.go index d1ae750..af2608f 100644 --- a/internal/interfaces/http/transaction.go +++ b/internal/interfaces/http/transaction.go @@ -17,7 +17,7 @@ import ( type TransactionService interface { CreateTransaction(ctx context.Context, UserID string, transaction *dto.CreateTransactionRequest) (*entities.Transaction, error) - GetTransactions(ctx context.Context, UserId string) ([]entities.Transaction, error) + GetTransactions(ctx context.Context, UserID string) ([]entities.Transaction, error) } // @Summary Get transactions diff --git a/internal/module/transaction/domain/interfaces.go b/internal/module/transaction/domain/interfaces.go new file mode 100644 index 0000000..8fa96d1 --- /dev/null +++ b/internal/module/transaction/domain/interfaces.go @@ -0,0 +1,15 @@ +package transaction_domain + +import ( + "context" + + "github.com/Financial-Partner/server/internal/entities" + "github.com/Financial-Partner/server/internal/interfaces/http/dto" +) + +//go:generate mockgen -source=interfaces.go -destination=interfaces_mock.go -package=transaction_domain + +type TransactionService interface { + CreateTransaction(ctx context.Context, UserID string, transaction *dto.CreateTransactionRequest) (*entities.Transaction, error) + GetTransactions(ctx context.Context, UserId string) ([]entities.Transaction, error) +} diff --git a/internal/module/transaction/domain/interfaces_mock.go b/internal/module/transaction/domain/interfaces_mock.go new file mode 100644 index 0000000..f3dd7a9 --- /dev/null +++ b/internal/module/transaction/domain/interfaces_mock.go @@ -0,0 +1,73 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: interfaces.go +// +// Generated by this command: +// +// mockgen -source=interfaces.go -destination=interfaces_mock.go -package=transaction_domain +// + +// Package transaction_domain is a generated GoMock package. +package transaction_domain + +import ( + context "context" + reflect "reflect" + + entities "github.com/Financial-Partner/server/internal/entities" + dto "github.com/Financial-Partner/server/internal/interfaces/http/dto" + gomock "go.uber.org/mock/gomock" +) + +// MockTransactionService is a mock of TransactionService interface. +type MockTransactionService struct { + ctrl *gomock.Controller + recorder *MockTransactionServiceMockRecorder + isgomock struct{} +} + +// MockTransactionServiceMockRecorder is the mock recorder for MockTransactionService. +type MockTransactionServiceMockRecorder struct { + mock *MockTransactionService +} + +// NewMockTransactionService creates a new mock instance. +func NewMockTransactionService(ctrl *gomock.Controller) *MockTransactionService { + mock := &MockTransactionService{ctrl: ctrl} + mock.recorder = &MockTransactionServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTransactionService) EXPECT() *MockTransactionServiceMockRecorder { + return m.recorder +} + +// CreateTransaction mocks base method. +func (m *MockTransactionService) CreateTransaction(ctx context.Context, UserID string, transaction *dto.CreateTransactionRequest) (*entities.Transaction, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateTransaction", ctx, UserID, transaction) + ret0, _ := ret[0].(*entities.Transaction) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateTransaction indicates an expected call of CreateTransaction. +func (mr *MockTransactionServiceMockRecorder) CreateTransaction(ctx, UserID, transaction any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTransaction", reflect.TypeOf((*MockTransactionService)(nil).CreateTransaction), ctx, UserID, transaction) +} + +// GetTransactions mocks base method. +func (m *MockTransactionService) GetTransactions(ctx context.Context, UserId string) ([]entities.Transaction, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTransactions", ctx, UserId) + ret0, _ := ret[0].([]entities.Transaction) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTransactions indicates an expected call of GetTransactions. +func (mr *MockTransactionServiceMockRecorder) GetTransactions(ctx, UserId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTransactions", reflect.TypeOf((*MockTransactionService)(nil).GetTransactions), ctx, UserId) +} diff --git a/internal/module/transaction/repository/repository.go b/internal/module/transaction/repository/repository.go new file mode 100644 index 0000000..eb5fe52 --- /dev/null +++ b/internal/module/transaction/repository/repository.go @@ -0,0 +1,20 @@ +package transaction_repository + +import ( + "context" + + "github.com/Financial-Partner/server/internal/entities" +) + +//go:generate mockgen -source=repository.go -destination=transaction_repository_mock.go -package=transaction_repository + +type Repository interface { + Create(ctx context.Context, transaction *entities.Transaction) (*entities.Transaction, error) + FindByUserId(ctx context.Context, userID string) ([]entities.Transaction, error) +} + +type TransactionStore interface { + GetByUserId(ctx context.Context, userID string) ([]entities.Transaction, error) + SetByUserId(ctx context.Context, userID string, transactions []entities.Transaction) error + DeleteByUserId(ctx context.Context, userID string) error +} diff --git a/internal/module/transaction/repository/transaction_repository_mock.go b/internal/module/transaction/repository/transaction_repository_mock.go new file mode 100644 index 0000000..8b9bfcf --- /dev/null +++ b/internal/module/transaction/repository/transaction_repository_mock.go @@ -0,0 +1,139 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: repository.go +// +// Generated by this command: +// +// mockgen -source=repository.go -destination=transaction_repository_mock.go -package=transaction_repository +// + +// Package transaction_repository is a generated GoMock package. +package transaction_repository + +import ( + context "context" + reflect "reflect" + + entities "github.com/Financial-Partner/server/internal/entities" + gomock "go.uber.org/mock/gomock" +) + +// MockRepository is a mock of Repository interface. +type MockRepository struct { + ctrl *gomock.Controller + recorder *MockRepositoryMockRecorder + isgomock struct{} +} + +// MockRepositoryMockRecorder is the mock recorder for MockRepository. +type MockRepositoryMockRecorder struct { + mock *MockRepository +} + +// NewMockRepository creates a new mock instance. +func NewMockRepository(ctrl *gomock.Controller) *MockRepository { + mock := &MockRepository{ctrl: ctrl} + mock.recorder = &MockRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockRepository) Create(ctx context.Context, transaction *entities.Transaction) (*entities.Transaction, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", ctx, transaction) + ret0, _ := ret[0].(*entities.Transaction) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockRepositoryMockRecorder) Create(ctx, transaction any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockRepository)(nil).Create), ctx, transaction) +} + +// FindByUserId mocks base method. +func (m *MockRepository) FindByUserId(ctx context.Context, userID string) ([]entities.Transaction, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindByUserId", ctx, userID) + ret0, _ := ret[0].([]entities.Transaction) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindByUserId indicates an expected call of FindByUserId. +func (mr *MockRepositoryMockRecorder) FindByUserId(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByUserId", reflect.TypeOf((*MockRepository)(nil).FindByUserId), ctx, userID) +} + +// MockTransactionStore is a mock of TransactionStore interface. +type MockTransactionStore struct { + ctrl *gomock.Controller + recorder *MockTransactionStoreMockRecorder + isgomock struct{} +} + +// MockTransactionStoreMockRecorder is the mock recorder for MockTransactionStore. +type MockTransactionStoreMockRecorder struct { + mock *MockTransactionStore +} + +// NewMockTransactionStore creates a new mock instance. +func NewMockTransactionStore(ctrl *gomock.Controller) *MockTransactionStore { + mock := &MockTransactionStore{ctrl: ctrl} + mock.recorder = &MockTransactionStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTransactionStore) EXPECT() *MockTransactionStoreMockRecorder { + return m.recorder +} + +// DeleteByUserId mocks base method. +func (m *MockTransactionStore) DeleteByUserId(ctx context.Context, userID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteByUserId", ctx, userID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteByUserId indicates an expected call of DeleteByUserId. +func (mr *MockTransactionStoreMockRecorder) DeleteByUserId(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteByUserId", reflect.TypeOf((*MockTransactionStore)(nil).DeleteByUserId), ctx, userID) +} + +// GetByUserId mocks base method. +func (m *MockTransactionStore) GetByUserId(ctx context.Context, userID string) ([]entities.Transaction, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetByUserId", ctx, userID) + ret0, _ := ret[0].([]entities.Transaction) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetByUserId indicates an expected call of GetByUserId. +func (mr *MockTransactionStoreMockRecorder) GetByUserId(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByUserId", reflect.TypeOf((*MockTransactionStore)(nil).GetByUserId), ctx, userID) +} + +// SetByUserId mocks base method. +func (m *MockTransactionStore) SetByUserId(ctx context.Context, userID string, transactions []entities.Transaction) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetByUserId", ctx, userID, transactions) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetByUserId indicates an expected call of SetByUserId. +func (mr *MockTransactionStoreMockRecorder) SetByUserId(ctx, userID, transactions any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetByUserId", reflect.TypeOf((*MockTransactionStore)(nil).SetByUserId), ctx, userID, transactions) +}