Files
jizhi/frontend/test/update_test.dart
T
2026-07-24 23:11:20 +08:00

270 lines
8.0 KiB
Dart

import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/update/update_api.dart';
import 'package:miaoji_zhang/shared/update/update_coordinator.dart';
import 'package:miaoji_zhang/shared/update/update_downloader.dart';
import 'package:miaoji_zhang/shared/update/update_models.dart';
import 'package:miaoji_zhang/shared/update/update_prompt.dart';
import 'package:miaoji_zhang/shared/version.dart';
import 'package:package_info_plus/package_info_plus.dart';
void main() {
const release = AppRelease(
id: 'release-7',
versionName: '1.2.2',
buildNumber: 7,
downloadUrl: 'https://downloads.example.com/jizhi-7.apk',
releaseNotes: '## 本次更新\n\n- 修复识别问题\n- 优化更新体验',
sha256: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
fileSize: 1024,
publishedAt: null,
);
test('使用原生 versionName 和 buildNumber 展示版本', () async {
PackageInfo.setMockInitialValues(
appName: '记之',
packageName: 'com.nx.miaoji.internal',
version: '1.2.1-internal',
buildNumber: '6',
buildSignature: '',
);
await AppVersion.initialize();
expect(AppVersion.versionName, '1.2.1-internal');
expect(AppVersion.buildNumber, 6);
expect(AppVersion.display, 'v1.2.1-internal (6)');
});
test('更新响应保留发布信息并且只按 buildNumber 判断新旧', () {
final response = UpdateCheckResponse.fromJson({
'hasUpdate': true,
'forceUpdate': false,
'release': {
'id': 'release-7',
'versionName': '0.1.0',
'buildNumber': 7,
'downloadUrl': 'https://downloads.example.com/jizhi-7.apk',
'releaseNotes': '测试版本',
'sha256': release.sha256,
'fileSize': 1024,
'publishedAt': '2026-07-21T12:00:00Z',
},
});
expect(UpdatePolicy.availableRelease(response, currentBuild: 6), isNotNull);
expect(UpdatePolicy.availableRelease(response, currentBuild: 7), isNull);
expect(response.release?.versionName, '0.1.0');
});
test('忽略普通版本但不影响手动检查和强制更新', () {
expect(
UpdatePolicy.shouldPresent(
release: release,
forced: false,
manual: false,
ignoredReleaseId: release.id,
),
isFalse,
);
expect(
UpdatePolicy.shouldPresent(
release: release,
forced: false,
manual: true,
ignoredReleaseId: release.id,
),
isTrue,
);
expect(
UpdatePolicy.shouldPresent(
release: release,
forced: true,
manual: false,
ignoredReleaseId: release.id,
),
isTrue,
);
});
test('UpdateApi 发送公开参数且不附带登录请求配置', () async {
final adapter = _FakeAdapter(
statusCode: 200,
body: jsonEncode({
'hasUpdate': true,
'forceUpdate': false,
'release': {
'id': release.id,
'versionName': release.versionName,
'buildNumber': release.buildNumber,
'downloadUrl': release.downloadUrl,
'releaseNotes': release.releaseNotes,
'sha256': release.sha256,
'fileSize': release.fileSize,
'publishedAt': '2026-07-21T12:00:00Z',
},
}),
);
final dio = Dio(BaseOptions(baseUrl: 'https://version.test'))
..httpClientAdapter = adapter;
final result = await UpdateApi(
dio: dio,
).check(platform: 'android', channel: 'beta', currentBuild: 6);
expect(result.release?.buildNumber, 7);
expect(adapter.request?.path, '/api/client/v1/update');
expect(adapter.request?.queryParameters['platform'], 'android');
expect(adapter.request?.queryParameters['channel'], 'beta');
expect(adapter.request?.queryParameters['currentBuild'], 6);
expect(adapter.request?.headers['Authorization'], isNull);
});
test('协调器合并并发检查请求', () async {
final adapter = _FakeAdapter(
statusCode: 200,
body: '{"hasUpdate":false,"forceUpdate":false,"release":null}',
delay: const Duration(milliseconds: 20),
);
final dio = Dio(BaseOptions(baseUrl: 'https://version.test'))
..httpClientAdapter = adapter;
final coordinator = UpdateCoordinator(api: UpdateApi(dio: dio));
await Future.wait([
coordinator.debugCheck(platform: 'android'),
coordinator.debugCheck(platform: 'android'),
]);
expect(adapter.callCount, 1);
});
test('Internal 更新缺少 SHA-256 时在下载前拒绝', () async {
const unsafeRelease = AppRelease(
id: 'unsafe',
versionName: '1.2.2',
buildNumber: 7,
downloadUrl: 'https://downloads.example.com/jizhi-7.apk',
releaseNotes: '',
sha256: null,
fileSize: null,
publishedAt: null,
);
await expectLater(
UpdateDownloader().downloadAndVerify(unsafeRelease, onProgress: (_) {}),
throwsA(
isA<UpdateDownloadException>().having(
(error) => error.message,
'message',
contains('SHA-256'),
),
),
);
});
test('UpdateApi 将 404 和 429 转换为稳定错误', () async {
for (final entry in {
404: UpdateFailureKind.unavailable,
429: UpdateFailureKind.rateLimited,
}.entries) {
final dio = Dio(BaseOptions(baseUrl: 'https://version.test'))
..httpClientAdapter = _FakeAdapter(statusCode: entry.key, body: '{}');
await expectLater(
UpdateApi(
dio: dio,
).check(platform: 'ios', channel: 'stable', currentBuild: 6),
throwsA(
isA<UpdateCheckException>().having(
(error) => error.kind,
'kind',
entry.value,
),
),
);
}
});
testWidgets('普通更新面板展示说明并允许忽略', (tester) async {
var ignored = false;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light,
home: Scaffold(
body: UpdatePromptSheet(
release: release,
forced: false,
onIgnore: () async => ignored = true,
),
),
),
);
expect(find.text('发现新版本'), findsOneWidget);
expect(find.textContaining('修复识别问题'), findsOneWidget);
expect(find.text('本次更新'), findsOneWidget);
expect(find.byType(MarkdownBody), findsOneWidget);
expect(find.text('忽略此版本'), findsOneWidget);
await tester.tap(find.text('忽略此版本'));
await tester.pump();
expect(ignored, isTrue);
});
testWidgets('强制更新面板没有忽略入口并适配深色主题', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.dark,
home: Scaffold(
body: UpdatePromptSheet(
release: release,
forced: true,
onIgnore: () async {},
),
),
),
);
expect(find.text('必须更新后继续使用'), findsOneWidget);
expect(find.text('忽略此版本'), findsNothing);
expect(find.text('立即更新'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
class _FakeAdapter implements HttpClientAdapter {
final int statusCode;
final String body;
RequestOptions? request;
int callCount = 0;
final Duration delay;
_FakeAdapter({
required this.statusCode,
required this.body,
this.delay = Duration.zero,
});
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
request = options;
callCount++;
if (delay > Duration.zero) await Future<void>.delayed(delay);
return ResponseBody.fromString(
body,
statusCode,
headers: {
Headers.contentTypeHeader: ['application/json'],
},
);
}
@override
void close({bool force = false}) {}
}