todo-app/test/attachments/image_store_io_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

179 lines
5.7 KiB
Dart

import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/attachments/image_password_store.dart';
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/attachments/image_store_io.dart';
import 'package:path/path.dart' as p;
import 'package:todo/images/image_cache_store.dart';
import '../fake_secure_storage.dart';
import '../images/fake_dufs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory root;
late FakeDufs dufs;
late IoImageStore store;
late int notified;
IoImageStore make({Future<Uint8List?> Function(Uint8List)? prepare}) {
final s = IoImageStore(
ImageCacheStore(root),
pickerCopies: p.join(root.parent.path, '${p.basename(root.path)}-picks'),
httpClient: dufs.httpClient,
prepare: prepare ?? (b) async => b.first == 0xFF ? b : null,
);
notified = 0;
s.addListener(() => notified++);
return s;
}
setUp(() {
root = Directory.systemTemp.createTempSync('io_store');
dufs = FakeDufs();
installFakeSecureStorage(initial: {'dufsImagesPassword': 'pw'});
store = make();
});
tearDown(() => root.deleteSync(recursive: true));
test('attach stores, notifies and uploads straight away', () async {
final ref = (await store.attach('n1', Uint8List.fromList([0xFF, 1])))!;
expect(ref.noteId, 'n1');
expect(store.sourceFor(ref), isA<LocalImageSource>());
expect(notified, greaterThanOrEqualTo(1));
await pumpEventQueue();
expect(dufs.files[ref.relativePath], [0xFF, 1]);
expect(store.isPending(ref), isFalse);
});
test('attach returns null for a non-image and stores nothing', () async {
expect(await store.attach('n1', Uint8List.fromList([1])), isNull);
expect(root.listSync(), isEmpty);
});
test('attach fails loudly when the image cannot be written', () async {
root.deleteSync(recursive: true);
File(root.path).writeAsStringSync('not a dir');
await expectLater(
store.attach('n1', Uint8List.fromList([0xFF])),
throwsA(isA<ImageStoreException>()),
);
File(root.path).deleteSync();
root.createSync();
});
test('without a password attach queues offline', () async {
installFakeSecureStorage();
final ref = (await store.attach('n1', Uint8List.fromList([0xFF])))!;
await pumpEventQueue();
expect(dufs.requests, isEmpty);
expect(store.isPending(ref), isTrue);
expect((await store.status()).configured, isFalse);
expect((await store.status()).pending, 1);
});
test('reconcile prefetches what notes link to and notifies', () async {
dufs.files['n2/b.jpg'] = [7];
final before = notified;
await store.reconcile([
'x\n![](https://kuhy-cloud.duckdns.org/todo-images/n2/b.jpg)',
]);
expect(notified, before + 1);
final status = await store.status();
expect((status.configured, status.pending), (true, 0));
});
test('a reconcile with nothing to do does not notify', () async {
await store.reconcile(['no images']);
expect(notified, 0);
});
group('attachFile', () {
late Directory picks;
late Directory elsewhere;
setUp(() {
picks = Directory(store.pickerCopies!)..createSync();
elsewhere = Directory.systemTemp.createTempSync('io_store_src');
});
tearDown(() {
picks.deleteSync(recursive: true);
elsewhere.deleteSync(recursive: true);
});
XFile file(Directory dir, String name, List<int> bytes) {
final path = p.join(dir.path, name);
File(path).writeAsBytesSync(bytes);
return XFile(path, name: name);
}
test('stores a file, notifies and uploads it', () async {
final a = await store.attachFile('n1', file(elsewhere, 'r.pdf', [1, 2]));
expect(a.isImage, isFalse);
expect(notified, greaterThanOrEqualTo(1));
expect(await store.sizeOf(a.ref), 2);
await pumpEventQueue();
expect(dufs.files[a.ref.relativePath], [1, 2]);
expect(File(p.join(elsewhere.path, 'r.pdf')).existsSync(), isTrue);
});
test("moves a picker's own copy instead of copying it", () async {
final picked = file(picks, 'big.bin', [3]);
final a = await store.attachFile('n1', picked);
expect(File(picked.path).existsSync(), isFalse);
expect(await store.sizeOf(a.ref), 1);
});
test('routes an image through the preparer', () async {
final a = await store.attachFile(
'n1',
file(elsewhere, 'p.jpg', [0xFF, 0xD8, 0xFF, 9]),
);
expect(a.isImage, isTrue);
});
test('fails loudly when the file cannot be stored', () async {
await expectLater(
store.attachFile('n1', XFile(p.join(elsewhere.path, 'gone.pdf'))),
throwsA(isA<ImageStoreException>()),
);
});
test('without picker copies everything is copied', () async {
final plain = IoImageStore(
ImageCacheStore(root),
httpClient: dufs.httpClient,
);
final picked = file(picks, 'kept.txt', [1]);
await plain.attachFile('n1', picked);
expect(File(picked.path).existsSync(), isTrue);
});
});
test('sizeOf is null for an attachment not on this device', () async {
final ref = attachmentRefsAcross([
'[a.pdf](https://kuhy-cloud.duckdns.org/todo-images/n9/z.pdf)',
]).single;
expect(await store.sizeOf(ref), isNull);
});
test('a missing image has a missing source', () {
final ref = attachmentRefsAcross([
'![](https://kuhy-cloud.duckdns.org/todo-images/n9/z.jpg)',
]).single;
expect(store.sourceFor(ref), isA<MissingImageSource>());
});
test('the default prepare and client are the real ones', () {
expect(
IoImageStore(
ImageCacheStore(root),
passwords: const ImagePasswordStore(),
),
isA<ImageStore>(),
);
});
}