package user import ( "bytes" "context" "database/sql/driver" "fmt" "strings" "testing" "time" "github.com/DATA-DOG/go-sqlmock" logmodel "github.com/perfect-panel/server/internal/model/log" usermodel "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/constant" "github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/xerr" "github.com/pkg/errors" "gorm.io/driver/mysql" "gorm.io/gorm" ) func TestQueryWithdrawalLog_WithWithdrawalBizType(t *testing.T) { const userID = int64(42) createdAt := time.Unix(1700000000, 0) updatedAt := createdAt.Add(time.Minute) db, mock, cleanup := newQueryWithdrawalLogTestDB(t) defer cleanup() mock.ExpectQuery("SELECT count(*) FROM `withdrawals` WHERE user_id = ?"). WithArgs(userID). WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) mock.ExpectQuery("SELECT * FROM `withdrawals` WHERE user_id = ? ORDER BY id DESC LIMIT ?"). WithArgs(userID, 10). WillReturnRows(sqlmock.NewRows([]string{ "id", "user_id", "amount", "content", "status", "reason", "method", "account", "qr_code_url", "created_at", "updated_at", }).AddRow( int64(1001), userID, int64(3000), "bank withdrawal", usermodel.WithdrawalStatusPending, "", uint8(3), "acct-001", "", createdAt, updatedAt, )) // buildSummary 触发的 4 个聚合查询 mock.ExpectQuery("SELECT COALESCE(commission, 0) FROM `user`"). WithArgs(userID). WillReturnRows(sqlmock.NewRows([]string{"commission"}).AddRow(int64(5000))) mock.ExpectQuery("SELECT COALESCE(SUM(amount), 0) FROM `withdrawals`"). WithArgs(userID, usermodel.WithdrawalStatusPending). WillReturnRows(sqlmock.NewRows([]string{"sum"}).AddRow(int64(3000))) mock.ExpectQuery("SELECT COALESCE(SUM(amount), 0) FROM `withdrawals`"). WithArgs(userID, usermodel.WithdrawalStatusApproved). WillReturnRows(sqlmock.NewRows([]string{"sum"}).AddRow(int64(0))) mock.ExpectQuery("FROM system_logs"). WithArgs( logmodel.CommissionTypePurchase, logmodel.CommissionTypeRenewal, logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel, logmodel.TypeCommission.Uint8(), userID, ). WillReturnRows(sqlmock.NewRows([]string{"income", "refund"}).AddRow(int64(8000), int64(0))) logic := newTestQueryWithdrawalLogLogic(t, db, userID) resp, err := logic.QueryWithdrawalLog(&types.QueryWithdrawalLogListRequest{ BizType: withdrawalLogBizTypeWithdrawal, Page: 1, Size: 10, }) if err != nil { t.Fatalf("QueryWithdrawalLog unexpected error: %v", err) } if resp.Total != 1 || len(resp.List) != 1 { t.Fatalf("QueryWithdrawalLog response = %+v, want one withdrawal", resp) } got := resp.List[0] if got.BizType != withdrawalLogBizTypeWithdrawal { t.Fatalf("BizType = %q, want %q", got.BizType, withdrawalLogBizTypeWithdrawal) } if got.Id != 1001 || got.UserId != userID || got.Amount != 3000 || got.CreatedAt != createdAt.Unix() || got.UpdatedAt != updatedAt.Unix() { t.Fatalf("withdrawal item = %+v", got) } if resp.Summary == nil { t.Fatalf("expected summary, got nil") } if resp.Summary.CommissionBalance != 5000 || resp.Summary.LockedByPending != 3000 || resp.Summary.AvailableToWithdraw != 2000 || resp.Summary.TotalIncomeAmount != 8000 { t.Fatalf("summary = %+v, want balance=5000 locked=3000 avail=2000 income=8000", resp.Summary) } assertQueryWithdrawalLogExpectations(t, mock) } func TestQueryCommissionReturnLog_HappyPathIncludes333337338(t *testing.T) { const userID = int64(42) createdAt := time.Unix(1700000000, 0) db, mock, cleanup := newQueryWithdrawalLogTestDB(t) defer cleanup() expectCommissionReturnQueries(mock, userID, 3, logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel) mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?"). WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10). WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}). AddRow(int64(2003), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":338,"amount":1500,"order_no":"ORDER-3","timestamp":1700000003123}`, createdAt). AddRow(int64(2002), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":337,"amount":2000,"order_no":"ORDER-2","timestamp":1700000002123}`, createdAt). AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, createdAt)) logic := NewQueryCommissionReturnLogLogic(newTestQueryCtx(userID), &svc.ServiceContext{DB: db}) resp, err := logic.QueryCommissionReturnLog(&types.QueryCommissionReturnLogRequest{Page: 1, Size: 10}) if err != nil { t.Fatalf("QueryCommissionReturnLog unexpected error: %v", err) } if resp.Total != 3 || len(resp.List) != 3 { t.Fatalf("QueryCommissionReturnLog response = %+v, want three logs", resp) } eventTypes := []uint16{resp.List[0].EventType, resp.List[1].EventType, resp.List[2].EventType} wantTypes := []uint16{logmodel.CommissionTypeWithdrawCancel, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeRefund} if fmt.Sprint(eventTypes) != fmt.Sprint(wantTypes) { t.Fatalf("event types = %v, want %v", eventTypes, wantTypes) } assertQueryWithdrawalLogExpectations(t, mock) } func TestQueryWithdrawalLog_WithCommissionRefundBizTypeOnlyIncludes333(t *testing.T) { const userID = int64(42) createdAt := time.Unix(1700000000, 0) db, mock, cleanup := newQueryWithdrawalLogTestDB(t) defer cleanup() expectCommissionReturnQueries(mock, userID, 1, logmodel.CommissionTypeRefund) mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ?) ORDER BY id DESC LIMIT ?"). WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", 10). WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}). AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, createdAt)) logic := newTestQueryWithdrawalLogLogic(t, db, userID) resp, err := logic.QueryWithdrawalLog(&types.QueryWithdrawalLogListRequest{ BizType: withdrawalLogBizTypeCommissionRefund, Page: 1, Size: 10, }) if err != nil { t.Fatalf("QueryWithdrawalLog unexpected error: %v", err) } if resp.Total != 1 || len(resp.List) != 1 { t.Fatalf("QueryWithdrawalLog response = %+v, want one commission refund", resp) } for _, item := range resp.List { if item.BizType != withdrawalLogBizTypeCommissionRefund { t.Fatalf("BizType = %q, want %q", item.BizType, withdrawalLogBizTypeCommissionRefund) } if item.Status != 0 || item.Reason != "" || item.Method != 0 || item.Account != "" || item.QrCodeUrl != "" { t.Fatalf("withdrawal-only fields should keep zero values, got %+v", item) } } if resp.List[0].Id != 2001 { t.Fatalf("commission refund item id = %d, want 2001", resp.List[0].Id) } assertQueryWithdrawalLogExpectations(t, mock) } func TestQueryCommissionReturnLog_SkipsInvalidJSONAndLogsWarn(t *testing.T) { const userID = int64(42) createdAt := time.Unix(1700000000, 0) db, mock, cleanup := newQueryWithdrawalLogTestDB(t) defer cleanup() expectCommissionReturnQueries(mock, userID, 2, logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel) mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?"). WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10). WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}). AddRow(int64(2002), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":337,"amount":2000,"order_no":"ORDER-2","timestamp":1700000002123}`, createdAt). AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":333`, createdAt)) var buf bytes.Buffer restoreLogger := captureTestLogs(&buf) defer restoreLogger() logic := NewQueryCommissionReturnLogLogic(newTestQueryCtx(userID), &svc.ServiceContext{DB: db}) resp, err := logic.QueryCommissionReturnLog(&types.QueryCommissionReturnLogRequest{Page: 1, Size: 10}) if err != nil { t.Fatalf("QueryCommissionReturnLog unexpected error: %v", err) } if resp.Total != 2 || len(resp.List) != 1 { t.Fatalf("QueryCommissionReturnLog response = %+v, want total=2 and one valid row", resp) } if !strings.Contains(buf.String(), "unmarshal commission return log content failed") { t.Fatalf("expected warn log, got %q", buf.String()) } assertQueryWithdrawalLogExpectations(t, mock) } func TestQueryCommissionReturnLog_FiltersOtherUsersByObjectID(t *testing.T) { const userID = int64(42) db, mock, cleanup := newQueryWithdrawalLogTestDB(t) defer cleanup() expectCommissionReturnQueries(mock, userID, 0, logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel) mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?"). WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10). WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"})) logic := NewQueryCommissionReturnLogLogic(newTestQueryCtx(userID), &svc.ServiceContext{DB: db}) resp, err := logic.QueryCommissionReturnLog(&types.QueryCommissionReturnLogRequest{Page: 1, Size: 10}) if err != nil { t.Fatalf("QueryCommissionReturnLog unexpected error: %v", err) } if resp.Total != 0 || len(resp.List) != 0 { t.Fatalf("QueryCommissionReturnLog response = %+v, want no rows for filtered user", resp) } assertQueryWithdrawalLogExpectations(t, mock) } func TestQueryWithdrawalLog_RejectsInvalidBizType(t *testing.T) { db, mock, cleanup := newQueryWithdrawalLogTestDB(t) defer cleanup() logic := newTestQueryWithdrawalLogLogic(t, db, 42) _, err := logic.QueryWithdrawalLog(&types.QueryWithdrawalLogListRequest{ BizType: "all", Page: 1, Size: 10, }) if !isQueryWithdrawalLogErrCode(err, xerr.InvalidParams) { t.Fatalf("QueryWithdrawalLog err = %v, want InvalidParams", err) } assertQueryWithdrawalLogExpectations(t, mock) } func newQueryWithdrawalLogTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { t.Helper() sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error { if strings.Contains(actualSQL, expectedSQL) { return nil } return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL) }))) if err != nil { t.Fatalf("create sqlmock: %v", err) } db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) if err != nil { _ = sqlDB.Close() t.Fatalf("open gorm db: %v", err) } return db, mock, func() { _ = sqlDB.Close() } } func newTestQueryWithdrawalLogLogic(t *testing.T, db *gorm.DB, userID int64) *QueryWithdrawalLogLogic { t.Helper() ctx := newTestQueryCtx(userID) return &QueryWithdrawalLogLogic{ Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: &svc.ServiceContext{ DB: db, }, } } func newTestQueryCtx(userID int64) context.Context { return context.WithValue(context.Background(), constant.CtxKeyUser, &usermodel.User{Id: userID}) } func expectCommissionReturnQueries(mock sqlmock.Sqlmock, userID int64, total int64, eventTypes ...uint16) { query := "SELECT count(*) FROM `system_logs` WHERE `type` = ? AND object_id = ?" args := []driver.Value{logmodel.TypeCommission.Uint8(), userID} if len(eventTypes) > 0 { clauses := make([]string, 0, len(eventTypes)) for _, eventType := range eventTypes { clauses = append(clauses, "`content` LIKE ?") args = append(args, fmt.Sprintf("%%\"type\":%d%%", eventType)) } query += " AND (" + strings.Join(clauses, " OR ") + ")" } mock.ExpectQuery(query). WithArgs(args...). WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(total)) } func assertQueryWithdrawalLogExpectations(t *testing.T, mock sqlmock.Sqlmock) { t.Helper() if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet sql expectations: %v", err) } } func captureTestLogs(buf *bytes.Buffer) func() { prevWriter := logger.Reset() prevLevel := logger.InfoLevel logger.SetLevel(logger.DebugLevel) logger.SetWriter(logger.NewWriter(buf)) return func() { logger.Reset() logger.SetLevel(prevLevel) if prevWriter != nil { logger.SetWriter(prevWriter) } } } func queryWithdrawalLogErrCodeOf(err error) uint32 { if err == nil { return 0 } type coder interface { GetErrCode() uint32 } cause := errors.Cause(err) if c, ok := cause.(coder); ok { return c.GetErrCode() } return 0 } func isQueryWithdrawalLogErrCode(err error, code uint32) bool { return queryWithdrawalLogErrCodeOf(err) == code }