todo-app/test/images/image_cache_store_test.dart
Krzysztof kuhy Rudnicki 9f2ffdb8a9
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file-length / file length <= 250 lines (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Attach any file to a note, of any size, not only images
📎 now picks any file (Android: Camera / Gallery / File; desktop: an
unfiltered dialog) and the desktop page accepts any dropped file. Photo and
screenshot formats up to 64 MB are still re-encoded and shown inline;
everything else is stored byte for byte on dufs beside the images and linked
as [name](url), shown as a row with its size that opens in the platform's
app (Android) or a new browser tab (desktop). BACKLOG.md rewrites file links
to ~/data/cloud/todo-images/ like image links.

There is no size cap, so no layer holds a file in memory: uploads and
downloads stream through DufsImageClient, writes land in a .tmp renamed when
whole (an interrupted download no longer leaves a truncated file that looks
done), the desktop page POSTs the browser's own Blob with fetch, and Android's
"File" uses file_picker, because file_selector's Android side reads the whole
file into memory on the UI thread. Verified with a 1 GB file end to end on
desktop (peak RSS +36 MB) and on an API 36 emulator.

The wrapper serves attachments from the origin that holds the GitHub token
and the notes, so only script-free types open inline (nosniff), the rest
download; writes carrying another site's Origin are refused (this also
closes cross-site overwrites of the backup files); and attachment routes run
concurrently so a long upload or a playing video cannot stall the page.

Also: cupertino_icons 2.0.0 (unused, but the freshness gate blocks the
pubspec otherwise), open_file 4.0.0 and file_picker 13.1.0 pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:17:06 +02:00

232 lines
8.2 KiB
Dart

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<void>.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<FileSystemException>()),
);
Process.runSync('chmod', ['755', locked.path]);
final report = await store.reconcile({}, null);
expect(report.pending, 0);
});
}