// Copyright 2022 E99p1ant. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package db import ( "context" "strings" "testing" "time" "github.com/stretchr/testify/require" ) // TestSiteAnalytics 验证 /stats 页的聚合口径。 // // 这里刻意连真库跑而不是塞 mock:整段都是数据库方言相关的 SQL // (to_char / EXTRACT / CHAR_LENGTH / SUM(CASE WHEN)), // 对着 mock 断言等于什么都没验证。 func TestSiteAnalytics(t *testing.T) { t.Parallel() db, cleanup := newTestDB(t) t.Cleanup(func() { if err := cleanup("users", "questions", "messages"); err != nil { t.Fatal(err) } }) ctx := context.Background() now := time.Now() store := NewSiteAnalyticsStore(db) questions := NewQuestionsStore(db) require.NoError(t, db.Create(&User{UID: "u1", Name: "box", Domain: "boxa", Email: "boxa@example.com"}).Error) // 一条登录用户提的、已回答的长问题 answered, err := questions.Create(ctx, CreateQuestionOptions{ UserID: 1, Content: strings.Repeat("字", 120), FromIP: "1.1.1.1", AskerUserID: 7, }) require.NoError(t, err) require.NoError(t, questions.AnswerByID(ctx, answered.ID, "答案")) // 一条匿名提的私密短问题,未读未答 _, err = questions.Create(ctx, CreateQuestionOptions{ UserID: 1, Content: "短问题", FromIP: "1.1.1.2", IsPrivate: true, }) require.NoError(t, err) // 一条被软删除的:不该出现在任何数字里 deleted, err := questions.Create(ctx, CreateQuestionOptions{ UserID: 1, Content: "会被删掉", FromIP: "1.1.1.3", }) require.NoError(t, err) require.NoError(t, questions.DeleteByID(ctx, deleted.ID)) got, err := store.SiteAnalytics(ctx, now) require.NoError(t, err) // 计数 require.EqualValues(t, 1, got.RegisteredUsers) require.EqualValues(t, 2, got.TotalQuestions, "软删除的提问不能计入总数") require.EqualValues(t, 1, got.Answered) require.EqualValues(t, 1, got.Private) require.EqualValues(t, 1, got.Read, "已回答的提问恒为已读") require.EqualValues(t, 1, got.Anonymous) require.EqualValues(t, 1, got.ActiveBoxes) require.EqualValues(t, 1, got.AnsweringBoxes) // 今日(提问是刚才写的,必然落在今天)—— 单独取,不随整页聚合缓存 dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) todayQ, todayA, err := store.TodayCounts(ctx, dayStart) require.NoError(t, err) require.EqualValues(t, 2, todayQ) require.EqualValues(t, 1, todayA) // 日/月趋势里能找到今天与本月 today := now.Format("2006-01-02") require.EqualValues(t, 2, countOf(got.Daily, today)) require.EqualValues(t, 2, countOf(got.Monthly, now.Format("2006-01"))) // 热力图:两条提问都发生在当前这一小时 dow := int(now.Weekday()) // Go 的 Weekday 与 EXTRACT(DOW) 都是周日 0 require.EqualValues(t, 2, got.Heatmap[dow][now.Hour()]) // 字数分桶:120 字落在 100–199(下标 3),"短问题" 3 字落在 <20(下标 0) require.EqualValues(t, 1, got.LengthBins[0]) require.EqualValues(t, 1, got.LengthBins[3]) // 回答耗时:刚回答完,落在 1 小时内(下标 0);未回答的不计入 require.EqualValues(t, 1, got.ReplyBins[0]) var replyTotal int64 for _, n := range got.ReplyBins { replyTotal += n } require.EqualValues(t, 1, replyTotal, "耗时分布只统计已回答的提问") // 最后时间戳 require.NotNil(t, got.LastQuestionAt) require.NotNil(t, got.LastAnswerAt) } // TestTodayAnswersIgnoreNonAnswerWrites 「今日回答」只数今天首次被回答的提问。 // // 原来按 updated_at 算,而置顶、公开/私密切换都会刷新 updated_at: // 箱主今晚整理一批三个月前的老问题(一次回答都没有),页面上「今日回答」 // 就凭空多出几十条。改成 answered_at 后,只有真的发生了「首次回答」才算。 func TestTodayAnswersIgnoreNonAnswerWrites(t *testing.T) { conn, clean := newTestDB(t) defer clean("questions") store := NewSiteAnalyticsStore(conn) questions := NewQuestionsStore(conn) ctx := context.Background() now := time.Now() dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) old := now.Add(-72 * time.Hour) // 三天前提出并回答的历史问题 legacy, err := questions.Create(ctx, CreateQuestionOptions{UserID: 1, Content: "旧问题", FromIP: "1.1.1.1"}) require.NoError(t, err) require.NoError(t, questions.AnswerByID(ctx, legacy.ID, "旧答案")) // 把三个时间都冻回三天前(AnswerByID 会把 answered_at / updated_at 刷成当下) require.NoError(t, conn.Model(&Question{}).Where("id = ?", legacy.ID).UpdateColumn("created_at", old).Error) require.NoError(t, conn.Model(&Question{}).Where("id = ?", legacy.ID).UpdateColumn("answered_at", old).Error) require.NoError(t, conn.Model(&Question{}).Where("id = ?", legacy.ID).UpdateColumn("updated_at", old).Error) // 今天真的答了一条 fresh, err := questions.Create(ctx, CreateQuestionOptions{UserID: 1, Content: "新问题", FromIP: "1.1.1.2"}) require.NoError(t, err) require.NoError(t, questions.AnswerByID(ctx, fresh.ID, "今天的答案")) _, answers, err := store.TodayCounts(ctx, dayStart) require.NoError(t, err) require.EqualValues(t, 1, answers) // 箱主今晚翻旧账:置顶、切私密、再切回公开 —— 一次回答都没发生 require.NoError(t, questions.SetPinned(ctx, legacy.ID, true)) require.NoError(t, questions.SetPrivate(ctx, legacy.ID)) require.NoError(t, questions.SetPublic(ctx, legacy.ID)) _, answers, err = store.TodayCounts(ctx, dayStart) require.NoError(t, err) require.EqualValues(t, 1, answers, "置顶 / 公开切换不该算成「今日回答」") } // TestSiteAnalyticsEmpty 空库不应报错,也不应把零值算成异常。 func TestSiteAnalyticsEmpty(t *testing.T) { t.Parallel() db, cleanup := newTestDB(t) t.Cleanup(func() { if err := cleanup("users", "questions", "messages"); err != nil { t.Fatal(err) } }) got, err := NewSiteAnalyticsStore(db).SiteAnalytics(context.Background(), time.Now()) require.NoError(t, err) require.Zero(t, got.TotalQuestions) require.Zero(t, got.RegisteredUsers) require.Nil(t, got.LastQuestionAt) require.Nil(t, got.LastAnswerAt) var heatTotal int64 for _, row := range got.Heatmap { for _, n := range row { heatTotal += n } } require.Zero(t, heatTotal) } func countOf(points []CountPoint, key string) int64 { for _, p := range points { if p.Key == key { return p.N } } return 0 }