368 lines
11 KiB
Dart
368 lines
11 KiB
Dart
|
|
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<CallEvent> _eventController =
|
||
|
|
StreamController<CallEvent>.broadcast();
|
||
|
|
bool makeCallResult = true;
|
||
|
|
bool acceptQueueCallShouldThrow = false;
|
||
|
|
String? lastCallTo;
|
||
|
|
String? lastCallerId;
|
||
|
|
bool answerCalled = false;
|
||
|
|
bool hangUpCalled = false;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Stream<CallEvent> get callEvents => _eventController.stream;
|
||
|
|
|
||
|
|
void emitEvent(CallEvent event) => _eventController.add(event);
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<bool> makeCall(String to, {String? callerId}) async {
|
||
|
|
lastCallTo = to;
|
||
|
|
lastCallerId = callerId;
|
||
|
|
return makeCallResult;
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<void> answer() async {
|
||
|
|
answerCalled = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<void> hangUp() async {
|
||
|
|
hangUpCalled = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<void> reject() async {}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<void> toggleMute(bool mute) async {}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<void> toggleSpeaker(bool speaker) async {}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<void> sendDigits(String digits) async {}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<List<Map<String, dynamic>>> getQueueCalls(int queueId) async => [];
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<void> acceptQueueCall(String callSid) async {
|
||
|
|
if (acceptQueueCallShouldThrow) {
|
||
|
|
throw Exception('Network error');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<void> holdCall(String callSid) async {}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<void> unholdCall(String callSid) async {}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<void> transferCall(String callSid, String target) async {}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<void> 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);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|