393 lines
12 KiB
Dart
393 lines
12 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:mocktail/mocktail.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:social_app/core/notifications/local_notification_service.dart';
|
|
import 'package:social_app/features/calendar/data/models/schedule_item_model.dart';
|
|
import 'package:timezone/data/latest.dart' as tz_data;
|
|
import 'package:timezone/timezone.dart' as tz;
|
|
|
|
class MockFlutterLocalNotificationsPlugin extends Mock
|
|
implements FlutterLocalNotificationsPlugin {}
|
|
|
|
class MockAndroidFlutterLocalNotificationsPlugin extends Mock
|
|
implements AndroidFlutterLocalNotificationsPlugin {}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
setUpAll(() {
|
|
tz_data.initializeTimeZones();
|
|
registerFallbackValue(tz.TZDateTime.now(tz.local));
|
|
registerFallbackValue(const NotificationDetails());
|
|
registerFallbackValue(
|
|
const InitializationSettings(
|
|
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
|
iOS: DarwinInitializationSettings(),
|
|
),
|
|
);
|
|
});
|
|
|
|
setUp(() {
|
|
SharedPreferences.setMockInitialValues({});
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
|
});
|
|
|
|
tearDown(() {
|
|
debugDefaultTargetPlatformOverride = null;
|
|
});
|
|
|
|
test(
|
|
'tracks fallback when Android notifications permission is denied',
|
|
() async {
|
|
final plugin = MockFlutterLocalNotificationsPlugin();
|
|
final androidImpl = MockAndroidFlutterLocalNotificationsPlugin();
|
|
final fallbackEvents = <Map<String, String>>[];
|
|
|
|
when(
|
|
() => plugin.initialize(
|
|
any(),
|
|
onDidReceiveNotificationResponse: any(
|
|
named: 'onDidReceiveNotificationResponse',
|
|
),
|
|
onDidReceiveBackgroundNotificationResponse: any(
|
|
named: 'onDidReceiveBackgroundNotificationResponse',
|
|
),
|
|
),
|
|
).thenAnswer((_) async => true);
|
|
when(
|
|
() => plugin
|
|
.resolvePlatformSpecificImplementation<
|
|
AndroidFlutterLocalNotificationsPlugin
|
|
>(),
|
|
).thenReturn(androidImpl);
|
|
when(
|
|
() => plugin
|
|
.resolvePlatformSpecificImplementation<
|
|
IOSFlutterLocalNotificationsPlugin
|
|
>(),
|
|
).thenReturn(null);
|
|
|
|
when(
|
|
() => androidImpl.requestNotificationsPermission(),
|
|
).thenAnswer((_) async => false);
|
|
when(
|
|
() => androidImpl.requestExactAlarmsPermission(),
|
|
).thenAnswer((_) async => true);
|
|
when(
|
|
() => androidImpl.requestFullScreenIntentPermission(),
|
|
).thenAnswer((_) async => true);
|
|
when(
|
|
() => androidImpl.areNotificationsEnabled(),
|
|
).thenAnswer((_) async => false);
|
|
|
|
final service = LocalNotificationService(
|
|
plugin: plugin,
|
|
permissionFallbackTracker:
|
|
({
|
|
required actionExecutionId,
|
|
required permissionState,
|
|
required appLifecycleState,
|
|
required platform,
|
|
}) {
|
|
fallbackEvents.add({
|
|
'actionExecutionId': actionExecutionId,
|
|
'permissionState': permissionState,
|
|
'appLifecycleState': appLifecycleState,
|
|
'platform': platform,
|
|
});
|
|
},
|
|
);
|
|
|
|
await service.initialize();
|
|
|
|
expect(fallbackEvents.length, 1);
|
|
expect(fallbackEvents.first['permissionState'], 'denied');
|
|
expect(fallbackEvents.first['platform'], 'android');
|
|
},
|
|
);
|
|
|
|
test(
|
|
'skips reminder scheduling when Android notifications are denied',
|
|
() async {
|
|
final plugin = MockFlutterLocalNotificationsPlugin();
|
|
final androidImpl = MockAndroidFlutterLocalNotificationsPlugin();
|
|
|
|
when(
|
|
() => plugin.initialize(
|
|
any(),
|
|
onDidReceiveNotificationResponse: any(
|
|
named: 'onDidReceiveNotificationResponse',
|
|
),
|
|
onDidReceiveBackgroundNotificationResponse: any(
|
|
named: 'onDidReceiveBackgroundNotificationResponse',
|
|
),
|
|
),
|
|
).thenAnswer((_) async => true);
|
|
when(
|
|
() => plugin
|
|
.resolvePlatformSpecificImplementation<
|
|
AndroidFlutterLocalNotificationsPlugin
|
|
>(),
|
|
).thenReturn(androidImpl);
|
|
when(
|
|
() => plugin
|
|
.resolvePlatformSpecificImplementation<
|
|
IOSFlutterLocalNotificationsPlugin
|
|
>(),
|
|
).thenReturn(null);
|
|
|
|
when(
|
|
() => androidImpl.requestNotificationsPermission(),
|
|
).thenAnswer((_) async => false);
|
|
when(
|
|
() => androidImpl.requestExactAlarmsPermission(),
|
|
).thenAnswer((_) async => true);
|
|
when(
|
|
() => androidImpl.requestFullScreenIntentPermission(),
|
|
).thenAnswer((_) async => true);
|
|
when(
|
|
() => androidImpl.areNotificationsEnabled(),
|
|
).thenAnswer((_) async => false);
|
|
|
|
final service = LocalNotificationService(plugin: plugin);
|
|
final event = ScheduleItemModel(
|
|
id: 'evt_1',
|
|
ownerId: 'u1',
|
|
title: 'sync',
|
|
startAt: DateTime.now().add(const Duration(minutes: 20)),
|
|
endAt: DateTime.now().add(const Duration(minutes: 50)),
|
|
metadata: ScheduleMetadata(reminderMinutes: 15),
|
|
);
|
|
|
|
await service.upsertEventReminder(event);
|
|
|
|
verifyNever(() => plugin.pendingNotificationRequests());
|
|
},
|
|
);
|
|
|
|
test(
|
|
'dispatches in-app reminder callback when notifications are denied',
|
|
() async {
|
|
final plugin = MockFlutterLocalNotificationsPlugin();
|
|
final androidImpl = MockAndroidFlutterLocalNotificationsPlugin();
|
|
final presentedEventIds = <String>[];
|
|
|
|
when(
|
|
() => plugin.initialize(
|
|
any(),
|
|
onDidReceiveNotificationResponse: any(
|
|
named: 'onDidReceiveNotificationResponse',
|
|
),
|
|
onDidReceiveBackgroundNotificationResponse: any(
|
|
named: 'onDidReceiveBackgroundNotificationResponse',
|
|
),
|
|
),
|
|
).thenAnswer((_) async => true);
|
|
when(
|
|
() => plugin
|
|
.resolvePlatformSpecificImplementation<
|
|
AndroidFlutterLocalNotificationsPlugin
|
|
>(),
|
|
).thenReturn(androidImpl);
|
|
when(
|
|
() => plugin
|
|
.resolvePlatformSpecificImplementation<
|
|
IOSFlutterLocalNotificationsPlugin
|
|
>(),
|
|
).thenReturn(null);
|
|
|
|
when(
|
|
() => androidImpl.requestNotificationsPermission(),
|
|
).thenAnswer((_) async => false);
|
|
when(
|
|
() => androidImpl.requestExactAlarmsPermission(),
|
|
).thenAnswer((_) async => true);
|
|
when(
|
|
() => androidImpl.requestFullScreenIntentPermission(),
|
|
).thenAnswer((_) async => true);
|
|
when(
|
|
() => androidImpl.areNotificationsEnabled(),
|
|
).thenAnswer((_) async => false);
|
|
|
|
final service = LocalNotificationService(plugin: plugin);
|
|
service.bindInAppReminderHandler((payload) async {
|
|
presentedEventIds.add(payload.eventId);
|
|
});
|
|
|
|
final event = ScheduleItemModel(
|
|
id: 'evt_2',
|
|
ownerId: 'u1',
|
|
title: 'retro',
|
|
startAt: DateTime.now().add(const Duration(minutes: 20)),
|
|
endAt: DateTime.now().add(const Duration(minutes: 50)),
|
|
metadata: ScheduleMetadata(reminderMinutes: 15),
|
|
);
|
|
|
|
await service.scheduleReminderAt(
|
|
event,
|
|
DateTime.now().add(const Duration(milliseconds: 20)),
|
|
);
|
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
|
|
|
expect(presentedEventIds, contains('evt_2'));
|
|
verifyNever(
|
|
() => plugin.zonedSchedule(
|
|
any(),
|
|
any(),
|
|
any(),
|
|
any(),
|
|
any(),
|
|
payload: any(named: 'payload'),
|
|
androidScheduleMode: any(named: 'androidScheduleMode'),
|
|
uiLocalNotificationDateInterpretation:
|
|
UILocalNotificationDateInterpretation.absoluteTime,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('rebuild twice only dispatches one aggregate in-app fallback', () async {
|
|
final plugin = MockFlutterLocalNotificationsPlugin();
|
|
final androidImpl = MockAndroidFlutterLocalNotificationsPlugin();
|
|
final presentedPayloads = <String>[];
|
|
|
|
when(
|
|
() => plugin.initialize(
|
|
any(),
|
|
onDidReceiveNotificationResponse: any(
|
|
named: 'onDidReceiveNotificationResponse',
|
|
),
|
|
onDidReceiveBackgroundNotificationResponse: any(
|
|
named: 'onDidReceiveBackgroundNotificationResponse',
|
|
),
|
|
),
|
|
).thenAnswer((_) async => true);
|
|
when(
|
|
() => plugin
|
|
.resolvePlatformSpecificImplementation<
|
|
AndroidFlutterLocalNotificationsPlugin
|
|
>(),
|
|
).thenReturn(androidImpl);
|
|
when(
|
|
() => plugin
|
|
.resolvePlatformSpecificImplementation<
|
|
IOSFlutterLocalNotificationsPlugin
|
|
>(),
|
|
).thenReturn(null);
|
|
|
|
when(
|
|
() => androidImpl.requestNotificationsPermission(),
|
|
).thenAnswer((_) async => false);
|
|
when(
|
|
() => androidImpl.requestExactAlarmsPermission(),
|
|
).thenAnswer((_) async => true);
|
|
when(
|
|
() => androidImpl.requestFullScreenIntentPermission(),
|
|
).thenAnswer((_) async => true);
|
|
when(
|
|
() => androidImpl.areNotificationsEnabled(),
|
|
).thenAnswer((_) async => false);
|
|
|
|
final service = LocalNotificationService(plugin: plugin);
|
|
service.bindInAppReminderHandler((payload) async {
|
|
presentedPayloads.add(payload.title);
|
|
});
|
|
|
|
final startAt = DateTime.now().add(const Duration(milliseconds: 50));
|
|
final event1 = ScheduleItemModel(
|
|
id: 'evt_a',
|
|
ownerId: 'u1',
|
|
title: 'evt_a',
|
|
startAt: startAt,
|
|
endAt: startAt.add(const Duration(minutes: 30)),
|
|
metadata: ScheduleMetadata(reminderMinutes: 0),
|
|
);
|
|
final event2 = ScheduleItemModel(
|
|
id: 'evt_b',
|
|
ownerId: 'u1',
|
|
title: 'evt_b',
|
|
startAt: startAt,
|
|
endAt: startAt.add(const Duration(minutes: 30)),
|
|
metadata: ScheduleMetadata(reminderMinutes: 0),
|
|
);
|
|
|
|
await service.rebuildUpcomingReminders([event1, event2]);
|
|
await service.rebuildUpcomingReminders([event1, event2]);
|
|
await Future<void>.delayed(const Duration(milliseconds: 180));
|
|
|
|
expect(
|
|
presentedPayloads.where((title) => title.contains('你有2个日程提醒')).length,
|
|
1,
|
|
);
|
|
});
|
|
|
|
test(
|
|
'rebuild clears stale in-app fallback timers for removed events',
|
|
() async {
|
|
final plugin = MockFlutterLocalNotificationsPlugin();
|
|
final androidImpl = MockAndroidFlutterLocalNotificationsPlugin();
|
|
final presentedEventIds = <String>[];
|
|
|
|
when(
|
|
() => plugin.initialize(
|
|
any(),
|
|
onDidReceiveNotificationResponse: any(
|
|
named: 'onDidReceiveNotificationResponse',
|
|
),
|
|
onDidReceiveBackgroundNotificationResponse: any(
|
|
named: 'onDidReceiveBackgroundNotificationResponse',
|
|
),
|
|
),
|
|
).thenAnswer((_) async => true);
|
|
when(
|
|
() => plugin
|
|
.resolvePlatformSpecificImplementation<
|
|
AndroidFlutterLocalNotificationsPlugin
|
|
>(),
|
|
).thenReturn(androidImpl);
|
|
when(
|
|
() => plugin
|
|
.resolvePlatformSpecificImplementation<
|
|
IOSFlutterLocalNotificationsPlugin
|
|
>(),
|
|
).thenReturn(null);
|
|
|
|
when(
|
|
() => androidImpl.requestNotificationsPermission(),
|
|
).thenAnswer((_) async => false);
|
|
when(
|
|
() => androidImpl.requestExactAlarmsPermission(),
|
|
).thenAnswer((_) async => true);
|
|
when(
|
|
() => androidImpl.requestFullScreenIntentPermission(),
|
|
).thenAnswer((_) async => true);
|
|
when(
|
|
() => androidImpl.areNotificationsEnabled(),
|
|
).thenAnswer((_) async => false);
|
|
|
|
final service = LocalNotificationService(plugin: plugin);
|
|
service.bindInAppReminderHandler((payload) async {
|
|
presentedEventIds.add(payload.eventId);
|
|
});
|
|
|
|
final startAt = DateTime.now().add(const Duration(milliseconds: 80));
|
|
final staleEvent = ScheduleItemModel(
|
|
id: 'evt_stale',
|
|
ownerId: 'u1',
|
|
title: 'evt_stale',
|
|
startAt: startAt,
|
|
endAt: startAt.add(const Duration(minutes: 20)),
|
|
metadata: ScheduleMetadata(reminderMinutes: 0),
|
|
);
|
|
|
|
await service.rebuildUpcomingReminders([staleEvent]);
|
|
await service.rebuildUpcomingReminders(const <ScheduleItemModel>[]);
|
|
await Future<void>.delayed(const Duration(milliseconds: 220));
|
|
|
|
expect(presentedEventIds, isNot(contains('evt_stale')));
|
|
},
|
|
);
|
|
}
|