owpengram-server/internal/app/phone/service_test.go

479 lines
18 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 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,
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)
}
}
func TestPhoneCallQuotaAndSweep(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)
}
// 双端崩溃兜底:超过 2×RingTimeout 的僵尸通话被纯年龄 GC 回收,配额释放。
clk.Advance(181 * time.Second)
if _, err := s.RequestCall(ctx, 1, domain.PhoneCallRequest{CalleeID: 99, RandomID: 99, GAHash: gaHash, Protocol: testProtocol()}); err != nil {
t.Fatalf("request after sweep: %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))
}
}