added support for simultan downloads in files and talk, support for background downloads, enhanced loading in files with retry

This commit is contained in:
2026-07-06 15:43:17 +02:00
parent 614ab159af
commit 124b1a5177
34 changed files with 1866 additions and 409 deletions
+62 -2
View File
@@ -8,7 +8,9 @@ import 'package:marianum_mobile/api/api_error.dart';
import 'package:marianum_mobile/api/errors/auth_exception.dart';
import 'package:marianum_mobile/api/errors/error_mapper.dart';
import 'package:marianum_mobile/api/errors/network_exception.dart';
import 'package:marianum_mobile/api/errors/not_found_exception.dart';
import 'package:marianum_mobile/api/errors/parse_exception.dart';
import 'package:nextcloud/nextcloud.dart';
void main() {
group('errorToUserMessage', () {
@@ -95,7 +97,7 @@ void main() {
expect(errorToUserMessage(ex), const NetworkException().userMessage);
});
test('DioException badResponse maps to a server status message', () {
test('DioException badResponse maps to the server error message', () {
final ex = DioException(
requestOptions: RequestOptions(path: '/x'),
type: DioExceptionType.badResponse,
@@ -104,7 +106,65 @@ void main() {
statusCode: 503,
),
);
expect(errorToUserMessage(ex), contains('503'));
// The status code lives in the technical details, not the message.
expect(
errorToUserMessage(ex),
contains('konnte die Anfrage gerade nicht verarbeiten'),
);
expect(errorToTechnicalDetails(ex), contains('503'));
});
});
group('DynamiteApiException mapping', () {
test('500 maps to the server error message without the raw dump', () {
const ex = DynamiteApiException(500, {'server': 'nginx'}, '');
expect(
errorToUserMessage(ex),
contains('konnte die Anfrage gerade nicht verarbeiten'),
);
expect(errorToTechnicalDetails(ex), 'HTTP 500');
expect(errorToTechnicalDetails(ex), isNot(contains('nginx')));
expect(errorAllowsRetry(ex), isTrue);
});
test('5xx details include a trimmed body preview', () {
const ex = DynamiteApiException(503, {}, ' Service\n Unavailable ');
expect(errorToTechnicalDetails(ex), 'HTTP 503 body=Service Unavailable');
});
test('long bodies are capped in the details', () {
final ex = DynamiteApiException(502, const {}, 'x' * 600);
final details = errorToTechnicalDetails(ex)!;
expect(details, startsWith('HTTP 502 body='));
expect(details, endsWith(''));
expect(details.length, lessThan(600));
});
test('401 maps to the unauthorized AuthException', () {
const ex = DynamiteApiException(401, {}, '');
expect(
errorToUserMessage(ex),
AuthException.unauthorized().userMessage,
);
expect(errorAllowsRetry(ex), isFalse);
});
test('403 maps to the forbidden AuthException', () {
const ex = DynamiteApiException(403, {}, '');
expect(errorToUserMessage(ex), AuthException.forbidden().userMessage);
expect(errorAllowsRetry(ex), isFalse);
});
test('404 maps to NotFoundException', () {
const ex = DynamiteApiException(404, {}, '');
expect(errorToUserMessage(ex), const NotFoundException().userMessage);
expect(errorAllowsRetry(ex), isFalse);
});
test('429 maps to a rate-limit message that allows retry', () {
const ex = DynamiteApiException(429, {}, '');
expect(errorToUserMessage(ex), contains('Zu viele Anfragen'));
expect(errorAllowsRetry(ex), isTrue);
});
});
+168
View File
@@ -0,0 +1,168 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/retry.dart';
import 'package:nextcloud/nextcloud.dart';
void main() {
// Instant delays so the tests never wait on real timers.
Duration noDelay(int _) => Duration.zero;
group('isTransientServerError', () {
test('5xx DynamiteApiException is transient', () {
expect(
isTransientServerError(const DynamiteApiException(500, {}, '')),
isTrue,
);
expect(
isTransientServerError(const DynamiteApiException(502, {}, '')),
isTrue,
);
expect(
isTransientServerError(const DynamiteApiException(503, {}, '')),
isTrue,
);
});
test('4xx DynamiteApiException is not transient', () {
expect(
isTransientServerError(const DynamiteApiException(404, {}, '')),
isFalse,
);
expect(
isTransientServerError(const DynamiteApiException(412, {}, '')),
isFalse,
);
expect(
isTransientServerError(const DynamiteApiException(429, {}, '')),
isFalse,
);
});
test('other error types are not transient', () {
expect(isTransientServerError(TimeoutException('slow')), isFalse);
expect(isTransientServerError(const SocketException('down')), isFalse);
expect(isTransientServerError(StateError('boom')), isFalse);
});
});
group('retryOnTransientError', () {
test('returns the result of a first-try success without retrying', () async {
var calls = 0;
final result = await retryOnTransientError(() async {
calls++;
return 'ok';
}, delayFor: noDelay);
expect(result, 'ok');
expect(calls, 1);
});
test('retries transient failures until an attempt succeeds', () async {
var calls = 0;
final result = await retryOnTransientError(() async {
calls++;
if (calls < 3) throw const DynamiteApiException(500, {}, '');
return 'ok';
}, delayFor: noDelay);
expect(result, 'ok');
expect(calls, 3);
});
test('rethrows the last error once maxAttempts is exhausted', () async {
var calls = 0;
await expectLater(
retryOnTransientError(() async {
calls++;
throw const DynamiteApiException(503, {}, '');
}, delayFor: noDelay),
throwsA(
isA<DynamiteApiException>().having(
(e) => e.statusCode,
'statusCode',
503,
),
),
);
expect(calls, 3);
});
test('rethrows non-transient errors immediately', () async {
var calls = 0;
await expectLater(
retryOnTransientError(() async {
calls++;
throw const DynamiteApiException(404, {}, '');
}, delayFor: noDelay),
throwsA(isA<DynamiteApiException>()),
);
expect(calls, 1);
});
test('rethrows unrelated exceptions immediately', () async {
var calls = 0;
await expectLater(
retryOnTransientError(() async {
calls++;
throw StateError('boom');
}, delayFor: noDelay),
throwsStateError,
);
expect(calls, 1);
});
test('reports upcoming attempts via onRetry and delays via delayFor', () async {
final retriesSeen = <(int, int)>[];
final delaysRequested = <int>[];
await expectLater(
retryOnTransientError(
() async => throw const DynamiteApiException(500, {}, ''),
delayFor: (retry) {
delaysRequested.add(retry);
return Duration.zero;
},
onRetry: (next, max) => retriesSeen.add((next, max)),
),
throwsA(isA<DynamiteApiException>()),
);
expect(retriesSeen, [(2, 3), (3, 3)]);
expect(delaysRequested, [1, 2]);
});
test('respects a custom shouldRetry predicate', () async {
var calls = 0;
final result = await retryOnTransientError(
() async {
calls++;
if (calls < 2) throw StateError('flaky');
return calls;
},
delayFor: noDelay,
shouldRetry: (e) => e is StateError,
);
expect(result, 2);
expect(calls, 2);
});
test('respects a custom maxAttempts', () async {
var calls = 0;
await expectLater(
retryOnTransientError(
() async {
calls++;
throw const DynamiteApiException(500, {}, '');
},
maxAttempts: 5,
delayFor: noDelay,
),
throwsA(isA<DynamiteApiException>()),
);
expect(calls, 5);
});
});
}