407 lines
14 KiB
Go
407 lines
14 KiB
Go
package rpc
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"github.com/iamxvbaba/td/tg"
|
||
"github.com/iamxvbaba/td/tgerr"
|
||
)
|
||
|
||
// sendTestPoll 用 sendMedia 发一个 poll,返回消息与 poll id。
|
||
func sendTestPoll(t *testing.T, r *Router, fromID int64, peer tg.InputPeerClass, randomID int64, mutate func(*tg.InputMediaPoll)) *tg.Message {
|
||
t.Helper()
|
||
media := &tg.InputMediaPoll{
|
||
Poll: tg.Poll{
|
||
Question: tg.TextWithEntities{Text: "favorite color?", Entities: []tg.MessageEntityClass{}},
|
||
Answers: []tg.PollAnswerClass{
|
||
&tg.PollAnswer{Text: tg.TextWithEntities{Text: "red", Entities: []tg.MessageEntityClass{}}, Option: []byte{0}},
|
||
&tg.PollAnswer{Text: tg.TextWithEntities{Text: "blue", Entities: []tg.MessageEntityClass{}}, Option: []byte{1}},
|
||
&tg.PollAnswer{Text: tg.TextWithEntities{Text: "green", Entities: []tg.MessageEntityClass{}}, Option: []byte{2}},
|
||
},
|
||
},
|
||
}
|
||
if mutate != nil {
|
||
mutate(media)
|
||
}
|
||
updates, err := r.onMessagesSendMedia(WithUserID(context.Background(), fromID), &tg.MessagesSendMediaRequest{
|
||
Peer: peer,
|
||
Media: media,
|
||
RandomID: randomID,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("sendMedia poll: %v", err)
|
||
}
|
||
return newMessageFromUpdates(t, updates)
|
||
}
|
||
|
||
func pollFromUpdates(t *testing.T, updates tg.UpdatesClass) (*tg.Poll, *tg.PollResults) {
|
||
t.Helper()
|
||
upd, ok := updates.(*tg.Updates)
|
||
if !ok {
|
||
t.Fatalf("expected *tg.Updates, got %T", updates)
|
||
}
|
||
for _, u := range upd.Updates {
|
||
if mp, ok := u.(*tg.UpdateMessagePoll); ok {
|
||
poll, _ := mp.GetPoll()
|
||
return &poll, &mp.Results
|
||
}
|
||
}
|
||
t.Fatalf("no updateMessagePoll in %#v", upd.Updates)
|
||
return nil, nil
|
||
}
|
||
|
||
func votersByOption(t *testing.T, results *tg.PollResults) map[byte]tg.PollAnswerVoters {
|
||
t.Helper()
|
||
out := map[byte]tg.PollAnswerVoters{}
|
||
list, _ := results.GetResults()
|
||
for _, item := range list {
|
||
if len(item.Option) != 1 {
|
||
t.Fatalf("unexpected option key %v", item.Option)
|
||
}
|
||
out[item.Option[0]] = item
|
||
}
|
||
return out
|
||
}
|
||
|
||
func TestSendMediaPollEcho(t *testing.T) {
|
||
ctx := context.Background()
|
||
_ = ctx
|
||
r, owner, friend := newMediaTestRouter(t)
|
||
|
||
msg := sendTestPoll(t, r, owner.ID, &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, 4001, nil)
|
||
media, ok := msg.Media.(*tg.MessageMediaPoll)
|
||
if !ok {
|
||
t.Fatalf("expected MessageMediaPoll, got %T", msg.Media)
|
||
}
|
||
if media.Poll.ID == 0 {
|
||
t.Error("poll id should be allocated server-side")
|
||
}
|
||
if media.Poll.Question.Text != "favorite color?" || len(media.Poll.Answers) != 3 {
|
||
t.Fatalf("poll definition mangled: %+v", media.Poll)
|
||
}
|
||
if media.Poll.Closed {
|
||
t.Error("fresh poll should be open")
|
||
}
|
||
}
|
||
|
||
func TestSendVotePrivateChooseChangeRetract(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, owner, friend := newMediaTestRouter(t)
|
||
peerForFriend := &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}
|
||
|
||
msg := sendTestPoll(t, r, owner.ID, &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, 4002, nil)
|
||
pollID := msg.Media.(*tg.MessageMediaPoll).Poll.ID
|
||
|
||
// friend 的 box id 与 owner 的相同(同一会话双方各自 box 计数都从 1 开始且只有这一条)。
|
||
friendCtx := WithUserID(ctx, friend.ID)
|
||
updates, err := r.onMessagesSendVote(friendCtx, &tg.MessagesSendVoteRequest{
|
||
Peer: peerForFriend,
|
||
MsgID: msg.ID,
|
||
Options: [][]byte{{1}},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("sendVote: %v", err)
|
||
}
|
||
poll, results := pollFromUpdates(t, updates)
|
||
if poll.ID != pollID {
|
||
t.Fatalf("update poll id = %d, want %d", poll.ID, pollID)
|
||
}
|
||
byOption := votersByOption(t, results)
|
||
if total, _ := results.GetTotalVoters(); total != 1 {
|
||
t.Fatalf("total voters = %d, want 1", total)
|
||
}
|
||
if v := byOption[1]; !v.Chosen || mustVoters(t, v) != 1 {
|
||
t.Fatalf("option 1 = %+v, want chosen with 1 voter", v)
|
||
}
|
||
if v := byOption[0]; v.Chosen || mustVoters(t, v) != 0 {
|
||
t.Fatalf("option 0 = %+v, want not chosen 0 voters", v)
|
||
}
|
||
|
||
// 改票:单选 poll 重投另一项。
|
||
updates, err = r.onMessagesSendVote(friendCtx, &tg.MessagesSendVoteRequest{
|
||
Peer: peerForFriend,
|
||
MsgID: msg.ID,
|
||
Options: [][]byte{{2}},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("sendVote change: %v", err)
|
||
}
|
||
_, results = pollFromUpdates(t, updates)
|
||
byOption = votersByOption(t, results)
|
||
if v := byOption[2]; !v.Chosen || mustVoters(t, v) != 1 {
|
||
t.Fatalf("after change option 2 = %+v, want chosen 1", v)
|
||
}
|
||
if v := byOption[1]; v.Chosen || mustVoters(t, v) != 0 {
|
||
t.Fatalf("after change option 1 = %+v, want cleared", v)
|
||
}
|
||
|
||
// 撤票:空 options。
|
||
updates, err = r.onMessagesSendVote(friendCtx, &tg.MessagesSendVoteRequest{
|
||
Peer: peerForFriend,
|
||
MsgID: msg.ID,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("sendVote retract: %v", err)
|
||
}
|
||
_, results = pollFromUpdates(t, updates)
|
||
if total, _ := results.GetTotalVoters(); total != 0 {
|
||
t.Fatalf("total after retract = %d, want 0", total)
|
||
}
|
||
|
||
// 非法选项。
|
||
if _, err := r.onMessagesSendVote(friendCtx, &tg.MessagesSendVoteRequest{
|
||
Peer: peerForFriend,
|
||
MsgID: msg.ID,
|
||
Options: [][]byte{{9}},
|
||
}); err == nil || !tgerr.Is(err, "OPTION_INVALID") {
|
||
t.Fatalf("invalid option err = %v, want OPTION_INVALID", err)
|
||
}
|
||
// 单选投两项。
|
||
if _, err := r.onMessagesSendVote(friendCtx, &tg.MessagesSendVoteRequest{
|
||
Peer: peerForFriend,
|
||
MsgID: msg.ID,
|
||
Options: [][]byte{{0}, {1}},
|
||
}); err == nil || !tgerr.Is(err, "OPTION_INVALID") {
|
||
t.Fatalf("multi options on single choice err = %v, want OPTION_INVALID", err)
|
||
}
|
||
}
|
||
|
||
func mustVoters(t *testing.T, v tg.PollAnswerVoters) int {
|
||
t.Helper()
|
||
count, _ := v.GetVoters()
|
||
return count
|
||
}
|
||
|
||
func TestSendVoteQuizRevealsAndLocks(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, owner, friend := newMediaTestRouter(t)
|
||
peerForFriend := &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}
|
||
|
||
msg := sendTestPoll(t, r, owner.ID, &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, 4003, func(in *tg.InputMediaPoll) {
|
||
in.Poll.Quiz = true
|
||
in.SetCorrectAnswers([]int{1})
|
||
in.SetSolution("blue is correct")
|
||
in.SetSolutionEntities([]tg.MessageEntityClass{})
|
||
})
|
||
friendCtx := WithUserID(ctx, friend.ID)
|
||
|
||
// 投票前(owner 自己查结果)不得泄漏 correct/solution。
|
||
ownerCtx := WithUserID(ctx, owner.ID)
|
||
preUpdates, err := r.onMessagesGetPollResults(ownerCtx, &tg.MessagesGetPollResultsRequest{
|
||
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||
MsgID: msg.ID,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("getPollResults pre-vote: %v", err)
|
||
}
|
||
_, preResults := pollFromUpdates(t, preUpdates)
|
||
if _, has := preResults.GetSolution(); has {
|
||
t.Fatal("solution must not leak before voting/closing")
|
||
}
|
||
for _, v := range votersByOption(t, preResults) {
|
||
if v.Correct {
|
||
t.Fatal("correct flag must not leak before voting/closing")
|
||
}
|
||
}
|
||
|
||
// friend 答错:correct 标在正确选项上,solution 揭示。
|
||
updates, err := r.onMessagesSendVote(friendCtx, &tg.MessagesSendVoteRequest{
|
||
Peer: peerForFriend,
|
||
MsgID: msg.ID,
|
||
Options: [][]byte{{0}},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("quiz vote: %v", err)
|
||
}
|
||
_, results := pollFromUpdates(t, updates)
|
||
byOption := votersByOption(t, results)
|
||
if !byOption[0].Chosen || byOption[0].Correct {
|
||
t.Fatalf("wrong answer entry = %+v, want chosen non-correct", byOption[0])
|
||
}
|
||
if !byOption[1].Correct {
|
||
t.Fatalf("correct answer entry = %+v, want correct flag", byOption[1])
|
||
}
|
||
if solution, has := results.GetSolution(); !has || solution != "blue is correct" {
|
||
t.Fatalf("solution = %q (%v), want revealed after voting", solution, has)
|
||
}
|
||
|
||
// quiz 不可改票/撤票。
|
||
if _, err := r.onMessagesSendVote(friendCtx, &tg.MessagesSendVoteRequest{
|
||
Peer: peerForFriend,
|
||
MsgID: msg.ID,
|
||
Options: [][]byte{{1}},
|
||
}); err == nil || !tgerr.Is(err, "REVOTE_NOT_ALLOWED") {
|
||
t.Fatalf("quiz revote err = %v, want REVOTE_NOT_ALLOWED", err)
|
||
}
|
||
if _, err := r.onMessagesSendVote(friendCtx, &tg.MessagesSendVoteRequest{
|
||
Peer: peerForFriend,
|
||
MsgID: msg.ID,
|
||
}); err == nil || !tgerr.Is(err, "REVOTE_NOT_ALLOWED") {
|
||
t.Fatalf("quiz retract err = %v, want REVOTE_NOT_ALLOWED", err)
|
||
}
|
||
}
|
||
|
||
func TestEditMessageClosePoll(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, owner, friend := newMediaTestRouter(t)
|
||
ownerPeer := &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}
|
||
friendPeer := &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}
|
||
|
||
msg := sendTestPoll(t, r, owner.ID, ownerPeer, 4004, nil)
|
||
pollID := msg.Media.(*tg.MessageMediaPoll).Poll.ID
|
||
ownerCtx := WithUserID(ctx, owner.ID)
|
||
friendCtx := WithUserID(ctx, friend.ID)
|
||
|
||
closeReq := func() *tg.MessagesEditMessageRequest {
|
||
req := &tg.MessagesEditMessageRequest{Peer: ownerPeer, ID: msg.ID}
|
||
closed := tg.Poll{ID: pollID, Closed: true}
|
||
req.SetMedia(&tg.InputMediaPoll{Poll: closed})
|
||
return req
|
||
}
|
||
|
||
// 非创建者不能关闭。
|
||
friendClose := &tg.MessagesEditMessageRequest{Peer: friendPeer, ID: msg.ID}
|
||
friendClose.SetMedia(&tg.InputMediaPoll{Poll: tg.Poll{ID: pollID, Closed: true}})
|
||
if _, err := r.onMessagesEditMessage(friendCtx, friendClose); err == nil || !tgerr.Is(err, "MESSAGE_AUTHOR_REQUIRED") {
|
||
t.Fatalf("non-creator close err = %v, want MESSAGE_AUTHOR_REQUIRED", err)
|
||
}
|
||
|
||
updates, err := r.onMessagesEditMessage(ownerCtx, closeReq())
|
||
if err != nil {
|
||
t.Fatalf("close poll: %v", err)
|
||
}
|
||
poll, _ := pollFromUpdates(t, updates)
|
||
if !poll.Closed {
|
||
t.Fatal("poll should be closed after edit")
|
||
}
|
||
|
||
// 关闭后投票必须被拒。
|
||
if _, err := r.onMessagesSendVote(friendCtx, &tg.MessagesSendVoteRequest{
|
||
Peer: friendPeer,
|
||
MsgID: msg.ID,
|
||
Options: [][]byte{{0}},
|
||
}); err == nil || !tgerr.Is(err, "MESSAGE_POLL_CLOSED") {
|
||
t.Fatalf("vote on closed poll err = %v, want MESSAGE_POLL_CLOSED", err)
|
||
}
|
||
|
||
// 对端视角读取也必须看到 closed(权威覆盖快照)。
|
||
res, err := r.onMessagesGetPollResults(friendCtx, &tg.MessagesGetPollResultsRequest{Peer: friendPeer, MsgID: msg.ID})
|
||
if err != nil {
|
||
t.Fatalf("getPollResults after close: %v", err)
|
||
}
|
||
peerPoll, _ := pollFromUpdates(t, res)
|
||
if !peerPoll.Closed {
|
||
t.Fatal("peer view should see closed poll (authority overrides snapshot)")
|
||
}
|
||
}
|
||
|
||
func TestGetPollVotesPublicOnly(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, owner, friend := newMediaTestRouter(t)
|
||
ownerPeer := &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}
|
||
friendPeer := &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}
|
||
ownerCtx := WithUserID(ctx, owner.ID)
|
||
friendCtx := WithUserID(ctx, friend.ID)
|
||
|
||
// 非公开 poll:getPollVotes 拒绝。
|
||
anonymous := sendTestPoll(t, r, owner.ID, ownerPeer, 4005, nil)
|
||
if _, err := r.onMessagesGetPollVotes(ownerCtx, &tg.MessagesGetPollVotesRequest{
|
||
Peer: ownerPeer, ID: anonymous.ID, Limit: 10,
|
||
}); err == nil || !tgerr.Is(err, "POLL_VOTE_REQUIRED") {
|
||
t.Fatalf("anonymous poll votes err = %v, want POLL_VOTE_REQUIRED", err)
|
||
}
|
||
|
||
public := sendTestPoll(t, r, owner.ID, ownerPeer, 4006, func(in *tg.InputMediaPoll) {
|
||
in.Poll.PublicVoters = true
|
||
})
|
||
if _, err := r.onMessagesSendVote(friendCtx, &tg.MessagesSendVoteRequest{
|
||
Peer: friendPeer, MsgID: public.ID, Options: [][]byte{{1}},
|
||
}); err != nil {
|
||
t.Fatalf("public vote: %v", err)
|
||
}
|
||
votes, err := r.onMessagesGetPollVotes(ownerCtx, &tg.MessagesGetPollVotesRequest{
|
||
Peer: ownerPeer, ID: public.ID, Limit: 10,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("getPollVotes: %v", err)
|
||
}
|
||
if votes.Count != 1 || len(votes.Votes) != 1 {
|
||
t.Fatalf("votes = %+v, want exactly friend's vote", votes)
|
||
}
|
||
single, ok := votes.Votes[0].(*tg.MessagePeerVote)
|
||
if !ok {
|
||
t.Fatalf("vote entry type = %T, want MessagePeerVote", votes.Votes[0])
|
||
}
|
||
if peerUser, ok := single.Peer.(*tg.PeerUser); !ok || peerUser.UserID != friend.ID {
|
||
t.Fatalf("vote peer = %#v, want friend", single.Peer)
|
||
}
|
||
if len(single.Option) != 1 || single.Option[0] != 1 {
|
||
t.Fatalf("vote option = %v, want [1]", single.Option)
|
||
}
|
||
foundFriend := false
|
||
for _, u := range votes.Users {
|
||
if got, ok := u.(*tg.User); ok && got.ID == friend.ID {
|
||
foundFriend = true
|
||
}
|
||
}
|
||
if !foundFriend {
|
||
t.Fatal("votes.users should contain the voter entity")
|
||
}
|
||
}
|
||
|
||
func TestSendMediaPollValidation(t *testing.T) {
|
||
ctx := context.Background()
|
||
r, owner, friend := newMediaTestRouter(t)
|
||
peer := &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}
|
||
ownerCtx := WithUserID(ctx, owner.ID)
|
||
|
||
send := func(mutate func(*tg.InputMediaPoll)) error {
|
||
media := &tg.InputMediaPoll{
|
||
Poll: tg.Poll{
|
||
Question: tg.TextWithEntities{Text: "q", Entities: []tg.MessageEntityClass{}},
|
||
Answers: []tg.PollAnswerClass{
|
||
&tg.PollAnswer{Text: tg.TextWithEntities{Text: "a", Entities: []tg.MessageEntityClass{}}, Option: []byte{0}},
|
||
&tg.PollAnswer{Text: tg.TextWithEntities{Text: "b", Entities: []tg.MessageEntityClass{}}, Option: []byte{1}},
|
||
},
|
||
},
|
||
}
|
||
if mutate != nil {
|
||
mutate(media)
|
||
}
|
||
_, err := r.onMessagesSendMedia(ownerCtx, &tg.MessagesSendMediaRequest{Peer: peer, Media: media, RandomID: int64(5000 + len(media.Poll.Answers))})
|
||
return err
|
||
}
|
||
|
||
if err := send(func(in *tg.InputMediaPoll) {
|
||
in.Poll.Answers = in.Poll.Answers[:1]
|
||
}); err == nil || !tgerr.Is(err, "OPTION_INVALID") {
|
||
t.Fatalf("single answer err = %v, want OPTION_INVALID", err)
|
||
}
|
||
if err := send(func(in *tg.InputMediaPoll) {
|
||
in.Poll.Question.Text = " "
|
||
}); err == nil || !tgerr.Is(err, "MEDIA_EMPTY") {
|
||
t.Fatalf("empty question err = %v, want MEDIA_EMPTY", err)
|
||
}
|
||
if err := send(func(in *tg.InputMediaPoll) {
|
||
in.Poll.Answers[1].(*tg.PollAnswer).Option = []byte{0}
|
||
}); err == nil || !tgerr.Is(err, "POLL_OPTION_INVALID") {
|
||
t.Fatalf("duplicate option err = %v, want POLL_OPTION_INVALID", err)
|
||
}
|
||
if err := send(func(in *tg.InputMediaPoll) {
|
||
in.Poll.Quiz = true
|
||
}); err == nil || !tgerr.Is(err, "QUIZ_CORRECT_ANSWERS_INVALID") {
|
||
t.Fatalf("quiz without correct err = %v, want QUIZ_CORRECT_ANSWERS_INVALID", err)
|
||
}
|
||
if err := send(func(in *tg.InputMediaPoll) {
|
||
in.Poll.Quiz = true
|
||
in.SetCorrectAnswers([]int{5})
|
||
}); err == nil || !tgerr.Is(err, "QUIZ_CORRECT_ANSWERS_INVALID") {
|
||
t.Fatalf("quiz bad index err = %v, want QUIZ_CORRECT_ANSWERS_INVALID", err)
|
||
}
|
||
if err := send(func(in *tg.InputMediaPoll) {
|
||
in.SetCorrectAnswers([]int{0})
|
||
}); err == nil || !tgerr.Is(err, "MEDIA_INVALID") {
|
||
t.Fatalf("non-quiz with correct err = %v, want MEDIA_INVALID", err)
|
||
}
|
||
}
|