From 3a587d6cf9e3792df661773c8b8c967f721f6ab9 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 21:29:34 +0200 Subject: [PATCH] Split github_mirror_screen_test.dart (470) into four 402 Dart tests before and after. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y --- .../github_mirror_screen_auth_test.dart | 148 ++++++++ .../github_mirror_screen_flow_test.dart | 201 +++++++++++ .../github_mirror_screen_push_test.dart | 183 ++++++++++ .../screens/github_mirror_screen_test.dart | 316 ------------------ 4 files changed, 532 insertions(+), 316 deletions(-) create mode 100644 stronglift_replacement/workout_app/test/screens/github_mirror_screen_auth_test.dart create mode 100644 stronglift_replacement/workout_app/test/screens/github_mirror_screen_flow_test.dart create mode 100644 stronglift_replacement/workout_app/test/screens/github_mirror_screen_push_test.dart diff --git a/stronglift_replacement/workout_app/test/screens/github_mirror_screen_auth_test.dart b/stronglift_replacement/workout_app/test/screens/github_mirror_screen_auth_test.dart new file mode 100644 index 0000000..6e7ce3e --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/github_mirror_screen_auth_test.dart @@ -0,0 +1,148 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:url_launcher_platform_interface/link.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; +import 'package:workout_app/screens/github_mirror_screen.dart'; +import 'package:workout_app/services/backup_service.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/services/sync_settings.dart'; +import 'package:workout_app/ui/theme.dart'; + +import '../fake_secure_storage.dart'; + +/// Stub launcher that records the URL instead of opening it, so the device +/// dialog's "Open GitHub & copy code" can be exercised without a real +/// platform channel. +class _FakeUrlLauncher extends UrlLauncherPlatform + with MockPlatformInterfaceMixin { + String? launched; + + @override + final LinkDelegate? linkDelegate = null; + + @override + Future supportsMode(PreferredLaunchMode mode) async => true; + + @override + Future launchUrl(String url, LaunchOptions options) async { + launched = url; + return true; + } +} + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + installFakeSecureStorage(); + }); + + Widget _wrap({http.Client? httpClient}) => MaterialApp( + theme: buildAppTheme(), + home: GitHubMirrorScreen(httpClient: httpClient), + ); + + /// Drains the device flow's real `Future.delayed` poll (GitHubDeviceAuth + /// injects no test delay, so under `runAsync` it is a genuine Timer, not + /// the fake-clock one `tester.pump(duration)` advances) by interleaving + /// real waits with frame pumps until [done] is true or [maxTries] is hit. + Future _pumpUntil( + WidgetTester tester, + bool Function() done, { + int maxTries = 200, + }) async { + for (var i = 0; i < maxTries && !done(); i++) { + await Future.delayed(const Duration(milliseconds: 10)); + await tester.pump(); + } + } + + testWidgets('device flow failure to start shows a message', (tester) async { + final mock = MockClient((_) async => http.Response('nope', 422)); + await tester.runAsync(() async { + await tester.pumpWidget(_wrap(httpClient: mock)); + await Future.delayed(const Duration(milliseconds: 300)); + await tester.pump(); + + await tester.tap(find.text('Connect GitHub')); + await _pumpUntil( + tester, + () => find + .textContaining('Could not start device flow') + .evaluate() + .isNotEmpty, + ); + + expect( + find.textContaining('Could not start device flow'), + findsOneWidget, + ); + }); + }); + + testWidgets('device flow happy path saves and verifies the token', ( + tester, + ) async { + final mock = MockClient((req) async { + if (req.url.path.contains('device/code')) { + return http.Response( + jsonEncode({ + 'device_code': 'dev123', + 'user_code': 'WXYZ-1234', + 'verification_uri': 'https://github.com/login/device', + 'interval': 0, + 'expires_in': 900, + }), + 200, + ); + } + if (req.url.path.contains('oauth/access_token')) { + return http.Response(jsonEncode({'access_token': 'gho_test'}), 200); + } + // The post-connect verification GET on devices/phone/log.json. + return http.Response( + jsonEncode({'content': base64Encode(utf8.encode('{}'))}), + 200, + ); + }); + + await tester.runAsync(() async { + await tester.pumpWidget(_wrap(httpClient: mock)); + await Future.delayed(const Duration(milliseconds: 300)); + await tester.pump(); + + await tester.tap(find.text('Connect GitHub')); + await _pumpUntil( + tester, + () => find.text('WXYZ-1234').evaluate().isNotEmpty, + ); + expect(find.text('WXYZ-1234'), findsOneWidget); + + await _pumpUntil( + tester, + () => find + .textContaining('Connected and verified via GitHub') + .evaluate() + .isNotEmpty, + ); + + expect( + find.textContaining('Connected and verified via GitHub'), + findsOneWidget, + ); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/github_mirror_screen_flow_test.dart b/stronglift_replacement/workout_app/test/screens/github_mirror_screen_flow_test.dart new file mode 100644 index 0000000..e3540bb --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/github_mirror_screen_flow_test.dart @@ -0,0 +1,201 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:url_launcher_platform_interface/link.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; +import 'package:workout_app/screens/github_mirror_screen.dart'; +import 'package:workout_app/services/backup_service.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/services/sync_settings.dart'; +import 'package:workout_app/ui/theme.dart'; + +import '../fake_secure_storage.dart'; + +/// Stub launcher that records the URL instead of opening it, so the device +/// dialog's "Open GitHub & copy code" can be exercised without a real +/// platform channel. +class _FakeUrlLauncher extends UrlLauncherPlatform + with MockPlatformInterfaceMixin { + String? launched; + + @override + final LinkDelegate? linkDelegate = null; + + @override + Future supportsMode(PreferredLaunchMode mode) async => true; + + @override + Future launchUrl(String url, LaunchOptions options) async { + launched = url; + return true; + } +} + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + installFakeSecureStorage(); + }); + + Widget _wrap({http.Client? httpClient}) => MaterialApp( + theme: buildAppTheme(), + home: GitHubMirrorScreen(httpClient: httpClient), + ); + + /// Drains the device flow's real `Future.delayed` poll (GitHubDeviceAuth + /// injects no test delay, so under `runAsync` it is a genuine Timer, not + /// the fake-clock one `tester.pump(duration)` advances) by interleaving + /// real waits with frame pumps until [done] is true or [maxTries] is hit. + Future _pumpUntil( + WidgetTester tester, + bool Function() done, { + int maxTries = 200, + }) async { + for (var i = 0; i < maxTries && !done(); i++) { + await Future.delayed(const Duration(milliseconds: 10)); + await tester.pump(); + } + } + + testWidgets('device flow: shows a message when verification fails', ( + tester, + ) async { + final mock = MockClient((req) async { + if (req.url.path.contains('device/code')) { + return http.Response( + jsonEncode({ + 'device_code': 'dev123', + 'user_code': 'WXYZ-1234', + 'verification_uri': 'https://github.com/login/device', + 'interval': 0, + 'expires_in': 900, + }), + 200, + ); + } + if (req.url.path.contains('oauth/access_token')) { + return http.Response(jsonEncode({'access_token': 'gho_test'}), 200); + } + // The post-connect verification GET fails → GitHubSyncError. + return http.Response('boom', 500); + }); + + await tester.runAsync(() async { + await tester.pumpWidget(_wrap(httpClient: mock)); + await Future.delayed(const Duration(milliseconds: 300)); + await tester.pump(); + + await tester.tap(find.text('Connect GitHub')); + await _pumpUntil( + tester, + () => find.textContaining('NOT syncing').evaluate().isNotEmpty, + ); + expect(find.textContaining('NOT syncing'), findsOneWidget); + }); + }); + + testWidgets('device flow: renders the pending "verifying" status', ( + tester, + ) async { + final mock = MockClient((req) async { + if (req.url.path.contains('device/code')) { + return http.Response( + jsonEncode({ + 'device_code': 'dev123', + 'user_code': 'WXYZ-1234', + 'verification_uri': 'https://github.com/login/device', + 'interval': 0, + 'expires_in': 900, + }), + 200, + ); + } + if (req.url.path.contains('oauth/access_token')) { + return http.Response(jsonEncode({'access_token': 'gho_test'}), 200); + } + // Hold the verification in-flight so the pending status tile is + // observable before it resolves to success. + await Future.delayed(const Duration(milliseconds: 600)); + return http.Response( + jsonEncode({'content': base64Encode(utf8.encode('{}'))}), + 200, + ); + }); + + await tester.runAsync(() async { + await tester.pumpWidget(_wrap(httpClient: mock)); + await Future.delayed(const Duration(milliseconds: 300)); + await tester.pump(); + + await tester.tap(find.text('Connect GitHub')); + await _pumpUntil( + tester, + () => find.textContaining('verifying').evaluate().isNotEmpty, + ); + // Pending tile (spinner + white70 status colour) is now on screen. + expect(find.textContaining('verifying'), findsOneWidget); + + await _pumpUntil( + tester, + () => + find.textContaining('Connected and verified').evaluate().isNotEmpty, + ); + expect(find.textContaining('Connected and verified'), findsOneWidget); + }); + }); + + testWidgets( + 'a rejected stored token recovers from the backup instead of nagging', + (tester) async { + // load() only consults the backup when the keystore is EMPTY, so a + // stale keystore entry would otherwise shadow a good backup forever + // and demand a pointless re-authorization. + final tempDir = Directory.systemTemp.createTempSync('settings_recover_'); + BackupService.baseDirForTesting = tempDir.path; + addTearDown(() { + BackupService.baseDirForTesting = kBackupDir; + tempDir.deleteSync(recursive: true); + }); + + installFakeSecureStorage(initial: {'sync.token': 'stale'}); + // save() writes the external-storage backup: real file I/O, which hangs + // in the widget-test fake-async zone (same reason _seed uses runAsync). + await tester.runAsync( + () async => const SyncSettings(token: 'good').save(), + ); + installFakeSecureStorage(initial: {'sync.token': 'stale'}); + + final mock = MockClient((req) async { + if (req.headers['Authorization']?.contains('good') != true) { + return http.Response('Bad credentials', 401); + } + return http.Response( + jsonEncode({'content': base64Encode(utf8.encode('{}')), 'sha': 's'}), + 200, + ); + }); + + await tester.runAsync(() async { + await tester.pumpWidget(_wrap(httpClient: mock)); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(); + + expect(find.textContaining('recovered the saved token'), findsOneWidget); + expect(find.textContaining('NOT connected'), findsNothing); + }, + ); +} diff --git a/stronglift_replacement/workout_app/test/screens/github_mirror_screen_push_test.dart b/stronglift_replacement/workout_app/test/screens/github_mirror_screen_push_test.dart new file mode 100644 index 0000000..c099c62 --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/github_mirror_screen_push_test.dart @@ -0,0 +1,183 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:url_launcher_platform_interface/link.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; +import 'package:workout_app/screens/github_mirror_screen.dart'; +import 'package:workout_app/services/backup_service.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/services/sync_settings.dart'; +import 'package:workout_app/ui/theme.dart'; + +import '../fake_secure_storage.dart'; + +/// Stub launcher that records the URL instead of opening it, so the device +/// dialog's "Open GitHub & copy code" can be exercised without a real +/// platform channel. +class _FakeUrlLauncher extends UrlLauncherPlatform + with MockPlatformInterfaceMixin { + String? launched; + + @override + final LinkDelegate? linkDelegate = null; + + @override + Future supportsMode(PreferredLaunchMode mode) async => true; + + @override + Future launchUrl(String url, LaunchOptions options) async { + launched = url; + return true; + } +} + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + installFakeSecureStorage(); + }); + + Widget _wrap({http.Client? httpClient}) => MaterialApp( + theme: buildAppTheme(), + home: GitHubMirrorScreen(httpClient: httpClient), + ); + + /// Drains the device flow's real `Future.delayed` poll (GitHubDeviceAuth + /// injects no test delay, so under `runAsync` it is a genuine Timer, not + /// the fake-clock one `tester.pump(duration)` advances) by interleaving + /// real waits with frame pumps until [done] is true or [maxTries] is hit. + Future _pumpUntil( + WidgetTester tester, + bool Function() done, { + int maxTries = 200, + }) async { + for (var i = 0; i < maxTries && !done(); i++) { + await Future.delayed(const Duration(milliseconds: 10)); + await tester.pump(); + } + } + + testWidgets('device dialog: failed poll shows the error and Open launches', ( + tester, + ) async { + final launcher = _FakeUrlLauncher(); + UrlLauncherPlatform.instance = launcher; + + // The dialog's Open button copies the code to the clipboard first; + // there's no clipboard plugin in the test host, so stub the channel. + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async => null, + ); + addTearDown( + () => messenger.setMockMethodCallHandler(SystemChannels.platform, null), + ); + + final mock = MockClient((req) async { + if (req.url.path.contains('device/code')) { + return http.Response( + jsonEncode({ + 'device_code': 'dev123', + 'user_code': 'WXYZ-1234', + 'verification_uri': 'https://github.com/login/device', + 'interval': 0, + 'expires_in': 900, + }), + 200, + ); + } + return http.Response( + jsonEncode({'error': 'access_denied', 'error_description': 'no'}), + 200, + ); + }); + + await tester.runAsync(() async { + await tester.pumpWidget(_wrap(httpClient: mock)); + await Future.delayed(const Duration(milliseconds: 300)); + await tester.pump(); + + await tester.tap(find.text('Connect GitHub')); + await _pumpUntil( + tester, + () => find.text('WXYZ-1234').evaluate().isNotEmpty, + ); + expect(find.text('WXYZ-1234'), findsOneWidget); + + await _pumpUntil( + tester, + () => find.textContaining('access_denied').evaluate().isNotEmpty, + ); + expect(find.textContaining('access_denied'), findsOneWidget); + + await tester.tap(find.text('Open GitHub & copy code')); + await tester.pump(); + expect(launcher.launched, 'https://github.com/login/device'); + + await tester.tap(find.text('Cancel')); + await tester.pump(); + }); + }); + + testWidgets('device flow: shows a message when the token cannot be saved', ( + tester, + ) async { + // Throwing secure storage makes SyncSettings.save() fail. + installFakeSecureStorage(throwing: true); + final mock = MockClient((req) async { + if (req.url.path.contains('device/code')) { + return http.Response( + jsonEncode({ + 'device_code': 'dev123', + 'user_code': 'WXYZ-1234', + 'verification_uri': 'https://github.com/login/device', + 'interval': 0, + 'expires_in': 900, + }), + 200, + ); + } + if (req.url.path.contains('oauth/access_token')) { + return http.Response(jsonEncode({'access_token': 'gho_test'}), 200); + } + return http.Response( + jsonEncode({'content': base64Encode(utf8.encode('{}'))}), + 200, + ); + }); + + await tester.runAsync(() async { + await tester.pumpWidget(_wrap(httpClient: mock)); + await Future.delayed(const Duration(milliseconds: 300)); + await tester.pump(); + + await tester.tap(find.text('Connect GitHub')); + await _pumpUntil( + tester, + () => find + .textContaining('could not save the token') + .evaluate() + .isNotEmpty, + ); + expect( + find.textContaining('could not save the token'), + findsOneWidget, + ); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/github_mirror_screen_test.dart b/stronglift_replacement/workout_app/test/screens/github_mirror_screen_test.dart index a013673..fff66a6 100644 --- a/stronglift_replacement/workout_app/test/screens/github_mirror_screen_test.dart +++ b/stronglift_replacement/workout_app/test/screens/github_mirror_screen_test.dart @@ -151,320 +151,4 @@ void main() { await tester.pump(); expect(find.text('Sync token saved.'), findsOneWidget); }); - - testWidgets('device flow failure to start shows a message', (tester) async { - final mock = MockClient((_) async => http.Response('nope', 422)); - await tester.runAsync(() async { - await tester.pumpWidget(_wrap(httpClient: mock)); - await Future.delayed(const Duration(milliseconds: 300)); - await tester.pump(); - - await tester.tap(find.text('Connect GitHub')); - await _pumpUntil( - tester, - () => find - .textContaining('Could not start device flow') - .evaluate() - .isNotEmpty, - ); - - expect( - find.textContaining('Could not start device flow'), - findsOneWidget, - ); - }); - }); - - testWidgets('device flow happy path saves and verifies the token', ( - tester, - ) async { - final mock = MockClient((req) async { - if (req.url.path.contains('device/code')) { - return http.Response( - jsonEncode({ - 'device_code': 'dev123', - 'user_code': 'WXYZ-1234', - 'verification_uri': 'https://github.com/login/device', - 'interval': 0, - 'expires_in': 900, - }), - 200, - ); - } - if (req.url.path.contains('oauth/access_token')) { - return http.Response(jsonEncode({'access_token': 'gho_test'}), 200); - } - // The post-connect verification GET on devices/phone/log.json. - return http.Response( - jsonEncode({'content': base64Encode(utf8.encode('{}'))}), - 200, - ); - }); - - await tester.runAsync(() async { - await tester.pumpWidget(_wrap(httpClient: mock)); - await Future.delayed(const Duration(milliseconds: 300)); - await tester.pump(); - - await tester.tap(find.text('Connect GitHub')); - await _pumpUntil( - tester, - () => find.text('WXYZ-1234').evaluate().isNotEmpty, - ); - expect(find.text('WXYZ-1234'), findsOneWidget); - - await _pumpUntil( - tester, - () => find - .textContaining('Connected and verified via GitHub') - .evaluate() - .isNotEmpty, - ); - - expect( - find.textContaining('Connected and verified via GitHub'), - findsOneWidget, - ); - }); - }); - - testWidgets('device dialog: failed poll shows the error and Open launches', ( - tester, - ) async { - final launcher = _FakeUrlLauncher(); - UrlLauncherPlatform.instance = launcher; - - // The dialog's Open button copies the code to the clipboard first; - // there's no clipboard plugin in the test host, so stub the channel. - final messenger = - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; - messenger.setMockMethodCallHandler( - SystemChannels.platform, - (call) async => null, - ); - addTearDown( - () => messenger.setMockMethodCallHandler(SystemChannels.platform, null), - ); - - final mock = MockClient((req) async { - if (req.url.path.contains('device/code')) { - return http.Response( - jsonEncode({ - 'device_code': 'dev123', - 'user_code': 'WXYZ-1234', - 'verification_uri': 'https://github.com/login/device', - 'interval': 0, - 'expires_in': 900, - }), - 200, - ); - } - return http.Response( - jsonEncode({'error': 'access_denied', 'error_description': 'no'}), - 200, - ); - }); - - await tester.runAsync(() async { - await tester.pumpWidget(_wrap(httpClient: mock)); - await Future.delayed(const Duration(milliseconds: 300)); - await tester.pump(); - - await tester.tap(find.text('Connect GitHub')); - await _pumpUntil( - tester, - () => find.text('WXYZ-1234').evaluate().isNotEmpty, - ); - expect(find.text('WXYZ-1234'), findsOneWidget); - - await _pumpUntil( - tester, - () => find.textContaining('access_denied').evaluate().isNotEmpty, - ); - expect(find.textContaining('access_denied'), findsOneWidget); - - await tester.tap(find.text('Open GitHub & copy code')); - await tester.pump(); - expect(launcher.launched, 'https://github.com/login/device'); - - await tester.tap(find.text('Cancel')); - await tester.pump(); - }); - }); - - testWidgets('device flow: shows a message when the token cannot be saved', ( - tester, - ) async { - // Throwing secure storage makes SyncSettings.save() fail. - installFakeSecureStorage(throwing: true); - final mock = MockClient((req) async { - if (req.url.path.contains('device/code')) { - return http.Response( - jsonEncode({ - 'device_code': 'dev123', - 'user_code': 'WXYZ-1234', - 'verification_uri': 'https://github.com/login/device', - 'interval': 0, - 'expires_in': 900, - }), - 200, - ); - } - if (req.url.path.contains('oauth/access_token')) { - return http.Response(jsonEncode({'access_token': 'gho_test'}), 200); - } - return http.Response( - jsonEncode({'content': base64Encode(utf8.encode('{}'))}), - 200, - ); - }); - - await tester.runAsync(() async { - await tester.pumpWidget(_wrap(httpClient: mock)); - await Future.delayed(const Duration(milliseconds: 300)); - await tester.pump(); - - await tester.tap(find.text('Connect GitHub')); - await _pumpUntil( - tester, - () => find - .textContaining('could not save the token') - .evaluate() - .isNotEmpty, - ); - expect( - find.textContaining('could not save the token'), - findsOneWidget, - ); - }); - }); - - testWidgets('device flow: shows a message when verification fails', ( - tester, - ) async { - final mock = MockClient((req) async { - if (req.url.path.contains('device/code')) { - return http.Response( - jsonEncode({ - 'device_code': 'dev123', - 'user_code': 'WXYZ-1234', - 'verification_uri': 'https://github.com/login/device', - 'interval': 0, - 'expires_in': 900, - }), - 200, - ); - } - if (req.url.path.contains('oauth/access_token')) { - return http.Response(jsonEncode({'access_token': 'gho_test'}), 200); - } - // The post-connect verification GET fails → GitHubSyncError. - return http.Response('boom', 500); - }); - - await tester.runAsync(() async { - await tester.pumpWidget(_wrap(httpClient: mock)); - await Future.delayed(const Duration(milliseconds: 300)); - await tester.pump(); - - await tester.tap(find.text('Connect GitHub')); - await _pumpUntil( - tester, - () => find.textContaining('NOT syncing').evaluate().isNotEmpty, - ); - expect(find.textContaining('NOT syncing'), findsOneWidget); - }); - }); - - testWidgets('device flow: renders the pending "verifying" status', ( - tester, - ) async { - final mock = MockClient((req) async { - if (req.url.path.contains('device/code')) { - return http.Response( - jsonEncode({ - 'device_code': 'dev123', - 'user_code': 'WXYZ-1234', - 'verification_uri': 'https://github.com/login/device', - 'interval': 0, - 'expires_in': 900, - }), - 200, - ); - } - if (req.url.path.contains('oauth/access_token')) { - return http.Response(jsonEncode({'access_token': 'gho_test'}), 200); - } - // Hold the verification in-flight so the pending status tile is - // observable before it resolves to success. - await Future.delayed(const Duration(milliseconds: 600)); - return http.Response( - jsonEncode({'content': base64Encode(utf8.encode('{}'))}), - 200, - ); - }); - - await tester.runAsync(() async { - await tester.pumpWidget(_wrap(httpClient: mock)); - await Future.delayed(const Duration(milliseconds: 300)); - await tester.pump(); - - await tester.tap(find.text('Connect GitHub')); - await _pumpUntil( - tester, - () => find.textContaining('verifying').evaluate().isNotEmpty, - ); - // Pending tile (spinner + white70 status colour) is now on screen. - expect(find.textContaining('verifying'), findsOneWidget); - - await _pumpUntil( - tester, - () => - find.textContaining('Connected and verified').evaluate().isNotEmpty, - ); - expect(find.textContaining('Connected and verified'), findsOneWidget); - }); - }); - - testWidgets( - 'a rejected stored token recovers from the backup instead of nagging', - (tester) async { - // load() only consults the backup when the keystore is EMPTY, so a - // stale keystore entry would otherwise shadow a good backup forever - // and demand a pointless re-authorization. - final tempDir = Directory.systemTemp.createTempSync('settings_recover_'); - BackupService.baseDirForTesting = tempDir.path; - addTearDown(() { - BackupService.baseDirForTesting = kBackupDir; - tempDir.deleteSync(recursive: true); - }); - - installFakeSecureStorage(initial: {'sync.token': 'stale'}); - // save() writes the external-storage backup: real file I/O, which hangs - // in the widget-test fake-async zone (same reason _seed uses runAsync). - await tester.runAsync( - () async => const SyncSettings(token: 'good').save(), - ); - installFakeSecureStorage(initial: {'sync.token': 'stale'}); - - final mock = MockClient((req) async { - if (req.headers['Authorization']?.contains('good') != true) { - return http.Response('Bad credentials', 401); - } - return http.Response( - jsonEncode({'content': base64Encode(utf8.encode('{}')), 'sha': 's'}), - 200, - ); - }); - - await tester.runAsync(() async { - await tester.pumpWidget(_wrap(httpClient: mock)); - await Future.delayed(const Duration(milliseconds: 300)); - }); - await tester.pump(); - - expect(find.textContaining('recovered the saved token'), findsOneWidget); - expect(find.textContaining('NOT connected'), findsNothing); - }, - ); }