mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 06:28:27 +02:00
317 lines
9.8 KiB
Dart
317 lines
9.8 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
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 = <String, List<int>>{};
|
|
final folders = <String>{'/music/'};
|
|
final methods = <String>[];
|
|
bool rejectWrites = false;
|
|
bool loseMoveResponse = false;
|
|
int puts = 0;
|
|
Future<void> 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('<d:multistatus xmlns:d="DAV:">');
|
|
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(
|
|
'<d:response><d:href>$href</d:href><d:propstat><d:prop>'
|
|
'<d:resourcetype>${folders.contains(name) ? '<d:collection/>' : ''}</d:resourcetype>'
|
|
'<d:getcontentlength>${files[name]?.length ?? 0}</d:getcontentlength>'
|
|
'</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
|
|
);
|
|
}
|
|
r.response.write('</d:multistatus>');
|
|
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<List<int>>([], (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() {
|
|
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 {
|
|
await storage.dispose();
|
|
await dav.server.close(force: true);
|
|
await temp.delete(recursive: true);
|
|
});
|
|
Future<File> 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<String> publish({void Function()? checkpoint}) => staging.publish(
|
|
'item',
|
|
checkpoint: checkpoint ?? () {},
|
|
progress: (_, _) {},
|
|
);
|
|
|
|
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<HttpException>()),
|
|
);
|
|
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<HttpException>()));
|
|
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(
|
|
'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);
|
|
},
|
|
);
|
|
}
|