import 'dart:async'; 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/services/baka_auth.dart'; import 'package:melo/services/gast_zugang.dart'; import 'package:melo/services/yt_search_service.dart'; class _MemorySpeicher implements TokenSpeicher { 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( client: MockClient( (_) async => http.Response(jsonEncode({'token': 'jwt-abc'}), 200)), speicher: _MemorySpeicher(), ); await auth.anmelden('Baka', 'geheim'); return auth; } void main() { group('youtubeVideoId', () { test('liest die ID aus watch?v=', () { expect(youtubeVideoId('https://www.youtube.com/watch?v=dQw4w9WgXcQ'), 'dQw4w9WgXcQ'); }); test('liest die ID aus einem youtu.be-Kurzlink', () { expect(youtubeVideoId('https://youtu.be/dQw4w9WgXcQ'), 'dQw4w9WgXcQ'); }); test('liest die ID aus einem /shorts/-Link', () { expect(youtubeVideoId('https://www.youtube.com/shorts/dQw4w9WgXcQ'), 'dQw4w9WgXcQ'); }); test('liefert null für eine URL ohne erkennbare ID', () { expect(youtubeVideoId('https://example.com/video'), isNull); }); test('liefert null für keine gültige URL', () { expect(youtubeVideoId(''), isNull); }); }); group('YtSuchTreffer.thumbnailUrl', () { test('baut die Thumbnail-Adresse aus der Video-ID', () { const treffer = YtSuchTreffer( title: 'Test', url: 'https://youtu.be/abc123', duration: Duration(seconds: 10), ); expect(treffer.thumbnailUrl, 'https://i.ytimg.com/vi/abc123/mqdefault.jpg'); }); test('ist null ohne erkennbare Video-ID', () { const treffer = YtSuchTreffer( title: 'Test', url: 'https://example.com/x', duration: Duration(seconds: 10), ); expect(treffer.thumbnailUrl, isNull); }); }); group('YtSearchService.suchen', () { test('ohne Anmeldung wird gar nicht erst gefragt', () async { var aufrufe = 0; final dienst = YtSearchService( auth: BakaAuth( client: MockClient((_) async => http.Response('', 500)), speicher: _MemorySpeicher()), client: MockClient((_) async { aufrufe++; return http.Response('[]', 200); }), ); await dienst.suchen('roses'); expect(dienst.fehler, 'Bitte zuerst beim Baka-Konto anmelden'); expect(dienst.treffer, isEmpty); expect(aufrufe, 0); }); test('leere Suchanfrage ruft den Proxy gar nicht erst auf', () async { var aufrufe = 0; final dienst = YtSearchService( auth: await _angemeldeteAuth(), client: MockClient((_) async { aufrufe++; return http.Response('[]', 200); }), ); await dienst.suchen(' '); expect(aufrufe, 0); }); test('erfolgreiche Suche füllt treffer und schickt den Bearer-Token', () async { final dienst = YtSearchService( auth: await _angemeldeteAuth(), client: MockClient((req) async { expect(req.url.path, '/api/search'); expect(req.url.queryParameters['q'], 'roses'); expect(req.headers['Authorization'], 'Bearer jwt-abc'); return http.Response( jsonEncode([ { 'title': 'Neoni - Roses', 'url': 'https://youtu.be/abc123', 'duration': 187, }, ]), 200); }), ); await dienst.suchen('roses'); expect(dienst.fehler, isNull); expect(dienst.laeuft, isFalse); expect(dienst.treffer, hasLength(1)); expect(dienst.treffer.first.title, 'Neoni - Roses'); expect(dienst.treffer.first.url, 'https://youtu.be/abc123'); expect(dienst.treffer.first.duration, const Duration(seconds: 187)); }); test('401 meldet abgelaufene Anmeldung', () async { final dienst = YtSearchService( auth: await _angemeldeteAuth(), client: MockClient((_) async => http.Response('Unauthorized', 401)), ); await dienst.suchen('roses'); expect(dienst.fehler, 'Anmeldung abgelaufen — bitte neu anmelden'); expect(dienst.treffer, isEmpty); }); test('Proxy-Fehlermeldung wird im Klartext durchgereicht', () async { final dienst = YtSearchService( auth: await _angemeldeteAuth(), client: MockClient((_) async => http.Response( jsonEncode({'error': 'Suche fehlgeschlagen'}), 500)), ); await dienst.suchen('roses'); expect(dienst.fehler, 'Suche fehlgeschlagen'); }); test('Zeitüberschreitung meldet sich verständlich', () async { final dienst = YtSearchService( auth: await _angemeldeteAuth(), client: MockClient((_) async => throw TimeoutException('zu lange')), ); await dienst.suchen('roses'); expect(dienst.fehler, 'Die Suche braucht zu lange — bitte später erneut versuchen'); }); test('eine neue Suche ersetzt die alten Treffer', () async { var anfrage = 0; final dienst = YtSearchService( auth: await _angemeldeteAuth(), client: MockClient((_) async { anfrage++; if (anfrage == 1) { return http.Response( jsonEncode([ {'title': 'Erstes', 'url': 'https://youtu.be/a', 'duration': 1} ]), 200); } return http.Response( jsonEncode([ {'title': 'Zweites', 'url': 'https://youtu.be/b', 'duration': 2} ]), 200); }), ); await dienst.suchen('erste suche'); expect(dienst.treffer.single.title, 'Erstes'); await dienst.suchen('zweite suche'); expect(dienst.treffer.single.title, 'Zweites'); }); test('Mit Gast-Token statt Anmeldung wird trotzdem gesucht', () async { final speicher = _MemorySpeicher(); speicher.werte['guest_token'] = 'gast-xyz'; final gast = GastZugang( client: MockClient((_) async => http.Response('', 500)), speicher: speicher, ); await gast.laden(); final dienst = YtSearchService( auth: BakaAuth( client: MockClient((_) async => http.Response('', 500)), speicher: _MemorySpeicher()), gast: gast, client: MockClient((req) async { expect(req.headers['X-Guest-Token'], 'gast-xyz'); expect(req.headers.containsKey('Authorization'), isFalse); return http.Response('[]', 200); }), ); await dienst.suchen('roses'); expect(dienst.fehler, isNull); }); test('401 bei Gast-Zugang holt automatisch einen neuen Gast-Token', () async { var tokenAufrufe = 0; final speicher = _MemorySpeicher(); speicher.werte['guest_token'] = 'alter-token'; final gast = GastZugang( client: MockClient((_) async { tokenAufrufe++; return http.Response( jsonEncode({'guest_token': 'neuer-token'}), 200); }), speicher: speicher, ); await gast.laden(); final dienst = YtSearchService( auth: BakaAuth( client: MockClient((_) async => http.Response('', 500)), speicher: _MemorySpeicher()), gast: gast, client: MockClient((_) async => http.Response('Unauthorized', 401)), ); await dienst.suchen('roses'); await Future.delayed(Duration.zero); expect(dienst.fehler, 'Gast-Zugang abgelaufen — bitte erneut versuchen'); expect(tokenAufrufe, 1); }); }); }