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

176 lines
5.0 KiB
Dart

import 'dart:convert';
import 'package:diet_guard_app/services/github_device_auth.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
/// Builds an auth instance whose polls resolve instantly (no real waiting).
GitHubDeviceAuth authWith(http.Client client) => GitHubDeviceAuth(
clientId: 'test-client-id',
httpClient: client,
delay: (_) => Future<void>.value(),
);
const _device = DeviceCodeResponse(
deviceCode: 'dev-123',
userCode: 'WXYZ-1234',
verificationUri: 'https://github.com/login/device',
interval: 1,
expiresIn: 900,
);
void main() {
test('requestDeviceCode parses the device + user code', () async {
final client = MockClient((req) async {
expect(req.url.toString(), contains('login/device/code'));
expect(req.bodyFields['client_id'], 'test-client-id');
expect(req.bodyFields['scope'], 'repo');
return http.Response(
jsonEncode({
'device_code': 'dev-123',
'user_code': 'WXYZ-1234',
'verification_uri': 'https://github.com/login/device',
'interval': 5,
'expires_in': 900,
}),
200,
);
});
final res = await authWith(client).requestDeviceCode();
expect(res.deviceCode, 'dev-123');
expect(res.userCode, 'WXYZ-1234');
expect(res.verificationUri, 'https://github.com/login/device');
});
test('requestDeviceCode throws on a non-200 response', () async {
final client = MockClient((_) async => http.Response('nope', 422));
expect(
() => authWith(client).requestDeviceCode(),
throwsA(isA<DeviceAuthException>()),
);
});
test('pollForToken returns the token after authorization_pending', () async {
var calls = 0;
final client = MockClient((req) async {
calls++;
// Pending on the first two polls, then success.
if (calls < 3) {
return http.Response(
jsonEncode({'error': 'authorization_pending'}),
200,
);
}
return http.Response(
jsonEncode({'access_token': 'gho_abc', 'token_type': 'bearer'}),
200,
);
});
final token = await authWith(client).pollForToken(_device);
expect(token, 'gho_abc');
expect(calls, 3);
});
test('pollForToken obeys slow_down and still succeeds', () async {
var calls = 0;
final client = MockClient((req) async {
calls++;
if (calls == 1) {
return http.Response(
jsonEncode({'error': 'slow_down', 'interval': 1}),
200,
);
}
return http.Response(jsonEncode({'access_token': 'gho_xyz'}), 200);
});
final token = await authWith(client).pollForToken(_device);
expect(token, 'gho_xyz');
});
test('pollForToken throws on access_denied', () async {
final client = MockClient(
(req) async => http.Response(
jsonEncode({'error': 'access_denied', 'error_description': 'no'}),
200,
),
);
expect(
() => authWith(client).pollForToken(_device),
throwsA(
isA<DeviceAuthException>().having(
(e) => e.code,
'code',
'access_denied',
),
),
);
});
test('pollForToken honors slow_down then succeeds', () async {
var calls = 0;
final client = MockClient((req) async {
calls++;
if (calls == 1) {
return http.Response(
jsonEncode({'error': 'slow_down', 'interval': 0}),
200,
);
}
return http.Response(jsonEncode({'access_token': 'gho_ok'}), 200);
});
expect(await authWith(client).pollForToken(_device), 'gho_ok');
expect(calls, 2);
});
test('pollForToken throws on an unexpected response shape', () async {
final client = MockClient(
(_) async => http.Response(jsonEncode({'foo': 'bar'}), 200),
);
expect(
() => authWith(client).pollForToken(_device),
throwsA(isA<DeviceAuthException>()),
);
});
test('pollForToken throws when the device code has expired', () async {
final client = MockClient(
(_) async => http.Response(jsonEncode({'access_token': 'x'}), 200),
);
const expired = DeviceCodeResponse(
deviceCode: 'd',
userCode: 'u',
verificationUri: 'v',
interval: 1,
expiresIn: 0, // deadline is now → loop body never runs
);
expect(
() => authWith(client).pollForToken(expired),
throwsA(
isA<DeviceAuthException>().having(
(e) => e.code,
'code',
'expired_token',
),
),
);
});
test('defaults to a real http client and delay when none are injected', () {
// Omitting httpClient/delay exercises the `?? http.Client()` and
// `?? Future.delayed` constructor fallbacks; no request is made.
final auth = GitHubDeviceAuth(clientId: 'c');
addTearDown(auth.close);
});
test('DeviceAuthException.toString includes code and message', () {
final e = DeviceAuthException('access_denied', 'no');
expect(e.toString(), 'DeviceAuthException(access_denied): no');
});
}