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>
262 lines
7.9 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|