Files
SpotiFLAC-Mobile/test/download_queue_failure_session_test.dart
T

245 lines
8.2 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqflite/sqflite.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/app_state_database.dart';
class _Settings extends SettingsNotifier {
_Settings(this._directory);
final String _directory;
@override
AppSettings build() => AppSettings(
downloadDirectory: _directory,
embedMetadata: false,
saveDownloadHistory: false,
);
}
class _Extensions extends ExtensionNotifier {
@override
ExtensionState build() => ExtensionState(
extensions: [
Extension.fromJson({
'id': 'example-provider',
'name': 'Example Provider',
'enabled': true,
'has_download_provider': true,
'skip_metadata_enrichment': true,
}),
],
);
@override
Future<void> ensureInitialized() async {}
}
DownloadItem _item(
String id, {
String service = 'example-provider',
String networkDownloadFolder = '',
}) => DownloadItem(
id: id,
track: Track(
id: 'extension:example:$id',
name: id,
artistName: 'Artist',
albumName: 'Album',
duration: 180,
),
service: service,
networkDownloadFolder: networkDownloadFolder,
createdAt: DateTime.utc(2026),
);
class _Queue extends DownloadQueueNotifier {
@override
DownloadQueueState build() {
super.build();
return const DownloadQueueState().copyWith(
items: [
_item('backend-failure'),
_item('exception'),
_item(
'network-failure',
service: 'unavailable-provider',
networkDownloadFolder: 'smb://example.test/music',
),
_item('unavailable', service: 'unavailable-provider'),
_item('cancelled'),
],
);
}
}
void main() {
final messenger =
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger;
test(
'queue notifications count attempted failures and reset on retry',
() async {
final directory = await Directory.systemTemp.createTemp(
'queue-failures-',
);
addTearDown(() => directory.delete(recursive: true));
final previousFactory = databaseFactoryOrNull;
databaseFactory = databaseFactorySqflitePlugin;
addTearDown(() => databaseFactory = previousFactory);
AndroidFlutterLocalNotificationsPlugin.registerWith();
SharedPreferences.setMockInitialValues({
'app_state_migrated_queue_to_sqlite_v1': true,
});
const backend = MethodChannel('com.zarz.spotiflac/backend');
const sqlite = MethodChannel('com.tekartik.sqflite');
const paths = MethodChannel('plugins.flutter.io/path_provider');
const notifications = MethodChannel(
'dexterous.com/flutter/local_notifications',
);
const connectivity = MethodChannel(
'dev.fluttercommunity.plus/connectivity_status',
);
const progress = MethodChannel(
'com.zarz.spotiflac/download_progress_stream',
);
final summaries = <String>[];
final attempts = <String>[];
final resetIds = <String>[];
messenger.setMockMethodCallHandler(paths, (_) async => directory.path);
messenger.setMockMethodCallHandler(connectivity, (_) async => null);
messenger.setMockMethodCallHandler(progress, (_) async => null);
messenger.setMockMethodCallHandler(sqlite, (call) async {
if (call.method == 'openDatabase') return {'id': 1};
if (call.method != 'query') return null;
final sql = (call.arguments as Map)['sql'] as String;
return sql.toLowerCase().contains('pragma user_version')
? {
'columns': ['user_version'],
'rows': [
[4],
],
}
: {'columns': <String>[], 'rows': <List<Object?>>[]};
});
messenger.setMockMethodCallHandler(notifications, (call) async {
if (call.method == 'initialize') return true;
if (call.method == 'show') {
final body = (call.arguments as Map)['body'] as String?;
if (body != null && body.contains('downloaded,')) summaries.add(body);
}
return null;
});
messenger.setMockMethodCallHandler(backend, (call) async {
if (call.method == 'resetDownloadCancel') {
resetIds.add((call.arguments as Map)['item_id'] as String);
}
if (call.method != 'downloadByStrategy') return null;
final request = jsonDecode(call.arguments as String) as Map;
final id = request['item_id'] as String;
attempts.add(id);
if (id == 'exception') {
throw PlatformException(
code: 'fixture',
message: 'Fixture processing error',
);
}
return {
'success': false,
'error': id == 'cancelled'
? 'Cancelled'
: 'Fixture track unavailable',
'error_type': id == 'cancelled' ? 'cancelled' : 'not_found',
};
});
addTearDown(() {
for (final channel in [
backend,
sqlite,
paths,
notifications,
connectivity,
progress,
]) {
messenger.setMockMethodCallHandler(channel, null);
}
});
final container = ProviderContainer(
overrides: [
settingsProvider.overrideWith(() => _Settings(directory.path)),
extensionProvider.overrideWith(_Extensions.new),
downloadQueueProvider.overrideWith(_Queue.new),
],
);
addTearDown(container.dispose);
final queue = container.read(downloadQueueProvider.notifier);
queue.pauseQueue(persistAcrossRestarts: false);
await queue.flushQueuePersistence();
var finished = Completer<void>();
container.listen(downloadQueueProvider, (previous, next) {
if (previous?.isProcessing == true &&
!next.isProcessing &&
!finished.isCompleted) {
finished.complete();
}
});
queue.resumeQueue();
await finished.future.timeout(const Duration(seconds: 10));
final first = container.read(downloadQueueProvider);
expect(attempts, ['backend-failure', 'exception', 'cancelled']);
expect(first.lookup.failedCount, 4);
expect(
first.lookup.byItemId['cancelled']!.status,
DownloadStatus.skipped,
);
expect(
first.lookup.byItemId['backend-failure']!.errorType,
DownloadErrorType.notFound,
);
expect(
first.lookup.byItemId['exception']!.error,
contains('Fixture processing error'),
);
expect(
first.lookup.byItemId['network-failure']!.error,
startsWith('Network download failed. Retry keeps prepared audio:'),
);
expect(
first.lookup.byItemId['unavailable']!.error,
'Download provider is no longer available',
);
// Provider availability failures and cancellation have never contributed
// to the session failure total, even though the queue retains their rows.
expect(summaries, ['0 downloaded, 3 failed']);
finished = Completer<void>();
await queue.retryItem('backend-failure');
await finished.future.timeout(const Duration(seconds: 10));
expect(resetIds, ['backend-failure']);
expect(attempts, [
'backend-failure',
'exception',
'cancelled',
'backend-failure',
]);
expect(container.read(downloadQueueProvider).lookup.failedCount, 4);
expect(summaries, ['0 downloaded, 3 failed', '0 downloaded, 1 failed']);
await queue.flushQueuePersistence();
await (await AppStateDatabase.instance.database).close();
},
);
}