import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/services/network_download_staging.dart'; import 'package:spotiflac_android/services/network_storage_service.dart'; class _Dav { late HttpServer server; final files = >{}; final folders = {'/music/'}; final methods = []; bool rejectWrites = false; bool loseMoveResponse = false; int puts = 0; Future start() async { server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); server.listen((r) async { final path = Uri.decodeComponent(r.uri.path); methods.add(r.method); if (r.headers.value('authorization') != 'Basic ${base64Encode(utf8.encode('user:secret'))}') { r.response.statusCode = 401; await r.response.close(); return; } switch (r.method) { case 'PROPFIND': if (!folders.contains(path)) { r.response.statusCode = 404; break; } r.response.statusCode = 207; r.response.write(''); for (final name in [...folders, ...files.keys]) { if (!name.startsWith(path) || name == path) continue; final tail = name .substring(path.length) .replaceFirst(RegExp(r'/$'), ''); if (tail.contains('/')) continue; final href = Uri(path: name).toString().replaceAll('&', '&'); r.response.write( '$href' '${folders.contains(name) ? '' : ''}' '${files[name]?.length ?? 0}' 'HTTP/1.1 200 OK', ); } r.response.write(''); case 'MKCOL': r.response.statusCode = folders.add(path) ? 201 : 405; case 'PUT': if (rejectWrites) { r.response.statusCode = 403; break; } puts++; try { files[path] = await r.fold>([], (a, b) => a..addAll(b)); } on HttpException { // A cancelled request may close halfway through its body. return; } r.response.statusCode = 201; case 'MOVE': final target = Uri.decodeComponent( Uri.parse(r.headers.value('destination')!).path, ); expect(r.headers.value('overwrite'), 'F'); if (files.containsKey(target)) { r.response.statusCode = 412; break; } files[target] = files.remove(path)!; r.response.statusCode = 201; if (loseMoveResponse) { loseMoveResponse = false; (await r.response.detachSocket(writeHeaders: false)).destroy(); return; } case 'DELETE': r.response.statusCode = files.remove(path) == null ? 404 : 204; case 'GET': final bytes = files[path]; if (bytes == null) { r.response.statusCode = 404; break; } r.response.contentLength = bytes.length; r.response.add(bytes); default: r.response.statusCode = 405; } await r.response.close(); }); } } void main() { TestWidgetsFlutterBinding.ensureInitialized(); // These integration tests use an authenticated in-process WebDAV server. HttpOverrides.global = null; const channel = MethodChannel('com.zarz.spotiflac/backend'); late _Dav dav; late NetworkStorageService storage; late NetworkDownloadStaging staging; late Directory temp; const folder = 'network://nas/'; setUp(() async { dav = _Dav(); await dav.start(); storage = NetworkStorageService( read: () async => null, write: (_) async {}, ); await storage.save( NetworkConnection( id: 'nas', name: 'NAS', protocol: NetworkProtocol.webdav, address: 'http://127.0.0.1:${dav.server.port}/music/', username: 'user', password: 'secret', ), ); temp = await Directory.systemTemp.createTemp('network-upload-test-'); staging = NetworkDownloadStaging( storage: storage, directory: () async => temp, ); }); tearDown(() async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, null); await storage.dispose(); await dav.server.close(force: true); await temp.delete(recursive: true); }); Future prepare({ String dir = 'Artist/Album/', bool lyrics = false, }) async { final work = await staging.workDirectory('item'); final audio = await File( '${work.path}/Song #1.flac', ).writeAsBytes(List.generate(300000, (i) => i % 251)); if (lyrics) { await File('${work.path}/Song #1.lrc').writeAsString('[00:01.00]Lyrics'); } await staging.save('item', { 'localPath': audio.path, 'folder': folder, 'relativeDir': dir, }); return audio; } Future publish({void Function()? checkpoint}) => staging.publish( 'item', checkpoint: checkpoint ?? () {}, progress: (_, _) {}, ); test('completion journal owns output and filters provider secrets', () async { final output = await File('${temp.path}/final.flac').writeAsBytes([1, 2]); await File('${temp.path}/final.lrc').writeAsString('Lyrics'); await staging.prepareCompletion( id: 'item', localPath: output.path, folder: folder, relativeDir: 'Artist/Album/', quality: '24-bit/96kHz', track: {'id': 'track'}, externalLrcWritten: true, result: { 'service': 'provider', 'actual_bit_depth': 24, 'actual_sample_rate': 96000, 'audio_codec': 'flac', 'title': 'Track', 'genre': null, 'download_url': 'https://example.test/audio?token=secret', 'authorization': 'Bearer secret', 'decryption_key': 'secret', 'extension_context': {'token': 'secret'}, }, ); await output.delete(); final journal = (await staging.read('item'))!; expect(journal['localPath'], isNot(output.path)); expect(journal['folder'], folder); expect(journal['relativeDir'], 'Artist/Album/'); expect(journal['quality'], '24-bit/96kHz'); expect(journal['track'], {'id': 'track'}); expect(journal['externalLrcWritten'], true); expect(journal['result'], { 'service': 'provider', 'actual_bit_depth': 24, 'actual_sample_rate': 96000, 'audio_codec': 'flac', 'title': 'Track', }); expect(jsonEncode(journal), isNot(contains('secret'))); await publish(); expect(dav.files['/music/Artist/Album/final.flac'], [1, 2]); expect(utf8.decode(dav.files['/music/Artist/Album/final.lrc']!), 'Lyrics'); }); test('retry keeps the first journal including its reserved target', () async { await prepare(dir: 'Original/'); dav.rejectWrites = true; await expectLater(publish(), throwsA(isA())); final original = (await staging.read('item'))!; expect(original['target'], isNotNull); await staging.prepareCompletion( id: 'item', localPath: '${temp.path}/missing-new-output.flac', folder: 'network://changed/', relativeDir: 'Changed/', quality: 'new quality', track: {'id': 'changed'}, externalLrcWritten: false, result: {'service': 'changed'}, ); expect(await staging.read('item'), original); dav.rejectWrites = false; await publish(); expect(dav.files.keys, ['/music/Original/Song #1.flac']); }); test( 'write probe creates and removes its own file; permission errors remain errors', () async { await storage.checkUploadAccess(folder); expect(dav.files, isEmpty); dav.rejectWrites = true; await expectLater( storage.checkUploadAccess(folder), throwsA(isA()), ); expect(dav.files, isEmpty); }, ); test( 'upload creates nested folders, publishes audio and LRC and retains staging until commit', () async { final file = await prepare(lyrics: true); final destination = await publish(); expect(Uri.parse(destination).pathSegments.last, 'Song #1.flac'); expect( dav.files['/music/Artist/Album/Song #1.flac'], await file.readAsBytes(), ); expect( utf8.decode(dav.files['/music/Artist/Album/Song #1.lrc']!), '[00:01.00]Lyrics', ); expect(dav.files.keys.any((p) => p.endsWith('.part')), false); expect(await file.exists(), true); await staging.discard('item'); expect(await file.exists(), false); }, ); test( 'existing name gets a suffix and existing bytes are unchanged', () async { dav.files['/music/Song #1.flac'] = [9]; await prepare(dir: ''); expect(Uri.parse(await publish()).pathSegments.last, 'Song #1 (2).flac'); expect(dav.files['/music/Song #1.flac'], [9]); }, ); test( 'failed write retries finalized local audio after a service restart', () async { final file = await prepare(dir: ''); dav.rejectWrites = true; await expectLater(publish(), throwsA(isA())); expect(await file.exists(), true); dav.rejectWrites = false; staging = NetworkDownloadStaging( storage: storage, directory: () async => temp, ); await publish(); expect(dav.puts, 1); expect(dav.files['/music/Song #1.flac'], await file.readAsBytes()); }, ); test('an existing lyrics sidecar also reserves its audio name', () async { dav.files['/music/Song #1.lrc'] = utf8.encode('Older lyrics'); await prepare(dir: '', lyrics: true); expect(Uri.parse(await publish()).pathSegments.last, 'Song #1 (2).flac'); expect(utf8.decode(dav.files['/music/Song #1.lrc']!), 'Older lyrics'); expect(dav.files.containsKey('/music/Song #1 (2).lrc'), true); }); test( 'post-processing output is adopted into owned staging before upload', () async { final output = await File( '${temp.path}/converted.m4a', ).writeAsBytes([1, 2, 3]); await File('${temp.path}/converted.lrc').writeAsString('Lyrics'); await staging.save('item', { 'localPath': output.path, 'folder': folder, 'relativeDir': '', }); final owned = (await staging.read('item'))!['localPath'] as String; expect(owned, isNot(output.path)); await output.delete(); await publish(); expect(dav.files['/music/converted.m4a'], [1, 2, 3]); expect(utf8.decode(dav.files['/music/converted.lrc']!), 'Lyrics'); }, ); test( 'lost MOVE response is reconciled by bytes without duplicate upload', () async { await prepare(dir: ''); dav.loseMoveResponse = true; await expectLater(publish(), throwsA(anything)); expect(dav.puts, 1); await publish(); expect(dav.puts, 1); expect(dav.files.keys.toList(), ['/music/Song #1.flac']); }, ); test( 'native recovery receives only the authenticated proxy and local path', () async { final file = await prepare(dir: ''); dav.loseMoveResponse = true; await expectLater(publish(), throwsA(anything)); var nativeHashes = 0; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async { final args = Map.from(call.arguments as Map); final request = jsonDecode(args['request_json'] as String) as Map; if (request['operation'] != 'matches_network_upload') { throw MissingPluginException(); } final uri = Uri.parse(request['url'] as String); expect(uri.scheme, 'http'); expect(uri.host, '127.0.0.1'); expect(uri.hasPort, true); expect(uri.path, matches(r'^/[a-f0-9]{48}$')); expect(jsonEncode(request), isNot(contains('secret'))); expect(request['length'], await file.length()); expect( await File(request['path'] as String).readAsBytes(), await file.readAsBytes(), ); expect(request['idle_timeout_ms'], 20000); nativeHashes++; return {'matches': true}; }); await publish(); expect(nativeHashes, 1); expect(dav.puts, 1); }, ); test( 'native recovery cancellation keeps staging until the worker completes', () async { final file = await prepare(dir: ''); dav.loseMoveResponse = true; await expectLater(publish(), throwsA(anything)); final started = Completer(); final cancelled = Completer(); final worker = Completer>(); String? requestId; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async { final args = Map.from(call.arguments as Map); if (call.method == 'cancelNativeDataJob') { expect(args['request_id'], requestId); cancelled.complete(); return null; } final request = jsonDecode(args['request_json'] as String) as Map; if (request['operation'] != 'matches_network_upload') { throw MissingPluginException(); } requestId = args['request_id'] as String; started.complete(); return worker.future; }); var stop = false; var completed = false; final originalError = StateError('user cancelled verification'); final result = publish( checkpoint: () { if (stop) throw originalError; }, ); final expectation = expectLater(result, throwsA(same(originalError))); unawaited( result.then( (_) => completed = true, onError: (Object _) { completed = true; }, ), ); await started.future; stop = true; await cancelled.future; expect(completed, false); expect(await file.exists(), true); worker.completeError(PlatformException(code: 'cancelled')); await expectation; expect(dav.puts, 1); expect(await file.exists(), true); }, ); test( 'same-length conflicting file is never overwritten or adopted', () async { final file = await prepare(dir: ''); final data = (await staging.read('item'))!; data['target'] = 'network://nas/Song%20%231.flac'; await staging.save('item', data); dav.files['/music/Song #1.flac'] = List.filled(await file.length(), 42); await expectLater(publish(), throwsStateError); expect(dav.puts, 0); expect(dav.files['/music/Song #1.flac']!.first, 42); }, ); test( 'cancellation before publish keeps local audio and removes partial file', () async { final file = await prepare(dir: ''); await expectLater( publish( checkpoint: () { if (dav.puts > 0) throw StateError('cancelled'); }, ), throwsStateError, ); expect(await file.exists(), true); expect(dav.files, isEmpty); }, ); test( 'plain HTTP and traversal cannot be used as write destinations', () async { await storage.save( const NetworkConnection( id: 'http', name: 'HTTP', protocol: NetworkProtocol.http, address: 'https://example.test/', ), ); await expectLater( storage.checkUploadAccess('network://http/'), throwsFormatException, ); await expectLater( storage.prepareUpload(folder, '../', 'song.flac'), throwsFormatException, ); expect(dav.puts, 0); }, ); }