mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-11 17:54:02 +01:00
Some checks failed
📎 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>
232 lines
8.2 KiB
Dart
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);
|
|
});
|
|
}
|