mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 22:27:13 +01:00
todo, home_inventory, diet-guard and wake-alarm each carried the OAuth device-flow client and the modal that drives it -- ~650 lines, with two pairs byte-for-byte identical (todo/home_inventory's clients, and diet-guard/wake-alarm's dialogs). Deliberately its own package rather than part of `sync_settings_ui`. That package is Firebase-only by an explicit 2026-08-12 decision recorded as "do not re-add a GitHub section without kuhy asking again", so folding a GitHub flow into it would reverse a standing decision. The GitHub mirror in these apps is legacy-but-load-bearing code that was explicitly not authorised for deletion, so it gets its own home instead of being merged into the Firebase path or dropped. One behaviour change, made once here instead of four times: the dialog catches `on Object`, not `on Exception`. An `Error` is not an `Exception` in Dart, so `ArgumentError` -- notably from URI parsing -- escaped `_poll()` and left the dialog spinning on "Waiting for authorization…" forever with nothing shown. That is the same trap that silently killed the Firebase sync tick across these apps in 2026-08. A test pins it with a mock that throws an Error rather than an Exception, so it cannot regress back to `on Exception` unnoticed. 20 tests, 100% line coverage, clean under very_good_analysis, with the same CI gate the other Dart package holds. Consumers are migrated separately so each app's own suite gates its own swap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
170 lines
5.3 KiB
Dart
170 lines
5.3 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:github_device_auth/github_device_auth.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart';
|
|
|
|
const _device = DeviceCodeResponse(
|
|
deviceCode: 'dc',
|
|
userCode: 'ABCD-1234',
|
|
verificationUri: 'https://github.com/login/device',
|
|
interval: 1,
|
|
expiresIn: 60,
|
|
);
|
|
|
|
/// Completes immediately -- for tests whose mock resolves on the first poll.
|
|
Future<void> _noDelay(Duration _) async {}
|
|
|
|
/// Never completes, so the poll loop suspends on its first delay.
|
|
///
|
|
/// The pending-state tests need the dialog to sit in "waiting" without
|
|
/// actually looping: with an instantly-completing delay and a mock that
|
|
/// always answers `authorization_pending`, `pollForToken` spins as fast as
|
|
/// the event loop allows and starves the test binding.
|
|
Future<void> _neverDelay(Duration _) => Completer<void>().future;
|
|
|
|
GitHubDeviceAuth _auth(
|
|
MockClient client, {
|
|
Future<void> Function(Duration) delay = _noDelay,
|
|
}) => GitHubDeviceAuth(clientId: 'cid', httpClient: client, delay: delay);
|
|
|
|
/// An auth client that never gets past its first poll delay.
|
|
GitHubDeviceAuth _pendingAuth() => _auth(
|
|
MockClient(
|
|
(_) async => http.Response(jsonEncode({'error': 'authorization_pending'}), 200),
|
|
),
|
|
delay: _neverDelay,
|
|
);
|
|
|
|
/// Opens the dialog and returns a getter for whatever it eventually pops.
|
|
///
|
|
/// The result is read through a closure rather than returned directly
|
|
/// because the dialog may still be polling when this returns -- a test that
|
|
/// cares about the popped value pumps first, then reads.
|
|
Future<Object? Function()> _showDialog(
|
|
WidgetTester tester,
|
|
GitHubDeviceAuth auth,
|
|
) async {
|
|
Object? popped;
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Builder(
|
|
builder: (context) => ElevatedButton(
|
|
onPressed: () async {
|
|
popped = await showDialog<String>(
|
|
context: context,
|
|
builder: (_) => DeviceCodeDialog(device: _device, auth: auth),
|
|
);
|
|
},
|
|
child: const Text('open'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.tap(find.text('open'));
|
|
await tester.pump();
|
|
return () => popped;
|
|
}
|
|
|
|
void main() {
|
|
testWidgets('shows the user code and a waiting state', (tester) async {
|
|
await _showDialog(tester, _pendingAuth());
|
|
|
|
expect(find.text('Authorize on GitHub'), findsOneWidget);
|
|
expect(find.text('ABCD-1234'), findsOneWidget);
|
|
expect(find.text('Waiting for authorization…'), findsOneWidget);
|
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('pops the token once authorized', (tester) async {
|
|
final auth = _auth(
|
|
MockClient(
|
|
(_) async => http.Response(jsonEncode({'access_token': 'tok'}), 200),
|
|
),
|
|
);
|
|
|
|
final popped = await _showDialog(tester, auth);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(AlertDialog), findsNothing);
|
|
expect(popped(), 'tok');
|
|
});
|
|
|
|
testWidgets('shows the error instead of spinning forever', (tester) async {
|
|
final auth = _auth(
|
|
MockClient(
|
|
(_) async => http.Response(
|
|
jsonEncode({
|
|
'error': 'access_denied',
|
|
'error_description': 'user declined',
|
|
}),
|
|
200,
|
|
),
|
|
),
|
|
);
|
|
|
|
await _showDialog(tester, auth);
|
|
await tester.pump();
|
|
|
|
expect(find.byType(CircularProgressIndicator), findsNothing);
|
|
expect(find.textContaining('access_denied'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('surfaces an Error, not just an Exception', (tester) async {
|
|
// The regression this dialog's `on Object catch` exists for: an
|
|
// `ArgumentError` is an Error, not an Exception, so `on Exception` would
|
|
// let it escape and leave the dialog spinning with nothing shown.
|
|
final auth = _auth(
|
|
MockClient((_) async => throw ArgumentError('No host specified in URI')),
|
|
);
|
|
|
|
await _showDialog(tester, auth);
|
|
await tester.pump();
|
|
|
|
expect(find.byType(CircularProgressIndicator), findsNothing);
|
|
expect(find.textContaining('No host specified'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('cancel pops without a token', (tester) async {
|
|
final popped = await _showDialog(tester, _pendingAuth());
|
|
await tester.tap(find.text('Cancel'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(AlertDialog), findsNothing);
|
|
expect(popped(), isNull);
|
|
});
|
|
|
|
testWidgets('the action button copies the code to the clipboard', (
|
|
tester,
|
|
) async {
|
|
final auth = _pendingAuth();
|
|
|
|
String? copied;
|
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
SystemChannels.platform,
|
|
(call) async {
|
|
if (call.method == 'Clipboard.setData') {
|
|
copied =
|
|
((call.arguments as Map<Object?, Object?>)['text'] as String?);
|
|
}
|
|
return null;
|
|
},
|
|
);
|
|
// url_launcher would otherwise hit a missing plugin on the test binding.
|
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
const MethodChannel('plugins.flutter.io/url_launcher'),
|
|
(_) async => true,
|
|
);
|
|
|
|
await _showDialog(tester, auth);
|
|
await tester.tap(find.text('Open GitHub & copy code'));
|
|
await tester.pump();
|
|
|
|
expect(copied, 'ABCD-1234');
|
|
});
|
|
}
|