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), ¶ms); 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 是非可选 int:0 会让两端视频/屏幕共享闸门 //(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 %d(DrKLO 缺它会 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 在写阶段撞 ErrGroupCallNotJoined(participant 已被 // 并发 leave 置 Left,presentation 随主连接离会级联清掉)或 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("非竞态错误必须传播,却被幂等吞掉") } }