package signaling import ( "testing" "time" ) type fakeCaller struct { started bool terminated bool terminateReason TerminationReason } func (f *fakeCaller) Start() { f.started = true } func (f *fakeCaller) Terminate(r TerminationReason) { f.terminated = true f.terminateReason = r } type fakeCallee struct { ringing bool accepted bool terminated bool terminateReason TerminationReason } func (f *fakeCallee) Ringing() { f.ringing = true } func (f *fakeCallee) Accept() { f.accepted = true } func (f *fakeCallee) Terminate(r TerminationReason) { f.terminated = true f.terminateReason = r } type fakeTimers struct { set map[TimerKind]time.Duration cancel map[TimerKind]int } func newFakeTimers() *fakeTimers { return &fakeTimers{set: map[TimerKind]time.Duration{}, cancel: map[TimerKind]int{}} } func (f *fakeTimers) Set(k TimerKind, d time.Duration) { f.set[k] = d } func (f *fakeTimers) Cancel(k TimerKind) { f.cancel[k]++ } type fakes struct { caller *fakeCaller callee *fakeCallee timers *fakeTimers } func newBridge() (*Bridge, *fakes) { f := &fakes{caller: &fakeCaller{}, callee: &fakeCallee{}, timers: newFakeTimers()} b := New(Config{Caller: f.caller, Callee: f.callee, Timers: f.timers}) return b, f } func driveTo(t *testing.T, target State) (*Bridge, *fakes) { t.Helper() b, f := newBridge() if target == StateIdle { return b, f } b.Start() if target == StateOriginating { return b, f } b.Ringing() if target == StateRinging { return b, f } b.CalleeAccepted() if target == StateAccepted { return b, f } b.MediaConnected(CallerSide) b.MediaConnected(CalleeSide) if target == StateEstablished { return b, f } t.Fatalf("driveTo: unknown target %s", target) return nil, nil } func TestTerminalStateIgnoresEvents(t *testing.T) { b, _ := driveTo(t, StateRinging) b.Timeout(TimerRing) if b.State() != StateTerminated { t.Fatalf("setup: state = %s", b.State()) } b.Start() b.Ringing() b.CalleeAccepted() b.MediaConnected(CallerSide) b.MediaConnected(CalleeSide) b.Terminate(ReasonHangup) b.Timeout(TimerExchange) if b.State() != StateTerminated { t.Errorf("state drifted to %s", b.State()) } } // teardown matrix: every call-ending event tears down both sides via // Terminate, leaves the bridge in StateTerminated, and cancels every timer func checkTerminated(t *testing.T, b *Bridge, f *fakes, wantReason TerminationReason) { t.Helper() if b.State() != StateTerminated { t.Fatalf("bridge state = %s, want terminated", b.State()) } if !f.caller.terminated { t.Errorf("caller.terminated = false, want true") } if !f.callee.terminated { t.Errorf("callee.terminated = false, want true") } if f.caller.terminateReason != wantReason { t.Errorf("caller.terminateReason = %v, want %v", f.caller.terminateReason, wantReason) } if f.callee.terminateReason != wantReason { t.Errorf("callee.terminateReason = %v, want %v", f.callee.terminateReason, wantReason) } for _, k := range []TimerKind{TimerRing, TimerExchange, TimerConnect} { if f.timers.cancel[k] == 0 { t.Errorf("timer %v not cancelled on terminate", k) } } } func TestBridgeTeardown_TearsDownBoth(t *testing.T) { cases := []struct { name string states []State trigger func(*Bridge) want TerminationReason }{ {"Terminate", []State{StateOriginating, StateRinging, StateAccepted, StateEstablished}, func(b *Bridge) { b.Terminate(ReasonHangup) }, ReasonHangup}, {"MediaFailed", []State{StateAccepted, StateEstablished}, func(b *Bridge) { b.MediaFailed(ReasonMediaFailed) }, ReasonMediaFailed}, {"TimerRing", []State{StateOriginating, StateRinging}, func(b *Bridge) { b.Timeout(TimerRing) }, ReasonRingTimeout}, {"TimerExchange", []State{StateAccepted}, func(b *Bridge) { b.Timeout(TimerExchange) }, ReasonExchangeTimeout}, {"TimerConnect", []State{StateEstablished}, func(b *Bridge) { b.Timeout(TimerConnect) }, ReasonConnectTimeout}, } for _, tc := range cases { for _, st := range tc.states { t.Run(tc.name+"/"+st.String(), func(t *testing.T) { b, f := driveTo(t, st) tc.trigger(b) checkTerminated(t, b, f, tc.want) }) } } } // further teardowns after the first must not re-fire Terminate func TestBridgeTeardown_Idempotent(t *testing.T) { b, f := driveTo(t, StateEstablished) b.Terminate(ReasonHangup) if !f.caller.terminated || !f.callee.terminated { t.Fatalf("setup: both sides should be torn down") } // subsequent Terminates must no-op; recorded reasons must not change f.caller.terminateReason = ReasonUnknown // sentinel f.callee.terminateReason = ReasonUnknown // sentinel b.Terminate(ReasonMediaFailed) b.Terminate(ReasonDecline) b.MediaFailed(ReasonMediaFailed) b.Timeout(TimerRing) if f.caller.terminateReason != ReasonUnknown { t.Errorf("caller.Terminate re-fired (reason now %v)", f.caller.terminateReason) } if f.callee.terminateReason != ReasonUnknown { t.Errorf("callee.Terminate re-fired (reason now %v)", f.callee.terminateReason) } } // 0 before Established, positive after; needed by tgsig.DiscardCall func TestBridge_Duration(t *testing.T) { b, _ := newBridge() if d := b.Duration(); d != 0 { t.Errorf("Duration before Start = %v, want 0", d) } b.Start() b.Ringing() b.CalleeAccepted() b.MediaConnected(CallerSide) if d := b.Duration(); d != 0 { t.Errorf("Duration after one-sided MediaConnected = %v, want 0 (not yet Established)", d) } b.MediaConnected(CalleeSide) time.Sleep(20 * time.Millisecond) if d := b.Duration(); d < 10*time.Millisecond { t.Errorf("Duration after Established = %v, want >= 10ms", d) } } // only one side up: cancel TimerExchange, arm TimerConnect, don't fire OnEstablished func TestBridge_MediaConnectedOneSide_DoesNotEstablish(t *testing.T) { f := &fakes{caller: &fakeCaller{}, callee: &fakeCallee{}, timers: newFakeTimers()} var established int b := New(Config{ Caller: f.caller, Callee: f.callee, Timers: f.timers, OnEstablished: func() { established++ }, }) b.Start() b.Ringing() b.CalleeAccepted() b.MediaConnected(CallerSide) if b.State() != StateAccepted { t.Errorf("state after one-sided MediaConnected = %s, want accepted", b.State()) } if established != 0 { t.Errorf("OnEstablished fired prematurely (%d times) with only one side up", established) } if _, ok := f.timers.set[TimerConnect]; !ok { t.Errorf("TimerConnect not armed after first MediaConnected") } if f.timers.cancel[TimerExchange] == 0 { t.Errorf("TimerExchange not cancelled after first MediaConnected") } } // OnEstablished / OnTerminated fire exactly once func TestBridge_LifecycleHooks(t *testing.T) { f := &fakes{caller: &fakeCaller{}, callee: &fakeCallee{}, timers: newFakeTimers()} var established, terminated int b := New(Config{ Caller: f.caller, Callee: f.callee, Timers: f.timers, OnEstablished: func() { established++ }, OnTerminated: func() { terminated++ }, }) b.Start() b.Ringing() b.CalleeAccepted() b.MediaConnected(CallerSide) b.MediaConnected(CalleeSide) b.MediaConnected(CallerSide) // duplicate post-Established; must be no-op b.Terminate(ReasonHangup) b.Terminate(ReasonHangup) // duplicate; must not re-fire OnTerminated if established != 1 { t.Errorf("OnEstablished fired %d times, want 1", established) } if terminated != 1 { t.Errorf("OnTerminated fired %d times, want 1", terminated) } } // CalleeAccepted outside Originating/Ringing must be a no-op func TestBridge_CalleeAcceptedStateGuard(t *testing.T) { for _, st := range []State{StateIdle, StateAccepted, StateEstablished, StateTerminated} { t.Run(st.String(), func(t *testing.T) { var b *Bridge var f *fakes if st == StateTerminated { b, f = driveTo(t, StateRinging) b.Terminate(ReasonHangup) } else { b, f = driveTo(t, st) } // driveTo invokes CalleeAccepted along the way; reset so we // observe only the upcoming call f.callee.accepted = false b.CalleeAccepted() if f.callee.accepted { t.Errorf("Callee.Accept fired from state %s", st) } }) } }