|
| 1 | +import 'dart:async'; |
| 2 | + |
1 | 3 | import 'package:clock/clock.dart'; |
2 | 4 | import 'package:file/file.dart'; |
3 | 5 | import 'package:file/memory.dart'; |
4 | 6 | import 'package:flutter_cache_manager/src/cache_store.dart'; |
| 7 | +import 'package:flutter_cache_manager/src/logger.dart'; |
5 | 8 | import 'package:flutter_cache_manager/src/storage/cache_object.dart'; |
6 | 9 | import 'package:flutter_test/flutter_test.dart'; |
7 | 10 | import 'package:mockito/mockito.dart'; |
@@ -54,6 +57,81 @@ void main() { |
54 | 57 | expect(await store.getFile('baseflow.com/test.png'), isNotNull); |
55 | 58 | }); |
56 | 59 |
|
| 60 | + test( |
| 61 | + 'Store should return FileInfo and handle error when database update fails on cache hit', |
| 62 | + () async { |
| 63 | + final testLogger = setUpTestLogger(); |
| 64 | + |
| 65 | + var config = createTestConfig(); |
| 66 | + await config.returnsFile(fileName); |
| 67 | + config.returnsCacheObject( |
| 68 | + fileUrl, |
| 69 | + fileName, |
| 70 | + DateTime.now(), |
| 71 | + id: fileId, |
| 72 | + ); |
| 73 | + when( |
| 74 | + config.mockRepo.updateOrInsert(any), |
| 75 | + ).thenAnswer((_) => Future.error(Exception('Database update failed'))); |
| 76 | + |
| 77 | + var store = CacheStore(config); |
| 78 | + |
| 79 | + final fileInfo = await store.getFile(fileUrl); |
| 80 | + expect(fileInfo, isNotNull); |
| 81 | + |
| 82 | + await untilCalled(config.mockRepo.updateOrInsert(any)); |
| 83 | + verify(config.mockRepo.updateOrInsert(any)).called(1); |
| 84 | + await testLogger.waitForLog('Failed to update cache metadata'); |
| 85 | + }, |
| 86 | + ); |
| 87 | + |
| 88 | + test( |
| 89 | + 'Store should treat failed database read as cache miss and clean up pending cache', |
| 90 | + () async { |
| 91 | + final testLogger = setUpTestLogger(); |
| 92 | + |
| 93 | + var config = createTestConfig(); |
| 94 | + when( |
| 95 | + config.mockRepo.get(any), |
| 96 | + ).thenAnswer((_) => Future.error(Exception('Database read failed'))); |
| 97 | + |
| 98 | + var store = CacheStore(config); |
| 99 | + |
| 100 | + final result1 = await store.retrieveCacheData('failed-key'); |
| 101 | + expect(result1, isNull); |
| 102 | + |
| 103 | + final result2 = await store.retrieveCacheData('failed-key'); |
| 104 | + expect(result2, isNull); |
| 105 | + |
| 106 | + verify(config.mockRepo.get(any)).called(2); |
| 107 | + await testLogger.waitForLog('Failed to read cache metadata'); |
| 108 | + }, |
| 109 | + ); |
| 110 | + |
| 111 | + test( |
| 112 | + 'Store cleanup error should be handled without unhandled async error', |
| 113 | + () async { |
| 114 | + final testLogger = setUpTestLogger(); |
| 115 | + |
| 116 | + var config = createTestConfig(); |
| 117 | + var store = CacheStore(config); |
| 118 | + store.cleanupRunMinInterval = const Duration(milliseconds: 1); |
| 119 | + await config.returnsFile(fileName); |
| 120 | + config.returnsCacheObject(fileUrl, fileName, validTill, id: fileId); |
| 121 | + |
| 122 | + when( |
| 123 | + config.mockRepo.getObjectsOverCapacity(any), |
| 124 | + ).thenAnswer((_) => Future.error(Exception('Cleanup failure'))); |
| 125 | + |
| 126 | + expect(await store.getFile(fileUrl), isNotNull); |
| 127 | + |
| 128 | + await untilCalled(config.mockRepo.getObjectsOverCapacity(any)); |
| 129 | + verify(config.mockRepo.getObjectsOverCapacity(any)).called(1); |
| 130 | + |
| 131 | + await testLogger.waitForLog('Failed to cleanup cache'); |
| 132 | + }, |
| 133 | + ); |
| 134 | + |
57 | 135 | test('Store should return null if file is not cached', () async { |
58 | 136 | var config = createTestConfig(); |
59 | 137 | await config.returnsFile(fileName); |
@@ -211,6 +289,54 @@ void main() { |
211 | 289 | expect(result!.id, isNotNull); |
212 | 290 | }, |
213 | 291 | ); |
| 292 | + |
| 293 | + test( |
| 294 | + 'putFile restores or removes memory cache entry when updateOrInsert fails', |
| 295 | + () async { |
| 296 | + var config = createTestConfig(); |
| 297 | + var store = CacheStore(config); |
| 298 | + |
| 299 | + var initialObject = CacheObject( |
| 300 | + 'baseflow.com/test.png', |
| 301 | + relativePath: 'testimage_old.png', |
| 302 | + validTill: clock.now().add(const Duration(days: 7)), |
| 303 | + ); |
| 304 | + var newObject = CacheObject( |
| 305 | + 'baseflow.com/test.png', |
| 306 | + relativePath: 'testimage_new.png', |
| 307 | + validTill: clock.now().add(const Duration(days: 7)), |
| 308 | + ); |
| 309 | + |
| 310 | + // Case 1: Nothing was in memory cache before, putFile fails |
| 311 | + when( |
| 312 | + config.mockRepo.updateOrInsert(newObject), |
| 313 | + ).thenAnswer((_) => Future.error(Exception('DB error'))); |
| 314 | + |
| 315 | + await expectLater( |
| 316 | + () => store.putFile(newObject), |
| 317 | + throwsA(isA<Exception>()), |
| 318 | + ); |
| 319 | + expect(store.memoryCacheContainsKey(newObject.key), isFalse); |
| 320 | + |
| 321 | + // Case 2: initialObject succeeded, then updating to newObject fails |
| 322 | + when( |
| 323 | + config.mockRepo.updateOrInsert(initialObject), |
| 324 | + ).thenAnswer((_) => Future.value(initialObject)); |
| 325 | + await store.putFile(initialObject); |
| 326 | + expect(store.memoryCacheContainsKey(initialObject.key), isTrue); |
| 327 | + |
| 328 | + await expectLater( |
| 329 | + () => store.putFile(newObject), |
| 330 | + throwsA(isA<Exception>()), |
| 331 | + ); |
| 332 | + // Should have restored initialObject, not left pointing at newObject |
| 333 | + expect(store.memoryCacheContainsKey(initialObject.key), isTrue); |
| 334 | + expect( |
| 335 | + (await store.getFileFromMemory(initialObject.key))?.file.path, |
| 336 | + contains('testimage_old.png'), |
| 337 | + ); |
| 338 | + }, |
| 339 | + ); |
214 | 340 | }); |
215 | 341 |
|
216 | 342 | group('Removing files in store', () { |
@@ -464,3 +590,37 @@ Future<Directory> createDir() async { |
464 | 590 | final fileSystem = MemoryFileSystem(); |
465 | 591 | return fileSystem.systemTempDirectory.createTemp('test'); |
466 | 592 | } |
| 593 | + |
| 594 | +class TestCacheLogger extends CacheLogger { |
| 595 | + final logs = <String>[]; |
| 596 | + Completer<String>? _nextLogCompleter; |
| 597 | + |
| 598 | + Future<String> waitForLog([Pattern? pattern]) async { |
| 599 | + while (true) { |
| 600 | + if (pattern != null) { |
| 601 | + final existing = logs.where((l) => l.contains(pattern)).firstOrNull; |
| 602 | + if (existing != null) return existing; |
| 603 | + } else if (logs.isNotEmpty) { |
| 604 | + return logs.first; |
| 605 | + } |
| 606 | + _nextLogCompleter ??= Completer<String>(); |
| 607 | + await _nextLogCompleter!.future; |
| 608 | + } |
| 609 | + } |
| 610 | + |
| 611 | + @override |
| 612 | + void log(String message, CacheManagerLogLevel level) { |
| 613 | + logs.add(message); |
| 614 | + final c = _nextLogCompleter; |
| 615 | + _nextLogCompleter = null; |
| 616 | + c?.complete(message); |
| 617 | + } |
| 618 | +} |
| 619 | + |
| 620 | +TestCacheLogger setUpTestLogger() { |
| 621 | + final originalLogger = cacheLogger; |
| 622 | + final testLogger = TestCacheLogger(); |
| 623 | + cacheLogger = testLogger; |
| 624 | + addTearDown(() => cacheLogger = originalLogger); |
| 625 | + return testLogger; |
| 626 | +} |
0 commit comments