538 lines
20 KiB
Go
538 lines
20 KiB
Go
package phone
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"errors"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/iamxvbaba/td/clock"
|
||
|
||
"telesrv/internal/domain"
|
||
)
|
||
|
||
type testClock struct {
|
||
mu sync.Mutex
|
||
now time.Time
|
||
}
|
||
|
||
func newTestClock() *testClock {
|
||
return &testClock{now: time.Unix(1_700_000_000, 0)}
|
||
}
|
||
|
||
func (c *testClock) Now() time.Time {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
return c.now
|
||
}
|
||
|
||
func (c *testClock) Advance(d time.Duration) {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
c.now = c.now.Add(d)
|
||
}
|
||
|
||
func (c *testClock) Timer(d time.Duration) clock.Timer { return clock.System.Timer(d) }
|
||
func (c *testClock) Ticker(d time.Duration) clock.Ticker { return clock.System.Ticker(d) }
|
||
|
||
func testProtocol(versions ...string) domain.PhoneCallProtocol {
|
||
if len(versions) == 0 {
|
||
versions = []string{"11.0.0", "10.0.0"}
|
||
}
|
||
return domain.PhoneCallProtocol{
|
||
UDPP2P: true,
|
||
UDPReflector: true,
|
||
MinLayer: 65,
|
||
MaxLayer: 92,
|
||
LibraryVersions: versions,
|
||
}
|
||
}
|
||
|
||
func testGA() ([]byte, []byte) {
|
||
ga := make([]byte, 256)
|
||
for i := range ga {
|
||
ga[i] = byte(i + 1)
|
||
}
|
||
hash := sha256.Sum256(ga)
|
||
return ga, hash[:]
|
||
}
|
||
|
||
func testGB() []byte {
|
||
gb := make([]byte, 256)
|
||
for i := range gb {
|
||
gb[i] = byte(255 - i%200)
|
||
}
|
||
return gb
|
||
}
|
||
|
||
func newTestService(clk clock.Clock, mutate ...func(*Config)) *Service {
|
||
cfg := Config{
|
||
RingTimeout: 90 * time.Second,
|
||
TombstoneTTL: 60 * time.Second,
|
||
MaxActivePerUser: 4,
|
||
MaxRegistryEntries: 10_000,
|
||
SignalingRatePerSecond: 50,
|
||
}
|
||
for _, fn := range mutate {
|
||
fn(&cfg)
|
||
}
|
||
return NewService(cfg, WithClock(clk))
|
||
}
|
||
|
||
func mustRequest(t *testing.T, s *Service, caller, callee int64, gaHash []byte) domain.PhoneCall {
|
||
t.Helper()
|
||
call, err := s.RequestCall(context.Background(), caller, domain.PhoneCallRequest{
|
||
CalleeID: callee,
|
||
RandomID: caller*1000 + callee,
|
||
GAHash: gaHash,
|
||
Protocol: testProtocol(),
|
||
PrivacyP2P: true, // rpc 层算定的 phone_p2p 双向放行(P3 起参与 p2p_allowed AND)
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("RequestCall: %v", err)
|
||
}
|
||
return call
|
||
}
|
||
|
||
func TestPhoneCallHappyPath(t *testing.T) {
|
||
clk := newTestClock()
|
||
s := newTestService(clk)
|
||
ctx := context.Background()
|
||
ga, gaHash := testGA()
|
||
gb := testGB()
|
||
|
||
call := mustRequest(t, s, 1, 2, gaHash)
|
||
if call.State != domain.PhoneCallStateRequested || call.AdminID != 1 || call.ParticipantID != 2 {
|
||
t.Fatalf("requested call = %+v", call)
|
||
}
|
||
|
||
clk.Advance(2 * time.Second)
|
||
ringing, transitioned, err := s.ReceivedCall(ctx, 2, call.ID, call.AccessHash)
|
||
if err != nil || !transitioned || ringing.State != domain.PhoneCallStateRinging || ringing.ReceiveDate == 0 {
|
||
t.Fatalf("ReceivedCall = %+v transitioned=%v err=%v", ringing, transitioned, err)
|
||
}
|
||
if _, again, err := s.ReceivedCall(ctx, 2, call.ID, call.AccessHash); err != nil || again {
|
||
t.Fatalf("second ReceivedCall transitioned=%v err=%v, want idempotent", again, err)
|
||
}
|
||
|
||
accepted, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, gb, testProtocol(), domain.SessionRef{SessionID: 22})
|
||
if err != nil || accepted.State != domain.PhoneCallStateAccepted {
|
||
t.Fatalf("AcceptCall = %+v err=%v", accepted, err)
|
||
}
|
||
if string(accepted.GB) != string(gb) {
|
||
t.Fatalf("accepted.GB mismatch")
|
||
}
|
||
|
||
confirmed, forced, err := s.ConfirmCall(ctx, 1, call.ID, call.AccessHash, ga, 0x1234, testProtocol())
|
||
if err != nil || forced || confirmed.State != domain.PhoneCallStateConfirmed {
|
||
t.Fatalf("ConfirmCall = %+v forced=%v err=%v", confirmed, forced, err)
|
||
}
|
||
if !confirmed.P2PAllowed || confirmed.KeyFingerprint != 0x1234 || confirmed.StartDate == 0 {
|
||
t.Fatalf("confirmed snapshot = %+v", confirmed)
|
||
}
|
||
|
||
clk.Advance(30 * time.Second)
|
||
discarded, already, err := s.DiscardCall(ctx, 2, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonHangup, 30)
|
||
if err != nil || already || discarded.State != domain.PhoneCallStateDiscarded || discarded.Duration != 30 {
|
||
t.Fatalf("DiscardCall = %+v already=%v err=%v", discarded, already, err)
|
||
}
|
||
// 双方同时挂断:后到者幂等拿快照,reason 由先到者决定。
|
||
again, already, err := s.DiscardCall(ctx, 1, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonBusy, 0)
|
||
if err != nil || !already || again.DiscardReason != domain.PhoneCallDiscardReasonHangup {
|
||
t.Fatalf("second DiscardCall = %+v already=%v err=%v", again, already, err)
|
||
}
|
||
}
|
||
|
||
func TestPhoneCallStateErrors(t *testing.T) {
|
||
clk := newTestClock()
|
||
s := newTestService(clk)
|
||
ctx := context.Background()
|
||
ga, gaHash := testGA()
|
||
gb := testGB()
|
||
call := mustRequest(t, s, 1, 2, gaHash)
|
||
|
||
// confirm 前置必须 Accepted。
|
||
if _, _, err := s.ConfirmCall(ctx, 1, call.ID, call.AccessHash, ga, 1, testProtocol()); !errors.Is(err, ErrPeerInvalid) {
|
||
t.Fatalf("confirm before accept err = %v, want ErrPeerInvalid", err)
|
||
}
|
||
// 非被叫不能 accept / receivedCall。
|
||
if _, err := s.AcceptCall(ctx, 1, call.ID, call.AccessHash, gb, testProtocol(), domain.SessionRef{}); !errors.Is(err, ErrPeerInvalid) {
|
||
t.Fatalf("accept by caller err = %v, want ErrPeerInvalid", err)
|
||
}
|
||
if _, _, err := s.ReceivedCall(ctx, 1, call.ID, call.AccessHash); !errors.Is(err, ErrPeerInvalid) {
|
||
t.Fatalf("receivedCall by caller err = %v, want ErrPeerInvalid", err)
|
||
}
|
||
// access_hash 不符。
|
||
if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash+1, gb, testProtocol(), domain.SessionRef{}); !errors.Is(err, ErrPeerInvalid) {
|
||
t.Fatalf("wrong access hash err = %v, want ErrPeerInvalid", err)
|
||
}
|
||
|
||
if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, gb, testProtocol(), domain.SessionRef{}); err != nil {
|
||
t.Fatalf("accept: %v", err)
|
||
}
|
||
if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, gb, testProtocol(), domain.SessionRef{}); !errors.Is(err, ErrAlreadyAccepted) {
|
||
t.Fatalf("double accept err = %v, want ErrAlreadyAccepted", err)
|
||
}
|
||
if _, _, err := s.DiscardCall(ctx, 1, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonHangup, 0); err != nil {
|
||
t.Fatalf("discard: %v", err)
|
||
}
|
||
if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, gb, testProtocol(), domain.SessionRef{}); !errors.Is(err, ErrAlreadyDeclined) {
|
||
t.Fatalf("accept after discard err = %v, want ErrAlreadyDeclined", err)
|
||
}
|
||
}
|
||
|
||
func TestPhoneCallGAHashMismatchForcesDiscard(t *testing.T) {
|
||
clk := newTestClock()
|
||
s := newTestService(clk)
|
||
ctx := context.Background()
|
||
_, gaHash := testGA()
|
||
call := mustRequest(t, s, 1, 2, gaHash)
|
||
if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, testGB(), testProtocol(), domain.SessionRef{}); err != nil {
|
||
t.Fatalf("accept: %v", err)
|
||
}
|
||
wrong := make([]byte, 256)
|
||
wrong[0] = 0x7
|
||
snap, forced, err := s.ConfirmCall(ctx, 1, call.ID, call.AccessHash, wrong, 1, testProtocol())
|
||
if !errors.Is(err, ErrGAHashMismatch) || !forced {
|
||
t.Fatalf("confirm with wrong ga: forced=%v err=%v", forced, err)
|
||
}
|
||
if snap.State != domain.PhoneCallStateDiscarded || snap.DiscardReason != domain.PhoneCallDiscardReasonDisconnect {
|
||
t.Fatalf("forced discard snapshot = %+v", snap)
|
||
}
|
||
}
|
||
|
||
func TestPhoneCallConcurrentAcceptSingleWinner(t *testing.T) {
|
||
clk := newTestClock()
|
||
s := newTestService(clk)
|
||
ctx := context.Background()
|
||
_, gaHash := testGA()
|
||
call := mustRequest(t, s, 1, 2, gaHash)
|
||
|
||
const devices = 8
|
||
var wg sync.WaitGroup
|
||
wins := make(chan int64, devices)
|
||
losses := make(chan error, devices)
|
||
for i := 0; i < devices; i++ {
|
||
wg.Add(1)
|
||
go func(sessionID int64) {
|
||
defer wg.Done()
|
||
_, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, testGB(), testProtocol(), domain.SessionRef{SessionID: sessionID})
|
||
if err == nil {
|
||
wins <- sessionID
|
||
} else {
|
||
losses <- err
|
||
}
|
||
}(int64(100 + i))
|
||
}
|
||
wg.Wait()
|
||
close(wins)
|
||
close(losses)
|
||
if len(wins) != 1 {
|
||
t.Fatalf("winners = %d, want exactly 1", len(wins))
|
||
}
|
||
for err := range losses {
|
||
if !errors.Is(err, ErrAlreadyAccepted) {
|
||
t.Fatalf("loser err = %v, want ErrAlreadyAccepted", err)
|
||
}
|
||
}
|
||
winner := <-wins
|
||
snap, ok := s.Lookup(ctx, call.ID, call.AccessHash)
|
||
if !ok || snap.CalleeDevice.SessionID != winner {
|
||
t.Fatalf("callee device = %+v, want session %d", snap.CalleeDevice, winner)
|
||
}
|
||
}
|
||
|
||
func TestPhoneCallRandomIDIdempotent(t *testing.T) {
|
||
clk := newTestClock()
|
||
s := newTestService(clk)
|
||
ctx := context.Background()
|
||
_, gaHash := testGA()
|
||
req := domain.PhoneCallRequest{CalleeID: 2, RandomID: 777, GAHash: gaHash, Protocol: testProtocol()}
|
||
first, err := s.RequestCall(ctx, 1, req)
|
||
if err != nil {
|
||
t.Fatalf("request: %v", err)
|
||
}
|
||
second, err := s.RequestCall(ctx, 1, req)
|
||
if err != nil || second.ID != first.ID {
|
||
t.Fatalf("retry id = %d err=%v, want %d", second.ID, err, first.ID)
|
||
}
|
||
// 终结后同 random_id 重新可用(新通话)。
|
||
if _, _, err := s.DiscardCall(ctx, 1, first.ID, first.AccessHash, domain.PhoneCallDiscardReasonHangup, 0); err != nil {
|
||
t.Fatalf("discard: %v", err)
|
||
}
|
||
third, err := s.RequestCall(ctx, 1, req)
|
||
if err != nil || third.ID == first.ID {
|
||
t.Fatalf("post-discard request id = %d err=%v, want fresh call", third.ID, err)
|
||
}
|
||
// 旧 tombstone 到期回收时,不得误删已改指向新 call 的 random_id 索引。
|
||
clk.Advance(61 * time.Second)
|
||
retry, err := s.RequestCall(ctx, 1, req)
|
||
if err != nil || retry.ID != third.ID {
|
||
t.Fatalf("retry after old tombstone GC id = %d err=%v, want %d", retry.ID, err, third.ID)
|
||
}
|
||
}
|
||
|
||
func TestPhoneCallQuotaAndExpiry(t *testing.T) {
|
||
clk := newTestClock()
|
||
s := newTestService(clk, func(c *Config) { c.MaxActivePerUser = 2 })
|
||
ctx := context.Background()
|
||
_, gaHash := testGA()
|
||
|
||
for i := int64(0); i < 2; i++ {
|
||
if _, err := s.RequestCall(ctx, 1, domain.PhoneCallRequest{CalleeID: 10 + i, RandomID: i, GAHash: gaHash, Protocol: testProtocol()}); err != nil {
|
||
t.Fatalf("request %d: %v", i, err)
|
||
}
|
||
}
|
||
if _, err := s.RequestCall(ctx, 1, domain.PhoneCallRequest{CalleeID: 99, RandomID: 99, GAHash: gaHash, Protocol: testProtocol()}); !errors.Is(err, ErrOccupyFailed) {
|
||
t.Fatalf("over quota err = %v, want ErrOccupyFailed", err)
|
||
}
|
||
// 未建立通话只能由 ExpireDue 迁入终态,确保 dispatcher 能推送并落历史;
|
||
// registry GC 不得静默删除 active call。
|
||
clk.Advance(91 * time.Second)
|
||
expired := s.ExpireDue(ctx, clk.Now())
|
||
if len(expired) != 2 {
|
||
t.Fatalf("expired = %d, want 2", len(expired))
|
||
}
|
||
if _, err := s.RequestCall(ctx, 1, domain.PhoneCallRequest{CalleeID: 99, RandomID: 99, GAHash: gaHash, Protocol: testProtocol()}); err != nil {
|
||
t.Fatalf("request after expiry: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestPhoneCallRegistryCapacityDoesNotEvictConfirmedCall(t *testing.T) {
|
||
clk := newTestClock()
|
||
s := newTestService(clk, func(c *Config) { c.MaxRegistryEntries = 1 })
|
||
ctx := context.Background()
|
||
ga, gaHash := testGA()
|
||
|
||
confirmed := mustRequest(t, s, 1, 2, gaHash)
|
||
if _, err := s.AcceptCall(ctx, 2, confirmed.ID, confirmed.AccessHash, testGB(), testProtocol(), domain.SessionRef{}); err != nil {
|
||
t.Fatalf("accept: %v", err)
|
||
}
|
||
if _, _, err := s.ConfirmCall(ctx, 1, confirmed.ID, confirmed.AccessHash, ga, 1, testProtocol()); err != nil {
|
||
t.Fatalf("confirm: %v", err)
|
||
}
|
||
|
||
clk.Advance(365 * 24 * time.Hour)
|
||
if got := s.ExpireDue(ctx, clk.Now()); len(got) != 0 {
|
||
t.Fatalf("confirmed call expired after one year: %+v", got)
|
||
}
|
||
if _, err := s.RequestCall(ctx, 3, domain.PhoneCallRequest{
|
||
CalleeID: 4, RandomID: 2, GAHash: gaHash, Protocol: testProtocol(),
|
||
}); !errors.Is(err, ErrOccupyFailed) {
|
||
t.Fatalf("request at registry capacity err = %v, want ErrOccupyFailed", err)
|
||
}
|
||
if snap, ok := s.Lookup(ctx, confirmed.ID, confirmed.AccessHash); !ok || snap.State != domain.PhoneCallStateConfirmed {
|
||
t.Fatalf("confirmed call = %+v ok=%v, want preserved", snap, ok)
|
||
}
|
||
|
||
if _, _, err := s.DiscardCall(ctx, 1, confirmed.ID, confirmed.AccessHash, domain.PhoneCallDiscardReasonHangup, 1); err != nil {
|
||
t.Fatalf("discard: %v", err)
|
||
}
|
||
clk.Advance(61 * time.Second)
|
||
if _, err := s.RequestCall(ctx, 3, domain.PhoneCallRequest{
|
||
CalleeID: 4, RandomID: 2, GAHash: gaHash, Protocol: testProtocol(),
|
||
}); err != nil {
|
||
t.Fatalf("request after tombstone GC: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestPhoneCallTombstoneGC(t *testing.T) {
|
||
clk := newTestClock()
|
||
s := newTestService(clk)
|
||
ctx := context.Background()
|
||
_, gaHash := testGA()
|
||
call := mustRequest(t, s, 1, 2, gaHash)
|
||
if _, _, err := s.DiscardCall(ctx, 1, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonHangup, 0); err != nil {
|
||
t.Fatalf("discard: %v", err)
|
||
}
|
||
if _, ok := s.Lookup(ctx, call.ID, call.AccessHash); !ok {
|
||
t.Fatalf("tombstone should be visible before TTL")
|
||
}
|
||
clk.Advance(61 * time.Second)
|
||
mustRequest(t, s, 3, 4, gaHash) // 触发 sweep
|
||
if _, ok := s.Lookup(ctx, call.ID, call.AccessHash); ok {
|
||
t.Fatalf("tombstone should be collected after TTL")
|
||
}
|
||
}
|
||
|
||
func TestPhoneCallDurationOnlyWhenConfirmed(t *testing.T) {
|
||
clk := newTestClock()
|
||
s := newTestService(clk)
|
||
ctx := context.Background()
|
||
_, gaHash := testGA()
|
||
call := mustRequest(t, s, 1, 2, gaHash)
|
||
snap, _, err := s.DiscardCall(ctx, 2, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonBusy, 55)
|
||
if err != nil || snap.Duration != 0 {
|
||
t.Fatalf("unconfirmed discard duration = %d err=%v, want 0", snap.Duration, err)
|
||
}
|
||
}
|
||
|
||
func TestPhoneCallSignal(t *testing.T) {
|
||
clk := newTestClock()
|
||
s := newTestService(clk, func(c *Config) { c.SignalingRatePerSecond = 2 })
|
||
ctx := context.Background()
|
||
_, gaHash := testGA()
|
||
call := mustRequest(t, s, 1, 2, gaHash)
|
||
|
||
// Accepted 前不可转发。
|
||
if _, err := s.Signal(ctx, 1, call.ID, call.AccessHash, func(int64, domain.SessionRef) {}); !errors.Is(err, ErrPeerInvalid) {
|
||
t.Fatalf("signal before accept err = %v, want ErrPeerInvalid", err)
|
||
}
|
||
if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, testGB(), testProtocol(), domain.SessionRef{}); err != nil {
|
||
t.Fatalf("accept: %v", err)
|
||
}
|
||
var forwarded []int64
|
||
forward := func(peer int64, _ domain.SessionRef) { forwarded = append(forwarded, peer) }
|
||
for i := 0; i < 3; i++ {
|
||
if _, err := s.Signal(ctx, 1, call.ID, call.AccessHash, forward); err != nil {
|
||
t.Fatalf("signal %d: %v", i, err)
|
||
}
|
||
}
|
||
// 限速 2/s:第三条被静默丢弃。
|
||
if len(forwarded) != 2 || forwarded[0] != 2 || forwarded[1] != 2 {
|
||
t.Fatalf("forwarded = %v, want [2 2]", forwarded)
|
||
}
|
||
clk.Advance(time.Second)
|
||
if drop, err := s.Signal(ctx, 2, call.ID, call.AccessHash, forward); err != nil || drop {
|
||
t.Fatalf("signal new window drop=%v err=%v", drop, err)
|
||
}
|
||
if forwarded[len(forwarded)-1] != 1 {
|
||
t.Fatalf("callee→caller forward peer = %d, want 1", forwarded[len(forwarded)-1])
|
||
}
|
||
// 终态尾包静默吞掉。
|
||
if _, _, err := s.DiscardCall(ctx, 1, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonHangup, 0); err != nil {
|
||
t.Fatalf("discard: %v", err)
|
||
}
|
||
if drop, err := s.Signal(ctx, 1, call.ID, call.AccessHash, forward); err != nil || !drop {
|
||
t.Fatalf("signal after discard drop=%v err=%v, want drop", drop, err)
|
||
}
|
||
}
|
||
|
||
func TestNegotiateProtocol(t *testing.T) {
|
||
base := func(min, max int, versions ...string) domain.PhoneCallProtocol {
|
||
return domain.PhoneCallProtocol{UDPP2P: true, UDPReflector: true, MinLayer: min, MaxLayer: max, LibraryVersions: versions}
|
||
}
|
||
t.Run("layer intersection", func(t *testing.T) {
|
||
out, err := negotiateProtocol(base(65, 92, "9.0.0"), base(70, 110, "9.0.0"))
|
||
if err != nil || out.MinLayer != 70 || out.MaxLayer != 92 {
|
||
t.Fatalf("negotiated = %+v err=%v", out, err)
|
||
}
|
||
})
|
||
t.Run("layer disjoint", func(t *testing.T) {
|
||
if _, err := negotiateProtocol(base(65, 70, "9.0.0"), base(80, 92, "9.0.0")); !errors.Is(err, ErrProtocolCompatLayerInvalid) {
|
||
t.Fatalf("err = %v, want ErrProtocolCompatLayerInvalid", err)
|
||
}
|
||
})
|
||
t.Run("best common version is semver max", func(t *testing.T) {
|
||
out, err := negotiateProtocol(base(65, 92, "11.0.0", "9.0.0", "2.4.4"), base(65, 92, "2.4.4", "9.0.0"))
|
||
if err != nil || len(out.LibraryVersions) != 1 || out.LibraryVersions[0] != "9.0.0" {
|
||
t.Fatalf("versions = %v err=%v, want [9.0.0]", out.LibraryVersions, err)
|
||
}
|
||
})
|
||
t.Run("preferred version beats semver max", func(t *testing.T) {
|
||
// ⚠ "9.0.0" 优先于更高版本:DrKLO 视频 gate 是字符串字典序比较
|
||
//("1x.0.0" < "2.7.7" 会判不支持视频),且 12/13 走 V3 SCTP 信令。
|
||
out, err := negotiateProtocol(base(65, 92, "13.0.0", "10.0.0", "9.0.0"), base(65, 92, "9.0.0", "10.0.0", "13.0.0"))
|
||
if err != nil || out.LibraryVersions[0] != "9.0.0" {
|
||
t.Fatalf("versions = %v err=%v, want preferred [9.0.0]", out.LibraryVersions, err)
|
||
}
|
||
})
|
||
t.Run("numeric compare fallback not lexicographic", func(t *testing.T) {
|
||
// 交集无 preferred 版本时退化为语义化最高(数值比较,非字典序)。
|
||
out, err := negotiateProtocol(base(65, 92, "10.0.0", "11.0.0"), base(65, 92, "11.0.0", "10.0.0"))
|
||
if err != nil || out.LibraryVersions[0] != "11.0.0" {
|
||
t.Fatalf("versions = %v err=%v, want [11.0.0]", out.LibraryVersions, err)
|
||
}
|
||
})
|
||
t.Run("no common versions passes callee list through", func(t *testing.T) {
|
||
// ⚠ P1-3:版本无交集绝不拒绝通话,透传被叫列表。
|
||
out, err := negotiateProtocol(base(65, 92, "11.0.0"), base(65, 92, "2.4.4", "3.0.0"))
|
||
if err != nil || len(out.LibraryVersions) != 2 || out.LibraryVersions[0] != "2.4.4" {
|
||
t.Fatalf("versions = %v err=%v, want callee passthrough", out.LibraryVersions, err)
|
||
}
|
||
})
|
||
}
|
||
|
||
func TestValidateProtocol(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
p domain.PhoneCallProtocol
|
||
want error
|
||
}{
|
||
{"min over max", domain.PhoneCallProtocol{UDPP2P: true, MinLayer: 93, MaxLayer: 92, LibraryVersions: []string{"9.0.0"}}, ErrProtocolLayerInvalid},
|
||
{"max below 65", domain.PhoneCallProtocol{UDPP2P: true, MinLayer: 60, MaxLayer: 64, LibraryVersions: []string{"9.0.0"}}, ErrProtocolCompatLayerInvalid},
|
||
{"no transport flags", domain.PhoneCallProtocol{MinLayer: 65, MaxLayer: 92, LibraryVersions: []string{"9.0.0"}}, ErrProtocolFlagsInvalid},
|
||
{"no versions", domain.PhoneCallProtocol{UDPP2P: true, MinLayer: 65, MaxLayer: 92}, ErrProtocolFlagsInvalid},
|
||
{"ok", testProtocol(), nil},
|
||
}
|
||
for _, tc := range cases {
|
||
if err := validateProtocol(tc.p); !errors.Is(err, tc.want) {
|
||
t.Fatalf("%s: err = %v, want %v", tc.name, err, tc.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestPhoneCallExpireDue(t *testing.T) {
|
||
clk := newTestClock()
|
||
s := newTestService(clk)
|
||
ctx := context.Background()
|
||
ga, gaHash := testGA()
|
||
|
||
// 三通通话:振铃中(→missed)、Accepted 悬挂(→disconnect)、Confirmed(不回收)。
|
||
ringingCall := mustRequest(t, s, 1, 2, gaHash)
|
||
acceptedCall, err := s.RequestCall(ctx, 3, domain.PhoneCallRequest{CalleeID: 4, RandomID: 1, GAHash: gaHash, Protocol: testProtocol()})
|
||
if err != nil {
|
||
t.Fatalf("request accepted call: %v", err)
|
||
}
|
||
if _, err := s.AcceptCall(ctx, 4, acceptedCall.ID, acceptedCall.AccessHash, testGB(), testProtocol(), domain.SessionRef{}); err != nil {
|
||
t.Fatalf("accept: %v", err)
|
||
}
|
||
confirmedCall, err := s.RequestCall(ctx, 5, domain.PhoneCallRequest{CalleeID: 6, RandomID: 2, GAHash: gaHash, Protocol: testProtocol()})
|
||
if err != nil {
|
||
t.Fatalf("request confirmed call: %v", err)
|
||
}
|
||
if _, err := s.AcceptCall(ctx, 6, confirmedCall.ID, confirmedCall.AccessHash, testGB(), testProtocol(), domain.SessionRef{}); err != nil {
|
||
t.Fatalf("accept confirmed: %v", err)
|
||
}
|
||
if _, _, err := s.ConfirmCall(ctx, 5, confirmedCall.ID, confirmedCall.AccessHash, ga, 1, testProtocol()); err != nil {
|
||
t.Fatalf("confirm: %v", err)
|
||
}
|
||
|
||
if got := s.ExpireDue(ctx, clk.Now()); len(got) != 0 {
|
||
t.Fatalf("nothing should expire yet, got %d", len(got))
|
||
}
|
||
clk.Advance(91 * time.Second)
|
||
expired := s.ExpireDue(ctx, clk.Now())
|
||
if len(expired) != 2 {
|
||
t.Fatalf("expired = %d, want 2 (ringing+accepted)", len(expired))
|
||
}
|
||
reasons := map[int64]domain.PhoneCallDiscardReason{}
|
||
for _, c := range expired {
|
||
reasons[c.ID] = c.DiscardReason
|
||
}
|
||
if reasons[ringingCall.ID] != domain.PhoneCallDiscardReasonMissed {
|
||
t.Fatalf("ringing call reason = %s, want missed", reasons[ringingCall.ID])
|
||
}
|
||
if reasons[acceptedCall.ID] != domain.PhoneCallDiscardReasonDisconnect {
|
||
t.Fatalf("accepted call reason = %s, want disconnect", reasons[acceptedCall.ID])
|
||
}
|
||
// Confirmed 通话不受服务端时长限制。
|
||
if snap, ok := s.Lookup(ctx, confirmedCall.ID, confirmedCall.AccessHash); !ok || snap.State != domain.PhoneCallStateConfirmed {
|
||
t.Fatalf("confirmed call = %+v ok=%v, want untouched", snap, ok)
|
||
}
|
||
// 幂等:再跑一轮无新增。
|
||
if got := s.ExpireDue(ctx, clk.Now()); len(got) != 0 {
|
||
t.Fatalf("second ExpireDue = %d, want 0", len(got))
|
||
}
|
||
// 回归:旧 registry GC 会在 2×RingTimeout 后静默删除 Confirmed,导致后续
|
||
// sendSignalingData/discardCall 返回 CALL_PEER_INVALID。
|
||
clk.Advance(91 * time.Second)
|
||
if got := s.ExpireDue(ctx, clk.Now()); len(got) != 0 {
|
||
t.Fatalf("confirmed call expired after 2×RingTimeout: %+v", got)
|
||
}
|
||
if _, ok := s.Lookup(ctx, confirmedCall.ID, confirmedCall.AccessHash); !ok {
|
||
t.Fatal("confirmed call must remain addressable after 2×RingTimeout")
|
||
}
|
||
}
|