import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:twilio_voice/twilio_voice.dart'; import 'package:twp_softphone/models/call_info.dart'; import 'package:twp_softphone/providers/call_provider.dart'; import 'package:twp_softphone/services/voice_service.dart'; /// Minimal mock of VoiceService for testing CallProvider logic. /// Only stubs methods that CallProvider calls directly. class MockVoiceService implements VoiceService { final StreamController _eventController = StreamController.broadcast(); bool makeCallResult = true; bool acceptQueueCallShouldThrow = false; String? lastCallTo; String? lastCallerId; bool answerCalled = false; bool hangUpCalled = false; @override Stream get callEvents => _eventController.stream; void emitEvent(CallEvent event) => _eventController.add(event); @override Future makeCall(String to, {String? callerId}) async { lastCallTo = to; lastCallerId = callerId; return makeCallResult; } @override Future answer() async { answerCalled = true; } @override Future hangUp() async { hangUpCalled = true; } @override Future reject() async {} @override Future toggleMute(bool mute) async {} @override Future toggleSpeaker(bool speaker) async {} @override Future sendDigits(String digits) async {} @override Future>> getQueueCalls(int queueId) async => []; @override Future acceptQueueCall(String callSid) async { if (acceptQueueCallShouldThrow) { throw Exception('Network error'); } } @override Future holdCall(String callSid) async {} @override Future unholdCall(String callSid) async {} @override Future transferCall(String callSid, String target) async {} @override Future initialize({String? deviceToken}) async {} @override String? get identity => 'agent2testuser'; @override void dispose() { _eventController.close(); } // Unused stubs required by the interface @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } void main() { group('CallProvider.makeCall', () { late MockVoiceService mockVoice; late CallProvider provider; setUp(() { mockVoice = MockVoiceService(); provider = CallProvider(mockVoice); }); tearDown(() { provider.dispose(); mockVoice.dispose(); }); test('sets state to connecting and passes number', () async { mockVoice.makeCallResult = true; await provider.makeCall('+19095737372'); expect(mockVoice.lastCallTo, '+19095737372'); expect(provider.callInfo.state, CallState.connecting); expect(provider.callInfo.callerNumber, '+19095737372'); }); test('passes callerId when provided', () async { mockVoice.makeCallResult = true; await provider.makeCall('+19095737372', callerId: '+19516215107'); expect(mockVoice.lastCallerId, '+19516215107'); }); test('resets to idle when call.place() returns false', () async { mockVoice.makeCallResult = false; await provider.makeCall('+19095737372'); expect(provider.callInfo.state, CallState.idle); expect(provider.callInfo.callerNumber, isNull); }); test('stays connecting when call.place() returns true', () async { mockVoice.makeCallResult = true; await provider.makeCall('+19095737372'); expect(provider.callInfo.state, CallState.connecting); expect(provider.callInfo.callerNumber, '+19095737372'); }); }); group('CallProvider.hangUp', () { late MockVoiceService mockVoice; late CallProvider provider; setUp(() { mockVoice = MockVoiceService(); provider = CallProvider(mockVoice); }); tearDown(() { provider.dispose(); mockVoice.dispose(); }); test('resets to idle even if SDK does not fire callEnded', () async { // Simulate a connecting state mockVoice.makeCallResult = true; await provider.makeCall('+19095737372'); expect(provider.callInfo.state, CallState.connecting); // Hang up without SDK firing callEnded await provider.hangUp(); expect(provider.callInfo.state, CallState.idle); expect(provider.callInfo.callerNumber, isNull); expect(mockVoice.hangUpCalled, true); }); }); group('CallProvider state transitions', () { test('outbound: connecting preserves callerNumber through state changes', () { // Simulating what CallProvider does internally var info = const CallInfo(); // makeCall sets connecting + callerNumber info = info.copyWith(state: CallState.connecting, callerNumber: '+19095737372'); expect(info.state, CallState.connecting); expect(info.callerNumber, '+19095737372'); // SDK fires connected — callerNumber preserved info = info.copyWith(state: CallState.connected); expect(info.state, CallState.connected); expect(info.callerNumber, '+19095737372'); }); test('makeCall failure resets cleanly to idle', () { var info = const CallInfo(); // makeCall sets connecting info = info.copyWith(state: CallState.connecting, callerNumber: '+19095737372'); expect(info.state, CallState.connecting); // call.place() returns false -> reset info = const CallInfo(); expect(info.state, CallState.idle); expect(info.callerNumber, isNull); expect(info.isActive, false); }); }); group('CallProvider.acceptQueueCall', () { late MockVoiceService mockVoice; late CallProvider provider; setUp(() { mockVoice = MockVoiceService(); provider = CallProvider(mockVoice); }); tearDown(() { provider.dispose(); mockVoice.dispose(); }); test('sets state to connecting before server call', () async { await provider.acceptQueueCall('CA123abc'); expect(provider.callInfo.state, CallState.connecting); }); test('auto-answers incoming call after acceptQueueCall', () async { await provider.acceptQueueCall('CA123abc'); // Simulate the FCM incoming call event that arrives after server redirect mockVoice.emitEvent(CallEvent.incoming); // Allow the stream listener to process await Future.delayed(Duration.zero); // Should have auto-answered expect(mockVoice.answerCalled, true); expect(provider.callInfo.state, CallState.connecting); }); test('normal incoming call shows ringing without auto-answer', () async { // Without calling acceptQueueCall first mockVoice.emitEvent(CallEvent.incoming); await Future.delayed(Duration.zero); expect(mockVoice.answerCalled, false); expect(provider.callInfo.state, CallState.ringing); }); test('connected event after auto-answer sets connected state', () async { await provider.acceptQueueCall('CA123abc'); mockVoice.emitEvent(CallEvent.incoming); await Future.delayed(Duration.zero); expect(mockVoice.answerCalled, true); mockVoice.emitEvent(CallEvent.connected); await Future.delayed(Duration.zero); expect(provider.callInfo.state, CallState.connected); }); test('resets to idle on API error and clears pendingAutoAnswer', () async { mockVoice.acceptQueueCallShouldThrow = true; await provider.acceptQueueCall('CA123abc'); // Should have reset to idle after error expect(provider.callInfo.state, CallState.idle); // Future incoming call should NOT be auto-answered mockVoice.emitEvent(CallEvent.incoming); await Future.delayed(Duration.zero); expect(mockVoice.answerCalled, false); expect(provider.callInfo.state, CallState.ringing); }); }); group('CallProvider.hangUp edge cases', () { late MockVoiceService mockVoice; late CallProvider provider; setUp(() { mockVoice = MockVoiceService(); provider = CallProvider(mockVoice); }); tearDown(() { provider.dispose(); mockVoice.dispose(); }); test('hangUp when already idle is a no-op', () async { expect(provider.callInfo.state, CallState.idle); await provider.hangUp(); expect(provider.callInfo.state, CallState.idle); expect(mockVoice.hangUpCalled, true); }); test('hangUp clears pendingAutoAnswer flag', () async { await provider.acceptQueueCall('CA123abc'); expect(provider.callInfo.state, CallState.connecting); await provider.hangUp(); expect(provider.callInfo.state, CallState.idle); // Incoming call should NOT auto-answer after hangUp cleared the flag mockVoice.emitEvent(CallEvent.incoming); await Future.delayed(Duration.zero); expect(mockVoice.answerCalled, false); expect(provider.callInfo.state, CallState.ringing); }); }); group('CallProvider.toggleMute and toggleSpeaker', () { late MockVoiceService mockVoice; late CallProvider provider; setUp(() { mockVoice = MockVoiceService(); provider = CallProvider(mockVoice); }); tearDown(() { provider.dispose(); mockVoice.dispose(); }); test('toggleMute flips isMuted state', () async { expect(provider.callInfo.isMuted, false); await provider.toggleMute(); expect(provider.callInfo.isMuted, true); await provider.toggleMute(); expect(provider.callInfo.isMuted, false); }); test('toggleSpeaker flips isSpeakerOn state', () async { expect(provider.callInfo.isSpeakerOn, false); await provider.toggleSpeaker(); expect(provider.callInfo.isSpeakerOn, true); await provider.toggleSpeaker(); expect(provider.callInfo.isSpeakerOn, false); }); }); group('CallProvider.callEnded', () { late MockVoiceService mockVoice; late CallProvider provider; setUp(() { mockVoice = MockVoiceService(); provider = CallProvider(mockVoice); }); tearDown(() { provider.dispose(); mockVoice.dispose(); }); test('callEnded resets state completely', () async { // Set up a connected call mockVoice.makeCallResult = true; await provider.makeCall('+19095737372'); mockVoice.emitEvent(CallEvent.connected); await Future.delayed(Duration.zero); expect(provider.callInfo.state, CallState.connected); expect(provider.callInfo.callerNumber, '+19095737372'); // End the call mockVoice.emitEvent(CallEvent.callEnded); await Future.delayed(Duration.zero); expect(provider.callInfo.state, CallState.idle); expect(provider.callInfo.callerNumber, isNull); expect(provider.callInfo.callSid, isNull); expect(provider.callInfo.isActive, false); }); }); }