mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-07 13:38:14 +08:00
feat(mobile): 服务器列表页与可注入仓库
将 ServerRepository 重构为接口,由 ApiServerRepository 提供 实际网络与解密实现,使列表页 widget 测试可以注入伪仓库覆盖 空列表、错误重试、连接失败 SnackBar 与登出按钮路径。
This commit is contained in:
+1
-1
@@ -124,7 +124,7 @@ class _EasyNodeAppState extends State<EasyNodeApp> {
|
||||
Widget home;
|
||||
if (_session != null && _apiClient != null && _publicKeyPem != null) {
|
||||
home = ServerListPage(
|
||||
repository: ServerRepository(
|
||||
repository: ApiServerRepository(
|
||||
apiClient: _apiClient!,
|
||||
publicKeyPem: _publicKeyPem!,
|
||||
),
|
||||
|
||||
@@ -9,9 +9,17 @@ import '../../core/crypto/rsa_crypto.dart';
|
||||
import '../terminal/ssh_connection_config.dart';
|
||||
import 'server_model.dart';
|
||||
|
||||
/// Backs the server list page and the connect action.
|
||||
class ServerRepository {
|
||||
ServerRepository({
|
||||
/// Backs the server list page and the connect action. The interface lets
|
||||
/// widget tests inject a fake without touching real HTTP / RSA code.
|
||||
abstract class ServerRepository {
|
||||
Future<List<ServerModel>> fetchHosts();
|
||||
Future<SshConnectionConfig> fetchSshConfig(String hostId);
|
||||
}
|
||||
|
||||
/// Default [ServerRepository] backed by [ApiClient] and the RSA public key
|
||||
/// fetched at login time.
|
||||
class ApiServerRepository implements ServerRepository {
|
||||
ApiServerRepository({
|
||||
required ApiClient apiClient,
|
||||
required String publicKeyPem,
|
||||
RsaCrypto? rsa,
|
||||
@@ -27,6 +35,7 @@ class ServerRepository {
|
||||
final Random _random;
|
||||
|
||||
/// Fetch the host list. Server returns `{ data: [host...] }`.
|
||||
@override
|
||||
Future<List<ServerModel>> fetchHosts() async {
|
||||
final response = await _api.getJson('/host-list');
|
||||
final raw = response['data'];
|
||||
@@ -46,6 +55,7 @@ class ServerRepository {
|
||||
/// 5. Return a strongly typed [SshConnectionConfig].
|
||||
///
|
||||
/// The temporary key and decrypted payload live only inside this method.
|
||||
@override
|
||||
Future<SshConnectionConfig> fetchSshConfig(String hostId) async {
|
||||
final keyBytes = _randomBytes(32);
|
||||
final encryptedKey = _rsa.encryptTemporaryKey(_publicKeyPem, keyBytes);
|
||||
@@ -90,7 +100,7 @@ class ServerRepository {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// Expose so [ServerRepository] can be JSON-serialized in widget tests.
|
||||
/// Expose so [ApiServerRepository] can be JSON-serialized in widget tests.
|
||||
// ignore: unused_element
|
||||
String _debugBase64Bytes(Uint8List bytes) => base64Encode(bytes);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mobile/features/auth/auth_session.dart';
|
||||
import 'package:mobile/features/servers/server_list_page.dart';
|
||||
import 'package:mobile/features/servers/server_model.dart';
|
||||
import 'package:mobile/features/servers/server_repository.dart';
|
||||
import 'package:mobile/features/terminal/ssh_connection_config.dart';
|
||||
|
||||
class _FakeRepository implements ServerRepository {
|
||||
_FakeRepository({
|
||||
this.hosts = const [],
|
||||
this.fetchError,
|
||||
SshConnectionConfig? config,
|
||||
this.sshError,
|
||||
}) : config = config ?? _defaultConfig;
|
||||
|
||||
List<ServerModel> hosts;
|
||||
Object? fetchError;
|
||||
SshConnectionConfig config;
|
||||
Object? sshError;
|
||||
int connectCalls = 0;
|
||||
|
||||
static const _defaultConfig = SshConnectionConfig(
|
||||
hostId: 'h1',
|
||||
name: 'srv',
|
||||
host: '10.0.0.2',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
authType: 'password',
|
||||
password: 'pwd',
|
||||
privateKey: '',
|
||||
passphrase: '',
|
||||
);
|
||||
|
||||
@override
|
||||
Future<List<ServerModel>> fetchHosts() async {
|
||||
if (fetchError != null) throw fetchError!;
|
||||
return hosts;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SshConnectionConfig> fetchSshConfig(String hostId) async {
|
||||
connectCalls++;
|
||||
if (sshError != null) throw sshError!;
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
const _session = AuthSession(
|
||||
serverAddress: 'https://example.com',
|
||||
username: 'root',
|
||||
token: 't',
|
||||
deviceId: 'd',
|
||||
);
|
||||
|
||||
ServerModel _server({String id = 'h1', bool canConnect = true}) {
|
||||
return ServerModel.fromJson({
|
||||
'id': id,
|
||||
'name': 'srv-$id',
|
||||
'host': '10.0.0.2',
|
||||
'port': 22,
|
||||
'username': 'root',
|
||||
'authType': 'password',
|
||||
'group': '',
|
||||
'tag': const [],
|
||||
'expired': !canConnect,
|
||||
'isConfig': canConnect,
|
||||
});
|
||||
}
|
||||
|
||||
Widget _wrap(Widget child) => MaterialApp(home: child);
|
||||
|
||||
void main() {
|
||||
testWidgets('shows empty state when host list is empty', (tester) async {
|
||||
final repo = _FakeRepository(hosts: const []);
|
||||
await tester.pumpWidget(_wrap(ServerListPage(
|
||||
repository: repo,
|
||||
session: _session,
|
||||
onLogout: () {},
|
||||
)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('暂无服务器'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders one tile per host with connect button', (tester) async {
|
||||
final repo = _FakeRepository(hosts: [
|
||||
_server(id: 'h1'),
|
||||
_server(id: 'h2', canConnect: false),
|
||||
]);
|
||||
await tester.pumpWidget(_wrap(ServerListPage(
|
||||
repository: repo,
|
||||
session: _session,
|
||||
onLogout: () {},
|
||||
)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('server-h1')), findsOneWidget);
|
||||
expect(find.byKey(const Key('server-h2')), findsOneWidget);
|
||||
expect(find.text('连接'), findsOneWidget);
|
||||
expect(find.text('未配置'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows error and retry button when fetch fails', (tester) async {
|
||||
final repo = _FakeRepository(fetchError: Exception('boom'));
|
||||
await tester.pumpWidget(_wrap(ServerListPage(
|
||||
repository: repo,
|
||||
session: _session,
|
||||
onLogout: () {},
|
||||
)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('boom'), findsOneWidget);
|
||||
expect(find.text('重试'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows snackbar when fetchSshConfig fails', (tester) async {
|
||||
final repo = _FakeRepository(
|
||||
hosts: [_server(id: 'h1')],
|
||||
sshError: Exception('nope'),
|
||||
);
|
||||
await tester.pumpWidget(_wrap(ServerListPage(
|
||||
repository: repo,
|
||||
session: _session,
|
||||
onLogout: () {},
|
||||
)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('连接'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(repo.connectCalls, 1);
|
||||
expect(find.textContaining('获取 SSH 参数失败'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('logout button calls onLogout', (tester) async {
|
||||
var loggedOut = 0;
|
||||
final repo = _FakeRepository(hosts: const []);
|
||||
await tester.pumpWidget(_wrap(ServerListPage(
|
||||
repository: repo,
|
||||
session: _session,
|
||||
onLogout: () => loggedOut++,
|
||||
)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byTooltip('退出登录'));
|
||||
await tester.pump();
|
||||
expect(loggedOut, 1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user