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 = >[]; 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 = []; 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.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 = []; 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.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 = []; 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 []); await Future.delayed(const Duration(milliseconds: 220)); expect(presentedEventIds, isNot(contains('evt_stale'))); }, ); }