Files
Melo/test/services/yt_search_service_test.dart
Hermes (Server)andClaude Sonnet 5 9e2294a480 YtSearchService: Gast-Zugang neben Baka-Auth unterstützen
Analog zu YtDownloadService: optionaler gast-Parameter, X-Guest-
Token statt Authorization, automatisches Token-Nachholen bei 401.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 20:56:58 +02:00

262 lines
7.9 KiB
Dart

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<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(
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<void>.delayed(Duration.zero);
expect(dienst.fehler, 'Gast-Zugang abgelaufen — bitte erneut versuchen');
expect(tokenAufrufe, 1);
});
});
}