// Copyright 2026 tamakyi. 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" "sync" "testing" "github.com/stretchr/testify/require" ) // TestSetPinnedIfCurrentOnlyAppliesOnce 覆盖「双击置顶」这个场景。 // // 原来的 handler 是 read-modify-write:SetPinned(id, !question.IsPinned)。 // 双击时两个请求都读到 is_pinned=false,都写 true,于是用户点了两次、期望 // 「置顶后取消」,结果仍是「置顶」。现在加 current 条件,第二次会因为状态 // 已变而影响 0 行 —— 语义变成「只切换一次」,符合用户本意(手抖点两下)。 func TestSetPinnedIfCurrentOnlyAppliesOnce(t *testing.T) { database, cleanup := newTestDB(t) ctx := context.Background() t.Cleanup(func() { if err := cleanup("questions"); err != nil { t.Fatal(err) } }) store := NewQuestionsStore(database) q, err := store.Create(ctx, CreateQuestionOptions{ FromIP: "114.5.1.4", UserID: 1, Content: "试试置顶", }) require.NoError(t, err) require.False(t, q.IsPinned) // 第一次切换:false -> true,生效 applied, err := store.SetPinnedIfCurrent(ctx, q.ID, false, true) require.NoError(t, err) require.True(t, applied, "第一次切换应生效") // 第二次切换:请求方仍然以为当前是 false(它读到的旧值),写入 true // 此时数据库已经是 true,条件不成立 -> 0 行 applied, err = store.SetPinnedIfCurrent(ctx, q.ID, false, true) require.NoError(t, err) require.False(t, applied, "状态已变,第二次切换不应再生效") got, err := store.GetByID(ctx, q.ID) require.NoError(t, err) require.True(t, got.IsPinned, "最终状态应仍是置顶,而不是被双击反转") // 正常取消置顶(条件匹配) applied, err = store.SetPinnedIfCurrent(ctx, q.ID, true, false) require.NoError(t, err) require.True(t, applied) got, err = store.GetByID(ctx, q.ID) require.NoError(t, err) require.False(t, got.IsPinned) } // TestFollowUpRejectsSecondWhilePreviousUnanswered 覆盖「追问必须等主人回复」。 // // 这条规则原来只在 handler 里判断(事务外)。双击提交时两个请求都能通过那次检查, // 随后被 CreateIfBelowLimit 的问题行锁串行化,各自 count 都 < 3 —— 两条内容相同的 // 追问都创建成功,吃掉 2/3 名额,箱主还会收到两封邮件。 // 现在判定挪进事务,第二个请求会拿到 ErrFollowUpAwaitingReply。 func TestFollowUpRejectsSecondWhilePreviousUnanswered(t *testing.T) { database, cleanup := newTestDB(t) ctx := context.Background() t.Cleanup(func() { if err := cleanup("follow_ups", "questions"); err != nil { t.Fatal(err) } }) qStore := NewQuestionsStore(database) q, err := qStore.Create(ctx, CreateQuestionOptions{ FromIP: "114.5.1.4", UserID: 1, Content: "允许我追问吗", }) require.NoError(t, err) fStore := NewFollowUpsStore(database) created, err := fStore.CreateIfBelowLimit(ctx, CreateFollowUpOptions{ QuestionID: q.ID, Content: "第一次追问", }) require.NoError(t, err) require.True(t, created) // 第一条还没被回复:第二次追问必须被拒 _, err = fStore.CreateIfBelowLimit(ctx, CreateFollowUpOptions{ QuestionID: q.ID, Content: "第一次追问", // 内容相同,模拟双击 }) require.ErrorIs(t, err, ErrFollowUpAwaitingReply) // 确认库里只有一条(没有因为双击写入两条) list, err := fStore.GetByQuestionID(ctx, q.ID) require.NoError(t, err) require.Len(t, list, 1, "双击不应产生两条追问") // 主人回复后,允许继续追问 require.NoError(t, fStore.AnswerByID(ctx, q.ID, list[0].ID, "答:可以")) created, err = fStore.CreateIfBelowLimit(ctx, CreateFollowUpOptions{ QuestionID: q.ID, Content: "第二次追问", }) require.NoError(t, err) require.True(t, created, "上一条已回复后应允许继续追问") } // TestMarkDeletedByOwnerConcurrentDecrementsQuotaOnce 覆盖并发删除会话时的配额回退。 // // 原来是 check-then-act:两次并发请求都读到 is_deleted_by_owner=false, // 都执行 UPDATE、都执行一次 IncrementQuota(-1)。计数不会变负,但会白吃掉 // 其他会话的额度,表现为定量收集提前关闭。 func TestMarkDeletedByOwnerConcurrentDecrementsQuotaOnce(t *testing.T) { database, cleanup := newTestDB(t) ctx := context.Background() t.Cleanup(func() { if err := cleanup("conversations", "feature_controls"); err != nil { t.Fatal(err) } }) const ownerID = 7 // 先把私聊配额计数做到 5 fcStore := NewFeatureControlsStore(database) require.NoError(t, fcStore.IncrementQuota(ctx, ownerID, FeatureChat, 5)) before, err := fcStore.Get(ctx, ownerID, FeatureChat) require.NoError(t, err) require.EqualValues(t, 5, before.QuotaCount) convStore := NewConversationStore(database) conv, err := convStore.Create(ctx, CreateConversationOptions{OwnerID: ownerID}) require.NoError(t, err) // 并发两次删除(模拟双击 / 前端重复提交) var wg sync.WaitGroup errs := make([]error, 2) for i := 0; i < 2; i++ { wg.Add(1) go func(idx int) { defer wg.Done() errs[idx] = convStore.MarkDeletedByOwner(ctx, conv.ID) }(i) } wg.Wait() require.NoError(t, errs[0]) require.NoError(t, errs[1]) after, err := fcStore.Get(ctx, ownerID, FeatureChat) require.NoError(t, err) require.EqualValues(t, 4, after.QuotaCount, "两个并发删除请求只应回退一次配额(5 -> 4),而不是两次(5 -> 3)") }