import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:todo/images/attachment_ref.dart'; import 'package:todo/images/image_cache_store.dart'; import 'fake_dufs.dart'; void main() { late Directory root; late ImageCacheStore store; late FakeDufs dufs; var now = DateTime(2026, 9, 25, 12); final a = AttachmentRef.tryParse('n1/a.jpg')!; final b = AttachmentRef.tryParse('n2/b.jpg')!; final bytes = Uint8List.fromList([1, 2, 3]); setUp(() { root = Directory.systemTemp.createTempSync('image_cache'); now = DateTime.now(); store = ImageCacheStore(root, now: () => now); dufs = FakeDufs(); }); tearDown(() => root.deleteSync(recursive: true)); void age(AttachmentRef ref, Duration by) => store.fileFor(ref).setLastModifiedSync(now.subtract(by)); test('put stores the file and queues it', () async { await store.put(a, bytes); expect(store.fileFor(a).readAsBytesSync(), bytes); expect(store.isPending(a), isTrue); expect(store.pending(), [a]); expect(File('${store.fileFor(a).path}.tmp').existsSync(), isFalse); }); test('adopt moves a finished file into place and queues it', () async { final pdf = AttachmentRef.tryParse('n1/c.pdf')!; final tmp = store.tempFor(pdf); await tmp.parent.create(recursive: true); tmp.writeAsBytesSync([4, 5]); await store.adopt(pdf, tmp); expect(store.fileFor(pdf).readAsBytesSync(), [4, 5]); expect(tmp.existsSync(), isFalse); expect(store.pending(), [pdf]); await store.reconcile({}, dufs.client()); expect(dufs.files['n1/c.pdf'], [4, 5]); }); test('an empty or missing root has nothing pending', () { root.deleteSync(recursive: true); expect(store.pending(), isEmpty); root.createSync(); }); test('reconcile uploads the queue and clears the marker', () async { await store.put(a, bytes); final report = await store.reconcile({a}, dufs.client()); expect(dufs.files['n1/a.jpg'], bytes); expect(store.isPending(a), isFalse); expect(report.toJson(), { 'uploaded': 1, 'downloaded': 0, 'evicted': 0, 'pending': 0, 'missing': 0, }); }); test('a failed upload keeps the marker for next time', () async { await store.put(a, bytes); dufs.online = false; final report = await store.reconcile({a}, dufs.client()); expect(store.isPending(a), isTrue); expect(report.pending, 1); dufs.online = true; expect((await store.reconcile({a}, dufs.client())).uploaded, 1); }); test('a marker whose file never landed is skipped, not uploaded', () async { await store.put(a, bytes); store.fileFor(a).deleteSync(); final report = await store.reconcile({}, dufs.client()); expect(report.uploaded, 0); expect(dufs.files, isEmpty); }); test('without a client only eviction runs', () async { await store.put(a, bytes); final report = await store.reconcile({b}, null); expect(dufs.requests, isEmpty); expect(report.pending, 1); expect(report.missing, 1); }); test('reconcile downloads linked images this device lacks', () async { dufs.files['n2/b.jpg'] = [9]; final report = await store.reconcile({b}, dufs.client()); expect(store.fileFor(b).readAsBytesSync(), [9]); expect(report.downloaded, 1); expect(report.missing, 0); }); test('a download dropped mid-body leaves no file behind', () async { dufs.files['n2/b.jpg'] = [9, 9]; dufs.dropMidBody.add('n2/b.jpg'); final report = await store.reconcile({b}, dufs.client()); expect(report.downloaded, 0); expect(report.missing, 1); expect(store.fileFor(b).existsSync(), isFalse); expect(store.tempFor(b).existsSync(), isFalse); dufs.dropMidBody.clear(); expect((await store.reconcile({b}, dufs.client())).downloaded, 1); }); test('an image missing on dufs stays missing', () async { final report = await store.reconcile({b}, dufs.client()); expect(report.downloaded, 0); expect(report.missing, 1); }); test('evicts old unlinked cached images only', () async { dufs.files ..['n1/a.jpg'] = [1] ..['n2/b.jpg'] = [2]; await store.reconcile({a, b}, dufs.client()); age(a, const Duration(hours: 1)); age(b, const Duration(hours: 1)); final report = await store.reconcile({b}, dufs.client()); expect(report.evicted, 1); expect(store.fileFor(a).existsSync(), isFalse); expect(store.fileFor(b).existsSync(), isTrue); expect(dufs.files.keys, contains('n1/a.jpg'), reason: 'never on dufs'); }); test('eviction spares queued and unparseable files', () async { await store.put(a, bytes); age(a, const Duration(hours: 1)); File(p.join(root.path, 'n1', 'odd name.jpg')) ..writeAsBytesSync([1]) ..setLastModifiedSync(now.subtract(const Duration(hours: 1))); dufs.online = false; // a stays queued final report = await store.reconcile({b}, dufs.client()); expect(report.evicted, 0); expect(store.fileFor(a).existsSync(), isTrue); dufs.online = true; // uploaded now, so old + unlinked => evicted expect((await store.reconcile({b}, dufs.client())).evicted, 1); expect(store.fileFor(a).existsSync(), isFalse); expect(File(p.join(root.path, 'n1', 'odd name.jpg')).existsSync(), isTrue); }); test('evicts any kind of attachment, never a marker', () async { final pdf = AttachmentRef.tryParse('n1/c.pdf')!; dufs.files['n1/c.pdf'] = [1]; await store.reconcile({pdf}, dufs.client()); age(pdf, const Duration(hours: 1)); expect((await store.reconcile({b}, dufs.client())).evicted, 1); expect(store.fileFor(pdf).existsSync(), isFalse); }); test('sweeps abandoned .tmp files once they are old', () async { final stale = store.tempFor(a); final fresh = store.tempFor(b); for (final tmp in [stale, fresh]) { await tmp.parent.create(recursive: true); tmp.writeAsBytesSync([1]); } stale.setLastModifiedSync(now.subtract(const Duration(hours: 1))); final report = await store.reconcile({b}, null); expect(report.evicted, 0, reason: 'a sweep is not an eviction'); expect(stale.existsSync(), isFalse); expect(fresh.existsSync(), isTrue); }); test('a young unlinked image survives the grace period', () async { dufs.files['n1/a.jpg'] = [1]; await store.reconcile({a}, dufs.client()); final report = await store.reconcile({b}, dufs.client()); expect(report.evicted, 0); expect(store.fileFor(a).existsSync(), isTrue); }); test('an empty ref set never evicts', () async { dufs.files['n1/a.jpg'] = [1]; await store.reconcile({a}, dufs.client()); age(a, const Duration(days: 1)); expect((await store.reconcile({}, dufs.client())).evicted, 0); expect(store.fileFor(a).existsSync(), isTrue); }); test('calls before a pass starts merge into it', () async { dufs.files ..['n1/a.jpg'] = [1] ..['n2/b.jpg'] = [2]; final first = store.reconcile({a}, null); final second = store.reconcile({}, dufs.client()); // upload-only final third = store.reconcile({b}, null); expect(identical(first, second) && identical(second, third), isTrue); final report = await first; // One pass: refs unioned, and the only client given was kept. expect(report.downloaded, 2); }); test('a call during a running pass gets one follow-up pass', () async { dufs.files['n2/b.jpg'] = [2]; final running = store.reconcile({}, dufs.client()); await Future.delayed(Duration.zero); // let it start final next = store.reconcile({b}, dufs.client()); expect(identical(running, next), isFalse); await running; expect((await next).downloaded, 1); }); test('a failing pass does not wedge the queue', () async { // An unreadable folder makes listing the queue throw. final locked = Directory(p.join(root.path, 'locked'))..createSync(); Process.runSync('chmod', ['000', locked.path]); addTearDown(() => Process.runSync('chmod', ['755', locked.path])); await expectLater( store.reconcile({a}, dufs.client()), throwsA(isA()), ); Process.runSync('chmod', ['755', locked.path]); final report = await store.reconcile({}, null); expect(report.pending, 0); }); }