Files
SpotiFLAC-Mobile/test/download_queue_startup_flush_test.dart
T

411 lines
15 KiB
Dart

import 'dart:async';
import 'package:flutter/services.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/settings_provider.dart';
import 'package:spotiflac_android/services/app_state_database.dart';
const _earlyTrack = Track(
id: 'example:early',
name: 'Immediate addition',
artistName: 'Artist',
albumName: 'Album',
duration: 180,
);
class _Settings extends SettingsNotifier {
@override
AppSettings build() =>
const AppSettings(downloadDirectory: '/fixture/output');
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'startup restores, merges and retries only complete owned snapshots',
() async {
SharedPreferences.setMockInitialValues({
'app_state_migrated_queue_to_sqlite_v1': true,
'download_queue_user_paused_v1': true,
});
final previousFactory = databaseFactoryOrNull;
databaseFactory = databaseFactorySqflitePlugin;
addTearDown(() => databaseFactory = previousFactory);
const paths = MethodChannel('plugins.flutter.io/path_provider');
const sqlite = MethodChannel('com.tekartik.sqflite');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
paths,
(_) async => '/fixture/documents',
);
addTearDown(() => messenger.setMockMethodCallHandler(paths, null));
final items = List.generate(
128,
(index) => DownloadItem(
id: 'queue-$index',
track: Track(
id: 'example:$index',
name: 'Song $index',
artistName: 'Artist',
albumName: 'Album',
duration: 180,
),
// Avoid extension/native download work; the persisted queue is paused.
service: '',
createdAt: DateTime.utc(2026).add(Duration(seconds: index)),
),
);
final rows = <String, Map<String, Object?>>{
for (final item in items)
item.id: {
'id': item.id,
'item_json': encodeDownloadQueueItemForPersistence(item),
'status': 'queued',
'created_at': item.createdAt.toIso8601String(),
'updated_at': item.createdAt.toIso8601String(),
},
};
var started = Completer<void>();
var release = Completer<void>();
var queueReads = 0;
var deletes = 0;
var writes = 0;
var failRead = false;
messenger.setMockMethodCallHandler(sqlite, (call) async {
if (call.method == 'openDatabase') return {'id': 1};
final arguments = call.arguments as Map? ?? {};
final sql = arguments['sql'] as String? ?? '';
if (call.method == 'query') {
if (sql.toLowerCase().contains('pragma user_version')) {
return {
'columns': ['user_version'],
'rows': [
[4],
],
};
}
if (!sql.contains('download_queue_items')) {
return {'columns': <String>[], 'rows': <List<Object?>>[]};
}
queueReads++;
final snapshot = rows.values.toList();
started.complete();
await release.future;
if (failRead) {
throw PlatformException(code: 'sqlite_error', message: 'Try again');
}
const columns = [
'id',
'item_json',
'status',
'created_at',
'updated_at',
];
return {
'columns': columns,
'rows': [
for (final row in snapshot)
[for (final column in columns) row[column]],
],
};
}
if (call.method == 'batch') {
for (final raw in arguments['operations'] as List) {
final operation = raw as Map;
final statement = operation['sql'] as String;
final values = operation['arguments'] as List;
if (statement.startsWith('DELETE FROM download_queue_items')) {
rows.remove(values.first as String);
deletes++;
} else if (statement.startsWith('INSERT')) {
const columns = [
'id',
'item_json',
'status',
'created_at',
'updated_at',
];
rows[values.first as String] = {
for (var i = 0; i < columns.length; i++) columns[i]: values[i],
};
writes++;
} else {
fail('Unexpected queue mutation: $statement');
}
}
return null;
}
return null;
});
addTearDown(() => messenger.setMockMethodCallHandler(sqlite, null));
ProviderContainer scope() => ProviderContainer(
overrides: [settingsProvider.overrideWith(_Settings.new)],
);
final container = scope();
final queue = container.read(downloadQueueProvider.notifier);
await started.future;
expect(container.read(downloadQueueProvider).items, isEmpty);
var flushed = false;
final background = queue.flushQueuePersistence().then(
(_) => flushed = true,
);
await Future<void>.delayed(Duration.zero);
expect(flushed, false);
expect([writes, deletes], [0, 0]);
release.complete();
await background;
expect(
container.read(downloadQueueProvider).items.map((item) => item.id),
items.map((item) => item.id),
);
expect(rows.keys, items.map((item) => item.id));
expect(
[writes, deletes],
[0, 0],
reason:
'restored canonical rows must survive the first background flush',
);
expect(queueReads, 1);
container.dispose();
// Disposal completes the restore gate but must not access provider state
// or persist an empty snapshot after ownership has ended.
started = Completer();
release = Completer();
final disposed = scope();
final disposedQueue = disposed.read(downloadQueueProvider.notifier);
await started.future;
final pending = disposedQueue.flushQueuePersistence();
disposed.dispose();
await pending;
expect(rows.keys, items.map((item) => item.id));
expect([writes, deletes], [0, 0]);
release.complete();
await Future<void>.delayed(const Duration(milliseconds: 20));
// All public add paths publish immediately, but neither processing nor
// the 350 ms debounce may act on this incomplete startup snapshot.
started = Completer();
release = Completer();
final additions = scope();
final additionsQueue = additions.read(downloadQueueProvider.notifier);
await started.future;
final singleId = additionsQueue.addToQueue(
items.first.track,
'',
qualityOverride: 'lossless',
);
additionsQueue.addMultipleToQueue(
[items.first.track, _earlyTrack],
'',
qualityOverride: 'high',
playlistName: 'Immediate playlist',
playlistPositions: [7, 9],
);
additionsQueue.addIndividualTracksToQueue([_earlyTrack, _earlyTrack], '');
final earlyItems = additions.read(downloadQueueProvider).items.toList();
expect(earlyItems.length, 5);
expect(earlyItems.map((item) => item.id).toSet(), hasLength(5));
expect(earlyItems.first.id, singleId);
expect(earlyItems.map((item) => item.qualityOverride), [
'lossless',
'high',
'high',
null,
null,
]);
final additionsFlush = additionsQueue.flushQueuePersistence();
await Future<void>.delayed(const Duration(milliseconds: 400));
expect(additions.read(downloadQueueProvider).isProcessing, false);
expect(
additions.read(downloadQueueProvider).items.map((item) => item.status),
everyElement(DownloadStatus.queued),
);
expect([writes, deletes], [0, 0]);
release.complete();
await additionsFlush;
final merged = additions.read(downloadQueueProvider);
expect(merged.items.map((item) => item.id), [
...items.map((item) => item.id),
...earlyItems.map((item) => item.id),
]);
for (var index = 0; index < earlyItems.length; index++) {
expect(merged.items[items.length + index], same(earlyItems[index]));
}
expect(merged.items[items.length + 2].playlistPosition, 9);
expect(merged.items[items.length + 2].fromBatch, true);
expect(merged.items.last.playlistPosition, isNull);
expect(merged.items.last.fromBatch, false);
expect(merged.isPaused, true);
expect(rows.keys, merged.items.map((item) => item.id));
expect([writes, deletes], [5, 0]);
additions.dispose();
// Even disposal cannot flush an early add over the restored disk rows.
final savedIds = rows.keys.toList();
started = Completer();
release = Completer();
final earlyDisposed = scope();
final earlyDisposedQueue = earlyDisposed.read(
downloadQueueProvider.notifier,
);
await started.future;
earlyDisposedQueue.addIndividualTracksToQueue([_earlyTrack], '');
final disposeFlush = earlyDisposedQueue.flushQueuePersistence();
await Future<void>.delayed(const Duration(milliseconds: 400));
earlyDisposed.dispose();
await disposeFlush;
release.complete();
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(rows.keys, savedIds);
expect([writes, deletes], [5, 0]);
// A failed restore retains the new UI item and existing disk contents.
// A subsequent add starts one fresh attempt shared by concurrent flushes.
started = Completer();
release = Completer();
failRead = true;
final recovery = scope();
final recoveryQueue = recovery.read(downloadQueueProvider.notifier);
await started.future;
final firstRecoveryId = recoveryQueue.addToQueue(_earlyTrack, '');
recoveryQueue.pauseQueue();
final failedFlush = expectLater(
recoveryQueue.flushQueuePersistence(),
throwsA(isA<StateError>()),
);
release.complete();
await failedFlush;
await Future<void>.delayed(const Duration(milliseconds: 400));
expect(rows.keys, savedIds);
expect([writes, deletes], [5, 0]);
expect(
recovery.read(downloadQueueProvider).items.single.id,
firstRecoveryId,
);
expect(recovery.read(downloadQueueProvider).isProcessing, false);
started = Completer();
release = Completer();
failRead = false;
final readsBeforeRetry = queueReads;
recoveryQueue.addIndividualTracksToQueue([_earlyTrack], '');
final secondRecoveryId = recovery
.read(downloadQueueProvider)
.items
.last
.id;
await started.future;
recoveryQueue.resumeQueue();
expect(recovery.read(downloadQueueProvider).isPaused, false);
final retryFlushes = Future.wait([
recoveryQueue.flushQueuePersistence(),
recoveryQueue.flushQueuePersistence(),
]);
bool? resumedPublication;
final subscription = recovery.listen(downloadQueueProvider, (_, next) {
if (resumedPublication == null &&
next.items.length == savedIds.length + 2) {
resumedPublication = next.isPaused;
// Pause immediately after observing the merged publication so the
// fixture never starts extension/download work after gate release.
recoveryQueue.pauseQueue();
}
});
release.complete();
await retryFlushes;
subscription.close();
expect(
resumedPublication,
false,
reason: 'newer resume overrides the persisted paused preference',
);
final recovered = recovery.read(downloadQueueProvider);
expect(recovered.items.map((item) => item.id), [
...savedIds,
firstRecoveryId,
secondRecoveryId,
]);
expect(
recovered.isPaused,
true,
reason: 'the latest pause while publishing also wins',
);
expect(rows.keys, recovered.items.map((item) => item.id));
expect(queueReads, readsBeforeRetry + 1);
expect([writes, deletes], [7, 0]);
recovery.dispose();
// Explicit flush and resume also retry a failed startup without needing
// another addition. Failed attempts may not touch the saved queue.
final recoveredIds = rows.keys.toList();
started = Completer();
release = Completer();
failRead = true;
final explicitRetry = scope();
final explicitQueue = explicitRetry.read(downloadQueueProvider.notifier);
await started.future;
explicitQueue.pauseQueue();
final firstFailure = expectLater(
explicitQueue.flushQueuePersistence(),
throwsA(isA<StateError>()),
);
release.complete();
await firstFailure;
expect(rows.keys, recoveredIds);
expect([writes, deletes], [7, 0]);
started = Completer();
release = Completer();
final flushRetryReads = queueReads;
final secondFailure = expectLater(
explicitQueue.flushQueuePersistence(),
throwsA(isA<StateError>()),
);
await started.future;
release.complete();
await secondFailure;
expect(queueReads, flushRetryReads + 1);
expect(rows.keys, recoveredIds);
expect([writes, deletes], [7, 0]);
started = Completer();
release = Completer();
failRead = false;
final resumeRetryReads = queueReads;
explicitQueue.resumeQueue();
await started.future;
final resumedFlush = explicitQueue.flushQueuePersistence();
bool? explicitResumePublication;
final resumedSubscription = explicitRetry.listen(downloadQueueProvider, (
_,
next,
) {
if (explicitResumePublication == null &&
next.items.length == recoveredIds.length) {
explicitResumePublication = next.isPaused;
explicitQueue.pauseQueue();
}
});
release.complete();
await resumedFlush;
resumedSubscription.close();
expect(explicitResumePublication, false);
expect(queueReads, resumeRetryReads + 1);
expect(rows.keys, recoveredIds);
expect([writes, deletes], [7, 0]);
explicitRetry.dispose();
await (await AppStateDatabase.instance.database).close();
},
);
}