diff --git a/go.mod b/go.mod index 89e1de8..5e46a1f 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/testcontainers/testcontainers-go v0.34.0 github.com/zeromicro/go-zero v1.8.1 go.mongodb.org/mongo-driver v1.17.3 + go.uber.org/mock v0.5.0 google.golang.org/grpc v1.71.0 google.golang.org/protobuf v1.36.5 ) diff --git a/go.sum b/go.sum index cef40c6..7d4ee47 100644 --- a/go.sum +++ b/go.sum @@ -278,6 +278,8 @@ go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= @@ -348,8 +350,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/usecase/category.go b/pkg/usecase/category.go new file mode 100644 index 0000000..9ca755f --- /dev/null +++ b/pkg/usecase/category.go @@ -0,0 +1,138 @@ +package usecase + +import ( + "code.30cm.net/digimon/app-cloudep-product-service/pkg/domain/entity" + "code.30cm.net/digimon/app-cloudep-product-service/pkg/domain/repository" + "code.30cm.net/digimon/app-cloudep-product-service/pkg/domain/usecase" + "code.30cm.net/digimon/library-go/errs" + "context" + "errors" + "github.com/zeromicro/go-zero/core/logx" + "github.com/zeromicro/go-zero/core/stores/mon" +) + +type CategoryUseCaseParam struct { + CategoryRepo repository.CategoryRepository +} + +type CategoryUseCase struct { + CategoryUseCaseParam +} + +func MustCategoryUseCase(param CategoryUseCaseParam) usecase.CategoryUseCase { + return &CategoryUseCase{ + param, + } +} + +func (use *CategoryUseCase) Insert(ctx context.Context, data *entity.Category) error { + err := use.CategoryRepo.Insert(ctx, &entity.Category{ + Name: data.Name, + }) + if err != nil { + return errs.DBErrorL( + logx.WithContext(ctx), + []logx.LogField{ + {Key: "req", Value: data}, + {Key: "func", Value: "CategoryRepo.Insert"}, + {Key: "err", Value: err.Error()}, + }, + "failed to create category").Wrap(err) + } + + return nil +} + +func (use *CategoryUseCase) FindOneByID(ctx context.Context, id string) (*entity.Category, error) { + res, err := use.CategoryRepo.FindOneByID(ctx, id) + if err != nil { + if errors.Is(err, mon.ErrNotFound) { + e := errs.ResourceNotFound( + "failed to get category", + ) + + return nil, e + } + + e := errs.DBErrorL( + logx.WithContext(ctx), + []logx.LogField{ + {Key: "req", Value: id}, + {Key: "func", Value: "CategoryRepo.FindOneByID"}, + {Key: "err", Value: err.Error()}, + }, + "failed to get category").Wrap(err) + + return nil, e + } + + return res, nil +} + +func (use *CategoryUseCase) Update(ctx context.Context, id string, data *entity.Category) error { + _, err := use.CategoryRepo.Update(ctx, id, &entity.Category{ + Name: data.Name, + }) + if err != nil { + if errors.Is(err, mon.ErrNotFound) { + e := errs.ResourceNotFound( + "failed to update category", + ) + + return e + } + + e := errs.DBErrorL( + logx.WithContext(ctx), + []logx.LogField{ + {Key: "req", Value: id}, + {Key: "func", Value: "CategoryRepo.Update"}, + {Key: "err", Value: err.Error()}, + }, + "failed to update category").Wrap(err) + + return e + } + + return nil +} + +func (use *CategoryUseCase) Delete(ctx context.Context, id string) error { + _, err := use.CategoryRepo.Delete(ctx, id) + if err != nil { + e := errs.DBErrorL( + logx.WithContext(ctx), + []logx.LogField{ + {Key: "req", Value: id}, + {Key: "func", Value: "CategoryRepo.Delete"}, + {Key: "err", Value: err.Error()}, + }, + "failed to delete category").Wrap(err) + + return e + } + + return nil +} + +func (use *CategoryUseCase) ListCategory(ctx context.Context, params usecase.CategoryQueryParams) ([]*entity.Category, int64, error) { + category, i, err := use.CategoryRepo.ListCategory(ctx, &repository.CategoryQueryParams{ + ID: params.ID, + PageIndex: params.PageIndex, + PageSize: params.PageSize, + }) + if err != nil { + e := errs.DBErrorL( + logx.WithContext(ctx), + []logx.LogField{ + {Key: "req", Value: params}, + {Key: "func", Value: "CategoryRepo.ListCategory"}, + {Key: "err", Value: err.Error()}, + }, + "failed to list category").Wrap(err) + + return nil, 0, e + } + + return category, i, nil +} diff --git a/pkg/usecase/category_test.go b/pkg/usecase/category_test.go new file mode 100644 index 0000000..9a409d9 --- /dev/null +++ b/pkg/usecase/category_test.go @@ -0,0 +1,414 @@ +package usecase + +import ( + "code.30cm.net/digimon/app-cloudep-product-service/pkg/domain/entity" + "code.30cm.net/digimon/app-cloudep-product-service/pkg/domain/repository" + "code.30cm.net/digimon/app-cloudep-product-service/pkg/domain/usecase" + "code.30cm.net/digimon/library-go/errs" + "context" + "errors" + "github.com/stretchr/testify/assert" + "github.com/zeromicro/go-zero/core/logx" + "github.com/zeromicro/go-zero/core/stores/mon" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/mock/gomock" + "testing" + + mockRepository "code.30cm.net/digimon/app-cloudep-product-service/pkg/mock/repository" +) + +func TestCategoryUseCase_FindOneByID(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockCategoryRepo := mockRepository.NewMockCategoryRepository(mockCtrl) + + useCase := MustCategoryUseCase(CategoryUseCaseParam{ + CategoryRepo: mockCategoryRepo, + }) + + ctx := context.Background() + id := primitive.NewObjectID() + tests := []struct { + name string + id primitive.ObjectID + mockSetup func() + expectedError error + expectedResult *entity.Category + }{ + { + name: "成功找到 Category", + id: id, + mockSetup: func() { + mockCategoryRepo.EXPECT().FindOneByID(ctx, id.Hex()).Return(&entity.Category{ + ID: id, + Name: "Test Category", + }, nil) + }, + expectedError: nil, + expectedResult: &entity.Category{ + ID: id, + Name: "Test Category", + }, + }, + { + name: "Category 不存在", + id: primitive.NewObjectID(), + mockSetup: func() { + mockCategoryRepo.EXPECT().FindOneByID(ctx, gomock.Any()).Return(nil, mon.ErrNotFound) + }, + expectedError: errs.ResourceNotFound("failed to get category"), + expectedResult: nil, + }, + { + name: "資料庫錯誤", + id: primitive.NewObjectID(), + mockSetup: func() { + mockCategoryRepo.EXPECT().FindOneByID(ctx, gomock.Any()).Return(nil, errors.New("database error")) + }, + expectedError: errs.DBErrorL( + logx.WithContext(ctx), + []logx.LogField{ + {Key: "req", Value: "db_error_id"}, + {Key: "func", Value: "CategoryRepo.FindOneByID"}, + {Key: "err", Value: "database error"}, + }, + "failed to get category").Wrap(errors.New("database error")), + expectedResult: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + result, err := useCase.FindOneByID(ctx, tt.id.Hex()) + + if tt.expectedError == nil { + assert.NoError(t, err) + assert.Equal(t, tt.expectedResult, result) + } else { + assert.Error(t, err) + assert.Equal(t, tt.expectedError.Error(), err.Error()) + assert.Nil(t, result) + } + }) + } +} + +func TestCategoryUseCase_Update(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockCategoryRepo := mockRepository.NewMockCategoryRepository(mockCtrl) + + useCase := MustCategoryUseCase(CategoryUseCaseParam{ + CategoryRepo: mockCategoryRepo, + }) + + ctx := context.Background() + + tests := []struct { + name string + id string + data entity.Category + mockSetup func() + expectedError error + }{ + { + name: "成功更新 Category", + id: "valid_id", + data: entity.Category{ + Name: "Updated Category", + }, + mockSetup: func() { + mockCategoryRepo.EXPECT().Update(ctx, "valid_id", &entity.Category{ + Name: "Updated Category", + }).Return(nil, nil) + }, + expectedError: nil, + }, + { + name: "Category 不存在", + id: "non_existing_id", + data: entity.Category{ + Name: "Non-existing Category", + }, + mockSetup: func() { + mockCategoryRepo.EXPECT().Update(ctx, "non_existing_id", &entity.Category{ + Name: "Non-existing Category", + }).Return(nil, mon.ErrNotFound) + }, + expectedError: errs.ResourceNotFound( + "failed to update category", + ), + }, + { + name: "資料庫錯誤", + id: "db_error_id", + data: entity.Category{ + Name: "DB Error Category", + }, + mockSetup: func() { + mockCategoryRepo.EXPECT().Update(ctx, "db_error_id", &entity.Category{ + Name: "DB Error Category", + }).Return(nil, errors.New("database error")) + }, + expectedError: errs.DBErrorL( + logx.WithContext(ctx), + []logx.LogField{ + {Key: "req", Value: "db_error_id"}, + {Key: "func", Value: "CategoryRepo.Update"}, + {Key: "err", Value: "database error"}, + }, + "failed to update category").Wrap(errors.New("database error")), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + // 執行測試方法 + err := useCase.Update(ctx, tt.id, &tt.data) + + // 驗證結果 + if tt.expectedError == nil { + assert.NoError(t, err) + } else { + assert.Error(t, err) + assert.Equal(t, tt.expectedError.Error(), err.Error()) + } + }) + } +} + +func TestCategoryUseCase_Insert(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockCategoryRepo := mockRepository.NewMockCategoryRepository(mockCtrl) + + useCase := MustCategoryUseCase(CategoryUseCaseParam{ + CategoryRepo: mockCategoryRepo, + }) + + ctx := context.Background() + + tests := []struct { + name string + input entity.Category + mockSetup func() + expectedError error + }{ + { + name: "成功插入 Category", + input: entity.Category{ + Name: "Test Category", + }, + mockSetup: func() { + mockCategoryRepo.EXPECT().Insert(ctx, &entity.Category{ + Name: "Test Category", + }).Return(nil) + }, + expectedError: nil, + }, + { + name: "插入失敗 - Database Error", + input: entity.Category{ + Name: "Invalid Category", + }, + mockSetup: func() { + mockCategoryRepo.EXPECT().Insert(ctx, &entity.Category{ + Name: "Invalid Category", + }).Return(errors.New("database connection error")) + }, + expectedError: errs.DBErrorL( + logx.WithContext(ctx), + []logx.LogField{ + {Key: "req", Value: entity.Category{Name: "Invalid Category"}}, + {Key: "func", Value: "CategoryRepo.Insert"}, + {Key: "err", Value: "database connection error"}, + }, + "failed to create category", + ).Wrap(errors.New("database connection error")), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + // 執行測試方法 + err := useCase.Insert(ctx, &tt.input) + + // 驗證結果 + if tt.expectedError == nil { + assert.NoError(t, err) + } else { + assert.Error(t, err) + assert.Equal(t, tt.expectedError.Error(), err.Error()) + } + }) + } +} + +func TestCategoryUseCase_Delete(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockCategoryRepo := mockRepository.NewMockCategoryRepository(mockCtrl) + + useCase := MustCategoryUseCase(CategoryUseCaseParam{ + CategoryRepo: mockCategoryRepo, + }) + + ctx := context.Background() + + tests := []struct { + name string + id string + mockSetup func() + expectedError error + }{ + { + name: "成功刪除 Category", + id: "valid_id", + mockSetup: func() { + mockCategoryRepo.EXPECT().Delete(ctx, gomock.Any()).Return(int64(1), nil) + }, + expectedError: nil, + }, + { + name: "資料庫錯誤", + id: "db_error_id", + mockSetup: func() { + mockCategoryRepo.EXPECT().Delete(ctx, gomock.Any()).Return(int64(0), errors.New("database error")) + }, + expectedError: errs.DBErrorL( + logx.WithContext(ctx), + []logx.LogField{ + {Key: "req", Value: "db_error_id"}, + {Key: "func", Value: "CategoryRepo.Delete"}, + {Key: "err", Value: "database error"}, + }, + "failed to delete category").Wrap(errors.New("database error")), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + // 執行測試方法 + err := useCase.Delete(ctx, tt.id) + + // 驗證結果 + if tt.expectedError == nil { + assert.NoError(t, err) + } else { + assert.Error(t, err) + assert.Equal(t, tt.expectedError.Error(), err.Error()) + } + }) + } +} + +func TestCategoryUseCase_ListCategory(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockCategoryRepo := mockRepository.NewMockCategoryRepository(mockCtrl) + + useCase := MustCategoryUseCase(CategoryUseCaseParam{ + CategoryRepo: mockCategoryRepo, + }) + + ctx := context.Background() + id := primitive.NewObjectID() + tests := []struct { + name string + params usecase.CategoryQueryParams + mockSetup func() + expectedResult []*entity.Category + expectedCount int64 + expectedError error + }{ + { + name: "成功列出 Category", + params: usecase.CategoryQueryParams{ + ID: []string{id.Hex()}, + PageIndex: 1, + PageSize: 10, + }, + mockSetup: func() { + mockCategoryRepo.EXPECT().ListCategory(ctx, &repository.CategoryQueryParams{ + ID: []string{id.Hex()}, + PageIndex: 1, + PageSize: 10, + }).Return([]*entity.Category{ + { + ID: id, + Name: "Test Category", + }, + }, int64(1), nil) + }, + expectedResult: []*entity.Category{ + { + ID: id, + Name: "Test Category", + }, + }, + expectedCount: int64(1), + expectedError: nil, + }, + { + name: "資料庫錯誤", + params: usecase.CategoryQueryParams{ + ID: []string{"db_error_id"}, + PageIndex: 1, + PageSize: 10, + }, + mockSetup: func() { + mockCategoryRepo.EXPECT().ListCategory(ctx, &repository.CategoryQueryParams{ + ID: []string{"db_error_id"}, + PageIndex: 1, + PageSize: 10, + }).Return(nil, int64(0), errors.New("database error")) + }, + expectedResult: nil, + expectedCount: int64(0), + expectedError: errs.DBErrorL( + logx.WithContext(ctx), + []logx.LogField{ + {Key: "req", Value: usecase.CategoryQueryParams{ + ID: []string{"db_error_id"}, + PageIndex: 1, + PageSize: 10, + }}, + {Key: "func", Value: "CategoryRepo.ListCategory"}, + {Key: "err", Value: "database error"}, + }, + "failed to list category").Wrap(errors.New("database error")), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + // 執行測試方法 + result, count, err := useCase.ListCategory(ctx, tt.params) + + // 驗證結果 + assert.Equal(t, tt.expectedResult, result) + assert.Equal(t, tt.expectedCount, count) + if tt.expectedError == nil { + assert.NoError(t, err) + } else { + assert.Error(t, err) + assert.Equal(t, tt.expectedError.Error(), err.Error()) + } + }) + } +} diff --git a/pkg/usecase/kyc_test.go b/pkg/usecase/kyc_test.go new file mode 100644 index 0000000..64262b3 --- /dev/null +++ b/pkg/usecase/kyc_test.go @@ -0,0 +1,552 @@ +package usecase + +import ( + "code.30cm.net/digimon/app-cloudep-product-service/pkg/domain/entity" + "code.30cm.net/digimon/app-cloudep-product-service/pkg/domain/kyc" + "code.30cm.net/digimon/app-cloudep-product-service/pkg/domain/repository" + "code.30cm.net/digimon/app-cloudep-product-service/pkg/domain/usecase" + mockRepository "code.30cm.net/digimon/app-cloudep-product-service/pkg/mock/repository" + repo "code.30cm.net/digimon/app-cloudep-product-service/pkg/repository" + "code.30cm.net/digimon/library-go/errs" + "context" + "errors" + "fmt" + "github.com/stretchr/testify/assert" + "github.com/zeromicro/go-zero/core/logx" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/mock/gomock" + "google.golang.org/protobuf/proto" + "testing" +) + +func TestKYCUseCase_Create(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockKYCRepo := mockRepository.NewMockKYCRepository(mockCtrl) + + useCase := MustKYCUseCase(KYCUseCaseParam{ + KYCRepo: mockKYCRepo, + }) + + ctx := context.Background() + uid := "test-user" + + tests := []struct { + name string + input *entity.KYC + mockSetup func() + expectedError error + }{ + { + name: "查不到資料可以建立", + input: &entity.KYC{ + UID: uid, + }, + mockSetup: func() { + mockKYCRepo.EXPECT().FindLatestByUID(ctx, uid).Return(nil, repo.ErrNotFound) + mockKYCRepo.EXPECT().Create(ctx, gomock.Any()).Return(nil) + }, + expectedError: nil, + }, + { + name: "前一筆為 REJECTED 可建立", + input: &entity.KYC{ + UID: uid, + }, + mockSetup: func() { + mockKYCRepo.EXPECT().FindLatestByUID(ctx, uid).Return(&entity.KYC{ + UID: uid, + Status: kyc.StatusREJECTED, + }, nil) + mockKYCRepo.EXPECT().Create(ctx, gomock.Any()).Return(nil) + }, + expectedError: nil, + }, + { + name: "已存在未駁回資料,禁止建立", + input: &entity.KYC{ + UID: uid, + }, + mockSetup: func() { + mockKYCRepo.EXPECT().FindLatestByUID(ctx, uid).Return(&entity.KYC{ + UID: uid, + Status: kyc.StatusPending, + }, nil) + }, + expectedError: errs.ForbiddenL( + logx.WithContext(ctx), + []logx.LogField{ + {Key: "param", Value: &entity.KYC{UID: uid}}, + {Key: "func", Value: "KYCRepo.FindLatestByUID"}, + {Key: "reason", Value: "KYC already in progress or approved"}, + }, + "不能重複送出 KYC 資料", + ), + }, + { + name: "查詢資料庫錯誤", + input: &entity.KYC{ + UID: uid, + }, + mockSetup: func() { + mockKYCRepo.EXPECT().FindLatestByUID(ctx, uid).Return(nil, errors.New("database error")) + }, + expectedError: errs.DBErrorL( + logx.WithContext(ctx), + []logx.LogField{ + {Key: "param", Value: &entity.KYC{UID: uid}}, + {Key: "func", Value: "KYCRepo.FindLatestByUID"}, + {Key: "err", Value: "database error"}, + }, + "failed to get latest kyc", + ), + }, + { + name: "建立時資料庫錯誤", + input: &entity.KYC{ + UID: uid, + }, + mockSetup: func() { + mockKYCRepo.EXPECT().FindLatestByUID(ctx, uid).Return(nil, repo.ErrNotFound) + mockKYCRepo.EXPECT().Create(ctx, gomock.Any()).Return(errors.New("insert failed")) + }, + expectedError: errs.DBErrorL( + logx.WithContext(ctx), + []logx.LogField{ + {Key: "param", Value: &entity.KYC{UID: uid, Status: kyc.StatusPending}}, + {Key: "func", Value: "KYCRepo.Create"}, + {Key: "err", Value: "insert failed"}, + }, + "failed to create kyc review", + ), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + err := useCase.Create(ctx, tt.input) + + if tt.expectedError == nil { + assert.NoError(t, err) + } else { + assert.Error(t, err) + assert.Equal(t, tt.expectedError.Error(), err.Error()) + } + }) + } +} + +func TestKYCUseCase_FindLatestByUID(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockKYCRepo := mockRepository.NewMockKYCRepository(mockCtrl) + + useCase := MustKYCUseCase(KYCUseCaseParam{ + KYCRepo: mockKYCRepo, + }) + + ctx := context.Background() + uid := "test-user" + + tests := []struct { + name string + inputUID string + mockSetup func() + expectedResult *entity.KYC + expectedError error + }{ + { + name: "查詢成功", + inputUID: uid, + mockSetup: func() { + mockKYCRepo.EXPECT().FindLatestByUID(ctx, uid).Return(&entity.KYC{ + UID: uid, + Status: kyc.StatusPending, + }, nil) + }, + expectedResult: &entity.KYC{ + UID: uid, + Status: kyc.StatusPending, + }, + expectedError: nil, + }, + { + name: "資料庫錯誤", + inputUID: uid, + mockSetup: func() { + mockKYCRepo.EXPECT().FindLatestByUID(ctx, uid).Return(nil, errors.New("database error")) + }, + expectedResult: nil, + expectedError: errs.DBErrorL( + logx.WithContext(ctx), + []logx.LogField{ + {Key: "uid", Value: uid}, + {Key: "func", Value: "KYCRepo.FindLatestByUID"}, + {Key: "err", Value: "database error"}, + }, + "failed to get latest kyc", + ), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + result, err := useCase.FindLatestByUID(ctx, tt.inputUID) + + if tt.expectedError == nil { + assert.NoError(t, err) + assert.Equal(t, tt.expectedResult, result) + } else { + assert.Error(t, err) + assert.Equal(t, tt.expectedError.Error(), err.Error()) + assert.Nil(t, result) + } + }) + } +} + +func TestKYCUseCase_FindByID(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockKYCRepo := mockRepository.NewMockKYCRepository(mockCtrl) + + useCase := MustKYCUseCase(KYCUseCaseParam{ + KYCRepo: mockKYCRepo, + }) + + ctx := context.Background() + objID := primitive.NewObjectID() + idStr := objID.Hex() + + tests := []struct { + name string + inputID string + mockSetup func() + expectedResult *entity.KYC + expectedError error + }{ + { + name: "查詢成功", + inputID: idStr, + mockSetup: func() { + mockKYCRepo.EXPECT().FindByID(ctx, idStr).Return(&entity.KYC{ + ID: objID, + UID: "user-1", + Status: kyc.StatusAPPROVED, + }, nil) + }, + expectedResult: &entity.KYC{ + ID: objID, + UID: "user-1", + Status: kyc.StatusAPPROVED, + }, + expectedError: nil, + }, + { + name: "資料庫錯誤", + inputID: idStr, + mockSetup: func() { + mockKYCRepo.EXPECT().FindByID(ctx, idStr).Return(nil, errors.New("database error")) + }, + expectedResult: nil, + expectedError: errs.DBErrorL( + logx.WithContext(ctx), + []logx.LogField{ + {Key: "id", Value: idStr}, + {Key: "func", Value: "KYCRepo.FindByID"}, + {Key: "err", Value: "database error"}, + }, + "failed to get kyc", + ), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + result, err := useCase.FindByID(ctx, tt.inputID) + + if tt.expectedError == nil { + assert.NoError(t, err) + assert.Equal(t, tt.expectedResult, result) + } else { + assert.Error(t, err) + assert.Equal(t, tt.expectedError.Error(), err.Error()) + assert.Nil(t, result) + } + }) + } +} + +func TestKYCUseCase_List(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockKYCRepo := mockRepository.NewMockKYCRepository(mockCtrl) + + useCase := MustKYCUseCase(KYCUseCaseParam{ + KYCRepo: mockKYCRepo, + }) + + ctx := context.Background() + uid := "user-1" + country := "TW" + status := kyc.StatusAPPROVED + ss := status.ToString() + + tests := []struct { + name string + inputParams usecase.KYCQueryParams + mockSetup func() + expectedList []*entity.KYC + expectedCount int64 + expectedError error + }{ + { + name: "查詢成功", + inputParams: usecase.KYCQueryParams{ + PageIndex: 1, + PageSize: 10, + UID: &uid, + Country: &country, + Status: &ss, + }, + mockSetup: func() { + mockKYCRepo.EXPECT().List(ctx, repository.KYCQueryParams{ + PageIndex: 1, + PageSize: 10, + UID: &uid, + Country: &country, + Status: &ss, + SortByDate: true, + }).Return([]*entity.KYC{ + {UID: uid, CountryRegion: country, Status: status}, + }, int64(1), nil) + }, + expectedList: []*entity.KYC{ + {UID: uid, CountryRegion: country, Status: status}, + }, + expectedCount: 1, + expectedError: nil, + }, + { + name: "查詢失敗 - 資料庫錯誤", + inputParams: usecase.KYCQueryParams{ + PageIndex: 2, + PageSize: 20, + }, + mockSetup: func() { + mockKYCRepo.EXPECT().List(ctx, repository.KYCQueryParams{ + PageIndex: 2, + PageSize: 20, + SortByDate: true, + }).Return(nil, int64(0), errors.New("database error")) + }, + expectedList: nil, + expectedCount: 0, + expectedError: errs.DBErrorL(logx.WithContext(ctx), + []logx.LogField{ + {Key: "params", Value: usecase.KYCQueryParams{ + PageIndex: 2, + PageSize: 20, + }}, + {Key: "func", Value: "KYCRepo.List"}, + {Key: "err", Value: "database error"}, + }, "failed to list kyc"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + list, count, err := useCase.List(ctx, tt.inputParams) + + assert.Equal(t, tt.expectedList, list) + assert.Equal(t, tt.expectedCount, count) + + if tt.expectedError == nil { + assert.NoError(t, err) + } else { + assert.Error(t, err) + assert.Equal(t, tt.expectedError.Error(), err.Error()) + } + }) + } +} + +func TestKYCUseCase_UpdateStatus(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockKYCRepo := mockRepository.NewMockKYCRepository(mockCtrl) + + useCase := MustKYCUseCase(KYCUseCaseParam{ + KYCRepo: mockKYCRepo, + }) + + ctx := context.Background() + + id := primitive.NewObjectID().Hex() + status := kyc.StatusAPPROVED + reason := "all good" + + tests := []struct { + name string + id string + status kyc.Status + reason string + mockSetup func() + expectedError error + }{ + { + name: "更新成功", + id: id, + status: status, + reason: reason, + mockSetup: func() { + mockKYCRepo.EXPECT().UpdateStatus(ctx, id, status.ToString(), reason).Return(nil) + }, + expectedError: nil, + }, + { + name: "更新失敗 - DB 錯誤", + id: id, + status: status, + reason: reason, + mockSetup: func() { + mockKYCRepo.EXPECT().UpdateStatus(ctx, id, status.ToString(), reason).Return(errors.New("db error")) + }, + expectedError: errs.DBErrorL( + logx.WithContext(ctx), + []logx.LogField{ + {Key: "params", Value: fmt.Sprintf("id:%s, status:%s, reason: %s", id, status, reason)}, + {Key: "func", Value: "KYCRepo.UpdateStatus"}, + {Key: "err", Value: "db error"}, + }, + "failed to update kyc status", + ), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + err := useCase.UpdateStatus(ctx, tt.id, tt.status.ToString(), tt.reason) + + if tt.expectedError == nil { + assert.NoError(t, err) + } else { + assert.Error(t, err) + assert.Equal(t, tt.expectedError.Error(), err.Error()) + } + }) + } +} + +func TestKYCUseCase_UpdateKYCInfo(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockKYCRepo := mockRepository.NewMockKYCRepository(mockCtrl) + + useCase := MustKYCUseCase(KYCUseCaseParam{ + KYCRepo: mockKYCRepo, + }) + + ctx := context.Background() + id := primitive.NewObjectID().Hex() + + updateParams := &usecase.KYCUpdateParams{ + Name: proto.String("Daniel Wang"), + Identification: proto.String("A123456789"), + IdentificationType: proto.String("passport"), + Address: proto.String("Taipei City"), + PostalCode: proto.String("100"), + DateOfBirth: proto.String("1993-04-17"), + Gender: proto.String("M"), + IDFrontImage: proto.String("https://example.com/front.jpg"), + IDBackImage: proto.String("https://example.com/back.jpg"), + BankStatementImg: proto.String("https://example.com/bank.jpg"), + BankCode: proto.String("123"), + BankName: proto.String("ABC Bank"), + BranchCode: proto.String("001"), + BranchName: proto.String("Taipei Branch"), + BankAccount: proto.String("1234567890"), + } + + tests := []struct { + name string + id string + input *usecase.KYCUpdateParams + mockSetup func() + expectedError error + }{ + { + name: "更新成功", + id: id, + input: updateParams, + mockSetup: func() { + mockKYCRepo.EXPECT().UpdateKYCInfo(ctx, id, &repository.KYCUpdateParams{ + Name: updateParams.Name, + Identification: updateParams.Identification, + IdentificationType: updateParams.IdentificationType, + Address: updateParams.Address, + PostalCode: updateParams.PostalCode, + DateOfBirth: updateParams.DateOfBirth, + Gender: updateParams.Gender, + IDFrontImage: updateParams.IDFrontImage, + IDBackImage: updateParams.IDBackImage, + BankStatementImg: updateParams.BankStatementImg, + BankCode: updateParams.BankCode, + BankName: updateParams.BankName, + BranchCode: updateParams.BranchCode, + BranchName: updateParams.BranchName, + BankAccount: updateParams.BankAccount, + }).Return(nil) + }, + expectedError: nil, + }, + { + name: "更新失敗 - DB 錯誤", + id: id, + input: updateParams, + mockSetup: func() { + mockKYCRepo.EXPECT().UpdateKYCInfo(ctx, id, gomock.Any()).Return(errors.New("db error")) + }, + expectedError: errs.DBErrorL(logx.WithContext(ctx), + []logx.LogField{ + {Key: "id", Value: id}, + {Key: "params", Value: updateParams}, + {Key: "func", Value: "KYCRepo.UpdateKYCInfo"}, + {Key: "err", Value: "db error"}, + }, + "failed to update kyc", + ), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + + err := useCase.UpdateKYCInfo(ctx, tt.id, tt.input) + + if tt.expectedError == nil { + assert.NoError(t, err) + } else { + assert.Error(t, err) + assert.Equal(t, tt.expectedError.Error(), err.Error()) + } + }) + } +} diff --git a/pkg/usecase/product_test.go b/pkg/usecase/product_test.go new file mode 100644 index 0000000..6984f00 --- /dev/null +++ b/pkg/usecase/product_test.go @@ -0,0 +1,640 @@ +package usecase + +import ( + "code.30cm.net/digimon/app-cloudep-product-service/pkg/domain/entity" + "code.30cm.net/digimon/app-cloudep-product-service/pkg/domain/repository" + "code.30cm.net/digimon/app-cloudep-product-service/pkg/domain/usecase" + mockRepository "code.30cm.net/digimon/app-cloudep-product-service/pkg/mock/repository" + repo "code.30cm.net/digimon/app-cloudep-product-service/pkg/repository" + "context" + "errors" + "github.com/stretchr/testify/assert" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "go.uber.org/mock/gomock" + "testing" + "time" +) + +func TestProductUseCase_Create(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockProductRepo := mockRepository.NewMockProductRepository(mockCtrl) + mockStatsRepo := mockRepository.NewMockProductStatisticsRepo(mockCtrl) + mockTagBinding := mockRepository.NewMockTagBindingRepo(mockCtrl) + + useCase := MustProductUseCase(ProductUseCaseParam{ + ProductRepo: mockProductRepo, + ProductStatisticsRepo: mockStatsRepo, + TagBinding: mockTagBinding, + }) + + ctx := context.Background() + + input := &usecase.Product{ + UID: ptr("user-1"), + Title: ptr("Test Product"), + ShortTitle: ptr("Short"), + Details: ptr("Details here"), + ShortDescription: ptr("Desc"), + Media: []usecase.Media{{Sort: 1, Type: "image", URL: "http://image"}}, + Slug: ptr("test-product"), + IsPublished: ptr(true), + Amount: 1000, + StartTime: ptr("2025-04-04T00:00:00Z"), + EndTime: ptr("2025-04-10T00:00:00Z"), + Category: ptr("education"), + CustomFields: []usecase.CustomFields{{Key: "Level", Value: "Beginner"}}, + Tags: []string{"t1", "t2"}, + } + + tests := []struct { + name string + mockSetup func() + expectedError string + }{ + { + name: "建立成功", + mockSetup: func() { + mockProductRepo.EXPECT().Transaction(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, fn func(sessCtx mongo.SessionContext) (any, error), opts ...*options.TransactionOptions) error { + // ✅ 這邊用 gomock.Any() 模擬 sessCtx + mockProductRepo.EXPECT().Insert(gomock.Any(), gomock.Any()).Return(nil) + mockStatsRepo.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + mockTagBinding.EXPECT().BindTags(gomock.Any(), gomock.Any()).Return(nil) + _, err := fn(nil) + return err + }) + }, + }, + { + name: "插入失敗", + mockSetup: func() { + mockProductRepo.EXPECT().Transaction(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, fn func(sessCtx mongo.SessionContext) (any, error), opts ...*options.TransactionOptions) error { + mockProductRepo.EXPECT().Insert(gomock.Any(), gomock.Any()).Return(errors.New("failed to create product")) + _, err := fn(nil) + return err + }) + }, + expectedError: "failed to create product", + }, + { + name: "統計建立失敗", + mockSetup: func() { + mockProductRepo.EXPECT().Transaction(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, fn func(sessCtx mongo.SessionContext) (any, error), opts ...*options.TransactionOptions) error { + mockProductRepo.EXPECT().Insert(gomock.Any(), gomock.Any()).Return(nil) + mockStatsRepo.EXPECT().Create(gomock.Any(), gomock.Any()).Return(errors.New("failed to create product statistics")) + _, err := fn(nil) + return err + }) + }, + expectedError: "failed to create product statistics", + }, + { + name: "綁定標籤失敗", + mockSetup: func() { + mockProductRepo.EXPECT().Transaction(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, fn func(sessCtx mongo.SessionContext) (any, error), opts ...*options.TransactionOptions) error { + mockProductRepo.EXPECT().Insert(gomock.Any(), gomock.Any()).Return(nil) + mockStatsRepo.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + mockTagBinding.EXPECT().BindTags(gomock.Any(), gomock.Any()).Return(errors.New("failed to bind product tags")) + _, err := fn(nil) + return err + }) + }, + expectedError: "failed to bind product tags", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockSetup() + err := useCase.Create(ctx, input) + if tt.expectedError == "" { + assert.NoError(t, err) + } else { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } + }) + } +} + +func TestProductUseCase_IncOrders(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockStatsRepo := mockRepository.NewMockProductStatisticsRepo(mockCtrl) + useCase := MustProductUseCase(ProductUseCaseParam{ + ProductStatisticsRepo: mockStatsRepo, + }) + + ctx := context.Background() + productID := primitive.NewObjectID().Hex() + + t.Run("成功增加訂單", func(t *testing.T) { + mockStatsRepo.EXPECT().IncOrders(ctx, productID, int64(2)).Return(nil) + err := useCase.IncOrders(ctx, productID, 2) + assert.NoError(t, err) + }) + + t.Run("資料庫錯誤", func(t *testing.T) { + mockStatsRepo.EXPECT().IncOrders(ctx, productID, int64(1)).Return(errors.New("db error")) + err := useCase.IncOrders(ctx, productID, 1) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to inc order") + }) +} + +func TestProductUseCase_DecOrders(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockStatsRepo := mockRepository.NewMockProductStatisticsRepo(mockCtrl) + useCase := MustProductUseCase(ProductUseCaseParam{ + ProductStatisticsRepo: mockStatsRepo, + }) + + ctx := context.Background() + productID := primitive.NewObjectID().Hex() + + t.Run("成功減少訂單", func(t *testing.T) { + mockStatsRepo.EXPECT().DecOrders(ctx, productID, int64(3)).Return(nil) + err := useCase.DecOrders(ctx, productID, 3) + assert.NoError(t, err) + }) + + t.Run("資料庫錯誤", func(t *testing.T) { + mockStatsRepo.EXPECT().DecOrders(ctx, productID, int64(1)).Return(errors.New("db error")) + err := useCase.DecOrders(ctx, productID, 1) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to dec order") + }) +} + +func TestProductUseCase_UpdateAverageRating(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockStatsRepo := mockRepository.NewMockProductStatisticsRepo(mockCtrl) + useCase := MustProductUseCase(ProductUseCaseParam{ + ProductStatisticsRepo: mockStatsRepo, + }) + + ctx := context.Background() + productID := primitive.NewObjectID().Hex() + + t.Run("成功更新評價", func(t *testing.T) { + mockStatsRepo.EXPECT().UpdateAverageRating(ctx, productID, 4.5).Return(nil) + err := useCase.UpdateAverageRating(ctx, productID, 4.5) + assert.NoError(t, err) + }) + + t.Run("資料庫錯誤", func(t *testing.T) { + mockStatsRepo.EXPECT().UpdateAverageRating(ctx, productID, 3.0).Return(errors.New("db error")) + err := useCase.UpdateAverageRating(ctx, productID, 3.0) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to update average rating") + }) +} + +func TestProductUseCase_IncFansCount(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockStatsRepo := mockRepository.NewMockProductStatisticsRepo(mockCtrl) + useCase := MustProductUseCase(ProductUseCaseParam{ + ProductStatisticsRepo: mockStatsRepo, + }) + + ctx := context.Background() + productID := primitive.NewObjectID().Hex() + + t.Run("成功增加追蹤人數", func(t *testing.T) { + mockStatsRepo.EXPECT().IncFansCount(ctx, productID, uint64(10)).Return(nil) + err := useCase.IncFansCount(ctx, productID, 10) + assert.NoError(t, err) + }) + + t.Run("資料庫錯誤", func(t *testing.T) { + mockStatsRepo.EXPECT().IncFansCount(ctx, productID, uint64(5)).Return(errors.New("db error")) + err := useCase.IncFansCount(ctx, productID, 5) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to inc fans count") + }) +} + +func TestProductUseCase_DecFansCount(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockStatsRepo := mockRepository.NewMockProductStatisticsRepo(mockCtrl) + useCase := MustProductUseCase(ProductUseCaseParam{ + ProductStatisticsRepo: mockStatsRepo, + }) + + ctx := context.Background() + productID := primitive.NewObjectID().Hex() + + t.Run("成功減少追蹤人數", func(t *testing.T) { + mockStatsRepo.EXPECT().DecFansCount(ctx, productID, uint64(7)).Return(nil) + err := useCase.DecFansCount(ctx, productID, 7) + assert.NoError(t, err) + }) + + t.Run("資料庫錯誤", func(t *testing.T) { + mockStatsRepo.EXPECT().DecFansCount(ctx, productID, uint64(2)).Return(errors.New("db error")) + err := useCase.DecFansCount(ctx, productID, 2) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to dec fans count") + }) +} + +func TestProductUseCase_Update(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockProductRepo := mockRepository.NewMockProductRepository(mockCtrl) + mockTagBinding := mockRepository.NewMockTagBindingRepo(mockCtrl) + + useCase := MustProductUseCase(ProductUseCaseParam{ + ProductRepo: mockProductRepo, + TagBinding: mockTagBinding, + }) + ctx := context.Background() + id := primitive.NewObjectID().Hex() + product := &usecase.Product{ + Title: ptr("Updated Product"), + Tags: []string{"t1", "t2"}, + } + t.Run("更新成功", func(t *testing.T) { + mockProductRepo.EXPECT().Transaction(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, fn func(sessCtx mongo.SessionContext) (any, error), opts ...*options.TransactionOptions) error { + mockProductRepo.EXPECT().Update(gomock.Any(), id, gomock.Any()).Return(nil, nil) + mockTagBinding.EXPECT().UnbindTagByReferenceID(gomock.Any(), id).Return(nil) + mockTagBinding.EXPECT().BindTags(gomock.Any(), gomock.Any()).Return(nil) + _, err := fn(nil) + return err + }, + ) + err := useCase.Update(ctx, id, product) + assert.NoError(t, err) + }) + + t.Run("更新失敗 - ProductRepo", func(t *testing.T) { + mockProductRepo.EXPECT().Transaction(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, fn func(sessCtx mongo.SessionContext) (any, error), opts ...*options.TransactionOptions) error { + mockProductRepo.EXPECT().Update(gomock.Any(), id, gomock.Any()).Return(nil, errors.New("update failed")) + _, err := fn(nil) + return err + }, + ) + err := useCase.Update(ctx, id, product) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to update product") + }) + + t.Run("解除綁定失敗", func(t *testing.T) { + mockProductRepo.EXPECT().Transaction(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, fn func(sessCtx mongo.SessionContext) (any, error), opts ...*options.TransactionOptions) error { + mockProductRepo.EXPECT().Update(gomock.Any(), id, gomock.Any()).Return(nil, nil) + mockTagBinding.EXPECT().UnbindTagByReferenceID(gomock.Any(), id).Return(errors.New("unbind error")) + _, err := fn(nil) + return err + }, + ) + err := useCase.Update(ctx, id, product) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to unbind tags") + }) + + t.Run("綁定失敗", func(t *testing.T) { + mockProductRepo.EXPECT().Transaction(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, fn func(sessCtx mongo.SessionContext) (any, error), opts ...*options.TransactionOptions) error { + mockProductRepo.EXPECT().Update(gomock.Any(), id, gomock.Any()).Return(nil, nil) + mockTagBinding.EXPECT().UnbindTagByReferenceID(gomock.Any(), id).Return(nil) + mockTagBinding.EXPECT().BindTags(gomock.Any(), gomock.Any()).Return(errors.New("bind error")) + _, err := fn(nil) + return err + }, + ) + err := useCase.Update(ctx, id, product) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to bind tags") + }) +} + +// ========================= Delete ========================= + +func TestProductUseCase_Delete(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + mockProductRepo := mockRepository.NewMockProductRepository(mockCtrl) + mockTagRepo := mockRepository.NewMockTagRepo(mockCtrl) + useCase := MustProductUseCase(ProductUseCaseParam{ + ProductRepo: mockProductRepo, + TagRepo: mockTagRepo, + }) + ctx := context.Background() + id := primitive.NewObjectID().Hex() + t.Run("刪除成功", func(t *testing.T) { + mockProductRepo.EXPECT().Transaction(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, fn func(sessCtx mongo.SessionContext) (any, error), opts ...*options.TransactionOptions) error { + mockProductRepo.EXPECT().Delete(gomock.Any(), id).Return(nil) + mockTagRepo.EXPECT().UnbindTagByReferenceID(gomock.Any(), id).Return(nil) + _, err := fn(nil) + return err + }, + ) + err := useCase.Delete(ctx, id) + assert.NoError(t, err) + }) + + t.Run("刪除失敗", func(t *testing.T) { + mockProductRepo.EXPECT().Transaction(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, fn func(sessCtx mongo.SessionContext) (any, error), opts ...*options.TransactionOptions) error { + mockProductRepo.EXPECT().Delete(gomock.Any(), id).Return(errors.New("delete error")) + _, err := fn(nil) + return err + }, + ) + err := useCase.Delete(ctx, id) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to delete product") + }) + + t.Run("解除綁定失敗", func(t *testing.T) { + mockProductRepo.EXPECT().Transaction(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, fn func(sessCtx mongo.SessionContext) (any, error), opts ...*options.TransactionOptions) error { + mockProductRepo.EXPECT().Delete(gomock.Any(), id).Return(nil) + mockTagRepo.EXPECT().UnbindTagByReferenceID(gomock.Any(), id).Return(errors.New("unbind tag error")) + _, err := fn(nil) + return err + }, + ) + err := useCase.Delete(ctx, id) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to unbind tags") + }) +} + +// ========================= Get ========================= + +func TestProductUseCase_Get(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + mockProductRepo := mockRepository.NewMockProductRepository(mockCtrl) + mockStatsRepo := mockRepository.NewMockProductStatisticsRepo(mockCtrl) + mockTagRepo := mockRepository.NewMockTagRepo(mockCtrl) + + useCase := MustProductUseCase(ProductUseCaseParam{ + ProductRepo: mockProductRepo, + ProductStatisticsRepo: mockStatsRepo, + TagRepo: mockTagRepo, + }) + ctx := context.Background() + id := primitive.NewObjectID() + t.Run("查詢成功", func(t *testing.T) { + mockProductRepo.EXPECT().FindOneByID(ctx, id.Hex()).Return(&entity.Product{ + ID: id, + UID: "u1", + Title: "test", + IsPublished: true, + Media: []entity.Media{}, + CustomFields: []entity.CustomFields{}, + CreatedAt: time.Now().Unix(), + UpdatedAt: time.Now().Unix(), + }, nil) + mockStatsRepo.EXPECT().GetByID(ctx, id.Hex()).Return(&entity.ProductStatistics{}, nil) + mockTagRepo.EXPECT().GetBindingsByReference(ctx, id.Hex()).Return([]*entity.TagsBindingTable{}, nil) + mockTagRepo.EXPECT().GetByIDs(ctx, gomock.Any()).Return([]*entity.Tags{}, nil) + resp, err := useCase.Get(ctx, id.Hex()) + assert.NoError(t, err) + assert.Equal(t, "u1", resp.UID) + }) + + t.Run("查詢失敗 - ProductRepo", func(t *testing.T) { + mockProductRepo.EXPECT().FindOneByID(ctx, id.Hex()).Return(nil, errors.New("db error")) + resp, err := useCase.Get(ctx, id.Hex()) + assert.Error(t, err) + assert.Nil(t, resp) + }) +} + +// ========================= Tag Binding ========================= + +func TestProductUseCase_BindTag(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + mockProductRepo := mockRepository.NewMockProductRepository(mockCtrl) + mockTagRepo := mockRepository.NewMockTagRepo(mockCtrl) + mockTagBinding := mockRepository.NewMockTagBindingRepo(mockCtrl) + + useCase := MustProductUseCase(ProductUseCaseParam{ + ProductRepo: mockProductRepo, + TagRepo: mockTagRepo, + TagBinding: mockTagBinding, + }) + ctx := context.Background() + binding := usecase.TagsBindingTable{ + ReferenceID: "pid-123", + TagID: "tag-123", + } + + t.Run("成功綁定", func(t *testing.T) { + mockTagRepo.EXPECT().GetByID(ctx, binding.TagID).Return(&entity.Tags{}, nil) + mockProductRepo.EXPECT().FindOneByID(ctx, binding.ReferenceID).Return(&entity.Product{}, nil) + mockTagBinding.EXPECT().BindTags(ctx, gomock.Any()).Return(nil) + err := useCase.BindTag(ctx, binding) + assert.NoError(t, err) + }) + + t.Run("Tag 不存在", func(t *testing.T) { + mockTagRepo.EXPECT().GetByID(ctx, binding.TagID).Return(nil, repo.ErrNotFound) + err := useCase.BindTag(ctx, binding) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get tags") + }) + + t.Run("Product 不存在", func(t *testing.T) { + mockTagRepo.EXPECT().GetByID(ctx, binding.TagID).Return(&entity.Tags{}, nil) + mockProductRepo.EXPECT().FindOneByID(ctx, binding.ReferenceID).Return(nil, repo.ErrNotFound) + err := useCase.BindTag(ctx, binding) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get tags") + }) + + t.Run("綁定錯誤", func(t *testing.T) { + mockTagRepo.EXPECT().GetByID(ctx, binding.TagID).Return(&entity.Tags{}, nil) + mockProductRepo.EXPECT().FindOneByID(ctx, binding.ReferenceID).Return(&entity.Product{}, nil) + mockTagBinding.EXPECT().BindTags(ctx, gomock.Any()).Return(errors.New("db error")) + err := useCase.BindTag(ctx, binding) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to bind tags") + }) +} + +func TestProductUseCase_UnbindTag(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + mockProductRepo := mockRepository.NewMockProductRepository(mockCtrl) + mockTagRepo := mockRepository.NewMockTagRepo(mockCtrl) + mockTagBinding := mockRepository.NewMockTagBindingRepo(mockCtrl) + + useCase := MustProductUseCase(ProductUseCaseParam{ + ProductRepo: mockProductRepo, + TagRepo: mockTagRepo, + TagBinding: mockTagBinding, + }) + ctx := context.Background() + binding := usecase.TagsBindingTable{ + ReferenceID: "pid-123", + TagID: "tag-123", + } + + t.Run("成功解除綁定", func(t *testing.T) { + mockTagRepo.EXPECT().GetByID(ctx, binding.TagID).Return(&entity.Tags{}, nil) + mockProductRepo.EXPECT().FindOneByID(ctx, binding.ReferenceID).Return(&entity.Product{}, nil) + mockTagBinding.EXPECT().UnbindTag(ctx, binding.TagID, binding.ReferenceID).Return(nil) + err := useCase.UnbindTag(ctx, binding) + assert.NoError(t, err) + }) + + t.Run("解除失敗", func(t *testing.T) { + mockTagRepo.EXPECT().GetByID(ctx, binding.TagID).Return(&entity.Tags{}, nil) + mockProductRepo.EXPECT().FindOneByID(ctx, binding.ReferenceID).Return(&entity.Product{}, nil) + mockTagBinding.EXPECT().UnbindTag(ctx, binding.TagID, binding.ReferenceID).Return(errors.New("unbind error")) + err := useCase.UnbindTag(ctx, binding) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to unbind tags") + }) +} + +func TestProductUseCase_GetBindingsByReference(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + mockTagBinding := mockRepository.NewMockTagBindingRepo(mockCtrl) + useCase := MustProductUseCase(ProductUseCaseParam{ + TagBinding: mockTagBinding, + }) + ctx := context.Background() + refID := "ref-123" + + t.Run("查詢成功", func(t *testing.T) { + now := time.Now().Unix() + mockTagBinding.EXPECT().GetBindingsByReference(ctx, refID).Return([]*entity.TagsBindingTable{ + { + ID: primitive.NewObjectID(), + ReferenceID: refID, + TagID: "tag-1", + CreatedAt: now, + UpdatedAt: now, + }, + }, nil) + resp, err := useCase.GetBindingsByReference(ctx, refID) + assert.NoError(t, err) + assert.Len(t, resp, 1) + }) + + t.Run("查詢失敗", func(t *testing.T) { + mockTagBinding.EXPECT().GetBindingsByReference(ctx, refID).Return(nil, errors.New("db error")) + resp, err := useCase.GetBindingsByReference(ctx, refID) + assert.Error(t, err) + assert.Nil(t, resp) + }) +} + +func TestProductUseCase_ListTagBinding(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + mockTagBinding := mockRepository.NewMockTagBindingRepo(mockCtrl) + useCase := MustProductUseCase(ProductUseCaseParam{ + TagBinding: mockTagBinding, + }) + ctx := context.Background() + params := usecase.TagBindingQueryParams{ + ReferenceID: ptr("r1"), + TagID: ptr("t1"), + PageSize: 10, + PageIndex: 1, + } + + t.Run("查詢成功", func(t *testing.T) { + now := time.Now().Unix() + mockTagBinding.EXPECT().ListTagBinding(ctx, repository.TagBindingQueryParams{ + ReferenceID: params.ReferenceID, + TagID: params.TagID, + PageSize: params.PageSize, + PageIndex: params.PageIndex, + }).Return([]*entity.TagsBindingTable{ + { + ID: primitive.NewObjectID(), + ReferenceID: *params.ReferenceID, + TagID: *params.TagID, + CreatedAt: now, + UpdatedAt: now, + }, + }, int64(1), nil) + + resp, total, err := useCase.ListTagBinding(ctx, params) + assert.NoError(t, err) + assert.Len(t, resp, 1) + assert.Equal(t, int64(1), total) + }) + + t.Run("查詢失敗", func(t *testing.T) { + mockTagBinding.EXPECT().ListTagBinding(ctx, repository.TagBindingQueryParams{ + ReferenceID: params.ReferenceID, + TagID: params.TagID, + PageSize: params.PageSize, + PageIndex: params.PageIndex, + }).Return(nil, int64(0), errors.New("db error")) + + resp, total, err := useCase.ListTagBinding(ctx, params) + assert.Error(t, err) + assert.Nil(t, resp) + assert.Equal(t, int64(0), total) + }) +} + +// ========================= List ========================= + +func TestProductUseCase_List(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + mockProductRepo := mockRepository.NewMockProductRepository(mockCtrl) + mockStatsRepo := mockRepository.NewMockProductStatisticsRepo(mockCtrl) + mockTagRepo := mockRepository.NewMockTagRepo(mockCtrl) + + useCase := MustProductUseCase(ProductUseCaseParam{ + ProductRepo: mockProductRepo, + ProductStatisticsRepo: mockStatsRepo, + TagRepo: mockTagRepo, + }) + ctx := context.Background() + id := primitive.NewObjectID() + mockProductRepo.EXPECT().ListProduct(ctx, &repository.ProductQueryParams{ + PageIndex: 1, + PageSize: 10, + }).Return([]*entity.Product{{ID: id, UID: "u1", Title: "T", CreatedAt: time.Now().Unix(), UpdatedAt: time.Now().Unix()}}, int64(1), nil) + mockStatsRepo.EXPECT().GetByID(ctx, id.Hex()).Return(&entity.ProductStatistics{}, nil) + mockTagRepo.EXPECT().GetBindingsByReference(ctx, id.Hex()).Return([]*entity.TagsBindingTable{}, nil) + mockTagRepo.EXPECT().GetByIDs(ctx, gomock.Any()).Return([]*entity.Tags{}, nil) + + list, count, err := useCase.List(ctx, usecase.ProductQueryParams{ + PageIndex: 1, + PageSize: 10, + }) + assert.NoError(t, err) + assert.Equal(t, int64(1), count) + assert.Len(t, list, 1) +} + +// 工具:指標轉換 +func ptr[T any](v T) *T { + return &v +}