added support for simultan downloads in files and talk, support for background downloads, enhanced loading in files with retry
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/widget/downloads/download_tray.dart';
|
||||
|
||||
void main() {
|
||||
group('shouldAutoOpenCompletion', () {
|
||||
test('opens a lone foreground download on its origin screen', () {
|
||||
expect(
|
||||
shouldAutoOpenCompletion(
|
||||
foreground: true,
|
||||
suppressAutoOpen: false,
|
||||
completedIsSoleVisibleJob: true,
|
||||
onOriginScreen: true,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not open while backgrounded', () {
|
||||
expect(
|
||||
shouldAutoOpenCompletion(
|
||||
foreground: false,
|
||||
suppressAutoOpen: false,
|
||||
completedIsSoleVisibleJob: true,
|
||||
onOriginScreen: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not open when other downloads are still visible', () {
|
||||
expect(
|
||||
shouldAutoOpenCompletion(
|
||||
foreground: true,
|
||||
suppressAutoOpen: false,
|
||||
completedIsSoleVisibleJob: false,
|
||||
onOriginScreen: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not open once parallelism was seen (suppressed), even if now sole', () {
|
||||
// A burst of parallel downloads drains one by one; the last one left must
|
||||
// not surprise the user by auto-opening.
|
||||
expect(
|
||||
shouldAutoOpenCompletion(
|
||||
foreground: true,
|
||||
suppressAutoOpen: true,
|
||||
completedIsSoleVisibleJob: true,
|
||||
onOriginScreen: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not open if the user left the screen it was started on', () {
|
||||
// The chip surfaces instead; nothing should pop open unexpectedly.
|
||||
expect(
|
||||
shouldAutoOpenCompletion(
|
||||
foreground: true,
|
||||
suppressAutoOpen: false,
|
||||
completedIsSoleVisibleJob: true,
|
||||
onOriginScreen: false,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('shouldShowDownloadChip', () {
|
||||
test('hidden while the overview sheet is open', () {
|
||||
expect(
|
||||
shouldShowDownloadChip(sheetOpen: true, jobCount: 3, anySurfaced: true),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('hidden when there are no jobs', () {
|
||||
expect(
|
||||
shouldShowDownloadChip(
|
||||
sheetOpen: false,
|
||||
jobCount: 0,
|
||||
anySurfaced: false,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('hidden for a lone in-progress download on its own screen', () {
|
||||
// Single job, not finished, screen not left → inline progress is enough.
|
||||
expect(
|
||||
shouldShowDownloadChip(
|
||||
sheetOpen: false,
|
||||
jobCount: 1,
|
||||
anySurfaced: false,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('shown for multiple downloads', () {
|
||||
expect(
|
||||
shouldShowDownloadChip(
|
||||
sheetOpen: false,
|
||||
jobCount: 2,
|
||||
anySurfaced: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('shown for a lone download once surfaced (finished or screen left)', () {
|
||||
expect(
|
||||
shouldShowDownloadChip(sheetOpen: false, jobCount: 1, anySurfaced: true),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart';
|
||||
|
||||
void main() {
|
||||
Widget wrap(Widget child) => MaterialApp(home: Scaffold(body: child));
|
||||
|
||||
const slowHint = LoadableStatePrimaryLoading.slowHintText;
|
||||
|
||||
testWidgets('shows the slow hint only after the threshold elapses', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
wrap(const LoadableStatePrimaryLoading(visible: true)),
|
||||
);
|
||||
|
||||
expect(find.text(slowHint), findsNothing);
|
||||
|
||||
await tester.pump(const Duration(seconds: 7));
|
||||
expect(find.text(slowHint), findsNothing);
|
||||
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
expect(find.text(slowHint), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an explicit statusText takes precedence over the slow hint', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
const LoadableStatePrimaryLoading(
|
||||
visible: true,
|
||||
statusText: 'Erneuter Versuch (2 von 3) …',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Erneuter Versuch (2 von 3) …'), findsOneWidget);
|
||||
|
||||
// Even after the slow-hint threshold the explicit status wins.
|
||||
await tester.pump(const Duration(seconds: 9));
|
||||
expect(find.text('Erneuter Versuch (2 von 3) …'), findsOneWidget);
|
||||
expect(find.text(slowHint), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('does not arm the slow hint while invisible', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
wrap(const LoadableStatePrimaryLoading(visible: false)),
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(seconds: 20));
|
||||
expect(find.text(slowHint), findsNothing);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user