// 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" "testing" "time" "github.com/stretchr/testify/require" ) // TestAnsweredAtOnlySetOnFirstAnswer 回答时间只记一次。 // // 背景:GORM 的 Update 会自动刷新 updated_at,而已读、置顶、公开/私密切换 // 都走 Update。若拿 updated_at 当回答时间,回答完再点一次「已读」, // 耗时就会从几分钟变成几十天 —— 线上那份「6 条超过 7 天」的数据就是这么来的。 func TestAnsweredAtOnlySetOnFirstAnswer(t *testing.T) { conn, clean := newTestDB(t) defer clean("questions") createdAt := time.Now().Add(-72 * time.Hour).Truncate(time.Second) q := Question{UserID: 1, Content: "hello", CreatedAt: createdAt, UpdatedAt: createdAt} require.NoError(t, conn.Create(&q).Error) store := NewQuestionsStore(conn) require.NoError(t, store.AnswerByID(context.Background(), q.ID, "first")) var got Question require.NoError(t, conn.First(&got, q.ID).Error) require.NotNil(t, got.AnsweredAt, "首次回答必须记下 answered_at") firstAt := *got.AnsweredAt require.WithinDuration(t, time.Now(), firstAt, time.Minute) // 改写回答:answered_at 不能被重置,否则「回答耗时」会跟着编辑动作变大。 require.NoError(t, store.AnswerByID(context.Background(), q.ID, "edited")) require.NoError(t, conn.First(&got, q.ID).Error) require.NotNil(t, got.AnsweredAt) require.Equal(t, firstAt.Unix(), got.AnsweredAt.Unix(), "改写回答不该重置 answered_at") } // TestAnsweredAtSetWhenAnsweringAsPrivate 强制私密那条 UPDATE 同样要记时间。 func TestAnsweredAtSetWhenAnsweringAsPrivate(t *testing.T) { conn, clean := newTestDB(t) defer clean("questions") q := Question{UserID: 1, Content: "secret"} require.NoError(t, conn.Create(&q).Error) require.NoError(t, NewQuestionsStore(conn).AnswerByIDPrivate(context.Background(), q.ID, "answer")) var got Question require.NoError(t, conn.First(&got, q.ID).Error) require.NotNil(t, got.AnsweredAt) require.True(t, got.IsPrivate) require.True(t, got.IsRead) } // TestReplyBinsUseAnsweredAt 回答耗时以 answered_at 为准,不受后来的「已读」影响。 // // 构造:72 小时前提问、10 分钟前回答(落第一档);但 updated_at 被改成当下 // (相当于回答完又被读了一次),差值是 72 小时 = 4320 分钟,正好落到第四档。 // 若仍在用 updated_at,这条会进第四档而不是第一档。 func TestReplyBinsUseAnsweredAt(t *testing.T) { conn, clean := newTestDB(t) defer clean("questions") now := time.Now() createdAt := now.Add(-72 * time.Hour) answeredAt := createdAt.Add(10 * time.Minute) // 提问后 10 分钟回答 → 第一档 q := Question{UserID: 1, Content: "hi", CreatedAt: createdAt, UpdatedAt: createdAt} require.NoError(t, conn.Create(&q).Error) // 直接写列,绕开 GORM 的 UpdatedAt 自动更新。 require.NoError(t, conn.Exec( "UPDATE questions SET answer = ?, is_read = ?, answered_at = ?, updated_at = ? WHERE id = ?", "answer", true, answeredAt, now, q.ID).Error) got, err := NewSiteAnalyticsStore(conn).SiteAnalytics(context.Background(), now) require.NoError(t, err) var total int64 for _, n := range got.ReplyBins { total += n } require.EqualValues(t, 1, total) require.EqualValues(t, 1, got.ReplyBins[0], "10 分钟的回答应落在第一档") require.EqualValues(t, 0, got.ReplyBins[4], "不该被 pushed 到当下的 updated_at 带偏") // 「最后一条回答」同理:取 answered_at 而不是被刷新过的 updated_at。 require.NotNil(t, got.LastAnswerAt) require.WithinDuration(t, answeredAt, *got.LastAnswerAt, time.Minute) } // TestReplyBinsFallBackToUpdatedAt 加列之前回答的历史行 answered_at 为空, // 退回 updated_at,不能让这些提问从分布里消失。 func TestReplyBinsFallBackToUpdatedAt(t *testing.T) { conn, clean := newTestDB(t) defer clean("questions") now := time.Now() // 10 天前提问、9 天前处理:差值 24 小时(1440 分钟),落在第三档。 createdAt := now.Add(-10 * 24 * time.Hour) updatedAt := now.Add(-9 * 24 * time.Hour) q := Question{UserID: 1, Content: "old", CreatedAt: createdAt, UpdatedAt: createdAt} require.NoError(t, conn.Create(&q).Error) require.NoError(t, conn.Exec( "UPDATE questions SET answer = ?, answered_at = NULL, updated_at = ? WHERE id = ?", "old answer", updatedAt, q.ID).Error) got, err := NewSiteAnalyticsStore(conn).SiteAnalytics(context.Background(), now) require.NoError(t, err) require.EqualValues(t, 1, got.Answered) require.EqualValues(t, 1, got.ReplyBins[3], "历史行应退回 updated_at 计入分布") } // TestQuestionStatsLastAnswerIgnoresLaterEdits 「最后一个回答」不能跟着置顶/公开切换跑。 // // QuestionStats 仍被公开接口 /api/v1/site-stats 使用。它原来取 MAX(updated_at), // 而 updated_at 会被 SetPinned / SetPrivate / SetPublic / SetRead 刷新, // 于是箱主整理一下旧问题(哪怕一次回答都没发生),接口就说「刚刚有人回答了」。 func TestQuestionStatsLastAnswerIgnoresLaterEdits(t *testing.T) { conn, clean := newTestDB(t) defer clean("questions") store := NewQuestionsStore(conn) ctx := context.Background() now := time.Now().Truncate(time.Second) answeredAt := now.Add(-9 * 24 * time.Hour) q := Question{UserID: 1, Content: "q", CreatedAt: answeredAt.Add(-time.Hour), UpdatedAt: answeredAt} require.NoError(t, conn.Create(&q).Error) require.NoError(t, store.AnswerByID(ctx, q.ID, "answer")) // AnswerByID 会把两个时间刷成当下,冻回真实回答时刻(模拟一条正常的历史数据)。 require.NoError(t, conn.Model(&Question{}).Where("id = ?", q.ID). UpdateColumn("updated_at", answeredAt).Error) require.NoError(t, conn.Model(&Question{}).Where("id = ?", q.ID). UpdateColumn("answered_at", answeredAt).Error) // 全是「没回答任何东西」的写操作。 require.NoError(t, store.SetPinned(ctx, q.ID, true)) require.NoError(t, store.SetPrivate(ctx, q.ID)) require.NoError(t, store.SetPublic(ctx, q.ID)) require.NoError(t, store.SetRead(ctx, q.ID)) qs, err := store.QuestionStats(ctx) require.NoError(t, err) require.NotNil(t, qs.LastAnswerAt) require.WithinDuration(t, answeredAt, *qs.LastAnswerAt, time.Minute, "最后一个回答被置顶/公开切换刷新过的 updated_at 带偏了") } // TestQuestionStatsLastAnswerFallsBackToUpdatedAt 加列之前的历史行 answered_at 为空, // 退回 updated_at —— 与改动前的行为一致,不能让这些行从统计里消失。 func TestQuestionStatsLastAnswerFallsBackToUpdatedAt(t *testing.T) { conn, clean := newTestDB(t) defer clean("questions") answeredAt := time.Now().Add(-5 * 24 * time.Hour).Truncate(time.Second) require.NoError(t, conn.Exec( "INSERT INTO questions (user_id, content, answer, is_private, is_read, is_pinned, created_at, updated_at) VALUES (1,'legacy','a',false,true,false,?,?)", answeredAt.Add(-time.Hour), answeredAt).Error) qs, err := NewQuestionsStore(conn).QuestionStats(context.Background()) require.NoError(t, err) require.NotNil(t, qs.LastAnswerAt) require.WithinDuration(t, answeredAt, *qs.LastAnswerAt, time.Second) } // TestBackfillAnsweredAt 历史行回填:拿 updated_at 冻结一次,之后不再随已读漂移。 func TestBackfillAnsweredAt(t *testing.T) { conn, clean := newTestDB(t) defer clean("questions") // 列由 AutoMigrate 建好,HasColumn 必须能认出来(启动时靠它判断「是不是刚加列」)。 require.True(t, conn.Migrator().HasColumn(&Question{}, "answered_at")) now := time.Now() createdAt := now.Add(-20 * 24 * time.Hour) answeredAtOld := now.Add(-19 * 24 * time.Hour) // 一条历史已回答(answered_at 为空),一条未回答(不该被回填)。 require.NoError(t, conn.Exec( "INSERT INTO questions (user_id, content, answer, is_private, is_read, is_pinned, created_at, updated_at) VALUES (1,'old','a',false,true,false,?,?)", createdAt, answeredAtOld).Error) require.NoError(t, conn.Exec( "INSERT INTO questions (user_id, content, answer, is_private, is_read, is_pinned, created_at, updated_at) VALUES (1,'open','',false,false,false,?,?)", createdAt, answeredAtOld).Error) require.NoError(t, backfillAnsweredAt(conn)) var answered Question require.NoError(t, conn.Where("content = ?", "old").First(&answered).Error) require.NotNil(t, answered.AnsweredAt) require.WithinDuration(t, answeredAtOld, *answered.AnsweredAt, time.Second) var open Question require.NoError(t, conn.Where("content = ?", "open").First(&open).Error) require.Nil(t, open.AnsweredAt, "未回答的提问不该被回填") }