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

136 lines
4.1 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:todo/images/attachment_ref.dart';
import 'package:todo/images/dufs_image_client.dart';
void main() {
final ref = AttachmentRef.tryParse('n1/a.jpg')!;
const login = DufsLogin(user: 'todo', password: 'pw', baseUrl: 'https://d');
final auth = 'Basic ${base64.encode(utf8.encode('todo:pw'))}';
DufsImageClient client(MockClientHandler handler) =>
DufsImageClient(login, httpClient: MockClient(handler));
test('the default login points at kuhy-cloud', () {
expect(const DufsLogin(user: 'u', password: 'p').baseUrl, kDufsBaseUrl);
});
Stream<List<int>> body(List<int> bytes) => Stream.value(bytes);
test('upload streams the body with basic auth and its length', () async {
late http.Request seen;
final ok = await client((r) async {
seen = r;
return http.Response('', 201);
}).upload(ref, body([1, 2]), 2);
expect(ok, isTrue);
expect(seen.method, 'PUT');
expect(seen.url.toString(), 'https://d/todo-images/n1/a.jpg');
expect(seen.headers['authorization'], auth);
expect(seen.headers['content-type'], 'image/jpeg');
expect(seen.bodyBytes, [1, 2]);
});
test('upload declares the length up front, so nothing is buffered', () async {
final capture = _CapturingClient();
final file = AttachmentRef.tryParse('n1/b.pdf')!;
await DufsImageClient(
login,
httpClient: capture,
).upload(file, body([1, 2, 3]), 3);
expect(capture.seen.contentLength, 3);
expect(capture.seen.headers['content-type'], 'application/octet-stream');
});
test('upload is false on a refusal or a network error', () async {
expect(
await client(
(_) async => http.Response('', 403),
).upload(ref, body([1]), 1),
isFalse,
);
expect(
await client(
(_) => throw const SocketException('down'),
).upload(ref, body([1]), 1),
isFalse,
);
});
test('openDownload streams the body on 200, null otherwise', () async {
final stream = await client((r) async {
expect(r.method, 'GET');
expect(r.headers['authorization'], auth);
return http.Response.bytes([7, 8], 200);
}).openDownload(ref);
expect(await stream!.expand((chunk) => chunk).toList(), [7, 8]);
expect(
await client((_) async => http.Response('', 404)).openDownload(ref),
isNull,
);
expect(
await client(
(_) => throw const SocketException('down'),
).openDownload(ref),
isNull,
);
});
test('check maps PROPFIND answers', () async {
Future<DufsCheck> answer(int code) => client((r) async {
expect(r.method, 'PROPFIND');
expect(r.headers['depth'], '0');
expect(r.url.toString(), 'https://d/todo-images/');
return http.Response('', code);
}).check();
expect(await answer(207), DufsCheck.ok);
expect(await answer(401), DufsCheck.rejected);
expect(await answer(403), DufsCheck.rejected);
expect(await answer(502), DufsCheck.unreachable);
expect(
await client((_) => throw const SocketException('down')).check(),
DufsCheck.unreachable,
);
});
test('a default http client is created when none is given', () {
DufsImageClient(login).close();
});
test('close closes the http client', () {
var closed = false;
final inner = _ClosingClient(() => closed = true);
DufsImageClient(login, httpClient: inner).close();
expect(closed, isTrue);
});
}
class _ClosingClient extends http.BaseClient {
_ClosingClient(this.onClose);
final void Function() onClose;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) =>
throw UnimplementedError();
@override
void close() => onClose();
}
/// Records the request as sent, before any mock flattens it.
class _CapturingClient extends http.BaseClient {
late http.BaseRequest seen;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
seen = request;
await request.finalize().drain<void>();
return http.StreamedResponse(const Stream.empty(), 201);
}
}