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

109 lines
3.5 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:todo/images/attachment_ref.dart';
void main() {
const rel = 'note-1/0815ff8a-203e-4f54-9cc5-8a58b27982ec.jpg';
final ref = AttachmentRef.tryParse(rel)!;
test('a ref exposes its parts, url and markdown', () {
expect(ref.noteId, 'note-1');
expect(ref.file, '0815ff8a-203e-4f54-9cc5-8a58b27982ec.jpg');
expect(ref.extension, 'jpg');
expect(ref.relativePath, rel);
expect(ref.url, '$kDufsBaseUrl/$kImagesDir/$rel');
expect(ref.imageMarkdown, '![](${ref.url})');
expect(ref.fileMarkdown('a.pdf'), '[a.pdf](${ref.url})');
});
test('create files a fresh uuid jpg under the note', () {
final a = AttachmentRef.create('n1');
final b = AttachmentRef.create('n1');
expect(a.noteId, 'n1');
expect(a.file, endsWith('.jpg'));
expect(a, isNot(b));
});
test('forFile keeps a plain extension, else falls back to bin', () {
expect(AttachmentRef.forFile('n1', 'Report.PDF').extension, 'pdf');
expect(AttachmentRef.forFile('n1', 'a.tar.gz').extension, 'gz');
for (final name in ['README', '.bashrc', 'x.', 'a.verylongextension']) {
expect(AttachmentRef.forFile('n1', name).extension, 'bin', reason: name);
}
expect(
AttachmentRef.forFile('n1', 'a.pdf'),
isNot(AttachmentRef.forFile('n1', 'a.pdf')),
);
});
test('create and forFile refuse an unsafe note id', () {
expect(() => AttachmentRef.create('../x'), throwsArgumentError);
expect(() => AttachmentRef.forFile('a/b', 'x.pdf'), throwsArgumentError);
expect(isSafeNoteId('note-1_A'), isTrue);
expect(isSafeNoteId('../x'), isFalse);
expect(isSafeNoteId(''), isFalse);
});
test('tryParse takes any short extension but rejects unsafe paths', () {
expect(AttachmentRef.tryParse('a/b.png'), isNotNull);
expect(AttachmentRef.tryParse('a/b.mp4'), isNotNull);
for (final bad in [
'../x/y.jpg',
'a/../b.jpg',
'a/b/c.jpg',
'a/.jpg',
'/a/b.jpg',
'a b/c.jpg',
'a/b.jpg.pending',
'a/b.pdf.tmp',
'a/b.PDF',
'a/b',
]) {
expect(AttachmentRef.tryParse(bad), isNull, reason: bad);
}
});
test('refs compare by path, so sets dedupe', () {
expect({ref, AttachmentRef.tryParse(rel)!}, hasLength(1));
});
test('linkLabel keeps a name readable but unable to break the link', () {
expect(linkLabel('Raport [final].pdf'), 'Raport (final).pdf');
expect(linkLabel('two\r\nlines.txt'), 'two lines.txt');
expect(linkLabel(' '), 'file');
});
group('Attachment', () {
final file = AttachmentRef.tryParse('n1/b.pdf')!;
test('markdown is an image line or a named file link', () {
expect(
Attachment(ref: ref, name: 'x.png', isImage: true).markdown,
ref.imageMarkdown,
);
expect(
Attachment(ref: file, name: 'a].pdf', isImage: false).markdown,
'[a).pdf](${file.url})',
);
});
test('round-trips through JSON', () {
final attachment = Attachment(ref: file, name: 'a.pdf', isImage: false);
final back = Attachment.fromJson(attachment.toJson());
expect(back.ref, file);
expect(back.name, 'a.pdf');
expect(back.isImage, isFalse);
});
test('fromJson rejects anything else', () {
for (final bad in <Object?>[
null,
'x',
{'ref': '../x.pdf', 'name': 'a', 'image': false},
{'ref': 'n1/b.pdf', 'name': 'a'},
]) {
expect(() => Attachment.fromJson(bad), throwsFormatException);
}
});
});
}