diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index fba6d73..fde6d38 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -1,17 +1,49 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../config/app_config.dart'; import '../services/melo_logger.dart'; +/// Minimal-Speicherabstraktion für die Token-Persistenz — macht den Service +/// in Tests mit einem In-Memory-Fake testbar (kein Platform-Channel nötig). +abstract class TokenSpeicher { + Future lesen(String key); + Future schreiben(String key, String? wert); + Future loeschen(String key); +} + +/// Produktions-Implementierung auf FlutterSecureStorage. +class SecureTokenSpeicher implements TokenSpeicher { + final FlutterSecureStorage _secure = const FlutterSecureStorage(); + + @override + Future lesen(String key) => _secure.read(key: key); + + @override + Future schreiben(String key, String? wert) => + _secure.write(key: key, value: wert); + + @override + Future loeschen(String key) => _secure.delete(key: key); +} + /// Baka-Auth Service – JWT-basierte Authentifizierung (v3 — Persistent Login) /// Integriert mit https://baka-net.de/auth class AuthService { static final AuthService _instance = AuthService._(); factory AuthService() => _instance; - AuthService._(); + AuthService._({http.Client? client, TokenSpeicher? speicher}) + : _client = client ?? http.Client(), + _speicher = speicher ?? SecureTokenSpeicher(); - final FlutterSecureStorage _secure = const FlutterSecureStorage(); + /// Nur für Tests: frische Instanz mit injizierbaren Abhängigkeiten. + @visibleForTesting + AuthService.fuerTests({http.Client? client, TokenSpeicher? speicher}) + : this._(client: client, speicher: speicher); + + final http.Client _client; + final TokenSpeicher _speicher; String? _token; String _user = ''; @@ -36,8 +68,8 @@ class AuthService { Future initialisieren() async { if (_initialisiert) return istEingeloggt; try { - _token = await _secure.read(key: 'baka_token'); - _user = await _secure.read(key: 'baka_user') ?? ''; + _token = await _speicher.lesen('baka_token'); + _user = await _speicher.lesen('baka_user') ?? ''; if (_token != null && _token!.isNotEmpty) { // Token beim Server validieren (nicht blind vertrauen) @@ -47,8 +79,8 @@ class AuthService { MeloLogger().zustand('auth_token_expired', {'user': _user}); _token = null; _user = ''; - await _secure.delete(key: 'baka_token'); - await _secure.delete(key: 'baka_user'); + await _speicher.loeschen('baka_token'); + await _speicher.loeschen('baka_user'); } else { MeloLogger().zustand('auth_restored', {'user': _user}); } @@ -64,7 +96,7 @@ class AuthService { Future _tokenOnlinePruefen() async { if (_token == null) return false; try { - final response = await http + final response = await _client .get( Uri.parse('${AppConfig.authUrl}/verify'), headers: authHeader, @@ -84,7 +116,7 @@ class AuthService { /// Login über Baka-Auth-Server Future login(String user, String password) async { try { - final response = await http + final response = await _client .post( Uri.parse('${AppConfig.authUrl}/login'), headers: {'Content-Type': 'application/json'}, @@ -101,8 +133,8 @@ class AuthService { _user = user; if (_token != null && _token!.isNotEmpty) { - await _secure.write(key: 'baka_token', value: _token!); - await _secure.write(key: 'baka_user', value: _user); + await _speicher.schreiben('baka_token', _token!); + await _speicher.schreiben('baka_user', _user); MeloLogger().aktion('auth_login_ok', {'user': _user}); return AuthResult.ok; } @@ -126,7 +158,7 @@ class AuthService { Future registrieren( String user, String password, String email) async { try { - final response = await http + final response = await _client .post( Uri.parse('${AppConfig.authUrl}/register'), headers: {'Content-Type': 'application/json'}, @@ -144,8 +176,8 @@ class AuthService { _user = user; if (_token != null && _token!.isNotEmpty) { - await _secure.write(key: 'baka_token', value: _token!); - await _secure.write(key: 'baka_user', value: _user); + await _speicher.schreiben('baka_token', _token!); + await _speicher.schreiben('baka_user', _user); MeloLogger().aktion('auth_register_ok', {'user': _user}); return AuthResult.ok; } @@ -171,8 +203,8 @@ class AuthService { Future logout() async { _token = null; _user = ''; - await _secure.delete(key: 'baka_token'); - await _secure.delete(key: 'baka_user'); + await _speicher.loeschen('baka_token'); + await _speicher.loeschen('baka_user'); MeloLogger().aktion('auth_logout', {}); } } diff --git a/lib/services/cloud_service.dart b/lib/services/cloud_service.dart index b25b29a..d2c4b08 100644 --- a/lib/services/cloud_service.dart +++ b/lib/services/cloud_service.dart @@ -21,7 +21,10 @@ enum CloudStatus { verbinde, verbunden, fehler } /// Merge-Strategie wird NICHT unterstützt (kein 3-Way-Merge) — es gewinnt immer /// der jüngste Timestamp. class CloudService extends ChangeNotifier { - static final http.Client _client = http.Client(); + /// Injizierbarer HTTP-Client (Tests: package:http/testing.dart MockClient). + CloudService({http.Client? client}) : _client = client ?? http.Client(); + + final http.Client _client; static String get _base => AppConfig.cloudUrl; diff --git a/test/auth_service_test.dart b/test/auth_service_test.dart new file mode 100644 index 0000000..7d0dd94 --- /dev/null +++ b/test/auth_service_test.dart @@ -0,0 +1,235 @@ +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/auth_service.dart'; + +/// AuthService-Tests mit http-Mocks (package:http/testing.dart). +/// Abgedeckt: Token-Ablauf-Pfade (401 → Logout + Speicher löschen), +/// Netzwerkfehler → Offline-Trust (Token behalten), 5xx ≠ 401, Login-Pfade. +/// In-Memory-TokenSpeicher (ersetzt FlutterSecureStorage im Test). +class FakeSpeicher implements TokenSpeicher { + final Map daten = {}; + @override + Future lesen(String key) async => daten[key]; + @override + Future schreiben(String key, String? wert) async { + if (wert == null) { + daten.remove(key); + } else { + daten[key] = wert; + } + } + + @override + Future loeschen(String key) async => daten.remove(key); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + /// MockClient, der /verify mit [verifyStatus] beantwortet. + /// [verifyStatus] == -1 → Netzwerkfehler (ClientException). + MockClient verifyClient(int verifyStatus) => MockClient((req) async { + if (req.url.path.endsWith('/verify')) { + if (verifyStatus == -1) { + throw http.ClientException('Verbindung fehlgeschlagen'); + } + return http.Response('{}', verifyStatus); + } + return http.Response('{}', 404); + }); + + AuthService neueInstanz(MockClient client, FakeSpeicher speicher) => + AuthService.fuerTests(client: client, speicher: speicher); + + group('initialisieren — Token-Ablauf-Pfade', () { + test('gültiger Token (200) → eingeloggt, Token bleibt', () async { + final speicher = FakeSpeicher() + ..daten['baka_token'] = 'jwt.valid' + ..daten['baka_user'] = 'dustin'; + final auth = neueInstanz(verifyClient(200), speicher); + + expect(await auth.initialisieren(), isTrue); + expect(auth.istEingeloggt, isTrue); + expect(auth.benutzer, 'dustin'); + expect(speicher.daten['baka_token'], 'jwt.valid'); // persistiert + }); + + test('401 (Token abgelaufen) → ausgeloggt, Token + User gelöscht', + () async { + final speicher = FakeSpeicher() + ..daten['baka_token'] = 'jwt.abgelaufen' + ..daten['baka_user'] = 'dustin'; + final auth = neueInstanz(verifyClient(401), speicher); + + expect(await auth.initialisieren(), isFalse); + expect(auth.istEingeloggt, isFalse); + expect(auth.benutzer, ''); + expect(speicher.daten.containsKey('baka_token'), isFalse); + expect(speicher.daten.containsKey('baka_user'), isFalse); + }); + + test('Netzwerkfehler → Offline-Trust: Token bleibt, eingeloggt', () async { + final speicher = FakeSpeicher() + ..daten['baka_token'] = 'jwt.offline' + ..daten['baka_user'] = 'dustin'; + final auth = neueInstanz(verifyClient(-1), speicher); + + expect(await auth.initialisieren(), isTrue); + expect(auth.istEingeloggt, isTrue); + expect(speicher.daten['baka_token'], 'jwt.offline'); // NICHT gelöscht + }); + + test('5xx (Server-Fehler) ≠ 401 → Token bleibt', () async { + final speicher = FakeSpeicher() + ..daten['baka_token'] = 'jwt.server500' + ..daten['baka_user'] = 'dustin'; + final auth = neueInstanz(verifyClient(500), speicher); + + expect(await auth.initialisieren(), isTrue); + expect(speicher.daten['baka_token'], 'jwt.server500'); + }); + + test('ohne gespeicherten Token → false, kein Verify-Request', () async { + var verifyRequests = 0; + final client = MockClient((req) async { + verifyRequests++; + return http.Response('{}', 200); + }); + final auth = neueInstanz(client, FakeSpeicher()); + + expect(await auth.initialisieren(), isFalse); + expect(verifyRequests, 0); + }); + }); + + group('tokenPruefen', () { + /// Client, der /login mit Token beantwortet (seedt _token) UND /verify + /// mit [verifyStatus] (verifyStatus == -1 → Netzwerkfehler). + MockClient tokenClient(int verifyStatus) => MockClient((req) async { + if (req.url.path.endsWith('/login')) { + return http.Response(jsonEncode({'token': 'jwt.test'}), 200); + } + if (req.url.path.endsWith('/verify')) { + if (verifyStatus == -1) { + throw http.ClientException('Verbindung fehlgeschlagen'); + } + return http.Response('{}', verifyStatus); + } + return http.Response('{}', 404); + }); + + Future eingeloggteInstanz(int verifyStatus) async { + final auth = neueInstanz(tokenClient(verifyStatus), FakeSpeicher()); + await auth.login('dustin', 'passwort'); // setzt _token + return auth; + } + + test('200 → true', () async { + final auth = await eingeloggteInstanz(200); + expect(await auth.tokenPruefen(), isTrue); + }); + + test('401 → false (abgelaufen)', () async { + final auth = await eingeloggteInstanz(401); + expect(await auth.tokenPruefen(), isFalse); + }); + + test('Netzwerkfehler → true (Offline-Trust)', () async { + final auth = await eingeloggteInstanz(-1); + expect(await auth.tokenPruefen(), isTrue); + }); + + test('503 → true (temporärer Server-Fehler ≠ Logout)', () async { + final auth = await eingeloggteInstanz(503); + expect(await auth.tokenPruefen(), isTrue); + }); + }); + + group('login', () { + MockClient loginClient(int status, {String? token}) => + MockClient((req) async { + if (req.url.path.endsWith('/login')) { + if (status == -1) throw http.ClientException('offline'); + return http.Response( + jsonEncode({if (token != null) 'token': token}), + status, + ); + } + return http.Response('{}', 404); + }); + + test('200 mit Token → ok, Token + User persistiert', () async { + final speicher = FakeSpeicher(); + final auth = neueInstanz(loginClient(200, token: 'jwt.frisch'), speicher); + + final result = await auth.login('dustin', 'passwort'); + expect(result.erfolg, isTrue); + expect(auth.istEingeloggt, isTrue); + expect(auth.benutzer, 'dustin'); + expect(speicher.daten['baka_token'], 'jwt.frisch'); + expect(speicher.daten['baka_user'], 'dustin'); + }); + + test('401 (falsches Passwort) → fehlgeschlagen', () async { + final auth = neueInstanz(loginClient(401), FakeSpeicher()); + final result = await auth.login('dustin', 'falsch'); + expect(result.erfolg, isFalse); + expect(result.fehler, contains('401')); + expect(auth.istEingeloggt, isFalse); + }); + + test('200 OHNE Token → fehlgeschlagen (Fall-Through-Bug-Regression)', + () async { + final auth = neueInstanz(loginClient(200), FakeSpeicher()); + final result = await auth.login('dustin', 'passwort'); + expect(result.erfolg, isFalse); + expect(auth.istEingeloggt, isFalse); + }); + + test('Netzwerkfehler → fehlgeschlagen mit Server-Meldung', () async { + final auth = neueInstanz(loginClient(-1), FakeSpeicher()); + final result = await auth.login('dustin', 'passwort'); + expect(result.erfolg, isFalse); + expect(result.fehler, contains('Keine Verbindung')); + }); + }); + + group('logout & authHeader', () { + test('logout löscht Token + User aus Speicher', () async { + final speicher = FakeSpeicher() + ..daten['baka_token'] = 'jwt.x' + ..daten['baka_user'] = 'dustin'; + final auth = neueInstanz(verifyClient(200), speicher); + + await auth.initialisieren(); // stellt Token her + expect(auth.istEingeloggt, isTrue); + + await auth.logout(); + expect(auth.istEingeloggt, isFalse); + expect(auth.benutzer, ''); + expect(speicher.daten.containsKey('baka_token'), isFalse); + expect(speicher.daten.containsKey('baka_user'), isFalse); + }); + + test('authHeader enthält Bearer-Token nach Login', () async { + final speicher = FakeSpeicher(); + final auth = neueInstanz( + MockClient((req) async => + http.Response(jsonEncode({'token': 'jwt.hdr'}), 200)), + speicher, + ); + await auth.login('dustin', 'passwort'); + + final header = auth.authHeader; + expect(header['Authorization'], 'Bearer jwt.hdr'); + expect(header['Content-Type'], 'application/json'); + }); + + test('authHeader ohne Token → kein Authorization', () async { + final auth = neueInstanz(verifyClient(200), FakeSpeicher()); + expect(auth.authHeader.containsKey('Authorization'), isFalse); + }); + }); +} diff --git a/test/cloud_service_test.dart b/test/cloud_service_test.dart new file mode 100644 index 0000000..d01a02e --- /dev/null +++ b/test/cloud_service_test.dart @@ -0,0 +1,288 @@ +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('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('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); + }); + }); +}