feat(video_calls): continous ringing on a defferent device fix

This commit is contained in:
misha
2026-10-21 14:25:59 +03:00
parent a6b94f835c
commit 141cf5543d
26 changed files with 871 additions and 93 deletions

View File

@@ -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));
},
);

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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({

View File

@@ -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),

View File

@@ -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,
);
});
}