// 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" "testing" "time" "github.com/stretchr/testify/require" "gorm.io/datatypes" "github.com/tamakyi/TamaBox/internal/dbutil" ) func TestVoteSubmissions(t *testing.T) { t.Parallel() db, cleanup := newTestDB(t) ctx := context.Background() store := NewVoteSubmissionsStore(db) for _, tc := range []struct { name string test func(t *testing.T, ctx context.Context, db *voteSubmissions) }{ {"Create", testVoteSubmissionsCreate}, {"UniqueUser", testVoteSubmissionsUniqueUser}, {"UniqueToken", testVoteSubmissionsUniqueToken}, {"UserAndAnonCoexist", testVoteSubmissionsUserAndAnonCoexist}, {"CrossVote", testVoteSubmissionsCrossVote}, {"Count", testVoteSubmissionsCount}, {"HasUserVoted", testVoteSubmissionsHasUserVoted}, {"HasTokenVoted", testVoteSubmissionsHasTokenVoted}, {"CountByIP", testVoteSubmissionsCountByIP}, {"ListByVoteID", testVoteSubmissionsListByVoteID}, {"DeleteByVoteID", testVoteSubmissionsDeleteByVoteID}, } { t.Run(tc.name, func(t *testing.T) { t.Cleanup(func() { if err := cleanup("vote_submissions"); err != nil { t.Fatal(err) } }) tc.test(t, ctx, store.(*voteSubmissions)) }) } } func userIDPtr(id uint) *uint { return &id } func tokenPtr(s string) *string { return &s } func testVoteSubmissionsCreate(t *testing.T, ctx context.Context, db *voteSubmissions) { got, err := db.Create(ctx, CreateVoteSubmissionOptions{ VoteID: 1, OptionID: 2, UserID: userIDPtr(10), VoterIP: "114.5.1.4", }) require.Nil(t, err) require.Equal(t, uint(1), got.ID) require.Equal(t, uint(10), *got.UserID) } // 核心防刷:同一登录用户对同一投票重复提交必须被数据库唯一索引拦截。 func testVoteSubmissionsUniqueUser(t *testing.T, ctx context.Context, db *voteSubmissions) { _, err := db.Create(ctx, CreateVoteSubmissionOptions{ VoteID: 1, OptionID: 1, UserID: userIDPtr(10), VoterIP: "114.5.1.4", }) require.Nil(t, err) t.Run("duplicate rejected", func(t *testing.T) { _, err := db.Create(ctx, CreateVoteSubmissionOptions{ VoteID: 1, OptionID: 2, UserID: userIDPtr(10), VoterIP: "114.5.1.4", }) require.Equal(t, ErrVoteAlreadyVoted, err) }) t.Run("different user allowed", func(t *testing.T) { _, err := db.Create(ctx, CreateVoteSubmissionOptions{ VoteID: 1, OptionID: 1, UserID: userIDPtr(11), VoterIP: "114.5.1.5", }) require.Nil(t, err) }) } // 核心防刷:同一匿名指纹对同一投票重复提交必须被拦截。 func testVoteSubmissionsUniqueToken(t *testing.T, ctx context.Context, db *voteSubmissions) { _, err := db.Create(ctx, CreateVoteSubmissionOptions{ VoteID: 1, OptionID: 1, VoterToken: tokenPtr("fp-token-001"), VoterIP: "114.5.1.4", }) require.Nil(t, err) t.Run("duplicate rejected", func(t *testing.T) { _, err := db.Create(ctx, CreateVoteSubmissionOptions{ VoteID: 1, OptionID: 2, VoterToken: tokenPtr("fp-token-001"), VoterIP: "114.5.1.4", }) require.Equal(t, ErrVoteAlreadyVoted, err) }) t.Run("different token allowed", func(t *testing.T) { _, err := db.Create(ctx, CreateVoteSubmissionOptions{ VoteID: 1, OptionID: 1, VoterToken: tokenPtr("fp-token-002"), VoterIP: "114.5.1.4", }) require.Nil(t, err) }) } // 登录用户与匿名指纹可在同一投票下并存(user_id 为 NULL 与 voter_token 为 NULL 不冲突)。 func testVoteSubmissionsUserAndAnonCoexist(t *testing.T, ctx context.Context, db *voteSubmissions) { _, err := db.Create(ctx, CreateVoteSubmissionOptions{ VoteID: 1, OptionID: 1, UserID: userIDPtr(10), VoterIP: "114.5.1.4", }) require.Nil(t, err) _, err = db.Create(ctx, CreateVoteSubmissionOptions{ VoteID: 1, OptionID: 2, VoterToken: tokenPtr("fp-anon-001"), VoterIP: "114.5.1.6", }) require.Nil(t, err) // 同一 user 换匿名 token 再来:user_id 相同仍应被拦截 _, err = db.Create(ctx, CreateVoteSubmissionOptions{ VoteID: 1, OptionID: 1, UserID: userIDPtr(10), VoterToken: tokenPtr("fp-anon-002"), VoterIP: "114.5.1.7", }) require.Equal(t, ErrVoteAlreadyVoted, err) count, err := db.CountByVoteID(ctx, 1) require.Nil(t, err) require.Equal(t, int64(2), count) } // 同一用户 / 同一 token 对不同投票不受约束。 func testVoteSubmissionsCrossVote(t *testing.T, ctx context.Context, db *voteSubmissions) { for voteID := uint(1); voteID <= 2; voteID++ { _, err := db.Create(ctx, CreateVoteSubmissionOptions{ VoteID: voteID, OptionID: 1, UserID: userIDPtr(10), VoterIP: "114.5.1.4", }) require.Nil(t, err) } // 同一 token 投两个不同投票也允许 _, err := db.Create(ctx, CreateVoteSubmissionOptions{ VoteID: 1, OptionID: 1, VoterToken: tokenPtr("fp-x"), VoterIP: "114.5.1.8", }) require.Nil(t, err) _, err = db.Create(ctx, CreateVoteSubmissionOptions{ VoteID: 2, OptionID: 1, VoterToken: tokenPtr("fp-x"), VoterIP: "114.5.1.8", }) require.Nil(t, err) count, err := db.CountByVoteID(ctx, 1) require.Nil(t, err) require.Equal(t, int64(2), count) } func testVoteSubmissionsCount(t *testing.T, ctx context.Context, db *voteSubmissions) { // 投票 1:选项 1 两票,选项 2 一票 _, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(1), VoterIP: "a"}) require.Nil(t, err) _, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(2), VoterIP: "b"}) require.Nil(t, err) _, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 2, UserID: userIDPtr(3), VoterIP: "c"}) require.Nil(t, err) // 投票 2:一票 _, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 2, OptionID: 1, UserID: userIDPtr(4), VoterIP: "d"}) require.Nil(t, err) t.Run("total", func(t *testing.T) { got, err := db.CountByVoteID(ctx, 1) require.Nil(t, err) require.Equal(t, int64(3), got) }) t.Run("by option", func(t *testing.T) { got, err := db.CountByOptionID(ctx, 1, 1) require.Nil(t, err) require.Equal(t, int64(2), got) got, err = db.CountByOptionID(ctx, 1, 2) require.Nil(t, err) require.Equal(t, int64(1), got) }) t.Run("empty", func(t *testing.T) { got, err := db.CountByVoteID(ctx, 404) require.Nil(t, err) require.Equal(t, int64(0), got) }) } func testVoteSubmissionsHasUserVoted(t *testing.T, ctx context.Context, db *voteSubmissions) { _, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(10), VoterIP: "a"}) require.Nil(t, err) got, err := db.HasUserVoted(ctx, 1, 10) require.Nil(t, err) require.True(t, got) got, err = db.HasUserVoted(ctx, 1, 11) require.Nil(t, err) require.False(t, got) } func testVoteSubmissionsHasTokenVoted(t *testing.T, ctx context.Context, db *voteSubmissions) { _, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, VoterToken: tokenPtr("fp-1"), VoterIP: "a"}) require.Nil(t, err) got, err := db.HasTokenVoted(ctx, 1, "fp-1") require.Nil(t, err) require.True(t, got) got, err = db.HasTokenVoted(ctx, 1, "fp-2") require.Nil(t, err) require.False(t, got) } func testVoteSubmissionsCountByIP(t *testing.T, ctx context.Context, db *voteSubmissions) { since := time.Now().Add(-1 * time.Hour) _, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(1), VoterIP: "1.2.3.4"}) require.Nil(t, err) _, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(2), VoterIP: "1.2.3.4"}) require.Nil(t, err) _, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(3), VoterIP: "5.6.7.8"}) require.Nil(t, err) got, err := db.CountByIP(ctx, 1, "1.2.3.4", since) require.Nil(t, err) require.Equal(t, int64(2), got) got, err = db.CountByIP(ctx, 1, "5.6.7.8", since) require.Nil(t, err) require.Equal(t, int64(1), got) } func testVoteSubmissionsListByVoteID(t *testing.T, ctx context.Context, db *voteSubmissions) { for i := 0; i < 3; i++ { _, err := db.Create(ctx, CreateVoteSubmissionOptions{ VoteID: 1, OptionID: 1, UserID: userIDPtr(uint(100 + i)), VoterIP: "x", FormData: datatypes.JSON(`{"nickname":"user` + string(rune('0'+i)) + `"}`), }) require.Nil(t, err) } got, err := db.ListByVoteID(ctx, 1, &dbutil.Cursor{Value: 2, PageSize: 10}) require.Nil(t, err) require.Len(t, got, 1) require.Equal(t, uint(1), got[0].ID, "游标取 id < 2 的记录") } func testVoteSubmissionsDeleteByVoteID(t *testing.T, ctx context.Context, db *voteSubmissions) { _, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(1), VoterIP: "a"}) require.Nil(t, err) _, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 2, OptionID: 1, UserID: userIDPtr(2), VoterIP: "b"}) require.Nil(t, err) require.Nil(t, db.DeleteByVoteID(ctx, 1)) got, err := db.CountByVoteID(ctx, 1) require.Nil(t, err) require.Equal(t, int64(0), got) got, err = db.CountByVoteID(ctx, 2) require.Nil(t, err) require.Equal(t, int64(1), got) }