Add FCM push notifications, queue alerts, caller ID fixes, and auto-revert agent status
All checks were successful
Create Release / build (push) Successful in 6s
All checks were successful
Create Release / build (push) Successful in 6s
Server-side: - Add push credential auto-creation for FCM incoming call notifications - Add queue alert FCM notifications (data-only for background delivery) - Add queue alert cancellation on call accept/disconnect - Fix caller ID to show caller's number instead of Twilio number - Fix FCM token storage when refresh_token is null - Add pre_call_status tracking to revert agent status 30s after call ends - Add SSE fallback polling for mobile app connectivity Mobile app: - Add Android telecom permissions and phone account registration - Add VoiceFirebaseMessagingService for incoming call push handling - Add insistent queue alert notifications with custom sound - Fix caller number display on active call screen - Add caller ID selection dropdown on dashboard - Add phone numbers endpoint and provider support - Add unit tests for CallInfo, QueueState, and CallProvider - Remove local.properties from tracking, add .gitignore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
367
mobile/test/call_provider_test.dart
Normal file
367
mobile/test/call_provider_test.dart
Normal file
@@ -0,0 +1,367 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user