загрзука клиента и хэширование

This commit is contained in:
2026-03-22 02:00:47 +04:00
parent 73d8798bb9
commit f0463f8cff
9 changed files with 1239 additions and 97 deletions
@@ -0,0 +1,244 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter_test/flutter_test.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.dart';
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
void main() {
group('ClientSyncUseCase', () {
test(
'completes without downloads when local snapshot matches manifest',
() async {
final manifest = _manifestFromFiles([
const ClientManifestFile(
path: 'Wow.exe',
size: 5,
sha256: 'wow-hash',
),
const ClientManifestFile(
path: 'Data/common.MPQ',
size: 10,
sha256: 'data-hash',
),
]);
final matchingSnapshot = ClientInstallationSnapshot.fromFiles([
const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
const LocalClientFile(
path: 'Data/common.MPQ',
size: 10,
sha256: 'data-hash',
),
]);
final api = _FakeLauncherApiClient(manifest);
final installationService = _FakeGameInstallationService([
matchingSnapshot,
]);
final useCase = ClientSyncUseCase(
launcherApiClient: api,
installationService: installationService,
);
final statuses = await useCase
.call(
ClientSyncUseCaseInput(
request: ClientSyncRequest(
installationDir: 'C:/MoonWell',
accessToken: 'token',
manifest: manifest,
),
),
)
.toList();
expect(statuses.last.stage, ClientSyncStage.completed);
expect(statuses.last.message, 'Client is up to date.');
expect(api.downloadedPaths, isEmpty);
expect(installationService.deletedRelativeFiles, isEmpty);
},
);
test(
'removes stale files and downloads changed files before final verify',
() async {
final manifest = _manifestFromFiles([
const ClientManifestFile(
path: 'Wow.exe',
size: 5,
sha256: 'wow-hash',
),
const ClientManifestFile(
path: 'Data/common.MPQ',
size: 10,
sha256: 'data-hash',
),
]);
final initialSnapshot = ClientInstallationSnapshot.fromFiles([
const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'old-wow'),
const LocalClientFile(path: 'legacy.txt', size: 2, sha256: 'legacy'),
]);
final verifiedSnapshot = ClientInstallationSnapshot.fromFiles([
const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
const LocalClientFile(
path: 'Data/common.MPQ',
size: 10,
sha256: 'data-hash',
),
]);
final api = _FakeLauncherApiClient(manifest);
final installationService = _FakeGameInstallationService([
initialSnapshot,
verifiedSnapshot,
]);
final useCase = ClientSyncUseCase(
launcherApiClient: api,
installationService: installationService,
);
final statuses = await useCase
.call(
ClientSyncUseCaseInput(
request: ClientSyncRequest(
installationDir: 'C:/MoonWell',
accessToken: 'token',
manifest: manifest,
),
),
)
.toList();
expect(
statuses.map((status) => status.stage),
containsAll([
ClientSyncStage.removingStaleFiles,
ClientSyncStage.downloadingFiles,
ClientSyncStage.verifyingInstallation,
ClientSyncStage.completed,
]),
);
expect(installationService.deletedRelativeFiles, ['legacy.txt']);
expect(api.downloadedPaths, ['Wow.exe', 'Data/common.MPQ']);
expect(statuses.last.message, 'Client is ready to play.');
},
);
});
}
ClientManifest _manifestFromFiles(List<ClientManifestFile> files) {
return ClientManifest.fromJson({
'files': files
.map(
(file) => {
'path': file.path,
'size': file.size,
'sha256': file.sha256,
},
)
.toList(),
});
}
class _FakeLauncherApiClient extends LauncherApiClient {
_FakeLauncherApiClient(this._manifest);
final ClientManifest _manifest;
final List<String> downloadedPaths = [];
@override
Future<ClientManifest> fetchManifest(String accessToken) async => _manifest;
@override
Future<void> downloadFile({
required String accessToken,
required ClientManifestFile file,
required String destinationPath,
required FutureOr<bool> Function()? isCancelled,
required void Function(int chunkBytes) onChunkReceived,
}) async {
downloadedPaths.add(file.path);
onChunkReceived(file.size);
}
}
class _FakeGameInstallationService extends GameInstallationService {
_FakeGameInstallationService(List<ClientInstallationSnapshot> snapshots)
: _snapshots = Queue.of(snapshots);
final Queue<ClientInstallationSnapshot> _snapshots;
final List<String> ensuredParentDirectories = [];
final List<String> deletedRelativeFiles = [];
final List<String> deletedFiles = [];
final List<String> replacedDestinations = [];
@override
Future<ClientInstallationSnapshot> scanInstallation(
String installationDir, {
InstallationScanProgressCallback? onProgress,
FutureOr<bool> Function()? isCancelled,
}) async {
final snapshot = _snapshots.removeFirst();
var processedFiles = 0;
var processedBytes = 0;
for (final file in snapshot.files) {
processedFiles += 1;
processedBytes += file.size;
onProgress?.call(
DownloadProgress(
speed: 0,
downloaded: processedBytes,
total: snapshot.files.fold<int>(0, (sum, item) => sum + item.size),
eta: Duration.zero,
),
file.path,
processedFiles,
snapshot.files.length,
);
}
return snapshot;
}
@override
Future<void> deleteRelativeFile(
String installationDir,
String relativePath,
) async {
deletedRelativeFiles.add(relativePath);
}
@override
String resolveClientPath(String installationDir, String relativePath) {
return '$installationDir/$relativePath';
}
@override
Future<void> ensureParentDirectoryExists(String filePath) async {
ensuredParentDirectories.add(filePath);
}
@override
Future<void> deleteFileIfExists(String filePath) async {
deletedFiles.add(filePath);
}
@override
Future<bool> verifySha256(String filePath, String expectedHash) async => true;
@override
Future<void> replaceFile({
required String temporaryPath,
required String destinationPath,
}) async {
replacedDestinations.add(destinationPath);
}
}
@@ -0,0 +1,148 @@
import 'dart:io';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:moonwell_launcher/config.dart';
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
void main() {
group('GameInstallationService', () {
late Directory rootDirectory;
late GameInstallationService service;
setUp(() async {
service = GameInstallationService();
rootDirectory = await Directory.systemTemp.createTemp(
'moonwell_launcher_',
);
await File(
'${rootDirectory.path}${Platform.pathSeparator}Wow.exe',
).writeAsString('wow');
await Directory(
'${rootDirectory.path}${Platform.pathSeparator}Data',
).create(recursive: true);
await File(
'${rootDirectory.path}${Platform.pathSeparator}Data${Platform.pathSeparator}common.MPQ',
).writeAsString('data');
await Directory(
'${rootDirectory.path}${Platform.pathSeparator}Cache',
).create(recursive: true);
await File(
'${rootDirectory.path}${Platform.pathSeparator}Cache${Platform.pathSeparator}cache.bin',
).writeAsString('cache');
await Directory(
'${rootDirectory.path}${Platform.pathSeparator}Logs',
).create(recursive: true);
await File(
'${rootDirectory.path}${Platform.pathSeparator}Logs${Platform.pathSeparator}launcher.log',
).writeAsString('log');
});
tearDown(() async {
if (await rootDirectory.exists()) {
await rootDirectory.delete(recursive: true);
}
});
test('scanInstallation ignores excluded directories', () async {
final snapshot = await service.scanInstallation(rootDirectory.path);
final paths = snapshot.files.map((file) => file.path).toList()..sort();
expect(paths, ['Data/common.MPQ', 'Wow.exe']);
expect(snapshot.buildHash, computeBuildHash(snapshot.files));
});
test(
'scanInstallation creates launcher hash cache outside verification set',
() async {
await service.scanInstallation(rootDirectory.path);
final cacheFile = File(service.getHashCachePath(rootDirectory.path));
expect(await cacheFile.exists(), isTrue);
final secondSnapshot = await service.scanInstallation(
rootDirectory.path,
);
final paths = secondSnapshot.files.map((file) => file.path).toList()
..sort();
expect(paths, ['Data/common.MPQ', 'Wow.exe']);
expect(
await Directory(
'${rootDirectory.path}${Platform.pathSeparator}${Config.launcherMetadataDirectoryName}',
).exists(),
isTrue,
);
},
);
test(
'scanInstallation reuses cached hash when file metadata did not change',
() async {
await service.scanInstallation(rootDirectory.path);
final cacheFile = File(service.getHashCachePath(rootDirectory.path));
final cacheJson =
jsonDecode(await cacheFile.readAsString()) as Map<String, dynamic>;
final entries = (cacheJson['entries'] as Map).cast<String, dynamic>();
entries['Wow.exe'] = {
'size': 3,
'modifiedMs': await File(
'${rootDirectory.path}${Platform.pathSeparator}Wow.exe',
).lastModified().then((value) => value.millisecondsSinceEpoch),
'sha256': 'cached-wow-hash',
};
await cacheFile.writeAsString(
jsonEncode({'version': 1, 'entries': entries}),
flush: true,
);
final snapshot = await service.scanInstallation(rootDirectory.path);
final wowFile = snapshot.files.firstWhere(
(file) => file.path == 'Wow.exe',
);
expect(wowFile.sha256, 'cached-wow-hash');
},
);
test('clearCache recreates an empty cache directory', () async {
await service.clearCache(rootDirectory.path);
final cacheDirectory = Directory(
'${rootDirectory.path}${Platform.pathSeparator}Cache',
);
expect(await cacheDirectory.exists(), isTrue);
expect(await cacheDirectory.list().isEmpty, isTrue);
});
test('ensureParentDirectoryExists creates nested directories', () async {
final tempPath =
'${rootDirectory.path}${Platform.pathSeparator}Data${Platform.pathSeparator}patches${Platform.pathSeparator}common-2.MPQ.moonwell.part';
await service.ensureParentDirectoryExists(tempPath);
expect(
await Directory(
'${rootDirectory.path}${Platform.pathSeparator}Data${Platform.pathSeparator}patches',
).exists(),
isTrue,
);
});
test('resolveClientPath rejects path traversal', () {
expect(
() => service.resolveClientPath(rootDirectory.path, '../evil.txt'),
throwsA(isA<LauncherSyncException>()),
);
});
});
}
@@ -0,0 +1,33 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
void main() {
group('client hash entry helpers', () {
test('normalizeClientPath normalizes slashes and strips leading dot', () {
expect(normalizeClientPath(r'.\Data\common.MPQ'), 'Data/common.MPQ');
expect(normalizeClientPath('./Wow.exe'), 'Wow.exe');
});
test('computeBuildHash is deterministic and order independent', () {
final left = [
const LocalClientFile(
path: r'.\Data\common.MPQ',
size: 12,
sha256: 'ABCDEF',
),
const LocalClientFile(path: 'Wow.exe', size: 34, sha256: '123456'),
];
final right = [
const LocalClientFile(path: 'Wow.exe', size: 34, sha256: '123456'),
const LocalClientFile(
path: 'Data/common.MPQ',
size: 12,
sha256: 'abcdef',
),
];
expect(computeBuildHash(left), computeBuildHash(right));
});
});
}