0904
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
package vote
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/dbutil"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
)
|
||||
|
||||
// mockVoteSubmissions 仅实现 aggregateFormFieldStats 依赖的 ListByVoteID。
|
||||
type mockVoteSubmissions struct {
|
||||
submissions []*db.VoteSubmission
|
||||
}
|
||||
|
||||
func (m *mockVoteSubmissions) ListByVoteID(ctx stdctx.Context, voteID uint, cursor *dbutil.Cursor) ([]*db.VoteSubmission, error) {
|
||||
return m.submissions, nil
|
||||
}
|
||||
func (m *mockVoteSubmissions) Create(ctx stdctx.Context, opts db.CreateVoteSubmissionOptions) (*db.VoteSubmission, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockVoteSubmissions) GetByID(ctx stdctx.Context, id uint) (*db.VoteSubmission, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockVoteSubmissions) CountByVoteID(ctx stdctx.Context, voteID uint) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (m *mockVoteSubmissions) CountByVoteIDs(ctx stdctx.Context, voteIDs []uint) (map[uint]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockVoteSubmissions) CountByOptionID(ctx stdctx.Context, voteID, optionID uint) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (m *mockVoteSubmissions) CountGroupByOptionID(ctx stdctx.Context, voteID uint) (map[uint]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockVoteSubmissions) CountByIP(ctx stdctx.Context, voteID uint, ip string, since time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (m *mockVoteSubmissions) HasUserVoted(ctx stdctx.Context, voteID, userID uint) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (m *mockVoteSubmissions) HasTokenVoted(ctx stdctx.Context, voteID uint, token string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (m *mockVoteSubmissions) DeleteByVoteID(ctx stdctx.Context, voteID uint) error { return nil }
|
||||
|
||||
func formData(t *testing.T, v map[string]interface{}) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal form data: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestParseMultipleOptionIDsRejectsDuplicates(t *testing.T) {
|
||||
valid := map[uint]bool{1: true, 2: true, 3: true}
|
||||
chosen, err := parseMultipleOptionIDs([]string{"1", "2"}, 2, valid, i18n.Default)
|
||||
if err != nil {
|
||||
t.Fatalf("valid multiple options rejected: %v", err)
|
||||
}
|
||||
if len(chosen) != 2 || chosen[0] != 1 || chosen[1] != 2 {
|
||||
t.Fatalf("unexpected parsed options: %#v", chosen)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
ids []string
|
||||
want string
|
||||
}{
|
||||
{"duplicate", []string{"1", "1"}, "选项不能重复选择"},
|
||||
{"too-many", []string{"1", "2", "3"}, "最多只能选择 2 个选项"},
|
||||
{"invalid", []string{"1", "99"}, "存在无效的选项"},
|
||||
{"overflow", []string{"18446744073709551616"}, "存在无效的选项"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := parseMultipleOptionIDs(tc.ids, 2, valid, i18n.Default)
|
||||
if err == nil || err.Error() != tc.want {
|
||||
t.Fatalf("parseMultipleOptionIDs(%v) error = %v, want %q", tc.ids, err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoterTokenSignatureValidation(t *testing.T) {
|
||||
oldKey := conf.Server.XSRFKey
|
||||
conf.Server.XSRFKey = "test-voter-token-secret"
|
||||
defer func() { conf.Server.XSRFKey = oldKey }()
|
||||
|
||||
token := newVoterToken()
|
||||
if !validVoterToken(token) {
|
||||
t.Fatalf("new voter token should validate: %q", token)
|
||||
}
|
||||
if len(token) != 108 {
|
||||
t.Fatalf("new voter token length = %d, want 108", len(token))
|
||||
}
|
||||
if validVoterToken(token + "x") {
|
||||
t.Fatal("tampered voter token should be rejected")
|
||||
}
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 2 || validVoterToken(parts[0]+"."+strings.Repeat("A", len(parts[1]))) {
|
||||
t.Fatal("token with forged signature should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateFormFieldStats(t *testing.T) {
|
||||
fields := []*db.VoteFormField{
|
||||
{Model: dbutil.Model{ID: 1}, VoteID: 9, FieldType: db.VoteFormFieldTypeRadio, Label: "满意度", Options: `["满意","一般"]`},
|
||||
{Model: dbutil.Model{ID: 2}, VoteID: 9, FieldType: db.VoteFormFieldTypeCheckbox, Label: "多选", Options: `["A","B"]`},
|
||||
{Model: dbutil.Model{ID: 3}, VoteID: 9, FieldType: db.VoteFormFieldTypeRating, Label: "评分"},
|
||||
{Model: dbutil.Model{ID: 4}, VoteID: 9, FieldType: db.VoteFormFieldTypeRank, Label: "排序", Options: `["X","Y","Z"]`},
|
||||
{Model: dbutil.Model{ID: 5}, VoteID: 9, FieldType: db.VoteFormFieldTypeNumber, Label: "数量"},
|
||||
{Model: dbutil.Model{ID: 6}, VoteID: 9, FieldType: db.VoteFormFieldTypeTextarea, Label: "建议"},
|
||||
}
|
||||
old := db.VoteSubmissions
|
||||
defer func() { db.VoteSubmissions = old }()
|
||||
|
||||
subs := []*db.VoteSubmission{
|
||||
{VoteID: 9, FormData: formData(t, map[string]interface{}{
|
||||
"满意度": "满意", "多选": []string{"A", "B"}, "评分": 9, "排序": map[string]float64{"X": 1, "Y": 2, "Z": 3}, "数量": 3, "建议": "很好",
|
||||
})},
|
||||
{VoteID: 9, FormData: formData(t, map[string]interface{}{
|
||||
"满意度": "满意", "多选": []string{"A"}, "评分": 7, "排序": map[string]float64{"X": 2, "Y": 1, "Z": 3}, "数量": 5, "建议": "",
|
||||
})},
|
||||
{VoteID: 9, FormData: formData(t, map[string]interface{}{
|
||||
"满意度": "一般", "评分": 8, "排序": map[string]float64{"X": 3, "Y": 2, "Z": 1}, "数量": 10,
|
||||
})},
|
||||
}
|
||||
db.VoteSubmissions = &mockVoteSubmissions{submissions: subs}
|
||||
|
||||
stats := aggregateFormFieldStatsWithSubs(fields, subs)
|
||||
if len(stats) != 6 {
|
||||
t.Fatalf("expected 6 field stats, got %d", len(stats))
|
||||
}
|
||||
|
||||
// radio:满意 2 / 一般 1
|
||||
radio := stats[0]
|
||||
if radio.Total != 3 || len(radio.OptionStats) != 2 {
|
||||
t.Fatalf("radio stats wrong: total=%d opts=%d", radio.Total, len(radio.OptionStats))
|
||||
}
|
||||
if radio.OptionStats[0].Count != 2 || radio.OptionStats[1].Count != 1 {
|
||||
t.Fatalf("radio counts wrong: %+v", radio.OptionStats)
|
||||
}
|
||||
|
||||
// checkbox:A 2 / B 1,回答数 2,总勾选数 3(A、B、A)
|
||||
cb := stats[1]
|
||||
if cb.Total != 2 || cb.OptionStats[0].Count != 2 || cb.OptionStats[1].Count != 1 {
|
||||
t.Fatalf("checkbox stats wrong: total=%d %+v", cb.Total, cb.OptionStats)
|
||||
}
|
||||
if cb.TotalSelections != 3 {
|
||||
t.Fatalf("checkbox TotalSelections wrong: got %d, want 3", cb.TotalSelections)
|
||||
}
|
||||
|
||||
// rating:均值 (9+7+8)/3=8.0,总 3
|
||||
rating := stats[2]
|
||||
if rating.Total != 3 || rating.AvgRating < 7.99 || rating.AvgRating > 8.01 {
|
||||
t.Fatalf("rating stats wrong: total=%d avg=%f", rating.Total, rating.AvgRating)
|
||||
}
|
||||
if len(rating.RatingCounts) != 10 {
|
||||
t.Fatalf("rating counts must have 10 buckets, got %d", len(rating.RatingCounts))
|
||||
}
|
||||
|
||||
// rank:X 平均 2,Y 平均 5/3≈1.67,Z 平均 7/3≈2.33,整体平均 2.0
|
||||
rank := stats[3]
|
||||
if rank.Total != 3 || rank.AvgRank < 1.99 || rank.AvgRank > 2.01 {
|
||||
t.Fatalf("rank avg wrong: total=%d avg=%f", rank.Total, rank.AvgRank)
|
||||
}
|
||||
if rank.OptionStats[0].AvgRank < 1.99 || rank.OptionStats[0].AvgRank > 2.01 {
|
||||
t.Fatalf("rank option X avg wrong: %f", rank.OptionStats[0].AvgRank)
|
||||
}
|
||||
if rank.OptionStats[1].AvgRank < 1.66 || rank.OptionStats[1].AvgRank > 1.68 {
|
||||
t.Fatalf("rank option Y avg wrong: %f", rank.OptionStats[1].AvgRank)
|
||||
}
|
||||
|
||||
// number:均值 (3+5+10)/3=6
|
||||
num := stats[4]
|
||||
if num.Total != 3 || num.AvgNumber < 5.99 || num.AvgNumber > 6.01 {
|
||||
t.Fatalf("number stats wrong: total=%d avg=%f", num.Total, num.AvgNumber)
|
||||
}
|
||||
|
||||
// textarea:填写 1;文本字段的"回答数"(Total) 应与"已填写数"(TextCount) 一致
|
||||
text := stats[5]
|
||||
if text.TextCount != 1 {
|
||||
t.Fatalf("textarea count wrong: %d", text.TextCount)
|
||||
}
|
||||
if text.Total != text.TextCount {
|
||||
t.Fatalf("textarea total should equal filled count, got total=%d filled=%d", text.Total, text.TextCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateFormFieldStatsEmpty(t *testing.T) {
|
||||
fields := []*db.VoteFormField{
|
||||
{Model: dbutil.Model{ID: 1}, VoteID: 9, FieldType: db.VoteFormFieldTypeRadio, Label: "满意度", Options: `["满意","一般"]`},
|
||||
}
|
||||
old := db.VoteSubmissions
|
||||
defer func() { db.VoteSubmissions = old }()
|
||||
db.VoteSubmissions = &mockVoteSubmissions{submissions: []*db.VoteSubmission{}}
|
||||
|
||||
stats := aggregateFormFieldStatsWithSubs(fields, nil)
|
||||
if len(stats) != 1 || stats[0].Total != 0 || stats[0].OptionStats[0].Count != 0 {
|
||||
t.Fatalf("empty stats wrong: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAggregateFormFieldStatsCustom 验证"允许补充自定义选项"字段:非预置值归入 CustomStats(按文本去重计数)。
|
||||
func TestAggregateFormFieldStatsCustom(t *testing.T) {
|
||||
fields := []*db.VoteFormField{
|
||||
{Model: dbutil.Model{ID: 1}, VoteID: 9, FieldType: db.VoteFormFieldTypeRadio, Label: "满意度", Options: `["满意","一般"]`, AllowCustom: true},
|
||||
{Model: dbutil.Model{ID: 2}, VoteID: 9, FieldType: db.VoteFormFieldTypeCheckbox, Label: "多选", Options: `["A","B"]`, AllowCustom: true},
|
||||
// 未开启 AllowCustom 的字段:非预置值仍按普通值计数(不归入 CustomStats)
|
||||
{Model: dbutil.Model{ID: 3}, VoteID: 9, FieldType: db.VoteFormFieldTypeRadio, Label: "无自定义", Options: `["X"]`},
|
||||
}
|
||||
subs := []*db.VoteSubmission{
|
||||
{VoteID: 9, FormData: formData(t, map[string]interface{}{
|
||||
"满意度": "满意", "多选": []string{"A", "自定义甲"}, "无自定义": "X",
|
||||
})},
|
||||
{VoteID: 9, FormData: formData(t, map[string]interface{}{
|
||||
"满意度": "自定义乙", "多选": []string{"自定义甲", "B"},
|
||||
})},
|
||||
{VoteID: 9, FormData: formData(t, map[string]interface{}{
|
||||
"满意度": "自定义乙", "多选": []string{"A"},
|
||||
})},
|
||||
}
|
||||
stats := aggregateFormFieldStatsWithSubs(fields, subs)
|
||||
|
||||
// radio(AllowCustom):满意 1 / 一般 0 / 自定义乙 2 / 自定义甲 0 → CustomStats 仅"自定义乙"计 2
|
||||
radio := stats[0]
|
||||
if radio.Total != 3 {
|
||||
t.Fatalf("radio total wrong: %d", radio.Total)
|
||||
}
|
||||
if radio.OptionStats[0].Count != 1 || radio.OptionStats[1].Count != 0 {
|
||||
t.Fatalf("radio preset counts wrong: %+v", radio.OptionStats)
|
||||
}
|
||||
if len(radio.CustomStats) != 1 || radio.CustomStats[0].Text != "自定义乙" || radio.CustomStats[0].Count != 2 {
|
||||
t.Fatalf("radio custom stats wrong: %+v", radio.CustomStats)
|
||||
}
|
||||
|
||||
// checkbox(AllowCustom):A 2 / B 1 / 自定义甲 2(两条提交各选一次)→ CustomStats 计 2
|
||||
cb := stats[1]
|
||||
if cb.Total != 3 || cb.TotalSelections != 5 {
|
||||
t.Fatalf("checkbox total/selections wrong: total=%d sel=%d", cb.Total, cb.TotalSelections)
|
||||
}
|
||||
if cb.OptionStats[0].Count != 2 || cb.OptionStats[1].Count != 1 {
|
||||
t.Fatalf("checkbox preset counts wrong: %+v", cb.OptionStats)
|
||||
}
|
||||
if len(cb.CustomStats) != 1 || cb.CustomStats[0].Text != "自定义甲" || cb.CustomStats[0].Count != 2 {
|
||||
t.Fatalf("checkbox custom stats wrong: %+v", cb.CustomStats)
|
||||
}
|
||||
|
||||
// 未开启 AllowCustom 的字段:非预置值不应出现(提交校验已拦截),即使出现也只按普通值计数
|
||||
noCustom := stats[2]
|
||||
if noCustom.Total != 1 || len(noCustom.CustomStats) != 0 {
|
||||
t.Fatalf("no-custom field should not produce CustomStats: %+v", noCustom)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user