diet-guard/app/test/services/notification_service_test.dart
Krzysztof kuhy Rudnicki adbfb20e9a Add OAuth device flow, background notifications, and fix AGP9 release crash (Milestones 3–4)
M3 – GitHub OAuth device flow: replace PAT-paste with a guided "Connect
GitHub" button that runs the device-code flow; tapping with no client id
now opens a setup dialog (instructions + inline paste field) rather than
a buried inline hint. Bakes in the app's own OAuth App client id so fresh
installs work with zero manual config. Auto-syncs immediately after
connect. Verified end-to-end on the real phone: OAuth flow → token saved
→ PC's 48-entry log merged in (confirmed via food-bank vs manual source
labels in History).

M4 – Background meal-slot notifications: WorkManager periodic task (15 min
floor) checks for overdue slots and posts/cancels notifications via
flutter_local_notifications. New permissions: POST_NOTIFICATIONS,
WAKE_LOCK, RECEIVE_BOOT_COMPLETED, REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
INTERNET (was missing — latent sync bug). "Disable battery optimization"
button in Settings. Verified on real phone: WorkManager registered, forced
run posted a real notification ("Meal not logged / You haven't logged your
16:00 meal yet."), isolated to background path (only caller is the
WorkManager dispatcher, not any foreground lifecycle hook).

AGP9 release crash fix: AGP 9 defaults isMinifyEnabled/isShrinkResources
to true for release even with no proguard config; R8 stripped
WorkDatabase_Impl's reflection-only constructor, crashing every launch
with NoSuchMethodException. Explicitly disabled both flags in
build.gradle.kts. Verified via dexdump (constructor present) and on-device
launch (no crash). Proper R8 keep rules are the long-term fix; tracked.

177 tests, flutter analyze clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWPUBzE24Ls9i9GMRwXnnn
2026-06-25 17:29:23 +02:00

106 lines
3.4 KiB
Dart

import 'package:diet_guard_app/services/notification_service.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_test/flutter_test.dart';
import '../fake_notifications.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
tearDown(NotificationService.resetForTesting);
group('on Android', () {
test('init constructs the real plugin singleton on first use', () async {
final log = installFakeAndroidNotifications();
NotificationService.resetForTesting(); // no _instance yet
await NotificationService.init();
expect(log.where((c) => c.method == 'initialize'), hasLength(1));
});
test('init calls the platform initialize method, idempotently', () async {
final log = installFakeAndroidNotifications();
NotificationService.resetForTesting(
plugin: FlutterLocalNotificationsPlugin(),
);
await NotificationService.init();
await NotificationService.init(); // second call must be a no-op
expect(log.where((c) => c.method == 'initialize'), hasLength(1));
});
test('requestPermission delegates to the Android implementation', () async {
installFakeAndroidNotifications();
NotificationService.resetForTesting(
plugin: FlutterLocalNotificationsPlugin(),
);
await NotificationService.init();
expect(await NotificationService.instance.requestPermission(), isTrue);
});
test('syncToSlots shows due slots and cancels the rest', () async {
final log = installFakeAndroidNotifications();
NotificationService.resetForTesting(
plugin: FlutterLocalNotificationsPlugin(),
);
await NotificationService.init();
log.clear();
await NotificationService.instance.syncToSlots([12, 20]);
final shown = log
.where((c) => c.method == 'show')
.map((c) => (c.arguments as Map)['id'])
.toSet();
final cancelled = log
.where((c) => c.method == 'cancel')
.map((c) => (c.arguments as Map)['id'])
.toSet();
expect(shown, {12, 20});
expect(cancelled, {8, 16});
});
test('syncToSlots with no due slots cancels every known slot', () async {
final log = installFakeAndroidNotifications();
NotificationService.resetForTesting(
plugin: FlutterLocalNotificationsPlugin(),
);
await NotificationService.init();
log.clear();
await NotificationService.instance.syncToSlots(const []);
expect(log.where((c) => c.method == 'show'), isEmpty);
expect(log.where((c) => c.method == 'cancel'), hasLength(4));
});
test(
'syncToSlots cancels a slot whose meal was logged after it fired',
() async {
final log = installFakeAndroidNotifications();
NotificationService.resetForTesting(
plugin: FlutterLocalNotificationsPlugin(),
);
await NotificationService.init();
await NotificationService.instance.syncToSlots([12]);
log.clear();
await NotificationService.instance.syncToSlots(const []); // logged
expect(
log
.where((c) => c.method == 'cancel')
.map((c) => (c.arguments as Map)['id']),
contains(12),
);
},
);
});
test('instance throws before init has ever been called', () {
expect(() => NotificationService.instance, throwsA(anything));
});
}