Files
SpotiFLAC-Mobile/test/download_queue_persistence_test.dart
T

417 lines
13 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.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/track.dart';
import 'package:spotiflac_android/services/app_state_database.dart';
import 'package:spotiflac_android/services/download_queue_persistence.dart';
DownloadItem _item(
String id, {
DownloadStatus status = DownloadStatus.queued,
}) => DownloadItem(
id: id,
track: const Track(
id: 'example:track',
name: 'Song',
artistName: 'Artist',
albumName: 'Album',
duration: 1000,
),
service: 'extension.example',
status: status,
createdAt: DateTime.utc(2026),
networkDownloadFolder: 'Albums',
qualityOverride: 'HI_RES',
preserveQualityVariant: true,
);
class _Store {
final Map<String, Map<String, dynamic>> rows = {};
int writes = 0;
Future<List<Map<String, dynamic>>> load() async => rows.values.toList();
Future<void> apply({
required List<Map<String, dynamic>> upserts,
required List<String> deletedIds,
}) async {
writes++;
for (final id in deletedIds) {
rows.remove(id);
}
for (final row in upserts) {
rows[row['id'] as String] = row;
}
}
void seed(DownloadItem item, {String? rowStatus}) {
rows[item.id] = {
'id': item.id,
'status': rowStatus ?? item.status.name,
'item_json': jsonEncode(item.toJson()),
};
}
}
class _CountingTrack extends Track {
_CountingTrack(String id)
: super(
id: id,
name: 'Song',
artistName: 'Artist',
albumName: 'Album',
duration: 1000,
);
int serializations = 0;
@override
Map<String, dynamic> toJson() {
serializations++;
return super.toJson();
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => SharedPreferences.setMockInitialValues({}));
test(
'restores active/cancelled items and repairs corrupt or terminal rows',
() async {
final store = _Store()
..seed(
_item(
'active',
status: DownloadStatus.finalizing,
).copyWith(progress: .99),
)
..seed(_item('cancelled', status: DownloadStatus.skipped))
..seed(_item('completed', status: DownloadStatus.completed))
..seed(_item('mismatch'), rowStatus: 'finalizing');
store.rows['corrupt'] = {
'id': 'corrupt',
'status': 'queued',
'item_json': '{broken',
};
store.rows['invalid-payload'] = {
'id': 'invalid-payload',
'status': 'queued',
'item_json': 123,
};
var items = <DownloadItem>[];
final errors = <Object>[];
final persistence = DownloadQueuePersistence(
currentItems: () => items,
onError: errors.add,
loadRows: store.load,
applyChanges: store.apply,
);
items = await persistence.restore();
expect(items.map((item) => item.id), ['active', 'cancelled', 'mismatch']);
expect(items.first.status, DownloadStatus.queued);
expect(items.first.progress, 0);
expect(items[1].status, DownloadStatus.skipped);
expect(items.first.networkDownloadFolder, 'Albums');
expect(items.first.qualityOverride, 'HI_RES');
expect(items.first.preserveQualityVariant, isTrue);
await persistence.flush();
expect(store.rows.keys, ['active', 'cancelled', 'mismatch']);
expect(store.rows['mismatch']!['status'], 'queued');
expect(
store.rows['active']!['item_json'],
encodeDownloadQueueItemForPersistence(items.first),
);
expect(errors, isEmpty);
},
);
test(
'transfer samples do not rewrite durable rows; completion deletes them',
() async {
final store = _Store();
var items = [_item('track')];
final persistence = DownloadQueuePersistence(
currentItems: () => items,
onError: (error) => fail('$error'),
loadRows: store.load,
applyChanges: store.apply,
);
await persistence.flush();
items = [
items.first.copyWith(
status: DownloadStatus.downloading,
progress: .42,
speedMBps: 3.2,
bytesReceived: 500,
bytesTotal: 1000,
preparationStage: 'decrypt',
),
];
await persistence.flush();
expect(store.writes, 1);
items = [items.first.copyWith(status: DownloadStatus.completed)];
await persistence.flush();
expect(store.writes, 2);
expect(store.rows, isEmpty);
},
);
test(
'untouched tracks are not serialized again; failed changes still retry',
() async {
final store = _Store();
final untouched = _CountingTrack('untouched');
final changed = _CountingTrack('changed');
var items = [
_item('untouched').copyWith(track: untouched),
_item('changed').copyWith(track: changed),
];
var rejectWrite = false;
final errors = <Object>[];
final persistence = DownloadQueuePersistence(
currentItems: () => items,
onError: errors.add,
loadRows: store.load,
applyChanges: ({required upserts, required deletedIds}) async {
if (rejectWrite) throw StateError('disk unavailable');
await store.apply(upserts: upserts, deletedIds: deletedIds);
},
);
await persistence.flush();
expect([untouched.serializations, changed.serializations], [1, 1]);
items = [
items.first,
items.last.copyWith(status: DownloadStatus.skipped),
];
rejectWrite = true;
await persistence.flush();
expect(errors, hasLength(1));
expect([untouched.serializations, changed.serializations], [1, 2]);
rejectWrite = false;
await persistence.flush();
expect(store.rows['changed']!['status'], 'skipped');
expect([untouched.serializations, changed.serializations], [1, 3]);
await persistence.flush();
expect([untouched.serializations, changed.serializations], [1, 3]);
},
);
test(
'failed writes retry and concurrent flushes retain the latest snapshot',
() async {
final store = _Store();
var items = [_item('first')];
final started = Completer<void>();
final release = Completer<void>();
final errors = <Object>[];
var attempts = 0;
final persistence = DownloadQueuePersistence(
currentItems: () => items,
onError: errors.add,
loadRows: store.load,
applyChanges: ({required upserts, required deletedIds}) async {
attempts++;
if (attempts == 1) throw StateError('disk temporarily unavailable');
if (attempts == 2) {
started.complete();
await release.future;
}
await store.apply(upserts: upserts, deletedIds: deletedIds);
},
);
await persistence.flush();
expect(errors, hasLength(1));
final first = persistence.flush();
await started.future;
items = [_item('latest', status: DownloadStatus.skipped)];
final second = persistence.flush();
await Future<void>.delayed(Duration.zero);
expect(attempts, 2);
release.complete();
await Future.wait([first, second]);
expect(attempts, 3);
expect(store.rows.keys, ['latest']);
expect(store.rows['latest']!['status'], 'skipped');
},
);
testWidgets(
'debounces mutations and captures a pending snapshot on disposal',
(tester) async {
final store = _Store();
var items = [_item('one')];
var disposed = false;
final persistence = DownloadQueuePersistence(
currentItems: () {
if (disposed) throw StateError('provider disposed');
return items;
},
onError: (error) => fail('$error'),
loadRows: store.load,
applyChanges: store.apply,
);
persistence.schedule();
await tester.pump(const Duration(milliseconds: 200));
items = [_item('two')];
persistence.schedule();
await tester.pump(const Duration(milliseconds: 200));
expect(store.writes, 0);
await tester.pump(const Duration(milliseconds: 150));
expect(store.rows.keys, ['two']);
items = [_item('three')];
persistence.schedule();
disposed = true;
persistence.dispose();
items = [];
await tester.pump();
expect(store.rows.keys, ['three']);
await tester.pump(const Duration(seconds: 1));
expect(store.writes, 2);
},
);
test('queued flushes retain snapshots after disposal', () async {
final store = _Store();
var items = [_item('first')];
var disposed = false;
final started = Completer<void>();
final release = Completer<void>();
final errors = <Object>[];
final persistence = DownloadQueuePersistence(
currentItems: () {
if (disposed) throw StateError('provider disposed');
return items;
},
onError: errors.add,
loadRows: store.load,
applyChanges: ({required upserts, required deletedIds}) async {
if (!started.isCompleted) {
started.complete();
await release.future;
}
await store.apply(upserts: upserts, deletedIds: deletedIds);
},
);
final first = persistence.flush();
await started.future;
items = [_item('latest')];
final second = persistence.flush();
persistence.dispose();
disposed = true;
release.complete();
await Future.wait([first, second]);
expect(store.rows.keys, ['latest']);
expect(errors, isEmpty);
});
test('flush waits for ordered user pause writes', () async {
final store = _Store();
final persistence = DownloadQueuePersistence(
currentItems: () => [],
onError: (error) => fail('$error'),
loadRows: store.load,
applyChanges: store.apply,
);
persistence.setUserPaused(true);
persistence.setUserPaused(false);
persistence.setUserPaused(true);
await persistence.flush();
expect(await persistence.loadUserPaused(), isTrue);
persistence.setUserPaused(false);
await persistence.flush();
expect(await persistence.loadUserPaused(), isFalse);
});
test('SQLite restores cancelled rows in creation order for retry', () async {
SharedPreferences.setMockInitialValues({
'app_state_migrated_queue_to_sqlite_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 => '/test/documents');
addTearDown(() => messenger.setMockMethodCallHandler(paths, null));
final fixture = [
for (final status in [
DownloadStatus.queued,
DownloadStatus.skipped,
DownloadStatus.downloading,
DownloadStatus.finalizing,
DownloadStatus.completed,
DownloadStatus.failed,
])
_item(status.name, status: status).toJson(),
];
messenger.setMockMethodCallHandler(sqlite, (call) async {
if (call.method == 'openDatabase') return {'id': 1};
if (call.method != 'query') return null;
final arguments = call.arguments as Map;
final sql = arguments['sql'] as String;
if (sql.toUpperCase().contains('PRAGMA')) {
return {
'columns': ['user_version'],
'rows': [
[4],
],
};
}
final result = await Process.run('python3', [
'-c',
r'''
import json, sqlite3, sys
data = json.loads(sys.argv[1])
db = sqlite3.connect(':memory:')
db.row_factory = sqlite3.Row
db.execute('CREATE TABLE download_queue_items (id, item_json, status, created_at, updated_at)')
for row in data['rows']:
db.execute('INSERT INTO download_queue_items VALUES (?, ?, ?, ?, ?)',
(row['id'], json.dumps(row), row['status'], row['createdAt'], row['createdAt']))
print(json.dumps([dict(row) for row in db.execute(data['sql'], data['arguments'])]))
''',
jsonEncode({
'rows': fixture,
'sql': sql,
'arguments': arguments['arguments'],
}),
]);
if (result.exitCode != 0) throw StateError('${result.stderr}');
final rows = (jsonDecode(result.stdout as String) as List)
.map((row) => Map<String, Object?>.from(row as Map))
.toList();
final columns = rows.first.keys.toList();
return {
'columns': columns,
'rows': [
for (final row in rows) [for (final column in columns) row[column]],
],
};
});
addTearDown(() => messenger.setMockMethodCallHandler(sqlite, null));
final persistence = DownloadQueuePersistence(
currentItems: () => [],
onError: (error) => fail('$error'),
);
final restored = await persistence.restore();
expect(restored.map((item) => item.id), [
'queued',
'skipped',
'downloading',
'finalizing',
]);
expect(restored[1].status, DownloadStatus.skipped);
expect(restored.last.status, DownloadStatus.queued);
await (await AppStateDatabase.instance.database).close();
});
}