todo-app/test/images/attachment_ingest_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

146 lines
5.0 KiB
Dart

import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:path/path.dart' as p;
import 'package:todo/images/attachment_ingest.dart';
import 'package:todo/images/image_cache_store.dart';
import 'package:todo/images/image_processing.dart';
void main() {
late Directory root;
late ImageCacheStore store;
late List<int> prepared;
final png = img.encodePng(img.Image(width: 4, height: 3));
final jpeg = Uint8List.fromList([0xFF, 0xD8, 0xFF, 1]);
AttachmentIngest ingest({Uint8List? Function(Uint8List)? prepare}) =>
AttachmentIngest(
store,
prepare: (bytes) async {
prepared.add(bytes.length);
return (prepare ?? (_) => jpeg)(bytes);
},
);
setUp(() {
root = Directory.systemTemp.createTempSync('ingest');
store = ImageCacheStore(Directory(p.join(root.path, 'store')));
prepared = [];
});
tearDown(() => root.deleteSync(recursive: true));
void expectNoTemps() => expect(
store.root.listSync(recursive: true).where((e) => e.path.endsWith('.tmp')),
isEmpty,
);
test('a non-image is stored byte for byte as a queued file', () async {
final pdf = '%PDF-1.7 hello'.codeUnits;
final a = await ingest().fromStream('n1', 'Raport.pdf', Stream.value(pdf));
expect(a.isImage, isFalse);
expect(a.name, 'Raport.pdf');
expect(a.ref.extension, 'pdf');
expect(store.fileFor(a.ref).readAsBytesSync(), pdf);
expect(store.isPending(a.ref), isTrue);
expect(prepared, isEmpty, reason: 'never decoded');
expectNoTemps();
});
test('an image is re-encoded and stored as a jpg', () async {
final a = await ingest().fromStream('n1', 'shot.png', Stream.value(png));
expect(a.isImage, isTrue);
expect(a.ref.extension, 'jpg');
expect(store.fileFor(a.ref).readAsBytesSync(), jpeg);
expect(store.isPending(a.ref), isTrue);
expect(prepared, [png.length]);
expectNoTemps();
});
test('the real preparer is the default', () async {
final a = await AttachmentIngest(
store,
).fromStream('n1', 'shot.png', Stream.value(png));
expect(img.decodeJpg(store.fileFor(a.ref).readAsBytesSync()), isNotNull);
});
test('an image that will not decode is kept as a file', () async {
final a = await ingest(
prepare: (_) => null,
).fromStream('n1', 'broken.png', Stream.value(png));
expect(a.isImage, isFalse);
expect(a.ref.extension, 'png');
expect(store.fileFor(a.ref).readAsBytesSync(), png);
});
test('an image too big to decode is kept as a file, unread', () async {
final chunk = Uint8List(1024 * 1024);
Stream<List<int>> huge() async* {
yield png.sublist(0, kImageSniffBytes);
for (var i = 0; i < kMaxImageDecodeBytes ~/ chunk.length; i++) {
yield chunk;
}
}
final a = await ingest().fromStream('n1', 'huge.png', huge());
expect(a.isImage, isFalse);
expect(prepared, isEmpty);
expect(
store.fileFor(a.ref).lengthSync(),
kMaxImageDecodeBytes + kImageSniffBytes,
);
});
test('a failed read leaves nothing behind', () async {
Stream<List<int>> broken() async* {
yield [1, 2];
throw const FileSystemException('gone');
}
await expectLater(
ingest().fromStream('n1', 'a.bin', broken()),
throwsA(isA<FileSystemException>()),
);
expectNoTemps();
expect(store.pending(), isEmpty);
});
test('fromFile copies a file it does not own', () async {
final source = File(p.join(root.path, 'mine.txt'))..writeAsStringSync('x');
final a = await ingest().fromFile('n1', 'mine.txt', source, move: false);
expect(store.fileFor(a.ref).readAsStringSync(), 'x');
expect(source.existsSync(), isTrue);
});
test('fromFile moves a picker copy instead of duplicating it', () async {
final source = File(p.join(root.path, 'copy.txt'))..writeAsStringSync('x');
final a = await ingest().fromFile('n1', 'copy.txt', source, move: true);
expect(store.fileFor(a.ref).readAsStringSync(), 'x');
expect(source.existsSync(), isFalse);
expectNoTemps();
});
test('a failure after the move cleans up the moved file', () async {
final source = File(p.join(root.path, 'copy.png'))..writeAsBytesSync(png);
await expectLater(
AttachmentIngest(
store,
prepare: (_) async => throw const FileSystemException('disk full'),
).fromFile('n1', 'copy.png', source, move: true),
throwsA(isA<FileSystemException>()),
);
expectNoTemps();
});
test('fromFile falls back to copy-and-delete across filesystems', () async {
final shm = Directory('/dev/shm').createTempSync('ingest');
addTearDown(() => shm.deleteSync(recursive: true));
final source = File(p.join(shm.path, 'far.txt'))..writeAsStringSync('y');
final a = await ingest().fromFile('n1', 'far.txt', source, move: true);
expect(store.fileFor(a.ref).readAsStringSync(), 'y');
expect(source.existsSync(), isFalse);
}, skip: Directory('/dev/shm').existsSync() ? false : 'needs /dev/shm');
}