v2.50.2 — Cloud/Auth-Tests (http-Mocks): LWW-Sync + Token-Ablauf-Pfade
## Testbarkeit (kein neues Package) - CloudService: http.Client injizierbar (Konstruktor-Parameter, Default unverändert) - AuthService: http.Client + TokenSpeicher injizierbar; SecureTokenSpeicher kapselt FlutterSecureStorage, @visibleForTesting AuthService.fuerTests - Alle HTTP-Calls über _client statt Top-Level http.get/post ## test/cloud_service_test.dart (22 Tests) - login: 200/401/Netzwerkfehler - verbinde() ohne Benutzer → Status fehler, kein Request - statusDaten: 200/401(Auth-Fehler)/Netzwerkfehler - syncChanges LWW-Konflikt-Pfade: updated_at bleibt für Client-Vergleich erhalten, leere/fehlende Changes, 401 → [], Netzwerkfehler → [], since-Body - syncAll (LWW-Schreibpfad): 200/401 - Playlists, Favoriten, History, listSongs, delete, globalList inkl. Fehlerpfade ## test/auth_service_test.dart (19 Tests) - initialisieren: 200 → eingeloggt, 401 → Logout + Speicher gelöscht, Netzwerkfehler → Offline-Trust, 5xx → Token bleibt, ohne Token → kein Request - tokenPruefen: 200/401/Netzwerkfehler/503 - login: ok mit Persistenz, 401, 200-ohne-Token (Fall-Through-Regression), Netzwerkfehler - logout löscht Speicher, authHeader mit/ohne Bearer
This commit is contained in:
@@ -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<String?> lesen(String key);
|
||||
Future<void> schreiben(String key, String? wert);
|
||||
Future<void> loeschen(String key);
|
||||
}
|
||||
|
||||
/// Produktions-Implementierung auf FlutterSecureStorage.
|
||||
class SecureTokenSpeicher implements TokenSpeicher {
|
||||
final FlutterSecureStorage _secure = const FlutterSecureStorage();
|
||||
|
||||
@override
|
||||
Future<String?> lesen(String key) => _secure.read(key: key);
|
||||
|
||||
@override
|
||||
Future<void> schreiben(String key, String? wert) =>
|
||||
_secure.write(key: key, value: wert);
|
||||
|
||||
@override
|
||||
Future<void> 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<bool> 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<bool> _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<AuthResult> 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<AuthResult> 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<void> 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', {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<String, String> daten = {};
|
||||
@override
|
||||
Future<String?> lesen(String key) async => daten[key];
|
||||
@override
|
||||
Future<void> schreiben(String key, String? wert) async {
|
||||
if (wert == null) {
|
||||
daten.remove(key);
|
||||
} else {
|
||||
daten[key] = wert;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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<AuthService> 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<String, dynamic> 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<String, dynamic> gesendeterBody;
|
||||
final cloud = CloudService(
|
||||
client: MockClient((req) async {
|
||||
gesendeterBody = jsonDecode(req.body) as Map<String, dynamic>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user