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) ListByVoteIDs(ctx stdctx.Context, voteIDs []uint) (map[uint][]*db.VoteSubmission, error) { out := make(map[uint][]*db.VoteSubmission) for _, s := range m.submissions { out[s.VoteID] = append(out[s.VoteID], s) } return out, 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) } }