package group import ( "context" "encoding/csv" "fmt" "strings" "testing" "time" "github.com/DATA-DOG/go-sqlmock" modelgroup "github.com/perfect-panel/server/internal/model/group" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/logger" "gorm.io/driver/mysql" "gorm.io/gorm" ) func TestGetGroupHistoryDetailReadsConfigFromSystem(t *testing.T) { db, mock, cleanup := newGroupTestDB(t) defer cleanup() now := time.Unix(1710000000, 0) mock.ExpectQuery("FROM `group_history`"). WithArgs(int64(9), 1). WillReturnRows(sqlmock.NewRows([]string{ "id", "group_mode", "trigger_type", "state", "total_users", "success_count", "failed_count", "start_time", "end_time", "error_message", "created_at", }).AddRow(int64(9), "traffic", "manual", "completed", 1, 1, 0, now, now, "", now)) mock.ExpectQuery("FROM `group_history_detail`"). WithArgs(int64(9)). WillReturnRows(sqlmock.NewRows([]string{ "id", "history_id", "node_group_id", "user_count", "node_count", "user_data", "created_at", }).AddRow(int64(1), int64(9), int64(3), 1, 2, `[{"id":7,"email":"u@example.com"}]`, now)) mock.ExpectQuery("FROM `system`"). WithArgs("group", "traffic_config"). WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow(`{"strategy":"closed"}`)) logic := newTestGroupHistoryDetailLogic(db) resp, err := logic.GetGroupHistoryDetail(&types.GetGroupHistoryDetailRequest{Id: 9}) if err != nil { t.Fatalf("GetGroupHistoryDetail error: %v", err) } if got := resp.ConfigSnapshot["config"].(map[string]interface{})["strategy"]; got != "closed" { t.Fatalf("config snapshot strategy = %v, want closed", got) } assertGroupExpectations(t, mock) } func TestExportGroupResultParsesHistoryUserDataJSON(t *testing.T) { db, mock, cleanup := newGroupTestDB(t) defer cleanup() historyID := int64(11) now := time.Unix(1710000000, 0) mock.ExpectQuery("FROM `group_history_detail`"). WithArgs(historyID). WillReturnRows(sqlmock.NewRows([]string{ "id", "history_id", "node_group_id", "user_count", "node_count", "user_data", "created_at", }).AddRow(int64(1), historyID, int64(8), 1, 2, `[{"id":42,"email":"u@example.com"}]`, now)) mock.ExpectQuery("FROM `node_group`"). WithArgs(int64(8), 1). WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(int64(8), "VIP")) logic := newTestExportGroupResultLogic(db) data, filename, err := logic.ExportGroupResult(&types.ExportGroupResultRequest{HistoryId: &historyID}) if err != nil { t.Fatalf("ExportGroupResult error: %v", err) } if filename != "group_result_11.csv" { t.Fatalf("filename = %q, want group_result_11.csv", filename) } records, err := csv.NewReader(strings.NewReader(strings.TrimPrefix(string(data), "\ufeff"))).ReadAll() if err != nil { t.Fatalf("read csv: %v", err) } if len(records) != 2 || strings.Join(records[1], ",") != "42,8,VIP" { t.Fatalf("csv records = %#v, want user/group row", records) } assertGroupExpectations(t, mock) } func TestExportGroupResultRejectsInvalidHistoryUserDataJSON(t *testing.T) { db, mock, cleanup := newGroupTestDB(t) defer cleanup() historyID := int64(12) now := time.Unix(1710000000, 0) mock.ExpectQuery("FROM `group_history_detail`"). WithArgs(historyID). WillReturnRows(sqlmock.NewRows([]string{ "id", "history_id", "node_group_id", "user_count", "node_count", "user_data", "created_at", }).AddRow(int64(1), historyID, int64(8), 1, 2, `{bad-json`, now)) logic := newTestExportGroupResultLogic(db) _, _, err := logic.ExportGroupResult(&types.ExportGroupResultRequest{HistoryId: &historyID}) if err == nil || !strings.Contains(err.Error(), "parse group history user_data failed") { t.Fatalf("ExportGroupResult error = %v, want parse error", err) } assertGroupExpectations(t, mock) } func TestDeleteNodeGroupRejectsSubscribeReferences(t *testing.T) { tests := []struct { name string defaultSubscribeCount int64 subscribeGroupCount int64 userSubscribeCount int64 wantErr string }{ {name: "subscribe default group", defaultSubscribeCount: 1, wantErr: "default node group"}, {name: "subscribe group list", subscribeGroupCount: 1, wantErr: "node group list"}, {name: "user subscribe group", userSubscribeCount: 1, wantErr: "user subscribes"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { db, mock, cleanup := newGroupTestDB(t) defer cleanup() expectDeleteNodeGroupBaseChecks(mock, 5) mock.ExpectQuery("FROM `subscribe`"). WithArgs(int64(5)). WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(tt.defaultSubscribeCount)) if tt.defaultSubscribeCount == 0 { mock.ExpectQuery("FROM `subscribe`"). WithArgs("[5]"). WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(tt.subscribeGroupCount)) } if tt.defaultSubscribeCount == 0 && tt.subscribeGroupCount == 0 { mock.ExpectQuery("FROM `user_subscribe`"). WithArgs(int64(5)). WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(tt.userSubscribeCount)) } logic := newTestDeleteNodeGroupLogic(db) err := logic.DeleteNodeGroup(&types.DeleteNodeGroupRequest{Id: 5}) if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("DeleteNodeGroup error = %v, want contains %q", err, tt.wantErr) } assertGroupExpectations(t, mock) }) } } func TestResetGroupsRollsBackOnFailure(t *testing.T) { db, mock, cleanup := newGroupTestDB(t) defer cleanup() mock.ExpectBegin() mock.ExpectExec("DELETE FROM `node_group`").WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec("UPDATE `subscribe`"). WithArgs(int64(0), "[]"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec("UPDATE `nodes`"). WithArgs("[]"). WillReturnError(fmt.Errorf("node update failed")) mock.ExpectRollback() logic := newTestResetGroupsLogic(db) err := logic.ResetGroups() if err == nil || !strings.Contains(err.Error(), "node update failed") { t.Fatalf("ResetGroups error = %v, want node update failure", err) } assertGroupExpectations(t, mock) } func TestPreviewUserNodesIncludesPublicNodesInGroupMode(t *testing.T) { db, mock, cleanup := newGroupTestDB(t) defer cleanup() now := time.Unix(1710000000, 0) mock.ExpectQuery("FROM `user_subscribe`"). WithArgs(int64(100), int8(0), int8(1)). WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subscribe_id", "node_group_id"}). AddRow(int64(1), int64(100), int64(10), int64(5))) mock.ExpectQuery("FROM `subscribe`"). WithArgs(int64(10)). WillReturnRows(sqlmock.NewRows([]string{"id", "node_group_id", "node_group_ids", "nodes", "node_tags"}). AddRow(int64(10), int64(0), "[]", "", "")) mock.ExpectQuery("FROM `system`"). WithArgs("group", "enabled"). WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("true")) mock.ExpectQuery("FROM `nodes`"). WithArgs(true). WillReturnRows(sqlmock.NewRows([]string{ "id", "name", "tags", "port", "address", "server_id", "protocol", "enabled", "sort", "node_group_ids", "created_at", "updated_at", }). AddRow(int64(1), "group-node", "", uint16(443), "g.example.com", int64(1), "vless", true, 1, "[5]", now, now). AddRow(int64(2), "public-node", "", uint16(443), "p.example.com", int64(1), "vless", true, 2, "[]", now, now)) mock.ExpectQuery("FROM `node_group`"). WithArgs(int64(5)). WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(int64(5), "Group A")) logic := newTestPreviewUserNodesLogic(db) resp, err := logic.PreviewUserNodes(&types.PreviewUserNodesRequest{UserId: 100}) if err != nil { t.Fatalf("PreviewUserNodes error: %v", err) } if !hasNodeGroup(resp.NodeGroups, 0, "public-node") { t.Fatalf("PreviewUserNodes node groups = %#v, want public node group", resp.NodeGroups) } assertGroupExpectations(t, mock) } func TestTrafficRangeMatchingUsesClosedBounds(t *testing.T) { min0, max10 := int64(0), int64(10) min10, max20 := int64(10), int64(20) nodeGroups := []modelgroup.NodeGroup{ {Id: 1, MinTrafficGB: &min0, MaxTrafficGB: &max10}, {Id: 2, MinTrafficGB: &min10, MaxTrafficGB: &max20}, } tests := map[float64]int64{ 0: 1, 10: 1, 15: 2, 21: 0, } for used, want := range tests { if got := matchTrafficNodeGroup(used, nodeGroups); got != want { t.Fatalf("matchTrafficNodeGroup(%v) = %d, want %d", used, got, want) } } } func TestNormalizePaginationDefaultsAndMax(t *testing.T) { tests := []struct { page, size int wantPage int wantSize int }{ {page: 0, size: 0, wantPage: 1, wantSize: defaultPageSize}, {page: -1, size: -5, wantPage: 1, wantSize: defaultPageSize}, {page: 2, size: maxPageSize + 1, wantPage: 2, wantSize: maxPageSize}, } for _, tt := range tests { gotPage, gotSize := normalizePagination(tt.page, tt.size) if gotPage != tt.wantPage || gotSize != tt.wantSize { t.Fatalf("normalizePagination(%d, %d) = (%d, %d), want (%d, %d)", tt.page, tt.size, gotPage, gotSize, tt.wantPage, tt.wantSize) } } } func newGroupTestDB(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 newTestGroupHistoryDetailLogic(db *gorm.DB) *GetGroupHistoryDetailLogic { ctx := context.Background() return &GetGroupHistoryDetailLogic{ Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: &svc.ServiceContext{DB: db}, } } func newTestExportGroupResultLogic(db *gorm.DB) *ExportGroupResultLogic { ctx := context.Background() return &ExportGroupResultLogic{ Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: &svc.ServiceContext{DB: db}, } } func newTestDeleteNodeGroupLogic(db *gorm.DB) *DeleteNodeGroupLogic { ctx := context.Background() return &DeleteNodeGroupLogic{ Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: &svc.ServiceContext{DB: db}, } } func newTestResetGroupsLogic(db *gorm.DB) *ResetGroupsLogic { ctx := context.Background() return &ResetGroupsLogic{ Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: &svc.ServiceContext{DB: db}, } } func newTestPreviewUserNodesLogic(db *gorm.DB) *PreviewUserNodesLogic { ctx := context.Background() return &PreviewUserNodesLogic{ Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: &svc.ServiceContext{DB: db}, } } func expectDeleteNodeGroupBaseChecks(mock sqlmock.Sqlmock, nodeGroupId int64) { now := time.Unix(1710000000, 0) mock.ExpectQuery("FROM `node_group`"). WithArgs(nodeGroupId, 1). WillReturnRows(sqlmock.NewRows([]string{"id", "name", "created_at", "updated_at"}). AddRow(nodeGroupId, "Group", now, now)) mock.ExpectQuery("FROM `nodes`"). WithArgs(fmt.Sprintf("[%d]", nodeGroupId)). WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) } func hasNodeGroup(items []types.NodeGroupItem, id int64, nodeName string) bool { for _, item := range items { if item.Id != id { continue } for _, n := range item.Nodes { if n.Name == nodeName { return true } } } return false } func assertGroupExpectations(t *testing.T, mock sqlmock.Sqlmock) { t.Helper() if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet sql expectations: %v", err) } }