owpengram-server/internal/rpc/phone_group_call_rpc_test.go

662 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package rpc
import (
"context"
"encoding/json"
"fmt"
"testing"
"time"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appgroupcalls "telesrv/internal/app/groupcalls"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
// groupCallSessions 在推送捕获之上叠加 OnlineUserProvider群通话扇出需要
type groupCallSessions struct {
phoneCaptureSessions
online []int64
}
func (s *groupCallSessions) IsUserOnline(userID int64) bool { return false }
func (s *groupCallSessions) OnlineUserIDsForCandidates(candidateUserIDs []int64, limit int) []int64 {
online := map[int64]struct{}{}
for _, id := range s.online {
online[id] = struct{}{}
}
out := make([]int64, 0, len(candidateUserIDs))
for _, id := range candidateUserIDs {
if _, ok := online[id]; ok {
out = append(out, id)
if limit > 0 && len(out) == limit {
break
}
}
}
return out
}
func (s *groupCallSessions) TrackChannelInterest([8]byte, int64, int64, []int64) {}
func (s *groupCallSessions) ClearChannelInterest([8]byte, int64, int64) {}
func (s *groupCallSessions) OnlineChannelUserIDs(int64, int) []int64 { return nil }
func (s *groupCallSessions) SetSessionChannelMemberships([8]byte, int64, int64, []int64) {}
func (s *groupCallSessions) AddUserChannelMembership(int64, int64) {}
func (s *groupCallSessions) RemoveUserChannelMembership(int64, int64) {}
func (s *groupCallSessions) OnlineChannelMemberUserIDs(channelID int64, limit int) []int64 {
return append([]int64(nil), s.online...)
}
type groupCallFixture struct {
t *testing.T
ctx context.Context
router *Router
sessions *groupCallSessions
clk *phoneTestClock
owner domain.User
member domain.User
outsider domain.User
channel *tg.Channel
}
func newGroupCallFixture(t *testing.T) *groupCallFixture {
t.Helper()
ctx := context.Background()
userStore := memory.NewUserStore()
channelStore := memory.NewChannelStore()
sessions := &groupCallSessions{}
clk := &phoneTestClock{now: time.Unix(1_700_000_000, 0)}
router := New(Config{GroupCallMaxParticipants: 8}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
GroupCalls: appgroupcalls.NewService(memory.NewGroupCallStore()),
Sessions: sessions,
}, zaptest.NewLogger(t), clk)
f := &groupCallFixture{t: t, ctx: ctx, router: router, sessions: sessions, clk: clk}
mk := func(hash int64, phone, name string) domain.User {
u, err := userStore.Create(ctx, domain.User{AccessHash: hash, Phone: phone, FirstName: name})
if err != nil {
t.Fatalf("create user %s: %v", name, err)
}
return u
}
f.owner = mk(2001, "13900000001", "Owner")
f.member = mk(2002, "13900000002", "Member")
f.outsider = mk(2003, "13900000003", "Outsider")
created, err := router.onMessagesCreateChat(f.userCtx(f.owner, 11), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: f.member.ID, AccessHash: f.member.AccessHash}},
Title: "voice room",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
updates := created.Updates.(*tg.Updates)
for _, chat := range updates.Chats {
if ch, ok := chat.(*tg.Channel); ok {
f.channel = ch
break
}
}
if f.channel == nil {
t.Fatalf("no channel in create chat result")
}
f.sessions.online = []int64{f.owner.ID, f.member.ID}
f.sessions.reset()
return f
}
func (f *groupCallFixture) userCtx(u domain.User, session int64) context.Context {
return WithSessionID(WithUserID(f.ctx, u.ID), session)
}
func groupCallJoinParams(t *testing.T, ssrc int32) tg.DataJSON {
t.Helper()
payload := map[string]any{
"ssrc": ssrc,
"ufrag": "clientufrag",
"pwd": "clientpwd-clientpwd-1234",
"fingerprints": []map[string]any{
{"hash": "sha-256", "fingerprint": "AA:BB:CC:DD", "setup": "passive"},
},
// TDesktop join 可能携带的多余字段:解析必须容忍。
"ssrc-groups": []map[string]any{},
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal join payload: %v", err)
}
return tg.DataJSON{Data: string(data)}
}
func findUpdate[T tg.UpdateClass](t *testing.T, updates tg.UpdatesClass) T {
t.Helper()
box, ok := updates.(*tg.Updates)
if !ok {
t.Fatalf("updates = %T, want *tg.Updates", updates)
}
for _, u := range box.Updates {
if v, ok := u.(T); ok {
return v
}
}
var zero T
t.Fatalf("updates %v missing %T", box.Updates, zero)
return zero
}
func TestGroupCallM0Lifecycle(t *testing.T) {
f := newGroupCallFixture(t)
ownerCtx := f.userCtx(f.owner, 11)
memberCtx := f.userCtx(f.member, 22)
// --- create ---
createRes, err := f.router.onPhoneCreateGroupCall(ownerCtx, &tg.PhoneCreateGroupCallRequest{
Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash},
RandomID: 1,
})
if err != nil {
t.Fatalf("createGroupCall: %v", err)
}
callUpdate := findUpdate[*tg.UpdateGroupCall](t, createRes)
call, ok := callUpdate.Call.(*tg.GroupCall)
if !ok || call.ID == 0 || call.AccessHash == 0 || call.Version != 1 {
t.Fatalf("created call = %+v", callUpdate.Call)
}
// started 服务消息(带频道 pts、messageActionGroupCall 无 duration
newMsg := findUpdate[*tg.UpdateNewChannelMessage](t, createRes)
svc, ok := newMsg.Message.(*tg.MessageService)
if !ok {
t.Fatalf("service message = %T", newMsg.Message)
}
action, ok := svc.Action.(*tg.MessageActionGroupCall)
if !ok {
t.Fatalf("service action = %T, want MessageActionGroupCall", svc.Action)
}
if _, hasDuration := action.GetDuration(); hasDuration {
t.Fatalf("started action must not carry duration")
}
if newMsg.Pts == 0 {
t.Fatalf("service message must carry channel pts")
}
inputCall := tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
// banner 数据源channel 行已挂 ActiveCall。
full, err := f.router.deps.Channels.GetChannel(f.ctx, f.member.ID, f.channel.ID)
if err != nil || full.Channel.ActiveCallID != call.ID || full.Channel.ActiveCallNotEmpty {
t.Fatalf("channel active call = %+v err=%v", full.Channel, err)
}
f.sessions.reset()
// --- owner join ---
joinRes, err := f.router.onPhoneJoinGroupCall(ownerCtx, &tg.PhoneJoinGroupCallRequest{
Call: &inputCall,
JoinAs: &tg.InputPeerSelf{},
Params: groupCallJoinParams(t, -100200300), // 负数 ssrcuint32 按位转 int32
})
if err != nil {
t.Fatalf("joinGroupCall: %v", err)
}
conn := findUpdate[*tg.UpdateGroupCallConnection](t, joinRes)
var params struct {
Transport struct {
Ufrag string `json:"ufrag"`
Pwd string `json:"pwd"`
Fingerprints []struct {
Hash string `json:"hash"`
Setup string `json:"setup"`
Print string `json:"fingerprint"`
} `json:"fingerprints"`
Candidates []any `json:"candidates"`
} `json:"transport"`
}
if err := json.Unmarshal([]byte(conn.Params.Data), &params); err != nil {
t.Fatalf("connection params not parseable: %v\n%s", err, conn.Params.Data)
}
// ⚠ P2-6M0 下发的 transport JSON 必须语法完备(合法 ufrag/pwd/sha-256 指纹 + 空 candidates
if params.Transport.Ufrag == "" || params.Transport.Pwd == "" ||
len(params.Transport.Fingerprints) != 1 ||
params.Transport.Fingerprints[0].Hash != "sha-256" ||
params.Transport.Fingerprints[0].Setup != "active" ||
params.Transport.Fingerprints[0].Print == "" {
t.Fatalf("transport params incomplete: %s", conn.Params.Data)
}
if len(params.Transport.Candidates) != 0 {
t.Fatalf("M0 candidates = %v, want empty", params.Transport.Candidates)
}
joinParts := findUpdate[*tg.UpdateGroupCallParticipants](t, joinRes)
if joinParts.Version != 2 || len(joinParts.Participants) != 1 {
t.Fatalf("join participants update = %+v", joinParts)
}
if !joinParts.Participants[0].Self || joinParts.Participants[0].Source != -100200300 {
t.Fatalf("join self participant = %+v", joinParts.Participants[0])
}
f.sessions.reset()
// --- member join在线成员收到 version=3 增量 ---
if _, err := f.router.onPhoneJoinGroupCall(memberCtx, &tg.PhoneJoinGroupCallRequest{
Call: &inputCall,
JoinAs: &tg.InputPeerSelf{},
Params: groupCallJoinParams(t, 500600700),
}); err != nil {
t.Fatalf("member join: %v", err)
}
sawParticipants := false
for _, rec := range f.sessions.records() {
box, ok := rec.msg.(*tg.Updates)
if !ok {
continue
}
for _, u := range box.Updates {
if parts, ok := u.(*tg.UpdateGroupCallParticipants); ok && rec.userID == f.owner.ID {
if parts.Version != 3 || len(parts.Participants) != 1 {
t.Fatalf("fanout participants = %+v", parts)
}
if parts.Participants[0].Self {
t.Fatalf("member row pushed to owner must not be self-flagged")
}
sawParticipants = true
}
}
}
if !sawParticipants {
t.Fatalf("owner must receive member-join participants update, got %+v", f.sessions.records())
}
// call_not_empty 翻转已写回 channel。
view, _ := f.router.deps.Channels.GetChannel(f.ctx, f.owner.ID, f.channel.ID)
if !view.Channel.ActiveCallNotEmpty {
t.Fatalf("call_not_empty should flip after first join")
}
f.sessions.reset()
// --- checkGroupCall在会返回自己的 ssrc 子集;未在会返回空 ---
ssrcs, err := f.router.onPhoneCheckGroupCall(memberCtx, &tg.PhoneCheckGroupCallRequest{
Call: &inputCall, Sources: []int{500600700, 42},
})
if err != nil || len(ssrcs) != 1 || ssrcs[0] != 500600700 {
t.Fatalf("checkGroupCall = %v err=%v", ssrcs, err)
}
// getGroupParticipants 携带当前 version。
parts, err := f.router.onPhoneGetGroupParticipants(memberCtx, &tg.PhoneGetGroupParticipantsRequest{
Call: &inputCall, Limit: 10,
})
if err != nil || parts.Count != 2 || parts.Version != 3 {
t.Fatalf("getGroupParticipants = %+v err=%v", parts, err)
}
// --- 自我静音M0 self-edit---
editRes, err := f.router.onPhoneEditGroupCallParticipant(memberCtx, &tg.PhoneEditGroupCallParticipantRequest{
Call: &inputCall,
Participant: &tg.InputPeerSelf{},
})
if err == nil {
t.Fatalf("edit without flags should be NOT_MODIFIED, got %+v", editRes)
}
req := &tg.PhoneEditGroupCallParticipantRequest{Call: &inputCall, Participant: &tg.InputPeerSelf{}}
req.SetMuted(true)
editRes, err = f.router.onPhoneEditGroupCallParticipant(memberCtx, req)
if err != nil {
t.Fatalf("self mute: %v", err)
}
muteParts := findUpdate[*tg.UpdateGroupCallParticipants](t, editRes)
if muteParts.Version != 4 || !muteParts.Participants[0].Muted || !muteParts.Participants[0].CanSelfUnmute {
t.Fatalf("self mute participants = %+v", muteParts.Participants[0])
}
f.sessions.reset()
// --- leave ---
leaveRes, err := f.router.onPhoneLeaveGroupCall(memberCtx, &tg.PhoneLeaveGroupCallRequest{
Call: &inputCall, Source: 500600700,
})
if err != nil {
t.Fatalf("leave: %v", err)
}
leaveParts := findUpdate[*tg.UpdateGroupCallParticipants](t, leaveRes)
if leaveParts.Version != 5 || !leaveParts.Participants[0].Left {
t.Fatalf("leave participants = %+v", leaveParts.Participants[0])
}
// --- discardowner---
f.sessions.reset()
f.clk.Advance(90 * time.Second) // 让 ended 服务消息有非零 duration
discardRes, err := f.router.onPhoneDiscardGroupCall(ownerCtx, &inputCall)
if err != nil {
t.Fatalf("discard: %v", err)
}
discardUpdate := findUpdate[*tg.UpdateGroupCall](t, discardRes)
if _, ok := discardUpdate.Call.(*tg.GroupCallDiscarded); !ok {
t.Fatalf("discard call = %T, want GroupCallDiscarded", discardUpdate.Call)
}
endedMsg := findUpdate[*tg.UpdateNewChannelMessage](t, discardRes)
endedAction := endedMsg.Message.(*tg.MessageService).Action.(*tg.MessageActionGroupCall)
if _, hasDuration := endedAction.GetDuration(); !hasDuration {
t.Fatalf("ended action must carry duration")
}
view, _ = f.router.deps.Channels.GetChannel(f.ctx, f.owner.ID, f.channel.ID)
if view.Channel.ActiveCallID != 0 || view.Channel.ActiveCallNotEmpty {
t.Fatalf("channel active call must clear after discard, got %+v", view.Channel)
}
// 重复 discard。
if _, err := f.router.onPhoneDiscardGroupCall(ownerCtx, &inputCall); err == nil {
t.Fatalf("double discard must fail")
}
}
func TestGroupCallM0Permissions(t *testing.T) {
f := newGroupCallFixture(t)
ownerCtx := f.userCtx(f.owner, 11)
memberCtx := f.userCtx(f.member, 22)
outsiderCtx := f.userCtx(f.outsider, 33)
// 普通成员建会CHAT_ADMIN_REQUIRED。
_, err := f.router.onPhoneCreateGroupCall(memberCtx, &tg.PhoneCreateGroupCallRequest{
Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash},
})
assertPhoneRPCErr(t, err, "CHAT_ADMIN_REQUIRED")
createRes, err := f.router.onPhoneCreateGroupCall(ownerCtx, &tg.PhoneCreateGroupCallRequest{
Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash},
})
if err != nil {
t.Fatalf("create: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, createRes).Call.(*tg.GroupCall)
inputCall := tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
// 重复建会。
_, err = f.router.onPhoneCreateGroupCall(ownerCtx, &tg.PhoneCreateGroupCallRequest{
Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash},
})
assertPhoneRPCErr(t, err, "GROUPCALL_ALREADY_STARTED")
// 非群成员。
_, err = f.router.onPhoneJoinGroupCall(outsiderCtx, &tg.PhoneJoinGroupCallRequest{
Call: &inputCall, JoinAs: &tg.InputPeerSelf{}, Params: groupCallJoinParams(t, 123),
})
assertPhoneRPCErr(t, err, "GROUPCALL_FORBIDDEN")
// access_hash 不符。
_, err = f.router.onPhoneGetGroupCall(memberCtx, &tg.PhoneGetGroupCallRequest{
Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash + 1}, Limit: 10,
})
assertPhoneRPCErr(t, err, "GROUPCALL_INVALID")
// ⚠ P1-6slug 变体conference 路径)须返回 GROUPCALL_INVALID 而非 panic。
_, err = f.router.onPhoneGetGroupCall(memberCtx, &tg.PhoneGetGroupCallRequest{
Call: &tg.InputGroupCallSlug{Slug: "x"}, Limit: 10,
})
assertPhoneRPCErr(t, err, "GROUPCALL_INVALID")
// ssrc 撞车member 用 owner 的 ssrc。
if _, err := f.router.onPhoneJoinGroupCall(ownerCtx, &tg.PhoneJoinGroupCallRequest{
Call: &inputCall, JoinAs: &tg.InputPeerSelf{}, Params: groupCallJoinParams(t, 777),
}); err != nil {
t.Fatalf("owner join: %v", err)
}
_, err = f.router.onPhoneJoinGroupCall(memberCtx, &tg.PhoneJoinGroupCallRequest{
Call: &inputCall, JoinAs: &tg.InputPeerSelf{}, Params: groupCallJoinParams(t, 777),
})
assertPhoneRPCErr(t, err, "GROUPCALL_SSRC_DUPLICATE_MUCH")
// 普通成员 mute 他人M2 起为 per-viewer 本地覆盖(合法,详见 TestGroupCallM2MemberOverride
muteOther := &tg.PhoneEditGroupCallParticipantRequest{
Call: &inputCall,
Participant: &tg.InputPeerUser{UserID: f.owner.ID, AccessHash: f.owner.AccessHash},
}
muteOther.SetMuted(true)
if _, err = f.router.onPhoneEditGroupCallParticipant(memberCtx, muteOther); err != nil {
t.Fatalf("member local-mute other: %v", err)
}
// 普通成员结束通话 / 改标题。
_, err = f.router.onPhoneDiscardGroupCall(memberCtx, &inputCall)
assertPhoneRPCErr(t, err, "CHAT_ADMIN_REQUIRED")
_, err = f.router.onPhoneEditGroupCallTitle(memberCtx, &tg.PhoneEditGroupCallTitleRequest{Call: &inputCall, Title: "x"})
assertPhoneRPCErr(t, err, "CHAT_ADMIN_REQUIRED")
}
func TestGroupCallSweeper(t *testing.T) {
f := newGroupCallFixture(t)
ownerCtx := f.userCtx(f.owner, 11)
createRes, err := f.router.onPhoneCreateGroupCall(ownerCtx, &tg.PhoneCreateGroupCallRequest{
Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash},
})
if err != nil {
t.Fatalf("create: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, createRes).Call.(*tg.GroupCall)
inputCall := tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
if _, err := f.router.onPhoneJoinGroupCall(ownerCtx, &tg.PhoneJoinGroupCallRequest{
Call: &inputCall, JoinAs: &tg.InputPeerSelf{}, Params: groupCallJoinParams(t, 9001),
}); err != nil {
t.Fatalf("join: %v", err)
}
dispatcher := NewGroupCallSweepDispatcher(f.router, zaptest.NewLogger(t), 10*time.Second, 45*time.Second)
// 30s心跳未过期不清。
f.clk.Advance(30 * time.Second)
dispatcher.DispatchOnce(f.ctx)
if ssrcs, err := f.router.onPhoneCheckGroupCall(ownerCtx, &tg.PhoneCheckGroupCallRequest{Call: &inputCall, Sources: []int{9001}}); err != nil || len(ssrcs) != 1 {
t.Fatalf("participant swept too early: %v err=%v", ssrcs, err)
}
// checkGroupCall 刷新水位后再过 30s仍不清水位以最后心跳为准
f.clk.Advance(30 * time.Second)
dispatcher.DispatchOnce(f.ctx)
if ssrcs, _ := f.router.onPhoneCheckGroupCall(ownerCtx, &tg.PhoneCheckGroupCallRequest{Call: &inputCall, Sources: []int{9001}}); len(ssrcs) != 1 {
t.Fatalf("heartbeat-refreshed participant must survive, got %v", ssrcs)
}
// 46s 无心跳清掉checkGroupCall 返回空集触发客户端 rejoin。
f.clk.Advance(46 * time.Second)
dispatcher.DispatchOnce(f.ctx)
ssrcs, err := f.router.onPhoneCheckGroupCall(ownerCtx, &tg.PhoneCheckGroupCallRequest{Call: &inputCall, Sources: []int{9001}})
if err != nil || len(ssrcs) != 0 {
t.Fatalf("stale participant must be swept, got %v err=%v", ssrcs, err)
}
// rejoin换 ssrc恢复。
if _, err := f.router.onPhoneJoinGroupCall(ownerCtx, &tg.PhoneJoinGroupCallRequest{
Call: &inputCall, JoinAs: &tg.InputPeerSelf{}, Params: groupCallJoinParams(t, 9002),
}); err != nil {
t.Fatalf("rejoin after sweep: %v", err)
}
}
// 防御未来误删fmt 占位避免 unused importfindUpdate 泛型在部分场景未实例化时)。
var _ = fmt.Sprintf
// TestGroupCallM2MuteMatrix 覆盖 editGroupCallParticipant 权限矩阵全表。
func TestGroupCallM2MuteMatrix(t *testing.T) {
f := newGroupCallFixture(t)
ownerCtx := f.userCtx(f.owner, 11)
memberCtx := f.userCtx(f.member, 22)
createRes, err := f.router.onPhoneCreateGroupCall(ownerCtx, &tg.PhoneCreateGroupCallRequest{
Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash},
})
if err != nil {
t.Fatalf("create: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, createRes).Call.(*tg.GroupCall)
inputCall := tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
for i, u := range []struct {
ctx context.Context
ssrc int32
}{{ownerCtx, 9001}, {memberCtx, 9002}} {
if _, err := f.router.onPhoneJoinGroupCall(u.ctx, &tg.PhoneJoinGroupCallRequest{
Call: &inputCall, JoinAs: &tg.InputPeerSelf{}, Params: groupCallJoinParams(t, u.ssrc),
}); err != nil {
t.Fatalf("join %d: %v", i, err)
}
}
memberPeer := &tg.InputPeerUser{UserID: f.member.ID, AccessHash: f.member.AccessHash}
// --- admin 禁言成员muted=true 且 can_self_unmute=false ---
muteReq := &tg.PhoneEditGroupCallParticipantRequest{Call: &inputCall, Participant: memberPeer}
muteReq.SetMuted(true)
res, err := f.router.onPhoneEditGroupCallParticipant(ownerCtx, muteReq)
if err != nil {
t.Fatalf("admin mute: %v", err)
}
row := findUpdate[*tg.UpdateGroupCallParticipants](t, res).Participants[0]
if !row.Muted || row.CanSelfUnmute {
t.Fatalf("admin-muted row = %+v, want muted && !can_self_unmute", row)
}
// --- 被禁言成员自行 unmuteforbidden客户端此时只会发 raise_hand---
selfUnmute := &tg.PhoneEditGroupCallParticipantRequest{Call: &inputCall, Participant: &tg.InputPeerSelf{}}
selfUnmute.SetMuted(false)
_, err = f.router.onPhoneEditGroupCallParticipant(memberCtx, selfUnmute)
assertPhoneRPCErr(t, err, "GROUPCALL_FORBIDDEN")
// --- 举手rating 单调、versioned 推送 ---
raise := &tg.PhoneEditGroupCallParticipantRequest{Call: &inputCall, Participant: &tg.InputPeerSelf{}}
raise.SetRaiseHand(true)
res, err = f.router.onPhoneEditGroupCallParticipant(memberCtx, raise)
if err != nil {
t.Fatalf("raise hand: %v", err)
}
row = findUpdate[*tg.UpdateGroupCallParticipants](t, res).Participants[0]
rating, hasRating := row.GetRaiseHandRating()
if !hasRating || rating <= 0 {
t.Fatalf("raise hand row = %+v, want rating > 0", row)
}
// --- admin 允许发言muted=false恢复 can_self_unmute、清举手、不替用户开麦 ---
allow := &tg.PhoneEditGroupCallParticipantRequest{Call: &inputCall, Participant: memberPeer}
allow.SetMuted(false)
res, err = f.router.onPhoneEditGroupCallParticipant(ownerCtx, allow)
if err != nil {
t.Fatalf("admin allow: %v", err)
}
row = findUpdate[*tg.UpdateGroupCallParticipants](t, res).Participants[0]
if !row.Muted || !row.CanSelfUnmute {
t.Fatalf("allow-to-speak row = %+v, want still muted && can_self_unmute", row)
}
if _, has := row.GetRaiseHandRating(); has {
t.Fatalf("allow-to-speak must clear raise hand, got %+v", row)
}
// --- 现在成员可自行 unmute ---
if _, err := f.router.onPhoneEditGroupCallParticipant(memberCtx, selfUnmute); err != nil {
t.Fatalf("self unmute after allow: %v", err)
}
// --- admin 设全局音量 ---
vol := &tg.PhoneEditGroupCallParticipantRequest{Call: &inputCall, Participant: memberPeer}
vol.SetVolume(5000)
res, err = f.router.onPhoneEditGroupCallParticipant(ownerCtx, vol)
if err != nil {
t.Fatalf("admin volume: %v", err)
}
row = findUpdate[*tg.UpdateGroupCallParticipants](t, res).Participants[0]
if v, has := row.GetVolume(); !has || v != 5000 || !row.VolumeByAdmin {
t.Fatalf("admin volume row = %+v", row)
}
}
// TestGroupCallM2MemberOverride 覆盖普通成员的本地静音/音量per-viewer overrides
func TestGroupCallM2MemberOverride(t *testing.T) {
f := newGroupCallFixture(t)
ownerCtx := f.userCtx(f.owner, 11)
memberCtx := f.userCtx(f.member, 22)
createRes, err := f.router.onPhoneCreateGroupCall(ownerCtx, &tg.PhoneCreateGroupCallRequest{
Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash},
})
if err != nil {
t.Fatalf("create: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, createRes).Call.(*tg.GroupCall)
inputCall := tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
for _, u := range []struct {
ctx context.Context
ssrc int32
}{{ownerCtx, 9001}, {memberCtx, 9002}} {
if _, err := f.router.onPhoneJoinGroupCall(u.ctx, &tg.PhoneJoinGroupCallRequest{
Call: &inputCall, JoinAs: &tg.InputPeerSelf{}, Params: groupCallJoinParams(t, u.ssrc),
}); err != nil {
t.Fatalf("join: %v", err)
}
}
versionBefore := 0
if c, found, _ := f.router.deps.GroupCalls.Get(f.ctx, call.ID); found {
versionBefore = c.Version
}
f.sessions.reset()
// member 本地静音 owner。
localMute := &tg.PhoneEditGroupCallParticipantRequest{
Call: &inputCall,
Participant: &tg.InputPeerUser{UserID: f.owner.ID, AccessHash: f.owner.AccessHash},
}
localMute.SetMuted(true)
res, err := f.router.onPhoneEditGroupCallParticipant(memberCtx, localMute)
if err != nil {
t.Fatalf("local mute: %v", err)
}
// ⚠ P2-7推给 setter 的行必须带 min flag + muted_by_you防 setter 其它设备
// 用该行覆盖本地 muted/volume 状态。
row := findUpdate[*tg.UpdateGroupCallParticipants](t, res).Participants[0]
if !row.Min || !row.MutedByYou {
t.Fatalf("override row = %+v, want min && muted_by_you", row)
}
// overrides 不推进全房间 version。
if c, found, _ := f.router.deps.GroupCalls.Get(f.ctx, call.ID); !found || c.Version != versionBefore {
t.Fatalf("override must not bump version: %d → %d", versionBefore, c.Version)
}
// target 自身权威态不受影响。
if p, found, _ := f.router.deps.GroupCalls.Participant(f.ctx, call.ID, f.owner.ID); !found || p.Muted {
t.Fatalf("target authoritative state polluted: %+v", p)
}
// 推送只发给 setter 自己。
for _, rec := range f.sessions.records() {
if rec.userID != f.member.ID {
t.Fatalf("override pushed to %d, want setter only", rec.userID)
}
}
}
// TestGroupCallM2Invite 覆盖 inviteToGroupCall 服务消息。
func TestGroupCallM2Invite(t *testing.T) {
f := newGroupCallFixture(t)
ownerCtx := f.userCtx(f.owner, 11)
createRes, err := f.router.onPhoneCreateGroupCall(ownerCtx, &tg.PhoneCreateGroupCallRequest{
Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash},
})
if err != nil {
t.Fatalf("create: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, createRes).Call.(*tg.GroupCall)
inputCall := tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
// 邀请群成员:产生带频道 pts 的 messageActionInviteToGroupCall。
res, err := f.router.onPhoneInviteToGroupCall(ownerCtx, &tg.PhoneInviteToGroupCallRequest{
Call: &inputCall,
Users: []tg.InputUserClass{&tg.InputUser{UserID: f.member.ID, AccessHash: f.member.AccessHash}},
})
if err != nil {
t.Fatalf("invite: %v", err)
}
msgUpdate := findUpdate[*tg.UpdateNewChannelMessage](t, res)
if msgUpdate.Pts == 0 {
t.Fatalf("invite service message must carry channel pts")
}
action, ok := msgUpdate.Message.(*tg.MessageService).Action.(*tg.MessageActionInviteToGroupCall)
if !ok {
t.Fatalf("invite action = %T", msgUpdate.Message.(*tg.MessageService).Action)
}
if len(action.Users) != 1 || action.Users[0] != f.member.ID {
t.Fatalf("invite action users = %v", action.Users)
}
// 邀请非群成员。
_, err = f.router.onPhoneInviteToGroupCall(ownerCtx, &tg.PhoneInviteToGroupCallRequest{
Call: &inputCall,
Users: []tg.InputUserClass{&tg.InputUser{UserID: f.outsider.ID, AccessHash: f.outsider.AccessHash}},
})
assertPhoneRPCErr(t, err, "USER_NOT_PARTICIPANT")
}