import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:melo_app/services/cloud_service.dart'; /// CloudService-Tests mit http-Mocks (package:http/testing.dart — Teil von /// http, kein neues Package). Abgedeckt: LWW-Sync-Konflikt-Pfade (updated_at /// bleibt für den Client-Vergleich erhalten), Auth-Fehler (401) und /// Netzwerkfehler (alle Pfade failen sauber statt zu crashen). void main() { TestWidgetsFlutterBinding.ensureInitialized(); http.Response jsonOk(Map body) => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'}); group('login (Auth)', () { test('200 → verbunden (true)', () async { final cloud = CloudService( client: MockClient((req) async => http.Response('{}', 200)), ); expect(await cloud.login('dustin'), isTrue); }); test('401 (Auth-Fehler) → false', () async { final cloud = CloudService( client: MockClient((req) async => http.Response('{}', 401)), ); expect(await cloud.login('dustin'), isFalse); }); test('Netzwerkfehler → false (kein Crash)', () async { final cloud = CloudService( client: MockClient( (req) async => throw http.ClientException('Verbindung weg')), ); expect(await cloud.login('dustin'), isFalse); }); }); group('verbinde()', () { test('ohne Benutzer → Status fehler (kein Request)', () async { var requests = 0; final cloud = CloudService( client: MockClient((req) async { requests++; return http.Response('{}', 200); }), ); await cloud.verbinde(); expect(cloud.verbindungGestartet, isTrue); expect(cloud.status, CloudStatus.fehler); expect(cloud.istVerbunden, isFalse); expect(requests, 0); }); }); group('statusDaten (Auth-Fehler)', () { test('200 → JSON-Map', () async { final cloud = CloudService( client: MockClient( (req) async => jsonOk({'songs': 5, 'users': 2})), ); final d = await cloud.statusDaten(); expect(d, isNotNull); expect(d!['songs'], 5); }); test('401 → null (Token abgelaufen)', () async { final cloud = CloudService( client: MockClient((req) async => http.Response('{}', 401)), ); expect(await cloud.statusDaten(), isNull); }); test('Netzwerkfehler → null', () async { final cloud = CloudService( client: MockClient( (req) async => throw http.ClientException('offline')), ); expect(await cloud.statusDaten(), isNull); }); }); group('syncChanges — LWW-Sync-Konflikt-Pfade', () { test('parst Changes inkl. updated_at (Grundlage für LWW-Vergleich)', () async { final cloud = CloudService( client: MockClient((req) async => jsonOk({ 'changes': [ { 'id': 'song-1', 'title': 'Neu vom Server', 'updated_at': '2026-08-04T10:00:00Z', }, { 'id': 'song-2', 'title': 'Älterer Eintrag', 'updated_at': '2026-08-04T09:00:00Z', }, ], })), ); final changes = await cloud.syncChanges('2026-08-04T08:00:00Z'); expect(changes, hasLength(2)); // LWW-Vertrag: Timestamps bleiben unverändert erhalten, damit der // Client lokal neueren Stand (Upload via syncAll) von server-neuerem // Stand (Übernahme) unterscheiden kann. expect(changes[0]['updated_at'], '2026-08-04T10:00:00Z'); expect(changes[1]['updated_at'], '2026-08-04T09:00:00Z'); expect(changes[0]['title'], 'Neu vom Server'); }); test('leere Changes-Liste → []', () async { final cloud = CloudService( client: MockClient((req) async => jsonOk({'changes': []})), ); expect(await cloud.syncChanges('2026-08-04T08:00:00Z'), isEmpty); }); test('fehlendes changes-Feld → []', () async { final cloud = CloudService( client: MockClient((req) async => jsonOk({})), ); expect(await cloud.syncChanges('2026-08-04T08:00:00Z'), isEmpty); }); test('401 (Auth-Fehler) → []', () async { final cloud = CloudService( client: MockClient((req) async => http.Response('{}', 401)), ); expect(await cloud.syncChanges('2026-08-04T08:00:00Z'), isEmpty); }); test('Netzwerkfehler → []', () async { final cloud = CloudService( client: MockClient( (req) async => throw http.ClientException('offline')), ); expect(await cloud.syncChanges('2026-08-04T08:00:00Z'), isEmpty); }); test('sendet since-Timestamp als Body', () async { late Map gesendeterBody; final cloud = CloudService( client: MockClient((req) async { gesendeterBody = jsonDecode(req.body) as Map; return jsonOk({'changes': []}); }), ); await cloud.syncChanges('2026-08-04T08:00:00Z'); expect(gesendeterBody['since'], '2026-08-04T08:00:00Z'); }); }); group('syncAll (LWW-Schreibpfad)', () { test('200 → Status-Map', () async { final cloud = CloudService( client: MockClient((req) async => jsonOk({'status': 'ok'})), ); final d = await cloud.syncAll(); expect(d?['status'], 'ok'); }); test('401 → null (Auth-Fehler)', () async { final cloud = CloudService( client: MockClient((req) async => http.Response('{}', 401)), ); expect(await cloud.syncAll(), isNull); }); }); group('Playlisten', () { test('getPlaylists parst Liste', () async { final cloud = CloudService( client: MockClient((req) async => jsonOk({ 'playlists': [ {'id': 1, 'name': 'Workout'}, ], })), ); final list = await cloud.getPlaylists(); expect(list, hasLength(1)); expect(list.first['name'], 'Workout'); }); test('createPlaylist 200 → Playlist-Objekt', () async { final cloud = CloudService( client: MockClient((req) async => jsonOk({'playlist': {'id': 7, 'name': 'Neu'}})), ); final p = await cloud.createPlaylist('Neu'); expect(p?['id'], 7); }); test('deletePlaylist 200 ohne status → false', () async { final cloud = CloudService( client: MockClient((req) async => jsonOk({'status': 'ok'})), ); expect(await cloud.deletePlaylist(3), isTrue); }); }); group('Favoriten', () { test('getFavorites parst Liste', () async { final cloud = CloudService( client: MockClient((req) async => jsonOk({ 'favorites': [ {'id': 's1'}, ], })), ); final list = await cloud.getFavorites(); expect(list, hasLength(1)); expect(list.first['id'], 's1'); }); test('syncFavorites mit status ok → true', () async { final cloud = CloudService( client: MockClient((req) async => jsonOk({'status': 'ok'})), ); expect(await cloud.syncFavorites(['s1', 's2']), isTrue); }); test('syncFavorites 401 → false (Auth-Fehler)', () async { final cloud = CloudService( client: MockClient((req) async => http.Response('{}', 401)), ); expect(await cloud.syncFavorites(['s1']), isFalse); }); }); group('ytUrlSetzen (F2: YT-Link → Server)', () { test('POST /yt-url mit song_id + yt_url, status ok → true', () async { http.Request? anfrage; final cloud = CloudService( client: MockClient((req) async { anfrage = req; return jsonOk({'status': 'ok'}); }), ); final ok = await cloud.ytUrlSetzen('s1', 'https://youtu.be/abc'); expect(ok, isTrue); expect(anfrage!.url.path, '/api/v1/cloud/yt-url'); final body = jsonDecode(anfrage!.body) as Map; expect(body['song_id'], 's1'); expect(body['yt_url'], 'https://youtu.be/abc'); }); test('ytUrlSetzen ohne status ok → false', () async { final cloud = CloudService( client: MockClient((req) async => jsonOk({'status': 'error'})), ); expect(await cloud.ytUrlSetzen('s1', 'https://youtu.be/abc'), isFalse); }); test('ytUrlSetzen 401 → false (Auth-Fehler)', () async { final cloud = CloudService( client: MockClient((req) async => http.Response('{}', 401)), ); expect(await cloud.ytUrlSetzen('s1', 'https://youtu.be/abc'), isFalse); }); }); group('History & Songs', () { test('getHistory parst history-Liste', () async { final cloud = CloudService( client: MockClient((req) async => jsonOk({ 'history': [ {'song_id': 's1', 'played_at': '2026-08-04T10:00:00Z'}, ], })), ); final list = await cloud.getHistory(); expect(list, hasLength(1)); expect(list.first['song_id'], 's1'); }); test('listSongs parst songs-Liste', () async { final cloud = CloudService( client: MockClient((req) async => jsonOk({ 'songs': [ {'id': 's1', 'title': 'Titel'}, ], })), ); final list = await cloud.listSongs(); expect(list, hasLength(1)); expect(list.first['title'], 'Titel'); }); }); group('delete / globalList (Fehlerpfade)', () { test('delete 200 → true, 401 → false', () async { final cloudOk = CloudService( client: MockClient((req) async => http.Response('{}', 200)), ); expect(await cloudOk.delete('s1'), isTrue); final cloud401 = CloudService( client: MockClient((req) async => http.Response('{}', 401)), ); expect(await cloud401.delete('s1'), isFalse); }); test('delete mit deletedAt sendet deleted_at im Body (Tombstone, Sprint E)', () async { late Map gesendeterBody; final cloud = CloudService( client: MockClient((req) async { gesendeterBody = jsonDecode(req.body) as Map; return http.Response('{}', 200); }), ); final ok = await cloud.delete('s1', deletedAt: '2026-08-05T10:00:00'); expect(ok, isTrue); expect(gesendeterBody['song_id'], 's1'); expect(gesendeterBody['deleted_at'], '2026-08-05T10:00:00'); }); test('delete ohne deletedAt sendet KEIN deleted_at-Feld', () async { late Map gesendeterBody; final cloud = CloudService( client: MockClient((req) async { gesendeterBody = jsonDecode(req.body) as Map; return http.Response('{}', 200); }), ); final ok = await cloud.delete('s1'); expect(ok, isTrue); expect(gesendeterBody['song_id'], 's1'); expect(gesendeterBody.containsKey('deleted_at'), isFalse); }); test('delete Netzwerkfehler → false (kein Crash)', () async { final cloud = CloudService( client: MockClient( (req) async => throw http.ClientException('offline')), ); expect(await cloud.delete('s1', deletedAt: '2026-08-05T10:00:00'), isFalse); }); test('globalList 200 → Songs, 401 → []', () async { final cloudOk = CloudService( client: MockClient((req) async => jsonOk({'songs': [{'id': 'g1'}]})), ); expect(await cloudOk.globalList(), hasLength(1)); final cloud401 = CloudService( client: MockClient((req) async => http.Response('{}', 401)), ); expect(await cloud401.globalList(), isEmpty); }); }); }