Files
SpotiFLAC-Mobile/test/download_file_finalizer_test.dart
T

398 lines
12 KiB
Dart

import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/models/download_result.dart';
import 'package:spotiflac_android/services/download_file_finalizer.dart';
import 'package:spotiflac_android/services/download_saf_file_replacer.dart';
void main() {
late Directory directory;
late List<String> warnings;
setUp(() async {
directory = await Directory.systemTemp.createTemp('download-finalization-');
warnings = [];
});
tearDown(() async => directory.delete(recursive: true));
DownloadFileFinalizer finalizer({
DecryptDownloadFile? decrypt,
ConvertDownloadAudio? convert,
ReplaceSafDownloadFile? replaceSaf,
Future<String?> Function(String)? probe,
Future<void> Function(String, String)? repair,
}) => DownloadFileFinalizer(
decryptFile:
decrypt ??
({
required inputPath,
required descriptor,
required deleteOriginal,
prepareOutput,
}) async => throw StateError('Unexpected decrypt'),
convertAudio:
convert ??
({
required inputPath,
required targetFormat,
required bitrate,
required deleteOriginal,
}) async => throw StateError('Unexpected conversion'),
replaceSafFile:
replaceSaf ??
({
required uri,
required treeUri,
required relativeDir,
required op,
avoidOverwrite = false,
onPublishedFileName,
}) async => throw StateError('Unexpected SAF access'),
probeCodec: probe ?? (_) async => throw StateError('Unexpected probe'),
repairAc4: repair ?? (_, _) async => throw StateError('Unexpected repair'),
warn: warnings.add,
info: (_) {},
);
DownloadResult encrypted({bool existing = false}) => DownloadResult.fromMap({
'already_exists': existing,
'decryption_key': 'abcd',
'extension_context': {'provider': 'example'},
});
Future<DownloadDecryptionOutcome> decrypt(
DownloadFileFinalizer stage,
DownloadResult result,
String path, {
bool saf = false,
String? treeUri = 'content://tree',
bool repair = false,
}) => stage.decrypt(
result: result,
filePath: path,
useSaf: saf,
treeUri: treeUri,
relativeDir: 'Artist/Album',
baseName: 'Song',
extensionFallback: '.flac',
repairContainer: repair,
);
test(
'existing or unencrypted files never enter the transform stages',
() async {
for (final result in [
encrypted(existing: true),
DownloadResult.fromMap({}),
]) {
final outcome = await decrypt(
finalizer(),
result,
'content://source',
saf: true,
);
expect(outcome.path, 'content://source');
expect(outcome.failure, isNull);
}
},
);
test(
'local AC-4 repair completes before source promotion and stays MP4',
() async {
final source = await File(
'${directory.path}/Song.mp4',
).writeAsString('encrypted');
final output = File('${directory.path}/work.mp4');
final stage = finalizer(
decrypt:
({
required inputPath,
required descriptor,
required deleteOriginal,
prepareOutput,
}) async {
expect(deleteOriginal, isTrue);
await output.writeAsString('decrypted');
await prepareOutput!(output.path);
await source.delete();
return (await output.rename(source.path)).path;
},
repair: (path, encryptedPath) async {
expect(await File(encryptedPath).readAsString(), 'encrypted');
await File(path).writeAsString('repaired');
},
probe: (_) async => 'ac4',
);
final outcome = await decrypt(
stage,
encrypted(),
source.path,
repair: true,
);
expect(outcome.path, source.path);
expect(await source.readAsString(), 'repaired');
expect(await directory.list().length, 1);
},
);
test('normalizes audio-only MP4 without overwriting a sibling', () async {
final output = await File(
'${directory.path}/Song.mp4',
).writeAsString('audio');
final stage = finalizer(
decrypt:
({
required inputPath,
required descriptor,
required deleteOriginal,
prepareOutput,
}) async => output.path,
probe: (_) async => 'aac',
);
final outcome = await decrypt(stage, encrypted(), output.path);
expect(outcome.path, '${directory.path}/Song.m4a');
expect(await File(outcome.path!).readAsString(), 'audio');
await output.writeAsString('second');
final collision = await decrypt(stage, encrypted(), output.path);
expect(collision.path, output.path);
expect(await File(outcome.path!).readAsString(), 'audio');
expect(warnings.single, contains('target already exists'));
});
test(
'SAF access, decrypt and publish failures retain distinct outcomes',
() async {
for (final failure in DownloadDecryptionFailure.values) {
final temp = File('${directory.path}/encrypted.flac');
final output = File('${directory.path}/decrypted.flac');
final replacer = DownloadSafFileReplacer(
copyToTemp: (_) async =>
failure == DownloadDecryptionFailure.safAccess
? null
: (await temp.writeAsString('encrypted')).path,
publish:
({
required treeUri,
required relativeDir,
required fileName,
required srcPath,
required avoidOverwrite,
required preservedSuffix,
collisionCleanFileName,
collisionVariantFileName,
}) async => null,
deleteSource: (_) async => fail('Source was deleted after failure'),
);
final stage = finalizer(
replaceSaf: replacer.replace,
decrypt:
({
required inputPath,
required descriptor,
required deleteOriginal,
prepareOutput,
}) async {
expect(deleteOriginal, isFalse);
return failure == DownloadDecryptionFailure.decrypt
? null
: (await output.writeAsString('decrypted')).path;
},
);
final outcome = await decrypt(
stage,
encrypted(),
'content://source',
saf: true,
);
expect(outcome.path, isNull);
expect(outcome.failure, failure);
expect(await directory.list().length, 0);
}
final missingTree = await decrypt(
finalizer(),
encrypted(),
'content://source',
saf: true,
treeUri: '',
);
expect(missingTree.failure, DownloadDecryptionFailure.safAccess);
},
);
Future<DownloadConversionOutcome> convert(
DownloadFileFinalizer stage,
DownloadResult result, {
String path = '/source.flac',
String? name,
bool saf = false,
bool enabled = true,
Future<void> Function(String, String)? embed,
void Function()? start,
}) => stage.autoConvert(
result: result,
filePath: path,
fileName: name,
quality: '24-bit/96kHz',
enabled: enabled,
targetFormat: 'opus',
targetBitrate: '192k',
useSaf: saf,
treeUri: 'content://tree',
embedMetadata: embed,
onStart: start,
);
test(
'disabled and already satisfied conversions leave the file alone',
() async {
final result = DownloadResult.fromMap({'bitrate': 192});
for (final enabled in [false, true]) {
final outcome = await convert(
finalizer(),
result,
path: '/Song.opus',
enabled: enabled,
start: () => fail('Unexpected finalizing progress'),
);
expect(outcome.converted, isFalse);
expect(outcome.filePath, '/Song.opus');
}
},
);
test(
'optional conversion failure preserves source, quality and extension data',
() async {
final result = encrypted();
final stage = finalizer(
convert:
({
required inputPath,
required targetFormat,
required bitrate,
required deleteOriginal,
}) async => null,
);
final outcome = await convert(stage, result, name: 'Song.flac');
expect(outcome.converted, isFalse);
expect(outcome.filePath, '/source.flac');
expect(outcome.fileName, 'Song.flac');
expect(outcome.quality, '24-bit/96kHz');
expect(result['extension_context'], {'provider': 'example'});
expect(
result['auto_conversion_warning'],
contains('no automatic conversion output'),
);
expect(result.filePath, isNull);
},
);
test(
'deferred SAF conversion retains its logical filename despite tag failure',
() async {
final result = DownloadResult.fromMap({
'actual_bit_depth': 24,
'actual_sample_rate': 96000,
'unknown': 'kept',
});
final stage = finalizer(
convert:
({
required inputPath,
required targetFormat,
required bitrate,
required deleteOriginal,
}) async {
expect(deleteOriginal, isTrue);
expect(targetFormat, 'opus');
expect(bitrate, '192k');
return '/cache/native_saf_work_123.opus';
},
);
final outcome = await convert(
stage,
result,
path: '/cache/native_saf_work_123.flac',
name: 'Artist - Song.flac',
saf: true,
embed: (_, _) async => throw StateError('Tags failed'),
);
expect(outcome.converted, isTrue);
expect(outcome.fileName, 'Artist - Song.opus');
expect(outcome.quality, 'OPUS 192kbps');
expect(result.fileName, outcome.fileName);
expect(result.actualBitDepth, isNull);
expect(result.actualSampleRate, isNull);
expect(result.audioCodec, 'opus');
expect(result['unknown'], 'kept');
expect(
result['auto_conversion_metadata_warning'],
contains('Tags failed'),
);
},
);
test(
'SAF conversion adopts the collision-safe published name and cleans temps',
() async {
final temp = File('${directory.path}/cache.flac');
final output = File('${directory.path}/cache.opus');
final events = <String>[];
final replacer = DownloadSafFileReplacer(
copyToTemp: (_) async => (await temp.writeAsString('source')).path,
publish:
({
required treeUri,
required relativeDir,
required fileName,
required srcPath,
required avoidOverwrite,
required preservedSuffix,
collisionCleanFileName,
collisionVariantFileName,
}) async {
expect(avoidOverwrite, isTrue);
expect(fileName, 'Song.opus');
expect(
await File(srcPath).readAsString(),
'converted and tagged',
);
events.add('published');
return (uri: 'content://converted', fileName: 'Song (2).opus');
},
deleteSource: (_) async => events.add('deleted'),
);
final stage = finalizer(
replaceSaf: replacer.replace,
convert:
({
required inputPath,
required targetFormat,
required bitrate,
required deleteOriginal,
}) async {
expect(deleteOriginal, isFalse);
return (await output.writeAsString('converted')).path;
},
);
final result = encrypted();
final outcome = await convert(
stage,
result,
path: 'content://source',
name: 'Song.flac',
saf: true,
embed: (path, format) async {
expect(format, 'opus');
await File(path).writeAsString('converted and tagged');
},
);
expect(outcome.filePath, 'content://converted');
expect(outcome.fileName, 'Song (2).opus');
expect(result.fileName, 'Song (2).opus');
expect(events, ['published', 'deleted']);
expect(await directory.list().length, 0);
},
);
}