import 'dart:async'; import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/providers/recent_access_provider.dart'; import 'package:spotiflac_android/services/app_state_database.dart'; import 'package:spotiflac_android/utils/logger.dart'; RecentAccessItem _artist(String id, {String? name}) => RecentAccessItem( id: id, name: name ?? id, type: RecentAccessType.artist, providerId: 'example', accessedAt: DateTime.utc(2026, 1, 1), ); class _Database implements AppStateDatabase { _Database({Iterable items = const []}) : items = {for (final item in items) item.uniqueKey: item}; final Map items; final hiddenIds = {'saved-download'}; final clearedAt = DateTime.utc(2025, 12, 1); final readStarted = Completer(); final releaseRead = Completer(); final clearStarted = Completer(); final releaseClear = Completer(); final operations = []; final failures = {}; final _completed = >{}; bool holdClear = false; bool failRead = false; Future whenCompleted(String operation) => (_completed[operation] ??= Completer()).future; void _complete(String operation) { operations.add(operation); if (failures.remove(operation)) throw StateError(operation); (_completed[operation] ??= Completer()).complete(); } @override Future migrateRecentAccessFromSharedPreferences() async => true; @override Future>> getRecentAccessRows({int? limit}) async { final snapshot = items.values .take(limit ?? items.length) .map((item) => {'item_json': jsonEncode(item.toJson())}) .toList(); readStarted.complete(); await releaseRead.future; if (failRead) throw StateError('recent_load_failed'); return snapshot; } @override Future> getHiddenRecentDownloadIds() async => {...hiddenIds}; @override Future getRecentDownloadsClearedAt() async => clearedAt; @override Future upsertRecentAccessRow({ required String uniqueKey, required String itemJson, required String accessedAt, }) async { final operation = 'record:$uniqueKey'; if (failures.contains(operation)) { _complete(operation); return; } items[uniqueKey] = RecentAccessItem.fromJson( jsonDecode(itemJson) as Map, ); _complete(operation); } @override Future deleteRecentAccessRow(String uniqueKey) async { items.remove(uniqueKey); _complete('remove:$uniqueKey'); } @override Future addHiddenRecentDownloadId(String downloadId) async { hiddenIds.add(downloadId); _complete('hide:$downloadId'); } @override Future clearHiddenRecentDownloadIds() async { hiddenIds.clear(); _complete('clear-hidden'); } @override Future clearAllRecentAccess() async { clearStarted.complete(); if (holdClear) await releaseClear.future; items.clear(); hiddenIds.clear(); _complete('clear'); return DateTime.utc(2026, 10, 1); } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } ProviderContainer _container(_Database database) => ProviderContainer( overrides: [ recentAccessProvider.overrideWith( () => RecentAccessNotifier(database: database), ), ], ); Future _waitUntilLoaded(ProviderContainer container) async { if (container.read(recentAccessProvider).isLoaded) return; final loaded = Completer(); final subscription = container.listen(recentAccessProvider, (_, next) { if (next.isLoaded && !loaded.isCompleted) loaded.complete(); }); await loaded.future; subscription.close(); } void main() { test( 'delayed startup replays edits without discarding saved recents', () async { final removed = _artist('removed'); final database = _Database( items: [ _artist('edited', name: 'Old name'), _artist('kept'), removed, ], ); final container = _container(database); addTearDown(container.dispose); final notifier = container.read(recentAccessProvider.notifier); await database.readStarted.future; notifier.recordArtistAccess( id: 'edited', name: 'New name', providerId: 'example', ); notifier.removeItem(removed); notifier.hideDownloadFromRecents('new-download'); expect( container.read(recentAccessProvider).items.single.name, 'New name', ); expect(database.operations, isEmpty); database.releaseRead.complete(); await _waitUntilLoaded(container); final state = container.read(recentAccessProvider); expect(state.items.map((item) => item.id), ['edited', 'kept']); expect(state.items.first.name, 'New name'); expect(state.hiddenDownloadIds, {'saved-download', 'new-download'}); expect(state.downloadsClearedAt, database.clearedAt); await database.whenCompleted('hide:new-download'); expect(database.items.keys, [ _artist('edited').uniqueKey, _artist('kept').uniqueKey, ]); expect(database.items[_artist('edited').uniqueKey]!.name, 'New name'); }, ); test('early hidden clear is replayed before a later hide', () async { final database = _Database(); final container = _container(database); addTearDown(container.dispose); final notifier = container.read(recentAccessProvider.notifier); await database.readStarted.future; notifier.clearHiddenDownloads(); notifier.hideDownloadFromRecents('new-download'); database.releaseRead.complete(); await _waitUntilLoaded(container); await database.whenCompleted('hide:new-download'); expect(container.read(recentAccessProvider).hiddenDownloadIds, { 'new-download', }); expect(database.hiddenIds, {'new-download'}); expect(database.operations, ['clear-hidden', 'hide:new-download']); }); test( 'startup clear preserves accesses made after the clear request', () async { final database = _Database(items: [_artist('old')])..holdClear = true; final container = _container(database); addTearDown(container.dispose); final notifier = container.read(recentAccessProvider.notifier); await database.readStarted.future; final clearing = notifier.clearHistory(); notifier.recordArtistAccess( id: 'later', name: 'Later', providerId: 'example', ); database.releaseRead.complete(); await database.clearStarted.future; database.releaseClear.complete(); await clearing; await database.whenCompleted('record:${_artist('later').uniqueKey}'); expect(container.read(recentAccessProvider).items.single.id, 'later'); expect(database.items.values.single.id, 'later'); expect( container.read(recentAccessProvider).downloadsClearedAt, DateTime.utc(2026, 10, 1), ); expect(database.operations, [ 'clear', 'record:${_artist('later').uniqueKey}', ]); }, ); test('owned write failure is logged and permits later writes', () async { final database = _Database(); final container = _container(database); addTearDown(container.dispose); final notifier = container.read(recentAccessProvider.notifier); await database.readStarted.future; database.releaseRead.complete(); await _waitUntilLoaded(container); final failedOperation = 'record:${_artist('failed').uniqueKey}'; database.failures.add(failedOperation); notifier.recordArtistAccess( id: 'failed', name: 'Failed', providerId: 'example', ); notifier.hideDownloadFromRecents('after-failure'); await database.whenCompleted('hide:after-failure'); expect(container.read(recentAccessProvider).items.single.id, 'failed'); expect(database.items, isEmpty); expect(database.hiddenIds, contains('after-failure')); expect( LogBuffer().entries.any( (entry) => entry.tag == 'RecentAccess' && entry.error?.contains(failedOperation) == true, ), isTrue, ); }); test('failed load preserves early state and still owns its writes', () async { final database = _Database()..failRead = true; final container = _container(database); addTearDown(container.dispose); final notifier = container.read(recentAccessProvider.notifier); await database.readStarted.future; notifier.recordArtistAccess( id: 'early', name: 'Early', providerId: 'example', ); database.releaseRead.complete(); await _waitUntilLoaded(container); await database.whenCompleted('record:${_artist('early').uniqueKey}'); expect(container.read(recentAccessProvider).items.single.id, 'early'); expect(database.items.values.single.id, 'early'); expect( LogBuffer().entries.any( (entry) => entry.tag == 'RecentAccess' && entry.error?.contains('recent_load_failed') == true, ), isTrue, ); }); }