загрзука клиента и хэширование
This commit is contained in:
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user