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 werte; @override Future lesen(String key) async => werte[key]; @override Future schreiben(String key, String wert) async => werte[key] = wert; @override Future loeschen(String key) async => werte.remove(key); } Future _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 baue( Future 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 = >[]; 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); 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 = >[]; 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 = []; 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)['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); }); group('ladeAusgewaehlteHoch', () { Future> dreiTitel(Directory ordner, MeloDb db) async { for (var i = 0; i < 3; i++) { final datei = File('${ordner.path}/auswahl$i.mp3'); await datei.writeAsBytes([i]); await db.upsertSongs([ SongsCompanion.insert( id: 'lokal-$i', path: datei.path, title: 'Titel $i', dateAddedMs: i, updatedAtMs: 0, ), ]); } return db.allSongs(); } test('lädt nur, was noch keine cloudId hat', () async { final songs = await dreiTitel(tempDir, db); await db.setCloudId('lokal-1', 'schon-da'); var uploads = 0; final sync = await baue((anfrage) async { if (anfrage.url.path.endsWith('/upload')) { uploads++; return http.Response( jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200); } return http.Response(jsonEncode({'status': 'ok'}), 200); }); final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs()); expect(songs, hasLength(3)); expect(uploads, 2); expect(ergebnis.hochgeladen, 2); expect(ergebnis.schonDa, 1); expect(ergebnis.fehler, isEmpty); }); test('ein abgelehnter Titel stoppt die übrigen nicht', () async { await dreiTitel(tempDir, db); var uploads = 0; final sync = await baue((anfrage) async { if (anfrage.url.path.endsWith('/upload')) { uploads++; // Der Server meldet „zu groß" im Körper — dieselbe Wirkung wie eine // lokal abgelehnte 50-MB-Datei, ohne 50 MB schreiben zu müssen. if (uploads == 2) { return http.Response( jsonEncode({'error': 'Datei zu groß (max 50 MB)'}), 200); } return http.Response( jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200); } return http.Response(jsonEncode({'status': 'ok'}), 200); }); final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs()); expect(ergebnis.hochgeladen, 2); expect(ergebnis.fehler, hasLength(1)); expect(ergebnis.fehler.single, contains('zu groß')); }); test('eine Zeitüberschreitung ist ein Einzelfehler, kein Laufabbruch', () async { await dreiTitel(tempDir, db); var uploads = 0; final sync = await baue((anfrage) async { if (anfrage.url.path.endsWith('/upload')) { uploads++; if (uploads == 1) throw TimeoutException('zu lang'); return http.Response( jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200); } return http.Response(jsonEncode({'status': 'ok'}), 200); }); final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs()); expect(ergebnis.hochgeladen, 2); expect(ergebnis.fehler, hasLength(1)); }); test('abbrechen() stoppt zwischen zwei Titeln', () async { await dreiTitel(tempDir, db); late SyncService sync; var uploads = 0; sync = await baue((anfrage) async { if (anfrage.url.path.endsWith('/upload')) { uploads++; sync.abbrechen(); return http.Response( jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200); } return http.Response(jsonEncode({'status': 'ok'}), 200); }); final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs()); expect(uploads, 1); expect(ergebnis.abgebrochen, isTrue); expect(sync.laeuft, isFalse); }); test('schreibt den Sync-Zeitstempel nicht', () async { await dreiTitel(tempDir, db); final sync = await baue((anfrage) async { if (anfrage.url.path.endsWith('/upload')) { return http.Response( jsonEncode({'status': 'ok', 'song_id': 'neu'}), 200); } return http.Response(jsonEncode({'status': 'ok'}), 200); }); await sync.ladeAusgewaehlteHoch(await db.allSongs()); // Ein Upload ist kein Abgleich: sonst unterdrückt er 15 Minuten den // Auto-Sync und verschiebt die 24-h-Uhr des Berichts. expect(sync.letzterLauf, isNull); }); test('während eines laufenden Abgleichs wird abgewiesen', () async { await dreiTitel(tempDir, db); late SyncService sync; UploadErgebnis? waehrendSync; sync = await baue((anfrage) async { if (anfrage.url.path.endsWith('/list')) { waehrendSync = await sync.ladeAusgewaehlteHoch(await db.allSongs()); return http.Response(jsonEncode({'songs': []}), 200); } return http.Response(jsonEncode({'status': 'ok'}), 200); }); await sync.synchronisiere(); expect(waehrendSync, isNotNull); expect(waehrendSync!.hochgeladen, 0); expect(sync.fehler, contains('Abgleich')); }); }); }