Files
Melo/test/services/sync_service_test.dart
T

489 lines
14 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:drift/drift.dart' show Value;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:melo/library/database.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/melo_cloud_service.dart';
import 'package:melo/services/sync_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _MemorySpeicher implements TokenSpeicher {
_MemorySpeicher(this.werte);
final Map<String, String> werte;
@override
Future<String?> lesen(String key) async => werte[key];
@override
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
@override
Future<void> loeschen(String key) async => werte.remove(key);
}
Future<BakaAuth> _angemeldeteAuth() async {
final auth = BakaAuth(
speicher: _MemorySpeicher({'baka_token': 'tok', 'baka_user': 'Baka'}),
);
await auth.laden();
return auth;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
late MeloDb db;
setUp(() async {
SharedPreferences.setMockInitialValues({});
tempDir = await Directory.systemTemp.createTemp('melo_sync');
db = MeloDb(NativeDatabase.memory());
});
tearDown(() async {
await db.close();
await tempDir.delete(recursive: true);
});
Future<SyncService> baue(
Future<http.Response> Function(http.Request) antwort,
) async {
return SyncService(
db: db,
cloud: MeloCloudService(
auth: await _angemeldeteAuth(),
client: MockClient(antwort),
),
musikOrdner: () async => tempDir,
);
}
test('ein Titel vom Server landet als Datei und in der Bibliothek',
() async {
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(
jsonEncode({
'songs': [
{'id': 'c1', 'title': 'Nachtpuls', 'artist': 'Rotklang', 'duration': 200}
]
}),
200,
);
}
if (pfad.contains('/download/')) {
return http.Response.bytes([1, 2, 3, 4], 200,
headers: {'content-type': 'audio/mp4'});
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(sync.fehler, isNull);
final songs = await db.allSongs();
expect(songs, hasLength(1));
expect(songs.single.title, 'Nachtpuls');
expect(songs.single.artist, 'Rotklang');
expect(songs.single.cloudId, 'c1');
expect(songs.single.durationMs, 200000);
expect(await File(songs.single.path).readAsBytes(), [1, 2, 3, 4]);
// Endung kommt vom echten Dateityp des Servers, nicht pauschal .mp3.
expect(songs.single.path, endsWith('.m4a'));
});
test('eine eigene Datei geht zum Server und bekommt die Server-ID', () async {
final datei = File('${tempDir.path}/eigen.mp3');
await datei.writeAsBytes([9, 9, 9]);
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
path: datei.path,
title: 'Eigenes Lied',
dateAddedMs: 0,
updatedAtMs: 0,
),
]);
var hochgeladen = 0;
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(jsonEncode({'songs': []}), 200);
}
if (pfad.endsWith('/upload')) {
hochgeladen++;
return http.Response(
jsonEncode({'status': 'ok', 'song_id': 'neu-42'}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(sync.fehler, isNull);
expect(hochgeladen, 1);
expect((await db.allSongs()).single.cloudId, 'neu-42');
});
test('ein am Server gelöschter Titel verschwindet auch auf dem Gerät',
() async {
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
path: '${tempDir.path}/weg.mp3',
title: 'Weg',
dateAddedMs: 0,
updatedAtMs: 0,
),
]);
await db.setCloudId('lokal-1', 'c9');
final sync = await baue((anfrage) async {
if (anfrage.url.path.endsWith('/list')) {
return http.Response(
jsonEncode({
'songs': [
{'id': 'c9', 'title': 'Weg', 'deleted': true}
]
}),
200,
);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect((await db.allSongs()).single.deleted, isTrue);
expect(await db.watchSongs().first, isEmpty);
});
test('lokale Favoriten werden additiv gepusht, nie als Voll-Ersatz', () async {
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
path: '${tempDir.path}/fav.mp3',
title: 'Lieblingslied',
dateAddedMs: 0,
updatedAtMs: 0,
),
]);
await db.setCloudId('lokal-1', 'c5');
await db.setFavorite('lokal-1', true);
final gepusht = <Map<String, dynamic>>[];
var vollErsatz = 0;
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(
jsonEncode({
'songs': [
{'id': 'c5', 'title': 'Lieblingslied'}
]
}),
200,
);
}
if (pfad.endsWith('/favorites/toggle')) {
gepusht.add(jsonDecode(anfrage.body) as Map<String, dynamic>);
return http.Response(jsonEncode({'status': 'ok'}), 200);
}
if (pfad.endsWith('/favorites')) {
if (anfrage.method == 'POST') vollErsatz++;
return http.Response(jsonEncode({'favorites': []}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(gepusht, [
{'song_id': 'c5', 'set': true}
]);
// Der Datenverlust-Bug ist strukturell weg: es gibt keinen Aufruf mehr,
// der den Server-Stand ersetzen könnte.
expect(vollErsatz, 0);
expect(sync.fehler, isNull);
});
test('ein Server-Favorit wird lokal nachgezogen', () async {
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
path: '${tempDir.path}/fav.mp3',
title: 'Lieblingslied',
dateAddedMs: 0,
updatedAtMs: 0,
),
]);
await db.setCloudId('lokal-1', 'c5');
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(
jsonEncode({
'songs': [
{'id': 'c5', 'title': 'Lieblingslied'}
]
}),
200,
);
}
if (pfad.endsWith('/favorites')) {
return http.Response(
jsonEncode({
'favorites': [
{'id': 'c5'}
]
}),
200,
);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(await db.favoriteSongIds(), ['lokal-1']);
});
test('200 mit Fehlerkörper überspringt die Favoriten-Phase ohne Push',
() async {
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
path: '${tempDir.path}/fav.mp3',
title: 'Lieblingslied',
dateAddedMs: 0,
updatedAtMs: 0,
),
]);
await db.setCloudId('lokal-1', 'c5');
await db.setFavorite('lokal-1', true);
var pushes = 0;
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(
jsonEncode({
'songs': [
{'id': 'c5', 'title': 'Lieblingslied'}
]
}),
200,
);
}
if (pfad.endsWith('/favorites/toggle')) {
pushes++;
return http.Response(jsonEncode({'status': 'ok'}), 200);
}
if (pfad.endsWith('/favorites')) {
return http.Response(
jsonEncode({'status': 'error', 'error': 'kaputt'}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(pushes, 0);
// Nur diese Phase fällt aus, der Lauf geht weiter.
expect(sync.fehler, isNull);
});
test('200 mit leerer Favoritenliste läuft normal durch', () async {
// Gegenprobe zum Test darüber: eine echte Leerantwort darf NICHT als
// Fehler gelten, sonst wäre die Sicherheitsregel trivial erfüllt.
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
path: '${tempDir.path}/fav.mp3',
title: 'Lieblingslied',
dateAddedMs: 0,
updatedAtMs: 0,
),
]);
await db.setCloudId('lokal-1', 'c5');
await db.setFavorite('lokal-1', true);
var pushes = 0;
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(
jsonEncode({
'songs': [
{'id': 'c5', 'title': 'Lieblingslied'}
]
}),
200,
);
}
if (pfad.endsWith('/favorites/toggle')) {
pushes++;
return http.Response(jsonEncode({'status': 'ok'}), 200);
}
if (pfad.endsWith('/favorites')) {
return http.Response(jsonEncode({'favorites': []}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(pushes, 1);
});
test('eine Zeitüberschreitung beim Hochladen reißt den Lauf nicht ab',
() async {
final datei = File('${tempDir.path}/haengt.mp3');
await datei.writeAsBytes([1]);
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
path: datei.path,
title: 'Hängt',
dateAddedMs: 0,
updatedAtMs: 0,
),
]);
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(jsonEncode({'songs': []}), 200);
}
if (pfad.endsWith('/upload')) {
// Der 120-s-Timeout in melo_cloud_service wirft TimeoutException,
// nicht CloudException — ohne eigenen Zweig riss ein einziger
// hängender Upload den ganzen Lauf ab.
throw TimeoutException('zu lang');
}
if (pfad.endsWith('/favorites')) {
return http.Response(jsonEncode({'favorites': []}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(sync.fehler, isNull);
expect((await db.songById('lokal-1'))!.cloudId, isNull);
});
test('eine Löschwelle wird nicht zum Server durchgereicht', () async {
// 12 lokal verschwundene Titel bei 12 am Server: sieht nach einem
// Speicherkarten-Unfall aus, nicht nach 12 Einzel-Löschungen.
final amServer = <Map<String, dynamic>>[];
for (var i = 0; i < 12; i++) {
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-$i',
path: '${tempDir.path}/weg$i.mp3',
title: 'Titel $i',
dateAddedMs: 0,
updatedAtMs: 0,
deleted: const Value(true),
),
]);
await db.setCloudId('lokal-$i', 'c$i');
amServer.add({'id': 'c$i', 'title': 'Titel $i'});
}
var geloescht = 0;
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(jsonEncode({'songs': amServer}), 200);
}
if (pfad.endsWith('/delete')) geloescht++;
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(geloescht, 0);
expect(sync.fehler, contains('Sicherheitsbremse'));
});
test('einzelne Löschungen laufen weiterhin durch', () async {
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
path: '${tempDir.path}/weg.mp3',
title: 'Weg',
dateAddedMs: 0,
updatedAtMs: 0,
deleted: const Value(true),
),
]);
await db.setCloudId('lokal-1', 'c1');
final gemeldet = <String>[];
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(
jsonEncode({
'songs': [
{'id': 'c1', 'title': 'Weg'},
{'id': 'c2', 'title': 'Bleibt'},
]
}),
200,
);
}
if (pfad.endsWith('/delete')) {
gemeldet.add(
(jsonDecode(anfrage.body) as Map<String, dynamic>)['song_id'] as String);
}
if (pfad.contains('/download/')) {
return http.Response.bytes([1], 200,
headers: {'content-type': 'audio/mpeg'});
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(gemeldet, ['c1']);
expect(sync.fehler, isNull);
});
test('ohne Anmeldung passiert nichts und der Grund steht da', () async {
final sync = SyncService(
db: db,
cloud: MeloCloudService(
auth: BakaAuth(speicher: _MemorySpeicher({})),
client: MockClient((_) async => http.Response('{}', 200)),
),
musikOrdner: () async => tempDir,
);
await sync.synchronisiere();
expect(sync.fehler, contains('anmelden'));
expect(sync.laeuft, isFalse);
});
test('ein Server-Fehler beendet den Lauf mit einer lesbaren Meldung',
() async {
final sync = await baue(
(_) async => http.Response(jsonEncode({'error': 'Auth required'}), 401));
await sync.synchronisiere();
expect(sync.fehler, contains('Anmeldung abgelaufen'));
expect(sync.laeuft, isFalse);
});
}