owpengram-server/internal/rpc/phone_group_call_video_rpc_test.go

392 lines
15 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"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
// groupCallVideoJoinParams 构造带视频 ssrc-groups 的上行 join JSON
// ssrc/sources 全部按 int32 位重解释写出,与 tgcalls serialize 一致)。
func groupCallVideoJoinParams(t *testing.T, audioSSRC int32, groups []map[string]any) tg.DataJSON {
t.Helper()
payload := map[string]any{
"ssrc": audioSSRC,
"ufrag": "clientufrag",
"pwd": "clientpwd-clientpwd-1234",
"fingerprints": []map[string]any{
{"hash": "sha-256", "fingerprint": "AA:BB:CC:DD", "setup": "passive"},
},
"ssrc-groups": groups,
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal join payload: %v", err)
}
return tg.DataJSON{Data: string(data)}
}
// simulcastGroups 是摄像头 3 层标准布局SIM[s,s+2,s+4] + 每层 FID 对。
func simulcastGroups(base int64) []map[string]any {
return []map[string]any{
{"semantics": "SIM", "sources": []int64{base, base + 2, base + 4}},
{"semantics": "FID", "sources": []int64{base, base + 1}},
{"semantics": "FID", "sources": []int64{base + 2, base + 3}},
{"semantics": "FID", "sources": []int64{base + 4, base + 5}},
}
}
func connectionVideoEndpoint(t *testing.T, conn *tg.UpdateGroupCallConnection) string {
t.Helper()
var params struct {
Video struct {
Endpoint string `json:"endpoint"`
PayloadTypes []map[string]any `json:"payload-types"`
RtpHdrexts []struct {
ID int `json:"id"`
} `json:"rtp-hdrexts"`
} `json:"video"`
}
if err := json.Unmarshal([]byte(conn.Params.Data), &params); err != nil {
t.Fatalf("parse connection params: %v", err)
}
if len(params.Video.PayloadTypes) < 4 {
t.Fatalf("video payload-types = %v, want VP8/rtx/VP9/rtx", params.Video.PayloadTypes)
}
ids := map[int]bool{}
for _, h := range params.Video.RtpHdrexts {
ids[h.ID] = true
}
// 客户端硬编码的扩展 id 表1=audio-level、2=abs-send-time、3=twcc、13=video-orientation。
for _, want := range []int{1, 2, 3, 13} {
if !ids[want] {
t.Fatalf("rtp-hdrexts missing id=%d: %v", want, params.Video.RtpHdrexts)
}
}
return params.Video.Endpoint
}
func participantRow(t *testing.T, updates tg.UpdatesClass) tg.GroupCallParticipant {
t.Helper()
parts := findUpdate[*tg.UpdateGroupCallParticipants](t, updates)
if len(parts.Participants) != 1 {
t.Fatalf("participants = %d, want 1", len(parts.Participants))
}
return parts.Participants[0]
}
func TestGroupCallVideoLifecycle(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},
RandomID: 1,
})
if err != nil {
t.Fatalf("createGroupCall: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, createRes).Call.(*tg.GroupCall)
// ⚠ unmuted_video_limit 是非可选 int0 会让两端视频/屏幕共享闸门
//activeVideoSendersCount >= limit恒真按钮直接报「不能共享」。
if call.UnmutedVideoLimit <= 0 || !call.CanStartVideo {
t.Fatalf("call video gates = limit %d can_start_video %v, want positive limit + can_start_video",
call.UnmutedVideoLimit, call.CanStartVideo)
}
inputCall := tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
f.sessions.reset()
// join带 simulcast 组 + video_stopped摄像头未开
audioSSRC := int32(-100200300)
videoBase := int64(audioSSRC) + 1
joinReq := &tg.PhoneJoinGroupCallRequest{
Call: &inputCall,
JoinAs: &tg.InputPeerSelf{},
Params: groupCallVideoJoinParams(t, audioSSRC, simulcastGroups(videoBase)),
}
joinReq.SetVideoStopped(true)
joinRes, err := f.router.onPhoneJoinGroupCall(ownerCtx, joinReq)
if err != nil {
t.Fatalf("joinGroupCall: %v", err)
}
conn := findUpdate[*tg.UpdateGroupCallConnection](t, joinRes)
if conn.Presentation {
t.Fatalf("main join connection must not carry presentation flag")
}
wantEndpoint := fmt.Sprintf("audio-%d", uint32(audioSSRC))
if got := connectionVideoEndpoint(t, conn); got != wantEndpoint {
t.Fatalf("join video.endpoint = %q, want %q", got, wantEndpoint)
}
row := participantRow(t, joinRes)
if !row.VideoJoined {
t.Fatalf("self row must carry video_joined缺它 TDesktop 强制关本端摄像头)")
}
if _, has := row.GetVideo(); has {
t.Fatalf("video_stopped join must not expose video field")
}
f.sessions.reset()
// 开摄像头editGroupCallParticipant(video_stopped=false) → video 字段出现,
// endpoint 与 join 响应一致、source_groups 原样回放。
editReq := &tg.PhoneEditGroupCallParticipantRequest{
Call: &inputCall,
Participant: &tg.InputPeerSelf{},
}
editReq.SetVideoStopped(false)
editRes, err := f.router.onPhoneEditGroupCallParticipant(ownerCtx, editReq)
if err != nil {
t.Fatalf("video_stopped=false: %v", err)
}
row = participantRow(t, editRes)
video, has := row.GetVideo()
if !has {
t.Fatalf("video field missing after camera on")
}
if video.Endpoint != wantEndpoint {
t.Fatalf("participant video.endpoint = %q, want %q必须与 join 响应逐字节一致)", video.Endpoint, wantEndpoint)
}
if len(video.SourceGroups) != 4 || video.SourceGroups[0].Semantics != "SIM" {
t.Fatalf("source_groups = %+v, want SIM+3×FID 原样回放", video.SourceGroups)
}
if got := video.SourceGroups[0].Sources[0]; int64(int32(got)) != int64(int32(videoBase)) {
t.Fatalf("SIM[0] = %d, want %d", got, videoBase)
}
// 幂等重放video 维度绝不报错)。
editAgain := &tg.PhoneEditGroupCallParticipantRequest{Call: &inputCall, Participant: &tg.InputPeerSelf{}}
editAgain.SetVideoStopped(false)
if _, err := f.router.onPhoneEditGroupCallParticipant(ownerCtx, editAgain); err != nil {
t.Fatalf("redundant video edit must succeed, got %v错误会触发客户端 rejoin 风暴)", err)
}
// 暂停:字段保留 + paused。
pauseReq := &tg.PhoneEditGroupCallParticipantRequest{Call: &inputCall, Participant: &tg.InputPeerSelf{}}
pauseReq.SetVideoPaused(true)
pauseRes, err := f.router.onPhoneEditGroupCallParticipant(ownerCtx, pauseReq)
if err != nil {
t.Fatalf("video_paused: %v", err)
}
pausedRow := participantRow(t, pauseRes)
video, has = pausedRow.GetVideo()
if !has || !video.Paused {
t.Fatalf("paused video = %+v has=%v, want paused field kept", video, has)
}
// 关摄像头:字段消失。
stopReq := &tg.PhoneEditGroupCallParticipantRequest{Call: &inputCall, Participant: &tg.InputPeerSelf{}}
stopReq.SetVideoStopped(true)
stopRes, err := f.router.onPhoneEditGroupCallParticipant(ownerCtx, stopReq)
if err != nil {
t.Fatalf("video_stopped=true: %v", err)
}
stopRow := participantRow(t, stopRes)
if _, has := stopRow.GetVideo(); has {
t.Fatalf("video field must disappear after camera off")
}
}
func TestGroupCallPresentationLifecycle(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},
RandomID: 1,
})
if err != nil {
t.Fatalf("createGroupCall: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, createRes).Call.(*tg.GroupCall)
inputCall := tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
// 未入会先共享GROUPCALL_JOIN_MISSING。
if _, err := f.router.onPhoneJoinGroupCallPresentation(memberCtx, &tg.PhoneJoinGroupCallPresentationRequest{
Call: &inputCall,
Params: groupCallVideoJoinParams(t, 7777, simulcastGroups(7778)),
}); err == nil {
t.Fatalf("presentation before main join must fail")
}
// 主 join。
const mainSSRC = int32(424242)
if _, err := f.router.onPhoneJoinGroupCall(memberCtx, &tg.PhoneJoinGroupCallRequest{
Call: &inputCall,
JoinAs: &tg.InputPeerSelf{},
Params: groupCallVideoJoinParams(t, mainSSRC, simulcastGroups(int64(mainSSRC)+1)),
}); err != nil {
t.Fatalf("main join: %v", err)
}
f.sessions.reset()
// 屏幕共享 join独立 ssrc、2 层布局。
const screenSSRC = int32(515151)
screenGroups := []map[string]any{
{"semantics": "SIM", "sources": []int64{int64(screenSSRC) + 1, int64(screenSSRC) + 3}},
{"semantics": "FID", "sources": []int64{int64(screenSSRC) + 1, int64(screenSSRC) + 2}},
{"semantics": "FID", "sources": []int64{int64(screenSSRC) + 3, int64(screenSSRC) + 4}},
}
presRes, err := f.router.onPhoneJoinGroupCallPresentation(memberCtx, &tg.PhoneJoinGroupCallPresentationRequest{
Call: &inputCall,
Params: groupCallVideoJoinParams(t, screenSSRC, screenGroups),
})
if err != nil {
t.Fatalf("joinGroupCallPresentation: %v", err)
}
conn := findUpdate[*tg.UpdateGroupCallConnection](t, presRes)
if !conn.Presentation {
t.Fatalf("presentation connection must carry presentation flag否则被主实例误食")
}
wantEndpoint := fmt.Sprintf("presentation-%d", uint32(screenSSRC))
if got := connectionVideoEndpoint(t, conn); got != wantEndpoint {
t.Fatalf("presentation video.endpoint = %q, want %q", got, wantEndpoint)
}
row := participantRow(t, presRes)
pres, has := row.GetPresentation()
if !has {
t.Fatalf("participant presentation field missing")
}
if pres.Endpoint != wantEndpoint {
t.Fatalf("presentation.endpoint = %q, want %q", pres.Endpoint, wantEndpoint)
}
audioSource, hasAudio := pres.GetAudioSource()
if !hasAudio || audioSource != int(screenSSRC) {
t.Fatalf("presentation.audio_source = %d has=%v, want %dDrKLO 缺它会 4s 循环重建)", audioSource, hasAudio, screenSSRC)
}
if len(pres.SourceGroups) != 3 {
t.Fatalf("presentation source_groups = %+v", pres.SourceGroups)
}
// checkGroupCall 必须把 presentation 的全部 ssrc 认作活跃。
sources := []int{int(mainSSRC), int(screenSSRC), int(screenSSRC) + 1, int(screenSSRC) + 3}
alive, err := f.router.onPhoneCheckGroupCall(memberCtx, &tg.PhoneCheckGroupCallRequest{
Call: &inputCall,
Sources: sources,
})
if err != nil {
t.Fatalf("checkGroupCall: %v", err)
}
if len(alive) != len(sources) {
t.Fatalf("checkGroupCall alive = %v, want all of %v缺失会触发屏幕实例循环重建", alive, sources)
}
// presentation_paused。
pauseReq := &tg.PhoneEditGroupCallParticipantRequest{Call: &inputCall, Participant: &tg.InputPeerSelf{}}
pauseReq.SetPresentationPaused(true)
pauseRes, err := f.router.onPhoneEditGroupCallParticipant(memberCtx, pauseReq)
if err != nil {
t.Fatalf("presentation_paused: %v", err)
}
pausedRow := participantRow(t, pauseRes)
pres, _ = pausedRow.GetPresentation()
if !pres.Paused {
t.Fatalf("presentation must be paused")
}
// leaveGroupCallPresentation字段消失。
leaveRes, err := f.router.onPhoneLeaveGroupCallPresentation(memberCtx, &inputCall)
if err != nil {
t.Fatalf("leaveGroupCallPresentation: %v", err)
}
leaveRow := participantRow(t, leaveRes)
if _, has := leaveRow.GetPresentation(); has {
t.Fatalf("presentation field must disappear after leave")
}
// 重复 leave 幂等。
if _, err := f.router.onPhoneLeaveGroupCallPresentation(memberCtx, &inputCall); err != nil {
t.Fatalf("double leave presentation: %v", err)
}
// rejoin 主连接 ⇒ 旧 presentation 作废(客户端会重发 joinGroupCallPresentation
if _, err := f.router.onPhoneJoinGroupCallPresentation(memberCtx, &tg.PhoneJoinGroupCallPresentationRequest{
Call: &inputCall,
Params: groupCallVideoJoinParams(t, screenSSRC, screenGroups),
}); err != nil {
t.Fatalf("re-share: %v", err)
}
rejoinRes, err := f.router.onPhoneJoinGroupCall(memberCtx, &tg.PhoneJoinGroupCallRequest{
Call: &inputCall,
JoinAs: &tg.InputPeerSelf{},
Params: groupCallVideoJoinParams(t, mainSSRC+1, simulcastGroups(int64(mainSSRC)+100)),
})
if err != nil {
t.Fatalf("main rejoin: %v", err)
}
rejoinRow := participantRow(t, rejoinRes)
if _, has := rejoinRow.GetPresentation(); has {
t.Fatalf("main rejoin must invalidate old presentation registration")
}
}
// raceLeavePresentationStore 包一层 GroupCallsService把 UpdateParticipant 强制返回
// 注入的错误,用来模拟 leaveGroupCallPresentation 读到 presentation 仍在、但写阶段与
// 并发 leaveGroupCall/discard 撞车的竞态其它方法照常委派Participant 仍返回真实行)。
type raceLeavePresentationStore struct {
GroupCallsService
updateErr error
}
func (s *raceLeavePresentationStore) UpdateParticipant(ctx context.Context, callID, userID int64, update domain.GroupCallParticipantUpdate) (domain.GroupCallMutation, bool, error) {
if s.updateErr != nil {
return domain.GroupCallMutation{}, false, s.updateErr
}
return s.GroupCallsService.UpdateParticipant(ctx, callID, userID, update)
}
// TestLeaveGroupCallPresentationIdempotentOnConcurrentLeave 回归:屏幕共享结束与整体离会
// 同时发生时leaveGroupCallPresentation 在写阶段撞 ErrGroupCallNotJoinedparticipant 已被
// 并发 leave 置 Leftpresentation 随主连接离会级联清掉)或 ErrGroupCallDiscarded主叫同时
// discard必须按幂等成功返回而非 GROUPCALL_JOIN_MISSING 400真双机 16:48 撞到过)。
func TestLeaveGroupCallPresentationIdempotentOnConcurrentLeave(t *testing.T) {
f := newGroupCallFixture(t)
memberCtx := f.userCtx(f.member, 22)
createRes, err := f.router.onPhoneCreateGroupCall(f.userCtx(f.owner, 11), &tg.PhoneCreateGroupCallRequest{
Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash},
RandomID: 1,
})
if err != nil {
t.Fatalf("createGroupCall: %v", err)
}
call := findUpdate[*tg.UpdateGroupCall](t, createRes).Call.(*tg.GroupCall)
inputCall := tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}
const mainSSRC = int32(424242)
if _, err := f.router.onPhoneJoinGroupCall(memberCtx, &tg.PhoneJoinGroupCallRequest{
Call: &inputCall,
JoinAs: &tg.InputPeerSelf{},
Params: groupCallVideoJoinParams(t, mainSSRC, simulcastGroups(int64(mainSSRC)+1)),
}); err != nil {
t.Fatalf("main join: %v", err)
}
const screenSSRC = int32(515151)
if _, err := f.router.onPhoneJoinGroupCallPresentation(memberCtx, &tg.PhoneJoinGroupCallPresentationRequest{
Call: &inputCall,
Params: groupCallVideoJoinParams(t, screenSSRC, simulcastGroups(int64(screenSSRC)+1)),
}); err != nil {
t.Fatalf("joinGroupCallPresentation: %v", err)
}
// 注入竞态Participant() 仍返回带 presentation 的真实行(过早期幂等检查),
// UpdateParticipant() 撞错误(模拟并发 leave 已置 Left / 并发 discard
race := &raceLeavePresentationStore{GroupCallsService: f.router.deps.GroupCalls}
f.router.deps.GroupCalls = race
for _, injected := range []error{domain.ErrGroupCallNotJoined, domain.ErrGroupCallDiscarded} {
race.updateErr = injected
if _, err := f.router.onPhoneLeaveGroupCallPresentation(memberCtx, &inputCall); err != nil {
t.Fatalf("leaveGroupCallPresentation 撞并发 %v 必须幂等成功got %v修复前=GROUPCALL_JOIN_MISSING 400", injected, err)
}
}
// 反向保险:非竞态类错误仍须如实抛出(不被幂等吞掉)。
race.updateErr = domain.ErrGroupCallInvalid
if _, err := f.router.onPhoneLeaveGroupCallPresentation(memberCtx, &inputCall); err == nil {
t.Fatalf("非竞态错误必须传播,却被幂等吞掉")
}
}