mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 19:27:01 +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>
136 lines
4.1 KiB
Dart
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);
|
|
}
|
|
}
|