import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:appsok/src/app.dart'; import 'package:appsok/src/features/settings/settings_page.dart'; import 'package:appsok/src/services/jenkins_client.dart'; import 'package:appsok/src/services/token_store.dart'; import 'package:appsok/src/theme/app_theme.dart'; void main() { setUp(() { FlutterSecureStorage.setMockInitialValues({}); }); // ── AppSokApp pre-stored session restore ───────────────────────── testWidgets( 'AppSokApp activates jobs loader when session is stored before build', (tester) async { final requestedPaths = []; FlutterSecureStorage.setMockInitialValues({ 'jenkins.baseUrl': 'https://jenkins.example', 'jenkins.username': 'toki', 'jenkins.apiToken': 'generated-api-token', }); await tester.pumpWidget( AppSokApp( jenkinsClient: JenkinsClient( client: MockClient((request) async { if (request.url.path.endsWith('whoAmI/api/json')) { requestedPaths.add('whoAmI'); return http.Response(jsonEncode({'id': 'toki'}), 200); } if (request.url.path.endsWith('/api/json')) { requestedPaths.add('jobs'); return http.Response( jsonEncode({ 'jobs': [ { 'name': 'post-login-job', 'fullName': 'post-login-job', 'url': 'https://jenkins.example/job/post-login-job/', }, ], }), 200, ); } return http.Response(jsonEncode({}), 200); }), ), ), ); await tester.pumpAndSettle(); expect(find.text('Jenkins 연결 필요'), findsNothing); expect(find.text('post-login-job'), findsOneWidget); expect(requestedPaths, containsAllInOrder(['whoAmI', 'jobs'])); }, ); // ── AppSokApp._saveToken + restore success path ────────────────── testWidgets( 'AppSokApp: web login with apiToken saves session, validates, activates jobs loader', (tester) async { // No stored session at startup. FlutterSecureStorage.setMockInitialValues({}); final requestedPaths = []; await tester.pumpWidget( AppSokApp( jenkinsClient: JenkinsClient( client: MockClient((request) async { requestedPaths.add(request.url.path); if (request.url.path.endsWith('whoAmI/api/json')) { return http.Response(jsonEncode({'id': 'toki'}), 200); } if (request.url.path.endsWith('/api/json')) { return http.Response( jsonEncode({ 'jobs': [ { 'name': 'web-login-job', 'fullName': 'web-login-job', 'url': 'https://jenkins.example/job/web-login-job/', }, ], }), 200, ); } return http.Response(jsonEncode({}), 200); }), ), // launcher returns apiToken → triggers real _saveToken path webLoginLauncher: (_) async => const JenkinsWebLoginResult( username: 'toki', displayName: 'Toki Lab', apiToken: 'issued-token-abc', ), ), ); await tester.pumpAndSettle(); // No session yet — jobs loader not active. expect(find.text('Jenkins 연결 필요'), findsOneWidget); // Navigate to Settings. await tester.tap( find.descendant( of: find.byType(NavigationRail), matching: find.text('설정'), ), ); await tester.pumpAndSettle(); await tester.enterText( find.byType(TextField).first, 'https://jenkins.example', ); await tester.pump(); // Tap Web Login — triggers real _saveToken + restore path. await tester.tap(find.byKey(const ValueKey('jenkins-web-login-button'))); await tester.pumpAndSettle(); // _saveToken saved and validated: token-saved-label must appear. expect(find.byKey(const ValueKey('token-saved-label')), findsOneWidget); // whoAmI was called (validate on save) and again (restore for _startup). expect( requestedPaths.where((p) => p.endsWith('whoAmI/api/json')).length, greaterThanOrEqualTo(1), ); // Navigate back to 빌드 tab to verify jobs loader is now active. await tester.tap( find.descendant( of: find.byType(NavigationRail), matching: find.text('빌드'), ), ); await tester.pumpAndSettle(); expect(find.text('Jenkins 연결 필요'), findsNothing); expect(find.text('web-login-job'), findsOneWidget); }, ); // ── AppSokApp._saveToken + restore failure path ────────────────── testWidgets( 'AppSokApp: web login with apiToken clears session and shows error when restore fails', (tester) async { FlutterSecureStorage.setMockInitialValues({}); await tester.pumpWidget( AppSokApp( jenkinsClient: JenkinsClient( client: MockClient((request) async { // All requests fail with 401. return http.Response('Unauthorized', 401); }), ), webLoginLauncher: (_) async => const JenkinsWebLoginResult( username: 'toki', displayName: 'Toki Lab', apiToken: 'bad-token', ), ), ); await tester.pumpAndSettle(); // Navigate to Settings. await tester.tap( find.descendant( of: find.byType(NavigationRail), matching: find.text('설정'), ), ); await tester.pumpAndSettle(); await tester.enterText( find.byType(TextField).first, 'https://jenkins.example', ); await tester.pump(); await tester.tap(find.byKey(const ValueKey('jenkins-web-login-button'))); await tester.pumpAndSettle(); // Save failed: error UI shown, success label absent. expect(find.byKey(const ValueKey('token-save-error')), findsOneWidget); expect(find.byKey(const ValueKey('token-saved-label')), findsNothing); // Session must have been cleared — verify indirectly via jobs loader state. await tester.tap( find.descendant( of: find.byType(NavigationRail), matching: find.text('빌드'), ), ); await tester.pumpAndSettle(); expect(find.text('Jenkins 연결 필요'), findsOneWidget); }, ); testWidgets( 'AppSokApp: clearing credential removes stored session and disables jobs loader', (tester) async { final requestedPaths = []; FlutterSecureStorage.setMockInitialValues({ 'jenkins.baseUrl': 'https://jenkins.example', 'jenkins.username': 'toki', 'jenkins.apiToken': 'generated-api-token', }); await tester.pumpWidget( AppSokApp( jenkinsClient: JenkinsClient( client: MockClient((request) async { requestedPaths.add(request.url.path); if (request.url.path.endsWith('whoAmI/api/json')) { return http.Response(jsonEncode({'id': 'toki'}), 200); } if (request.url.path.endsWith('/api/json')) { return http.Response( jsonEncode({ 'jobs': [ { 'name': 'stored-job', 'fullName': 'stored-job', 'url': 'https://jenkins.example/job/stored-job/', }, ], }), 200, ); } return http.Response(jsonEncode({}), 200); }), ), ), ); await tester.pumpAndSettle(); expect(find.text('stored-job'), findsOneWidget); expect(requestedPaths, containsAllInOrder(['/whoAmI/api/json'])); await tester.tap( find.descendant( of: find.byType(NavigationRail), matching: find.text('설정'), ), ); await tester.pumpAndSettle(); await tester.tap( find.byKey(const ValueKey('jenkins-clear-session-button')), ); await tester.pumpAndSettle(); expect( find.byKey(const ValueKey('session-cleared-label')), findsOneWidget, ); expect(await TokenStore().readSession(), isNull); await tester.tap( find.descendant( of: find.byType(NavigationRail), matching: find.text('빌드'), ), ); await tester.pumpAndSettle(); expect(find.text('Jenkins 연결 필요'), findsOneWidget); expect(find.text('stored-job'), findsNothing); }, ); // ── SettingsPage: apiToken → tokenSaver → onSessionSaved ──────── testWidgets( 'SettingsPage calls tokenSaver and onSessionSaved when launcher returns apiToken', (tester) async { var onSessionSavedCalled = false; String? savedUsername; String? savedToken; await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), home: Scaffold( body: SettingsPage( tokenSaver: (baseUrl, username, apiToken) async { savedUsername = username; savedToken = apiToken; return true; }, onSessionSaved: () => onSessionSavedCalled = true, webLoginLauncher: (_) async => const JenkinsWebLoginResult( username: 'toki', displayName: 'Toki Lab', apiToken: 'issued-token-abc', ), ), ), ), ); await tester.enterText( find.byType(TextField).first, 'https://jenkins.example.com', ); await tester.pump(); await tester.tap(find.byKey(const ValueKey('jenkins-web-login-button'))); await tester.pumpAndSettle(); expect(savedUsername, 'toki'); expect(savedToken, 'issued-token-abc'); expect(onSessionSavedCalled, isTrue); expect(find.byKey(const ValueKey('token-saved-label')), findsOneWidget); expect(tester.takeException(), isNull); }, ); testWidgets( 'SettingsPage shows token-save-error when tokenSaver returns false', (tester) async { await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), home: Scaffold( body: SettingsPage( tokenSaver: (baseUrl, username, apiToken) async => false, onSessionSaved: () {}, webLoginLauncher: (_) async => const JenkinsWebLoginResult( username: 'toki', displayName: 'Toki Lab', apiToken: 'bad-token', ), ), ), ), ); await tester.enterText( find.byType(TextField).first, 'https://jenkins.example.com', ); await tester.pump(); await tester.tap(find.byKey(const ValueKey('jenkins-web-login-button'))); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('token-save-error')), findsOneWidget); expect(find.byKey(const ValueKey('token-saved-label')), findsNothing); expect(tester.takeException(), isNull); }, ); testWidgets( 'SettingsPage shows error when launcher returns result without apiToken', (tester) async { var tokenSaverCalled = false; await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), home: Scaffold( body: SettingsPage( tokenSaver: (baseUrl, username, apiToken) async { tokenSaverCalled = true; return true; }, onSessionSaved: () {}, webLoginLauncher: (_) async => const JenkinsWebLoginResult( username: 'toki', displayName: 'Toki Lab', ), ), ), ), ); await tester.enterText( find.byType(TextField).first, 'https://jenkins.example.com', ); await tester.pump(); await tester.tap(find.byKey(const ValueKey('jenkins-web-login-button'))); await tester.pumpAndSettle(); expect(tokenSaverCalled, isFalse); expect(find.byKey(const ValueKey('web-login-result')), findsNothing); expect(find.textContaining('API token 발급에 실패했습니다'), findsOneWidget); expect(tester.takeException(), isNull); }, ); // ── reauth / network failure restore paths ─────────────────────── testWidgets( 'AppSokApp shows reauth required state when stored credential returns 401', (tester) async { FlutterSecureStorage.setMockInitialValues({ 'jenkins.baseUrl': 'https://jenkins.example', 'jenkins.username': 'toki', 'jenkins.apiToken': 'revoked-token', }); await tester.pumpWidget( AppSokApp( jenkinsClient: JenkinsClient( client: MockClient( (request) async => http.Response('Unauthorized', 401), ), ), ), ); await tester.pumpAndSettle(); expect(find.text('재로그인 필요'), findsOneWidget); // Credential must be cleared after 401. expect(await TokenStore().readSession(), isNull); }, ); testWidgets( 'AppSokApp shows reauth required state when stored credential returns 403', (tester) async { FlutterSecureStorage.setMockInitialValues({ 'jenkins.baseUrl': 'https://jenkins.example', 'jenkins.username': 'toki', 'jenkins.apiToken': 'forbidden-token', }); await tester.pumpWidget( AppSokApp( jenkinsClient: JenkinsClient( client: MockClient( (request) async => http.Response('Forbidden', 403), ), ), ), ); await tester.pumpAndSettle(); expect(find.text('재로그인 필요'), findsOneWidget); expect(await TokenStore().readSession(), isNull); }, ); testWidgets( 'AppSokApp shows network retry state when restore fails with IOException and preserves credential', (tester) async { FlutterSecureStorage.setMockInitialValues({ 'jenkins.baseUrl': 'https://jenkins.example', 'jenkins.username': 'toki', 'jenkins.apiToken': 'valid-token', }); await tester.pumpWidget( AppSokApp( jenkinsClient: JenkinsClient( client: MockClient( (request) async => throw const SocketException('Network unreachable'), ), ), ), ); await tester.pumpAndSettle(); expect(find.text('Jenkins 연결 실패'), findsOneWidget); // Credential preserved on network failure. expect(await TokenStore().readSession(), isNotNull); }, ); testWidgets( 'AppSokApp retry callback re-runs restore and activates jobs loader on success', (tester) async { FlutterSecureStorage.setMockInitialValues({ 'jenkins.baseUrl': 'https://jenkins.example', 'jenkins.username': 'toki', 'jenkins.apiToken': 'valid-token', }); var callCount = 0; await tester.pumpWidget( AppSokApp( jenkinsClient: JenkinsClient( client: MockClient((request) async { callCount++; if (callCount == 1) { throw const SocketException('Network unreachable'); } if (request.url.path.endsWith('whoAmI/api/json')) { return http.Response(jsonEncode({'id': 'toki'}), 200); } if (request.url.path.endsWith('/api/json')) { return http.Response( jsonEncode({ 'jobs': [ { 'name': 'retry-job', 'fullName': 'retry-job', 'url': 'https://jenkins.example/job/retry-job/', }, ], }), 200, ); } return http.Response(jsonEncode({}), 200); }), ), ), ); await tester.pumpAndSettle(); expect(find.text('Jenkins 연결 실패'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('job-status-action-button'))); await tester.pumpAndSettle(); expect(find.text('Jenkins 연결 실패'), findsNothing); expect(find.text('retry-job'), findsOneWidget); }, ); }