feat(mobile): 服务器列表页与可注入仓库

将 ServerRepository 重构为接口,由 ApiServerRepository 提供
实际网络与解密实现,使列表页 widget 测试可以注入伪仓库覆盖
空列表、错误重试、连接失败 SnackBar 与登出按钮路径。
This commit is contained in:
chaos-zhu
2026-05-16 17:18:51 +08:00
parent 53d9807daf
commit 8d89cd2d73
3 changed files with 166 additions and 5 deletions
+1 -1
View File
@@ -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);
});
}