diet-guard/app/test/services/background_check_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

88 lines
2.8 KiB
Dart

// `checkAndNotify` is the unit-testable half of the WorkManager periodic
// check; `backgroundCheckCallbackDispatcher` itself is integration-only
// (real WorkManager isolate, manual on-device smoke test) per the project
// plan, and is excluded from coverage.
import 'dart:io';
import 'package:diet_guard_app/models/nutrition.dart';
import 'package:diet_guard_app/services/background_check_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/notification_service.dart';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_test/flutter_test.dart';
import '../fake_notifications.dart';
const _manual = Nutrition(
kcal: 200,
proteinG: 10,
carbsG: 20,
fatG: 5,
grams: 100,
source: 'manual',
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
late List<MethodCall> notificationLog;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_bg_check_');
LogStorageService.resetForTesting(testDir: tempDir);
notificationLog = installFakeAndroidNotifications();
NotificationService.resetForTesting(
plugin: FlutterLocalNotificationsPlugin(),
);
});
tearDown(() async {
LogStorageService.resetForTesting();
NotificationService.resetForTesting();
await tempDir.delete(recursive: true);
});
test(
'shows due-and-unlogged slots, cancels logged and upcoming ones',
() async {
await LogStorageService.instance.logMeal('lunch', _manual, slot: 12);
await checkAndNotify(now: DateTime(2026, 1, 1, 16));
final shown = notificationLog
.where((c) => c.method == 'show')
.map((c) => (c.arguments as Map)['id'])
.toSet();
final cancelled = notificationLog
.where((c) => c.method == 'cancel')
.map((c) => (c.arguments as Map)['id'])
.toSet();
expect(shown, {8, 16});
expect(cancelled, {12, 20});
},
);
test('cancels everything when every due slot is logged', () async {
await LogStorageService.instance.logMeal('breakfast', _manual, slot: 8);
await checkAndNotify(now: DateTime(2026, 1, 1, 8));
expect(notificationLog.where((c) => c.method == 'show'), isEmpty);
expect(notificationLog.where((c) => c.method == 'cancel'), hasLength(4));
});
test('uses the real clock when now is omitted', () async {
// Just exercises the `now ?? DateTime.now()` branch without asserting
// on specific slots (which depend on the actual time the test runs).
await checkAndNotify();
expect(
notificationLog.where(
(c) => c.method == 'show' || c.method == 'cancel',
),
isNotEmpty,
);
});
}