Files
Immich/mobile/test/services/app_update_service_test.dart

161 lines
6.1 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:immich_mobile/services/app_update.service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const id = 'be34e6a7-5970-41ab-af01-71789e844f73';
final bytes = utf8.encode('APK test fixture');
Map<String, dynamic> manifest() => {
'slug': 'immich-local',
'release': {
'id': id,
'version': '3.3.3-local.2',
'androidVersionCode': 3030401,
'channel': 'stable',
'platform': 'android',
'packageKind': 'apk',
'downloadPath': '/api/apps/immich-local/releases/$id/download',
'packageSizeBytes': bytes.length,
'sha256': sha256.convert(bytes).toString(),
'notes': 'Changes',
},
};
test('compares Android build numbers; same and older releases cannot be installed', () {
final release = AppRelease.fromManifest(manifest())!;
expect(release.isNewerThan('3030400'), isTrue);
expect(release.isNewerThan('3030401'), isFalse);
expect(release.isNewerThan('3030402'), isFalse);
expect(() => release.isNewerThan('unknown'), throwsFormatException);
expect(release.downloadUri.host, 'argus.kusoft.xyz');
});
test('a catalog with no release is valid', () {
expect(AppRelease.fromManifest({'slug': 'immich-local', 'release': null}), isNull);
});
test('rejects another application catalog', () {
expect(() => AppRelease.fromManifest({...manifest(), 'slug': 'another-app'}), throwsFormatException);
});
for (final entry in <String, Object?>{
'id': '../invalid',
'platform': 'ios',
'channel': 'beta',
'packageKind': 'zip',
'androidVersionCode': '3030401',
'version': '',
'packageSizeBytes': 0,
'sha256': 'invalid',
'downloadPath': 'https://example.com/app.apk',
'notes': 42,
}.entries) {
test('rejects invalid ${entry.key}', () {
final data = manifest();
(data['release'] as Map<String, dynamic>)[entry.key] = entry.value;
expect(() => AppRelease.fromManifest(data), throwsFormatException);
});
}
test('queries only stable Android releases without cache or redirects', () async {
final service = AppUpdateService(
clientFactory: () => MockClient((request) async {
expect(
request.url.toString(),
'https://argus.kusoft.xyz/api/apps/immich-local/manifest?platform=android&channel=stable',
);
expect(request.followRedirects, isFalse);
expect(request.headers['Cache-Control'], 'no-cache');
return http.Response(jsonEncode(manifest()), 200);
}),
);
expect((await service.check())!.versionCode, 3030401);
service.dispose();
});
test('server errors are reported instead of saying up to date', () async {
final service = AppUpdateService(clientFactory: () => MockClient((_) async => http.Response('', 503)));
await expectLater(service.check(), throwsA(isA<HttpException>()));
service.dispose();
});
group('verified download', () {
late Directory directory;
setUp(() async => directory = await Directory.systemTemp.createTemp('immich-update-test-'));
tearDown(() async => directory.delete(recursive: true));
AppUpdateService service(List<int> body, {int status = 200}) => AppUpdateService(
temporaryDirectory: () async => directory,
clientFactory: () => MockClient((request) async {
expect(request.followRedirects, isFalse);
return http.Response.bytes(body, status);
}),
);
test('writes an APK only after its size and digest match', () async {
final updater = service(bytes);
final progress = <double>[];
final apk = await updater.download(AppRelease.fromManifest(manifest())!, progress.add);
expect(await apk.readAsBytes(), bytes);
expect(progress.last, 1);
expect(File('${apk.path}.part').existsSync(), isFalse);
updater.dispose();
});
for (final body in [
utf8.encode('wrong bytes here'),
bytes.sublist(1),
[...bytes, 0],
]) {
test('rejects corrupt or truncated content and removes the partial file (${body.length})', () async {
final updater = service(body);
await expectLater(updater.download(AppRelease.fromManifest(manifest())!, (_) {}), throwsA(isA<Exception>()));
expect(File('${directory.path}/immich-updates/update.apk').existsSync(), isFalse);
expect(File('${directory.path}/immich-updates/update.apk.part').existsSync(), isFalse);
updater.dispose();
});
}
test('refuses redirects', () async {
final updater = service(bytes, status: 302);
await expectLater(updater.download(AppRelease.fromManifest(manifest())!, (_) {}), throwsA(isA<HttpException>()));
updater.dispose();
});
test('cancellation never leaves an installable APK', () async {
final updater = service(bytes);
await expectLater(
updater.download(AppRelease.fromManifest(manifest())!, (_) => updater.cancel()),
throwsA(isA<UpdateCancelled>()),
);
expect(File('${directory.path}/immich-updates/update.apk').existsSync(), isFalse);
updater.dispose();
});
});
test('uses the native installer and reports missing install permission', () async {
final calls = <MethodCall>[];
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(AppUpdateService.channel, (call) async {
calls.add(call);
return call.method == 'install' ? 'permissionRequired' : null;
});
addTearDown(() => messenger.setMockMethodCallHandler(AppUpdateService.channel, null));
final updater = AppUpdateService();
expect(
await updater.install(File('/cache/immich-updates/update.apk'), AppRelease.fromManifest(manifest())!),
isFalse,
);
await updater.openInstallPermission();
expect(calls.map((call) => call.method), ['install', 'openInstallPermission']);
expect(calls.first.arguments['versionCode'], 3030401);
updater.dispose();
});
}