forked from eatvio/eatvio_app
feat(video_calls): continous ringing on a defferent device fix
This commit is contained in:
@@ -52,6 +52,7 @@ void main() {
|
||||
videoCallRepository: mockRepository,
|
||||
foregroundCoordinator: foreground,
|
||||
lifecycleBinding: lifecycleBinding,
|
||||
resolveHwId: () async => 'test-hw-id-android-01',
|
||||
);
|
||||
mockRoom = MockRoom();
|
||||
when(mockRoom.connectionState).thenReturn(lk.ConnectionState.connected);
|
||||
@@ -94,6 +95,13 @@ void main() {
|
||||
await manager.declineIncomingCall('test-uuid');
|
||||
|
||||
verifyNever(mockRepository.notifyCallEnded(any));
|
||||
verifyNever(
|
||||
mockRepository.notifyCallDeclined(
|
||||
conversationUuid: anyNamed('conversationUuid'),
|
||||
hwId: anyNamed('hwId'),
|
||||
callId: anyNamed('callId'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -112,15 +120,34 @@ void main() {
|
||||
);
|
||||
|
||||
test(
|
||||
'decline of a call that was never joined notifies the backend',
|
||||
'decline of a call that was never joined notifies the backend via /decline',
|
||||
() async {
|
||||
when(
|
||||
mockRepository.notifyCallEnded(any),
|
||||
).thenAnswer((_) async => const VideoCallEndedResponse(sent: true));
|
||||
mockRepository.notifyCallDeclined(
|
||||
conversationUuid: anyNamed('conversationUuid'),
|
||||
hwId: anyNamed('hwId'),
|
||||
callId: anyNamed('callId'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => const VideoCallDeclineResponse(
|
||||
sent: true,
|
||||
devicesNotified: 1,
|
||||
),
|
||||
);
|
||||
|
||||
await manager.declineIncomingCall('other-uuid');
|
||||
await manager.declineIncomingCall(
|
||||
'other-uuid',
|
||||
callId: 'call-uuid-1',
|
||||
);
|
||||
|
||||
verify(mockRepository.notifyCallEnded('other-uuid')).called(1);
|
||||
verify(
|
||||
mockRepository.notifyCallDeclined(
|
||||
conversationUuid: 'other-uuid',
|
||||
hwId: 'test-hw-id-android-01',
|
||||
callId: 'call-uuid-1',
|
||||
),
|
||||
).called(1);
|
||||
verifyNever(mockRepository.notifyCallEnded(any));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -24,8 +24,10 @@ void main() {
|
||||
final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json');
|
||||
final expected = VideoCallTokenResponse.fromJson(json);
|
||||
|
||||
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
|
||||
.thenAnswer((_) async => expected);
|
||||
when(mockService.getParticipantToken(
|
||||
conversationUuid: conversationUuid,
|
||||
callId: anyNamed('callId'),
|
||||
)).thenAnswer((_) async => expected);
|
||||
|
||||
final result = await repository.getParticipantToken(conversationUuid);
|
||||
|
||||
@@ -33,20 +35,28 @@ void main() {
|
||||
expect(result.token, expected.token);
|
||||
expect(result.wsUrl, expected.wsUrl);
|
||||
expect(result.roomName, expected.roomName);
|
||||
verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1);
|
||||
verify(mockService.getParticipantToken(
|
||||
conversationUuid: conversationUuid,
|
||||
callId: null,
|
||||
)).called(1);
|
||||
});
|
||||
|
||||
test('getParticipantToken - пробрасывает ошибку сервиса', () async {
|
||||
const conversationUuid = 'conv-uuid-broken';
|
||||
|
||||
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
|
||||
.thenThrow(Exception('No token'));
|
||||
when(mockService.getParticipantToken(
|
||||
conversationUuid: conversationUuid,
|
||||
callId: anyNamed('callId'),
|
||||
)).thenThrow(Exception('No token'));
|
||||
|
||||
expect(
|
||||
() => repository.getParticipantToken(conversationUuid),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1);
|
||||
verify(mockService.getParticipantToken(
|
||||
conversationUuid: conversationUuid,
|
||||
callId: null,
|
||||
)).called(1);
|
||||
});
|
||||
|
||||
test('getAdminToken - делегирует вызов сервису и возвращает админский токен', () async {
|
||||
@@ -91,6 +101,60 @@ void main() {
|
||||
expect(captured.single as Map<String, dynamic>, isEmpty);
|
||||
});
|
||||
|
||||
test('notifyCallEnded - передаёт call_id когда он известен', () async {
|
||||
const conversationUuid = 'conv-uuid-123';
|
||||
final json = FixtureReader.readAsMap('video_calls/video_call_ended_response.json');
|
||||
final expected = VideoCallEndedResponse.fromJson(json);
|
||||
|
||||
when(mockService.notifyCallEnded(
|
||||
conversationUuid: conversationUuid,
|
||||
body: anyNamed('body'),
|
||||
)).thenAnswer((_) async => expected);
|
||||
|
||||
await repository.notifyCallEnded(conversationUuid, callId: 'call-1');
|
||||
|
||||
final captured = verify(
|
||||
mockService.notifyCallEnded(
|
||||
conversationUuid: conversationUuid,
|
||||
body: captureAnyNamed('body'),
|
||||
),
|
||||
).captured.single as Map<String, dynamic>;
|
||||
expect(captured, <String, dynamic>{'call_id': 'call-1'});
|
||||
});
|
||||
|
||||
test('notifyCallDeclined - передаёт hw_id и call_id', () async {
|
||||
const conversationUuid = 'conv-uuid-123';
|
||||
final json = FixtureReader.readAsMap(
|
||||
'video_calls/video_call_decline_response.json',
|
||||
);
|
||||
final expected = VideoCallDeclineResponse.fromJson(json);
|
||||
|
||||
when(mockService.notifyCallDeclined(
|
||||
conversationUuid: conversationUuid,
|
||||
body: anyNamed('body'),
|
||||
)).thenAnswer((_) async => expected);
|
||||
|
||||
final result = await repository.notifyCallDeclined(
|
||||
conversationUuid: conversationUuid,
|
||||
hwId: 'device-android-001',
|
||||
callId: 'call-1',
|
||||
);
|
||||
|
||||
expect(result.sent, isTrue);
|
||||
expect(result.devicesNotified, 1);
|
||||
|
||||
final captured = verify(
|
||||
mockService.notifyCallDeclined(
|
||||
conversationUuid: conversationUuid,
|
||||
body: captureAnyNamed('body'),
|
||||
),
|
||||
).captured.single as Map<String, dynamic>;
|
||||
expect(captured, <String, dynamic>{
|
||||
'hw_id': 'device-android-001',
|
||||
'call_id': 'call-1',
|
||||
});
|
||||
});
|
||||
|
||||
test('notifyCallEnded - пробрасывает ошибку сервиса', () async {
|
||||
const conversationUuid = 'conv-uuid-broken';
|
||||
|
||||
|
||||
@@ -47,6 +47,36 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('VideoCallEndedPushHandler.callMatches', () {
|
||||
test('matches by call_id and ignores conversation fallback', () {
|
||||
const call = CallKitParams(
|
||||
id: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||
handle: conversation,
|
||||
extra: <String, dynamic>{
|
||||
'conversationUuid': conversation,
|
||||
'call_id': '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
VideoCallEndedPushHandler.callMatches(
|
||||
call: call,
|
||||
conversationUuid: conversation,
|
||||
callId: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
VideoCallEndedPushHandler.callMatches(
|
||||
call: call,
|
||||
conversationUuid: conversation,
|
||||
callId: 'stale-call-id',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('VideoCallEndedPushHandler.dismissIncomingCallUi (CallKit)', () {
|
||||
final calls = <MethodCall>[];
|
||||
late List<Map<String, Object?>> activeCalls;
|
||||
@@ -93,6 +123,40 @@ void main() {
|
||||
expect(VideoCallRemoteEndRegistry.contains('other-id'), isFalse);
|
||||
});
|
||||
|
||||
test('ends only the matching call_id', () async {
|
||||
activeCalls = <Map<String, Object?>>[
|
||||
<String, Object?>{
|
||||
'id': 'call-a',
|
||||
'handle': conversation,
|
||||
'extra': <String, Object?>{
|
||||
'conversationUuid': conversation,
|
||||
'call_id': 'call-a',
|
||||
},
|
||||
},
|
||||
<String, Object?>{
|
||||
'id': 'call-b',
|
||||
'handle': conversation,
|
||||
'extra': <String, Object?>{
|
||||
'conversationUuid': conversation,
|
||||
'call_id': 'call-b',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||
conversationUuid: conversation,
|
||||
callId: 'call-a',
|
||||
);
|
||||
|
||||
final ended = calls
|
||||
.where((call) => call.method == 'endCall')
|
||||
.map((call) => (call.arguments as Map)['id'])
|
||||
.toList();
|
||||
expect(ended, <String>['call-a']);
|
||||
expect(VideoCallRemoteEndRegistry.contains('call-a'), isTrue);
|
||||
expect(VideoCallRemoteEndRegistry.contains('call-b'), isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'without a conversation ends every call and marks all of them',
|
||||
() async {
|
||||
|
||||
@@ -60,6 +60,24 @@ void main() {
|
||||
expect(payload!.conversationUuid, 'abc-123');
|
||||
});
|
||||
|
||||
test('parses ended video call push with call_id and reason', () {
|
||||
final payload = VideoCallEndedPushPayload.tryParse({
|
||||
'type': 'video_call_ended',
|
||||
'conversation_uuid': 'abc-123',
|
||||
'call_id': '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||
'reason': 'declined',
|
||||
'accepted_device_id': 'device-a',
|
||||
'accepted_client': 'android',
|
||||
});
|
||||
|
||||
expect(payload, isNotNull);
|
||||
expect(payload!.conversationUuid, 'abc-123');
|
||||
expect(payload.callId, '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b');
|
||||
expect(payload.reason?.name, 'declined');
|
||||
expect(payload.acceptedDeviceId, 'device-a');
|
||||
expect(payload.acceptedClient, 'android');
|
||||
});
|
||||
|
||||
test('parses message id for a stable CallKit uuid', () {
|
||||
final payload = VideoCallPushPayload.tryParse({
|
||||
'type': 'video_call_started',
|
||||
@@ -77,6 +95,28 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('prefers call_id as CallKit id when present', () {
|
||||
final payload = VideoCallPushPayload.tryParse({
|
||||
'type': 'video_call_started',
|
||||
'conversation_uuid': '550e8400-e29b-41d4-a716-446655440000',
|
||||
'message_id': '42',
|
||||
'call_id': '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||
'expires_at': '2026-04-27T14:31:00+00:00',
|
||||
});
|
||||
|
||||
expect(payload?.callId, '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b');
|
||||
expect(payload?.expiresAt, isNotNull);
|
||||
expect(
|
||||
VideoCallKitId.resolve(
|
||||
callId: payload!.callId,
|
||||
conversationUuid: payload.conversationUuid,
|
||||
messageId: payload.messageId,
|
||||
isIos: true,
|
||||
),
|
||||
'0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores regular chat push', () {
|
||||
expect(
|
||||
VideoCallPushPayload.tryParse({
|
||||
|
||||
@@ -21,23 +21,32 @@ void main() {
|
||||
final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json');
|
||||
final expected = VideoCallTokenResponse.fromJson(json);
|
||||
|
||||
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
|
||||
.thenAnswer((_) async => expected);
|
||||
when(mockService.getParticipantToken(
|
||||
conversationUuid: conversationUuid,
|
||||
callId: anyNamed('callId'),
|
||||
)).thenAnswer((_) async => expected);
|
||||
|
||||
final result = await mockService.getParticipantToken(conversationUuid: conversationUuid);
|
||||
final result = await mockService.getParticipantToken(
|
||||
conversationUuid: conversationUuid,
|
||||
);
|
||||
|
||||
expect(result, isA<VideoCallTokenResponse>());
|
||||
expect(result.token, expected.token);
|
||||
expect(result.wsUrl, expected.wsUrl);
|
||||
expect(result.roomName, expected.roomName);
|
||||
verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1);
|
||||
verify(mockService.getParticipantToken(
|
||||
conversationUuid: conversationUuid,
|
||||
callId: null,
|
||||
)).called(1);
|
||||
});
|
||||
|
||||
test('getParticipantToken - пробрасывает ошибку', () async {
|
||||
const conversationUuid = 'conv-uuid-error';
|
||||
|
||||
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
|
||||
.thenThrow(Exception('Network error'));
|
||||
when(mockService.getParticipantToken(
|
||||
conversationUuid: conversationUuid,
|
||||
callId: anyNamed('callId'),
|
||||
)).thenThrow(Exception('Network error'));
|
||||
|
||||
expect(
|
||||
() => mockService.getParticipantToken(conversationUuid: conversationUuid),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_registry.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
tearDown(VideoCallTombstoneRegistry.clear);
|
||||
|
||||
test('marks a call id so a late incoming can be suppressed', () {
|
||||
const callId = '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b';
|
||||
final now = DateTime.utc(2026, 10, 21, 10);
|
||||
|
||||
VideoCallTombstoneRegistry.mark(callId, now: now);
|
||||
|
||||
expect(VideoCallTombstoneRegistry.contains(callId, now: now), isTrue);
|
||||
expect(
|
||||
VideoCallTombstoneRegistry.contains(
|
||||
callId,
|
||||
now: now.add(const Duration(minutes: 3)),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user