This repository has been archived on 2026-08-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
melo-app/lib/services/cloud_service.dart
T
Dustin 5b5f72b548 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
2026-08-04 20:46:51 +02:00

388 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config/app_config.dart';
import '../services/auth_service.dart';
import '../services/melo_logger.dart';
/// Verbindungsstatus der Cloud
enum CloudStatus { verbinde, verbunden, fehler }
/// Cloud-Sync Service für Melo Registry (v3 — vollständiges Sync-System)
/// Authentifizierung via Baka-Auth JWT-Token
///
/// ## Konfliktauflösung: Last-Write-Wins (LWW)
/// Bei Sync-Konflikten (Client und Server haben beide geändert) gewinnt der
/// Eintrag mit dem neueren `updated_at`-Timestamp. Strategie:
/// 1. `syncChanges(since)` lädt serverseitige Änderungen seit letztem Sync.
/// 2. Client vergleicht `updated_at` mit lokalem Stand — Server gewinnt bei Gleichstand.
/// 3. Lokale Änderungen werden danach per `syncAll()` hochgeladen.
/// Merge-Strategie wird NICHT unterstützt (kein 3-Way-Merge) — es gewinnt immer
/// der jüngste Timestamp.
class CloudService extends ChangeNotifier {
/// 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;
CloudStatus _status = CloudStatus.verbinde;
bool _verbindungGestartet = false;
/// Aktueller Verbindungsstatus der Cloud
CloudStatus get status => _status;
/// Ob die Cloud-Verbindung steht
bool get istVerbunden => _status == CloudStatus.verbunden;
/// Ob bereits ein Verbindungsversuch gestartet wurde (auch wenn er läuft)
bool get verbindungGestartet => _verbindungGestartet;
/// Anzeigetext für den Verbindungsstatus
String get statusText {
switch (_status) {
case CloudStatus.verbinde:
return 'Verbinde...';
case CloudStatus.verbunden:
return 'Verbunden';
case CloudStatus.fehler:
return 'Keine Verbindung';
}
}
/// Stellt die Cloud-Verbindung her (Login + Status-Check) und benachrichtigt
/// Listener über den neuen Status. Wird beim App-Start aufgerufen
/// (feuern-und-vergessen — blockiert nicht).
Future<void> verbinde() async {
_verbindungGestartet = true;
final user = AuthService().benutzer;
_status = CloudStatus.verbinde;
notifyListeners();
if (user.isEmpty) {
_status = CloudStatus.fehler;
notifyListeners();
return;
}
final ok = await login(user);
if (!ok) {
_status = CloudStatus.fehler;
notifyListeners();
return;
}
final st = await statusDaten();
_status = st != null ? CloudStatus.verbunden : CloudStatus.fehler;
notifyListeners();
}
/// Login mit Baka-Auth Token wird aus AuthService bezogen
Future<bool> login(String user) async {
try {
final r = await _client
.get(Uri.parse('$_base/api/v1/cloud/status'),
headers: _authHeader)
.timeout(const Duration(seconds: 5));
return r.statusCode == 200;
} catch (_) {
return false;
}
}
Map<String, String> get _authHeader {
final token = AuthService().token;
final headers = <String, String>{};
if (token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
return headers;
}
Map<String, String> get _jsonHeader => {
..._authHeader,
'Content-Type': 'application/json',
};
/// Server-Status abrufen (Song-Zähler etc.)
Future<Map?> statusDaten() => _get('/api/v1/cloud/status');
Future<Map?> syncStatus() => _get('/api/v1/cloud/sync-status');
Future<Map?> syncAll() => _post('/api/v1/cloud/sync-all', {});
Future<List<Map>> listSongs() async {
final r = await _get('/api/v1/cloud/list');
return List<Map>.from(r?['songs'] ?? []);
}
Future<String?> upload(String filepath, String filename) async {
try {
final req =
http.MultipartRequest('POST', Uri.parse('$_base/api/v1/cloud/upload'));
req.headers.addAll(_authHeader);
req.files.add(
await http.MultipartFile.fromPath('file', filepath,
filename: filename));
final resp = await _client.send(req).timeout(const Duration(seconds: 120));
final body = jsonDecode(await resp.stream.bytesToString());
return body['song_id'] as String?;
} catch (e) {
MeloLogger().fehler('cloud_upload', e);
return null;
}
}
Future<bool> download(String songId, String destPath) async {
try {
final r = await _client
.get(Uri.parse('$_base/api/v1/cloud/download/$songId'),
headers: _authHeader)
.timeout(const Duration(seconds: 120));
if (r.statusCode == 200) {
await File(destPath).writeAsBytes(r.bodyBytes);
return true;
}
return false;
} catch (e) {
MeloLogger().fehler('cloud_download', e);
return false;
}
}
Future<bool> delete(String songId) async {
try {
final r = await _client
.post(Uri.parse('$_base/api/v1/cloud/delete'),
headers: _jsonHeader,
body: jsonEncode({'song_id': songId}))
.timeout(const Duration(seconds: 10));
return r.statusCode == 200;
} catch (_) {
return false;
}
}
/// Sync-Änderungen seit einem Zeitstempel abrufen
Future<List<Map>> syncChanges(String since) async {
try {
final r = await _client
.post(Uri.parse('$_base/api/v1/cloud/sync'),
headers: _jsonHeader,
body: jsonEncode({'since': since}))
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) {
final d = jsonDecode(r.body);
return List<Map>.from(d['changes'] ?? []);
}
} catch (_) {}
return [];
}
// ─── 📋 Playlisten ───
/// Alle Playlisten des Users auf dem Server
Future<List<Map>> getPlaylists() async {
final r = await _get('/api/v1/cloud/playlists');
return List<Map>.from(r?['playlists'] ?? []);
}
/// Playlist erstellen
Future<Map?> createPlaylist(String name) async {
final r = await _post('/api/v1/cloud/playlists', {'name': name});
return r?['playlist'];
}
/// Playlist löschen
Future<bool> deletePlaylist(int id) async {
final r = await _post('/api/v1/cloud/playlists', {'id': id});
return r?['status'] == 'ok';
}
/// Playlist-Details mit Songs abrufen
Future<Map?> getPlaylist(int id) async {
return await _get('/api/v1/cloud/playlists/$id');
}
/// Songs zu Playlist hinzufügen
Future<int> addSongsToPlaylist(int playlistId, List<String> songIds) async {
final r = await _post(
'/api/v1/cloud/playlists/$playlistId/songs', {'song_ids': songIds});
return r?['added'] ?? 0;
}
/// Song aus Playlist entfernen
Future<bool> removeSongFromPlaylist(int playlistId, String songId) async {
try {
final r = await _client
.delete(
Uri.parse(
'$_base/api/v1/cloud/playlists/$playlistId/songs/$songId'),
headers: _authHeader)
.timeout(const Duration(seconds: 10));
return r.statusCode == 200;
} catch (_) {
return false;
}
}
/// Playlist-Positionen aktualisieren
Future<bool> updatePlaylistPositions(
int playlistId, List<Map<String, dynamic>> positions) async {
try {
final r = await _client
.put(
Uri.parse(
'$_base/api/v1/cloud/playlists/$playlistId/positions'),
headers: _jsonHeader,
body: jsonEncode({'positions': positions}))
.timeout(const Duration(seconds: 10));
return r.statusCode == 200;
} catch (_) {
return false;
}
}
// ─── ⭐ Favoriten ───
/// Favoriten vom Server abrufen
Future<List<Map>> getFavorites() async {
final r = await _get('/api/v1/cloud/favorites');
return List<Map>.from(r?['favorites'] ?? []);
}
/// Favoriten synchronisieren (komplette Liste senden)
Future<bool> syncFavorites(List<String> songIds) async {
final r =
await _post('/api/v1/cloud/favorites', {'song_ids': songIds});
return r?['status'] == 'ok';
}
/// Einzelnen Favoriten toggeln
Future<Map?> toggleFavorite(String songId) async {
return await _post('/api/v1/cloud/favorites/toggle', {'song_id': songId});
}
// ─── ✏️ Umbenennen ───
/// Song umbenennen (benutzerdefinierter Titel/Artist)
Future<Map?> renameSong(String songId,
{String? title, String? artist}) async {
final body = <String, dynamic>{'song_id': songId};
if (title != null) body['title'] = title;
if (artist != null) body['artist'] = artist;
return await _post('/api/v1/cloud/rename', body);
}
// ─── 🕐 Verlauf ───
/// Wiedergabe-Verlauf vom Server abrufen
Future<List<Map>> getHistory({int limit = 50}) async {
final r = await _get('/api/v1/cloud/history?limit=$limit');
return List<Map>.from(r?['history'] ?? []);
}
/// Wiedergabe-Verlauf-Einträge hochladen
Future<int> addHistory(List<Map<String, dynamic>> entries) async {
final r = await _post('/api/v1/cloud/history', {'entries': entries});
return r?['added'] ?? 0;
}
// ─── 🌐 Global / Shared ───
Future<List<Map>> globalList() async {
try {
final r = await _client
.get(Uri.parse('$_base/api/v1/cloud/global'), headers: _authHeader)
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) {
return List<Map>.from(jsonDecode(r.body)['songs'] ?? []);
}
} catch (_) {}
return [];
}
Future<bool> toggleGlobal(String songId) async {
try {
final r = await _client
.post(Uri.parse('$_base/api/v1/cloud/toggle-global'),
headers: _jsonHeader,
body: jsonEncode({'song_id': songId}))
.timeout(const Duration(seconds: 10));
return r.statusCode == 200;
} catch (_) {
return false;
}
}
Future<String?> share(List<String> songIds) async {
try {
final r = await _client
.post(Uri.parse('$_base/api/v1/cloud/share'),
headers: _jsonHeader,
body: jsonEncode({'song_ids': songIds}))
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) {
final d = jsonDecode(r.body);
return d['code'] as String?;
}
} catch (_) {}
return null;
}
Future<Map?> importCode(String code) async {
try {
final r = await _client
.post(Uri.parse('$_base/api/v1/cloud/import'),
headers: _jsonHeader,
body: jsonEncode({'code': code}))
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) return jsonDecode(r.body);
} catch (_) {}
return null;
}
Future<List<Map>> getCorrupted() async {
try {
final r = await _client
.get(Uri.parse('$_base/api/v1/cloud/corrupted'), headers: _authHeader)
.timeout(const Duration(seconds: 15));
if (r.statusCode == 200) {
final d = jsonDecode(r.body);
return List<Map>.from(d['corrupted'] ?? []);
}
} catch (e) {
MeloLogger().fehler('cloud_corrupted', e);
}
return [];
}
// ─── Hilfsmethoden ───
Future<Map?> _get(String path) async {
try {
final r = await _client
.get(Uri.parse('$_base$path'), headers: _authHeader)
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) return jsonDecode(r.body);
MeloLogger().fehler('cloud_get', '${r.statusCode} $path');
} catch (e) {
MeloLogger().fehler('cloud_get_error', '$path: $e');
}
return null;
}
Future<Map?> _post(String path, Map<String, dynamic> body) async {
try {
final r = await _client
.post(Uri.parse('$_base$path'),
headers: _jsonHeader, body: jsonEncode(body))
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) return jsonDecode(r.body);
MeloLogger().fehler('cloud_post', '${r.statusCode} $path');
} catch (e) {
MeloLogger().fehler('cloud_post_error', '$path: $e');
}
return null;
}
}