543 lines
17 KiB
Dart
543 lines
17 KiB
Dart
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 = <String>[];
|
|
|
|
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(<String>['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 = <String>[];
|
|
|
|
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 = <String>[];
|
|
|
|
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(<String>['/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);
|
|
},
|
|
);
|
|
}
|