package dashboard import ( "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/DATA-DOG/go-sqlmock" "github.com/gin-gonic/gin" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/config" "hyapp-admin-server/internal/middleware" ) func TestUserProfileOverviewAggregatesNaturalUsersAndDailyStatistics(t *testing.T) { userDB, sqlMock, err := sqlmock.New() if err != nil { t.Fatalf("create user sqlmock: %v", err) } defer userDB.Close() sqlMock.ExpectQuery(`(?s)SELECT CASE.*COALESCE\(gender, ''\).*FROM users.*WHERE app_code = \?.*profile_completed = 1.*game_robot.*quick_account.*GROUP BY gender_key.*ORDER BY user_count DESC`). WithArgs("huwaa"). WillReturnRows(sqlmock.NewRows([]string{"gender_key", "user_count"}). AddRow("male", int64(4)). AddRow("female", int64(2)). AddRow("unknown", int64(1))) sqlMock.ExpectQuery(`(?s)WITH natural_users AS.*FROM users.*app_code = \?.*profile_completed = 1.*game_robot.*quick_account.*ranked_login AS.*ROW_NUMBER\(\) OVER.*PARTITION BY audit.user_id.*ORDER BY audit.created_at_ms DESC, audit.id DESC.*audit.app_code = \?.*audit.result = 'success'.*audit.blocked = 0.*audit.login_type IN \('password', 'third_party', 'refresh'\).*LEFT JOIN ranked_login.*latest.login_rank = 1.*GROUP BY version_key`). WithArgs("huwaa", "huwaa"). WillReturnRows(sqlmock.NewRows([]string{"version_key", "user_count"}). AddRow("2.4.0", int64(4)). AddRow("unknown", int64(3))) requested := map[string]bool{} statisticsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requested[r.URL.Path] = true query := r.URL.Query() if query.Get("app_code") != "huwaa" || query.Get("stat_tz") != "Asia/Shanghai" || query.Get("start_ms") != "1783699200000" || query.Get("end_ms") != "1783785600000" { http.Error(w, "unexpected statistics query: "+r.URL.RawQuery, http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/internal/v1/statistics/overview": _ = json.NewEncoder(w).Encode(map[string]any{"active_users": 5, "new_users": 2}) default: http.NotFound(w, r) } })) defer statisticsServer.Close() service := NewService(nil, config.Config{StatisticsService: config.StatisticsServiceConfig{ BaseURL: statisticsServer.URL, RequestTimeout: time.Second, }}, nil, WithUserDB(userDB)) router := newDashboardUserProfileTestRouter(NewWithService(service), []string{"overview:view"}) request := httptest.NewRequest(http.MethodGet, "/api/v1/dashboard/user-profile-overview?app_code=Huwaa&stat_tz=Asia%2FShanghai&start_ms=1783699200000&end_ms=1783785600000", nil) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got=%d body=%s", recorder.Code, recorder.Body.String()) } var envelope struct { Code int `json:"code"` Data UserProfileOverview `json:"data"` } if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { t.Fatalf("decode response: %v", err) } if envelope.Code != 0 || envelope.Data.AppCode != "huwaa" || envelope.Data.TotalUsers != 7 || envelope.Data.NewUsers != 2 || envelope.Data.ActiveUsers != 5 || envelope.Data.UpdatedAtMS <= 0 { t.Fatalf("overview mismatch: %+v", envelope.Data) } if len(envelope.Data.GenderDistribution) != 3 || envelope.Data.GenderDistribution[0] != (UserProfileDistributionItem{Key: "male", Label: "男", Count: 4}) || envelope.Data.GenderDistribution[2] != (UserProfileDistributionItem{Key: "unknown", Label: "未知", Count: 1}) { t.Fatalf("gender distribution mismatch: %+v", envelope.Data.GenderDistribution) } if len(envelope.Data.AppVersionDistribution) != 2 || envelope.Data.AppVersionDistribution[0] != (UserProfileDistributionItem{Key: "2.4.0", Label: "2.4.0", Count: 4}) || envelope.Data.AppVersionDistribution[1] != (UserProfileDistributionItem{Key: "unknown", Label: "未知版本", Count: 3}) { t.Fatalf("version distribution mismatch: %+v", envelope.Data.AppVersionDistribution) } // 版本分布以全部自然用户为分母并直接查询 login_audit;statistics-service 只保留新增/DAU 口径。 if len(requested) != 1 || !requested["/internal/v1/statistics/overview"] { t.Fatalf("statistics calls mismatch: %+v", requested) } if err := sqlMock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations mismatch: %v", err) } } func TestGenderDistributionLabelMapsFlutterValues(t *testing.T) { tests := map[string]string{ "male": "男", "female": "女", "non_binary": "非二元", "unknown": "未知", } for input, expected := range tests { if actual := genderDistributionLabel(input); actual != expected { t.Fatalf("gender label mismatch: input=%q got=%q want=%q", input, actual, expected) } } } func TestUserProfileOverviewRouteRequiresOverviewPermission(t *testing.T) { router := newDashboardUserProfileTestRouter(NewWithService(&DashboardService{}), nil) request := httptest.NewRequest(http.MethodGet, "/api/v1/dashboard/user-profile-overview", nil) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusForbidden { t.Fatalf("status mismatch: got=%d body=%s", recorder.Code, recorder.Body.String()) } } func newDashboardUserProfileTestRouter(handler *Handler, permissions []string) *gin.Engine { gin.SetMode(gin.TestMode) router := gin.New() router.Use(func(c *gin.Context) { // 复现 AuthRequired/AppCode 已完成的上下文写入,让路由测试同时覆盖 overview:view 中间件和 query 优先级。 c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "lalu")) c.Set(middleware.ContextPermissions, permissions) c.Next() }) protected := router.Group("/api/v1") RegisterRoutes(protected, handler) return router }