Files
plezy/test/utils/media_server_http_client_shutdown_test.dart
T
edde746 e5dc9c3f89 fix(networking): surface cancelled responses as cancellation and stop crediting a failed Plex hub fetch
A response cancelled before or during body consumption completed as a successful empty body, so cancellation-triggered aggregation could overwrite valid state and a cancelled download could commit an empty file. The managed client now delivers an abort error, and a request whose own abort has fired classifies secondary teardown errors as cancelled (timeouts keep their type). A Plex server whose /hubs leg failed was also counted as succeeded when only the optional music leg returned, letting Discover replace good hubs with a music-only result.
2026-08-21 19:23:40 +02:00

153 lines
5.4 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:plezy/exceptions/media_server_exceptions.dart';
import 'package:plezy/utils/managed_http_client.dart';
import 'package:plezy/utils/media_server_http_client.dart';
void main() {
group('MediaServerHttpClient shutdown', () {
test('rejects new requests as a cancellation, not a transient failure', () async {
final client = MediaServerHttpClient(
client: ManagedHttpClient(_AbortAwareClient(), debugLabel: 'test'),
baseUrl: 'https://example.test/',
);
client.close();
await expectLater(
client.get('library/sections'),
throwsA(
isA<MediaServerHttpException>()
.having((e) => e.type, 'type', MediaServerHttpErrorType.cancelled)
.having((e) => e.isTransient, 'isTransient', isFalse),
),
);
});
test('the layer beneath reports the same shutdown as a transient connection error', () async {
final managed = ManagedHttpClient(_AbortAwareClient(), debugLabel: 'test');
await managed.closeGracefully(drainTimeout: Duration.zero);
await expectLater(
managed.send(http.Request('GET', Uri.parse('https://example.test/library/sections'))),
throwsA(
isA<http.ClientException>()
.having(
(e) => MediaServerHttpException.from(e).type,
'mapped type',
MediaServerHttpErrorType.connectionError,
)
.having((e) => MediaServerHttpException.from(e).isTransient, 'mapped isTransient', isTrue),
),
);
});
test('aborts requests already in flight at the transport', () async {
final transport = _AbortAwareClient();
final client = MediaServerHttpClient(client: transport, baseUrl: 'https://example.test/');
final pending = client.get('library/sections');
await Future<void>.delayed(Duration.zero);
client.close();
await expectLater(transport.abortTrigger, completes);
await expectLater(
pending,
throwsA(isA<MediaServerHttpException>().having((e) => e.type, 'type', MediaServerHttpErrorType.cancelled)),
);
});
test('shutdown during an already-streaming body surfaces as cancelled, not an empty success', () async {
final inner = _HangingBodyClient();
final client = MediaServerHttpClient(
client: ManagedHttpClient(inner, debugLabel: 'test'),
baseUrl: 'https://example.test/',
);
addTearDown(inner.dispose);
final pending = client.get('library/sections');
await Future<void>.delayed(Duration.zero);
inner.body.add([1, 2, 3]);
await Future<void>.delayed(Duration.zero);
final closing = client.closeGracefully(drainTimeout: const Duration(milliseconds: 100));
await expectLater(
pending,
throwsA(isA<MediaServerHttpException>().having((e) => e.type, 'type', MediaServerHttpErrorType.cancelled)),
);
await closing;
});
test('downloadFile cancellation fails without committing a renamed file', () async {
final dir = await Directory.systemTemp.createTemp('plezy_download_cancel_test');
addTearDown(() => dir.delete(recursive: true));
final filePath = '${dir.path}${Platform.pathSeparator}subtitle.srt';
final inner = _HangingBodyClient();
final client = MediaServerHttpClient(
client: ManagedHttpClient(inner, debugLabel: 'test'),
baseUrl: 'https://example.test/',
);
addTearDown(inner.dispose);
final pending = client.downloadFile('media/subtitle.srt', filePath);
await Future<void>.delayed(Duration.zero);
inner.body.add([1, 2, 3]);
// Let the partial chunk reach the sink before the abort lands.
await Future<void>.delayed(Duration.zero);
final closing = client.closeGracefully(drainTimeout: const Duration(milliseconds: 100));
await expectLater(
pending,
throwsA(isA<MediaServerHttpException>().having((e) => e.type, 'type', MediaServerHttpErrorType.cancelled)),
);
await closing;
expect(File(filePath).existsSync(), isFalse, reason: 'a cancelled download must not commit the final file');
expect(File('$filePath.download').existsSync(), isFalse, reason: 'the partial temp file must be cleaned up');
});
});
}
class _AbortAwareClient extends http.BaseClient {
final _response = Completer<http.StreamedResponse>();
late final Future<void> abortTrigger;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
final trigger = (request as http.Abortable).abortTrigger!;
abortTrigger = trigger;
unawaited(
trigger.whenComplete(() {
if (!_response.isCompleted) _response.completeError(http.RequestAbortedException(request.url));
}),
);
return _response.future;
}
@override
void close() {}
}
/// Returns a 200 whose body stays open until the test aborts it, so
/// cancellation lands while (or before) the consumer is reading.
class _HangingBodyClient extends http.BaseClient {
final StreamController<List<int>> body = StreamController<List<int>>();
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
return http.StreamedResponse(body.stream, 200);
}
@override
void close() {}
Future<void> dispose() async {
await body.close();
}
}