v2.52 — Favoriten-Sync-Fix: bidirektionaler Merge (lokal↔Server), Toggle-Push sofort, cloud_id-Verdrahtung
This commit is contained in:
@@ -81,6 +81,21 @@ void main() async {
|
|||||||
// Auth initialisieren (Token aus SharedPreferences laden + online validieren)
|
// Auth initialisieren (Token aus SharedPreferences laden + online validieren)
|
||||||
await AuthService().initialisieren();
|
await AuthService().initialisieren();
|
||||||
|
|
||||||
|
// Favoriten-Toggle → Server-Push (Feuer-und-Vergessen, deterministisch via
|
||||||
|
// `set` statt Toggle — verhindert Doppel-Toggle). Songs ohne cloud_id
|
||||||
|
// (nicht hochgeladen) werden übersprungen; ein Fehlschlag wird beim
|
||||||
|
// nächsten vollständigen Sync korrigiert (favoritenMerge-Vereinigung).
|
||||||
|
FavoritenService().onStatusGeaendert = (songId, neuerStatus) async {
|
||||||
|
try {
|
||||||
|
final song = await DbHelper().songNachId(songId);
|
||||||
|
final cid = song?.cloudId;
|
||||||
|
if (cid == null || cid.isEmpty) return;
|
||||||
|
await CloudService().setFavorite(cid, favorit: neuerStatus);
|
||||||
|
} catch (e) {
|
||||||
|
MeloLogger().fehler('favoriten_server_push', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Android-Berechtigungen beim Start abfragen (Issue #4)
|
// Android-Berechtigungen beim Start abfragen (Issue #4)
|
||||||
// Speicherzugriff für Musik-Scan (READ_MEDIA_AUDIO / Storage)
|
// Speicherzugriff für Musik-Scan (READ_MEDIA_AUDIO / Storage)
|
||||||
await MusikScanner().frageSpeicherZugriff();
|
await MusikScanner().frageSpeicherZugriff();
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class Song {
|
|||||||
final String hinzugefuegtAm;
|
final String hinzugefuegtAm;
|
||||||
final String downloadQuelle; // "local", "youtube", "server"
|
final String downloadQuelle; // "local", "youtube", "server"
|
||||||
final String? streamUrl; // Für Server-Streaming (Navidrome) – wird bewusst NICHT in der DB persistiert (Auth-Token-Schutz)
|
final String? streamUrl; // Für Server-Streaming (Navidrome) – wird bewusst NICHT in der DB persistiert (Auth-Token-Schutz)
|
||||||
final String? ytUrl; // YouTube-Quell-URL für Auto-Redownload bei korrupten Songs
|
final String? ytUrl; // YouTube-Quell-URL – NUR Fallback (Datei korrupt + kein Server-Download möglich); wird nicht mehr lokal geschrieben
|
||||||
|
final String? cloudId; // Verknüpfung lokal ↔ Cloud (Server-Song-ID) für bidirektionalen Favoriten-Sync
|
||||||
int? zuletztPosition; // Sekunden, für Wiederaufnahme
|
int? zuletztPosition; // Sekunden, für Wiederaufnahme
|
||||||
Set<int>? tagIds; // Cache für Tag-Filterung (nicht in DB gespeichert)
|
Set<int>? tagIds; // Cache für Tag-Filterung (nicht in DB gespeichert)
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ class Song {
|
|||||||
this.downloadQuelle = 'local',
|
this.downloadQuelle = 'local',
|
||||||
this.streamUrl,
|
this.streamUrl,
|
||||||
this.ytUrl,
|
this.ytUrl,
|
||||||
|
this.cloudId,
|
||||||
this.zuletztPosition,
|
this.zuletztPosition,
|
||||||
}) : hinzugefuegtAm = hinzugefuegtAm ?? DateTime.now().toIso8601String();
|
}) : hinzugefuegtAm = hinzugefuegtAm ?? DateTime.now().toIso8601String();
|
||||||
|
|
||||||
@@ -58,6 +60,7 @@ class Song {
|
|||||||
'download_quelle': downloadQuelle,
|
'download_quelle': downloadQuelle,
|
||||||
'stream_url': null, // Token-haltige Stream-URLs nie persistieren (Sicherheit)
|
'stream_url': null, // Token-haltige Stream-URLs nie persistieren (Sicherheit)
|
||||||
'yt_url': ytUrl,
|
'yt_url': ytUrl,
|
||||||
|
'cloud_id': cloudId,
|
||||||
'zuletzt_position': zuletztPosition,
|
'zuletzt_position': zuletztPosition,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,6 +82,7 @@ class Song {
|
|||||||
downloadQuelle: m['download_quelle'] as String? ?? 'local',
|
downloadQuelle: m['download_quelle'] as String? ?? 'local',
|
||||||
streamUrl: m['stream_url'] as String?,
|
streamUrl: m['stream_url'] as String?,
|
||||||
ytUrl: m['yt_url'] as String?,
|
ytUrl: m['yt_url'] as String?,
|
||||||
|
cloudId: m['cloud_id'] as String?,
|
||||||
zuletztPosition: m['zuletzt_position'] as int?,
|
zuletztPosition: m['zuletzt_position'] as int?,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|||||||
import '../utils/farb_theme.dart';
|
import '../utils/farb_theme.dart';
|
||||||
import '../utils/sanitize.dart';
|
import '../utils/sanitize.dart';
|
||||||
import '../services/cloud_service.dart';
|
import '../services/cloud_service.dart';
|
||||||
|
import '../services/favoriten_service.dart';
|
||||||
|
import '../services/favoriten_sync.dart';
|
||||||
import '../database/db_helper.dart';
|
import '../database/db_helper.dart';
|
||||||
import '../services/melo_logger.dart';
|
import '../services/melo_logger.dart';
|
||||||
import '../models/song.dart';
|
import '../models/song.dart';
|
||||||
@@ -36,6 +38,9 @@ class _CloudScreenState extends State<CloudScreen>
|
|||||||
bool _statusOk = false;
|
bool _statusOk = false;
|
||||||
bool _serverDatenGeladen = false;
|
bool _serverDatenGeladen = false;
|
||||||
|
|
||||||
|
// ─── Favoriten (bidirektionaler Sync) ───
|
||||||
|
final FavoritenService _favoriten = FavoritenService();
|
||||||
|
|
||||||
// ─── Sync-Animation ───
|
// ─── Sync-Animation ───
|
||||||
late final AnimationController _syncAnimController;
|
late final AnimationController _syncAnimController;
|
||||||
|
|
||||||
@@ -324,17 +329,29 @@ class _CloudScreenState extends State<CloudScreen>
|
|||||||
const Duration(milliseconds: 50)); // UI-Update erlauben
|
const Duration(milliseconds: 50)); // UI-Update erlauben
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: Favoriten syncen
|
// Phase 2: Favoriten bidirektional synchronisieren
|
||||||
_updateSync('Synchronisiere Favoriten...', 0.55);
|
// Root-Cause-Fix (Sprint D): Vorher wurden die Server-Favoriten nur
|
||||||
final favs = await widget.cloud.getFavorites();
|
// zurückgespiegelt (Server → Server) — lokale ⭐-Toggles gingen
|
||||||
final favIds = favs
|
// verloren. Jetzt: Vereinigung lokal ∪ Server, Push + lokales Markieren.
|
||||||
|
_updateSync('Sync Favoriten…', 0.55);
|
||||||
|
final serverFavs = await widget.cloud.getFavorites();
|
||||||
|
final serverIds = serverFavs
|
||||||
.map((f) => f['id']?.toString() ?? '')
|
.map((f) => f['id']?.toString() ?? '')
|
||||||
.where((id) => id.isNotEmpty)
|
.where((id) => id.isNotEmpty)
|
||||||
.toList();
|
.toSet();
|
||||||
await widget.cloud.syncFavorites(favIds);
|
final lokalIds = await _favoriten.favoritenCloudIds();
|
||||||
await db.serverFavoritesSet(favIds);
|
final merged = favoritenMerge(lokal: lokalIds, server: serverIds);
|
||||||
setState(() => _favServerCount = favIds.length);
|
// Lokal → Server: lokale Toggles erreichen den Server, Server-Favoriten
|
||||||
_syncedItems += favIds.length;
|
// bleiben erhalten (kein Datenverlust in beide Richtungen)
|
||||||
|
await widget.cloud.syncFavorites(merged);
|
||||||
|
// Server → Lokal: Server-Favoriten lokal als ⭐ markieren (wenn Song
|
||||||
|
// lokal existiert); `server_favorites` spiegelt den Merge-Zustand
|
||||||
|
for (final cid in serverIds) {
|
||||||
|
await _favoriten.merkeCloudFavorit(cid);
|
||||||
|
}
|
||||||
|
await db.serverFavoritesSet(merged);
|
||||||
|
setState(() => _favServerCount = merged.length);
|
||||||
|
_syncedItems += merged.length;
|
||||||
|
|
||||||
// Phase 3: Playlisten abgleichen
|
// Phase 3: Playlisten abgleichen
|
||||||
_updateSync('Lade Playlisten...', 0.7);
|
_updateSync('Lade Playlisten...', 0.7);
|
||||||
@@ -349,22 +366,22 @@ class _CloudScreenState extends State<CloudScreen>
|
|||||||
_updateSync('Fertig!', 1.0);
|
_updateSync('Fertig!', 1.0);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_syncedItems = downloaded + favIds.length;
|
_syncedItems = downloaded + merged.length;
|
||||||
});
|
});
|
||||||
_setzeStatus(
|
_setzeStatus(
|
||||||
'$downloaded Songs + ${favIds.length} Favoriten synchronisiert',
|
'$downloaded Songs + ${merged.length} Favoriten synchronisiert',
|
||||||
ok: true);
|
ok: true);
|
||||||
// Sync-Historie fürs Dashboard festhalten
|
// Sync-Historie fürs Dashboard festhalten
|
||||||
await _syncHistorieEintragen(
|
await _syncHistorieEintragen(
|
||||||
dateien: downloaded,
|
dateien: downloaded,
|
||||||
favoriten: favIds.length,
|
favoriten: merged.length,
|
||||||
playlists: _serverPlaylists.length,
|
playlists: _serverPlaylists.length,
|
||||||
);
|
);
|
||||||
await _ladeStatus();
|
await _ladeStatus();
|
||||||
}
|
}
|
||||||
MeloLogger().aktion('cloud_sync_all', {
|
MeloLogger().aktion('cloud_sync_all', {
|
||||||
'downloaded': downloaded,
|
'downloaded': downloaded,
|
||||||
'favorites': favIds.length,
|
'favorites': merged.length,
|
||||||
'playlists': _serverPlaylists.length,
|
'playlists': _serverPlaylists.length,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
@@ -714,6 +731,7 @@ class _CloudScreenState extends State<CloudScreen>
|
|||||||
setState(() => _ladt = true);
|
setState(() => _ladt = true);
|
||||||
_setzeStatus('Suche lokale Songs...');
|
_setzeStatus('Suche lokale Songs...');
|
||||||
try {
|
try {
|
||||||
|
final db = DbHelper();
|
||||||
final dir = Directory(
|
final dir = Directory(
|
||||||
'${(await getApplicationDocumentsDirectory()).path}/music');
|
'${(await getApplicationDocumentsDirectory()).path}/music');
|
||||||
if (!await dir.exists()) {
|
if (!await dir.exists()) {
|
||||||
@@ -754,7 +772,19 @@ class _CloudScreenState extends State<CloudScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
final sid = await widget.cloud.upload(f.path, f.path.split('/').last);
|
final sid = await widget.cloud.upload(f.path, f.path.split('/').last);
|
||||||
if (sid != null) count++;
|
if (sid != null) {
|
||||||
|
count++;
|
||||||
|
// Lokalen Song mit dem Server verknüpfen (cloud_id) — Grundlage
|
||||||
|
// für den bidirektionalen Favoriten-Sync (lokal → Server).
|
||||||
|
try {
|
||||||
|
final lokalerSong = await db.songNachPfad(f.path);
|
||||||
|
if (lokalerSong?.id != null) {
|
||||||
|
await db.cloudIdSetzen(lokalerSong!.id!, sid);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Verknüpfung ist nice-to-have — Upload selbst war erfolgreich
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Abschluss-Notification
|
// Abschluss-Notification
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
|
|||||||
@@ -262,6 +262,18 @@ class CloudService extends ChangeNotifier {
|
|||||||
return await _post('/api/v1/cloud/favorites/toggle', {'song_id': songId});
|
return await _post('/api/v1/cloud/favorites/toggle', {'song_id': songId});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Favoriten-Status deterministisch setzen (KEIN Toggle): der Server
|
||||||
|
/// erzwingt den gewünschten Zustand (`set`-Parameter). Verhindert
|
||||||
|
/// Doppel-Toggle, wenn der Server-Zustand vom lokalen abweicht
|
||||||
|
/// (Feuer-und-Vergessen-Push beim lokalen Favoriten-Toggle).
|
||||||
|
Future<bool> setFavorite(String songId, {required bool favorit}) async {
|
||||||
|
final r = await _post('/api/v1/cloud/favorites/toggle', {
|
||||||
|
'song_id': songId,
|
||||||
|
'set': favorit,
|
||||||
|
});
|
||||||
|
return r?['status'] == 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
// ─── ✏️ Umbenennen ───
|
// ─── ✏️ Umbenennen ───
|
||||||
|
|
||||||
/// Song umbenennen (benutzerdefinierter Titel/Artist)
|
/// Song umbenennen (benutzerdefinierter Titel/Artist)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import '../database/db_helper.dart';
|
import '../database/db_helper.dart';
|
||||||
import '../models/song.dart';
|
import '../models/song.dart';
|
||||||
@@ -11,6 +12,11 @@ class FavoritenService {
|
|||||||
final DbHelper _db = DbHelper();
|
final DbHelper _db = DbHelper();
|
||||||
int? _favoritenPlaylistId;
|
int? _favoritenPlaylistId;
|
||||||
|
|
||||||
|
/// Wird nach jedem erfolgreichen lokalen Toggle aufgerufen (Feuer-und-
|
||||||
|
/// Vergessen → Server-Push). Wird in `main()` verdrahtet; Fehler werden
|
||||||
|
/// beim nächsten vollständigen Sync korrigiert (favoritenMerge-Vereinigung).
|
||||||
|
Future<void> Function(int songId, bool neuerStatus)? onStatusGeaendert;
|
||||||
|
|
||||||
int? get favoritenId => _favoritenPlaylistId;
|
int? get favoritenId => _favoritenPlaylistId;
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
@@ -32,18 +38,32 @@ class FavoritenService {
|
|||||||
/// Schaltet den Favoriten-Status um und gibt den NEUEN Status zurück
|
/// Schaltet den Favoriten-Status um und gibt den NEUEN Status zurück
|
||||||
/// (true = jetzt Favorit). Bei Fehlern (z. B. DB-Race) wird der Zustand
|
/// (true = jetzt Favorit). Bei Fehlern (z. B. DB-Race) wird der Zustand
|
||||||
/// aus der DB neu geladen statt blind geflippt — kein unhandled Throw.
|
/// aus der DB neu geladen statt blind geflippt — kein unhandled Throw.
|
||||||
|
/// Nach dem lokalen Toggle wird [onStatusGeaendert] Feuer-und-Vergessen
|
||||||
|
/// aufgerufen (Server-Push; Retry = nächster vollständiger Sync).
|
||||||
Future<bool> umschalten(int songId) async {
|
Future<bool> umschalten(int songId) async {
|
||||||
if (_favoritenPlaylistId == null) return false;
|
if (_favoritenPlaylistId == null) return false;
|
||||||
try {
|
try {
|
||||||
|
bool neuerStatus;
|
||||||
if (await istFavorit(songId)) {
|
if (await istFavorit(songId)) {
|
||||||
await _db.songAusPlaylistEntfernen(_favoritenPlaylistId!, songId);
|
await _db.songAusPlaylistEntfernen(_favoritenPlaylistId!, songId);
|
||||||
return false;
|
neuerStatus = false;
|
||||||
} else {
|
} else {
|
||||||
final songs = await _db.songsDerPlaylist(_favoritenPlaylistId!);
|
final songs = await _db.songsDerPlaylist(_favoritenPlaylistId!);
|
||||||
// INSERT OR IGNORE in songZurPlaylist fängt Doppel-Tap-Races ab
|
// INSERT OR IGNORE in songZurPlaylist fängt Doppel-Tap-Races ab
|
||||||
await _db.songZurPlaylist(_favoritenPlaylistId!, songId, songs.length);
|
await _db.songZurPlaylist(_favoritenPlaylistId!, songId, songs.length);
|
||||||
return true;
|
neuerStatus = true;
|
||||||
}
|
}
|
||||||
|
final cb = onStatusGeaendert;
|
||||||
|
if (cb != null) {
|
||||||
|
unawaited(() async {
|
||||||
|
try {
|
||||||
|
await cb(songId, neuerStatus);
|
||||||
|
} catch (e) {
|
||||||
|
MeloLogger().fehler('favoriten_server_push', e);
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
return neuerStatus;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Favoriten-Toggle fehlgeschlagen: $e');
|
debugPrint('Favoriten-Toggle fehlgeschlagen: $e');
|
||||||
MeloLogger().fehler('favoriten_toggle', e);
|
MeloLogger().fehler('favoriten_toggle', e);
|
||||||
@@ -57,6 +77,31 @@ class FavoritenService {
|
|||||||
return _db.songsDerPlaylist(_favoritenPlaylistId!);
|
return _db.songsDerPlaylist(_favoritenPlaylistId!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cloud-IDs aller lokalen ⭐-Favoriten (Push-Richtung lokal → Server).
|
||||||
|
/// Songs ohne cloud_id (nicht hochgeladen) werden übersprungen.
|
||||||
|
Future<Set<String>> favoritenCloudIds() async {
|
||||||
|
if (_favoritenPlaylistId == null) return {};
|
||||||
|
final songs = await _db.songsDerPlaylist(_favoritenPlaylistId!);
|
||||||
|
return songs
|
||||||
|
.map((s) => s.cloudId)
|
||||||
|
.whereType<String>()
|
||||||
|
.where((c) => c.isNotEmpty)
|
||||||
|
.toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Markiert einen Server-Favoriten (cloud_id) lokal als ⭐-Favorit
|
||||||
|
/// (Richtung Server → lokal). Fehlt der Song lokal, passiert nichts —
|
||||||
|
/// der Favorit bleibt in `server_favorites` fürs Dashboard erhalten.
|
||||||
|
Future<void> merkeCloudFavorit(String cloudId) async {
|
||||||
|
if (_favoritenPlaylistId == null) return;
|
||||||
|
final song = await _db.songNachCloudId(cloudId);
|
||||||
|
final id = song?.id;
|
||||||
|
if (id == null) return;
|
||||||
|
if (await istFavorit(id)) return;
|
||||||
|
final songs = await _db.songsDerPlaylist(_favoritenPlaylistId!);
|
||||||
|
await _db.songZurPlaylist(_favoritenPlaylistId!, id, songs.length);
|
||||||
|
}
|
||||||
|
|
||||||
Future<int> anzahlFavoriten() async {
|
Future<int> anzahlFavoriten() async {
|
||||||
if (_favoritenPlaylistId == null) await init();
|
if (_favoritenPlaylistId == null) await init();
|
||||||
if (_favoritenPlaylistId == null) return 0;
|
if (_favoritenPlaylistId == null) return 0;
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import 'cloud_service.dart';
|
||||||
|
|
||||||
|
/// Bidirektionaler Favoriten-Sync (Sprint D, v2.52).
|
||||||
|
///
|
||||||
|
/// Root-Cause des alten Bugs: `_syncAlles` las die Server-Favoriten und
|
||||||
|
/// schrieb sie per `syncFavorites` UNVERÄNDERT zurück auf den Server —
|
||||||
|
/// lokale ⭐-Toggles gingen verloren, `server_favorites` wurde nie genutzt.
|
||||||
|
///
|
||||||
|
/// Neues Verhalten (Merge statt Server-Spiegel):
|
||||||
|
/// 1. **Lokal → Server:** Alle cloud_ids lokaler ⭐-Favoriten werden
|
||||||
|
/// zusammen mit den Server-Favoriten hochgeladen (Vereinigung). Lokale
|
||||||
|
/// Toggles erreichen den Server damit garantiert.
|
||||||
|
/// 2. **Server → Lokal:** Server-Favoriten werden lokal als ⭐ markiert,
|
||||||
|
/// sobald der Song lokal existiert (cloud_id-Verknüpfung).
|
||||||
|
/// 3. **Feuer-und-Vergessen beim Toggle:** `CloudService.setFavorite`
|
||||||
|
/// (deterministisch, kein Toggle) direkt beim lokalen Umschalten; ein
|
||||||
|
/// Fehlschlag wird durch den nächsten vollständigen Sync korrigiert.
|
||||||
|
|
||||||
|
/// Vereinigt lokale und Server-Favoriten (cloud_ids) — keine Datenverluste
|
||||||
|
/// in beide Richtungen. Sortiert für deterministische Tests.
|
||||||
|
List<String> favoritenMerge({
|
||||||
|
required Set<String> lokal,
|
||||||
|
required Set<String> server,
|
||||||
|
}) {
|
||||||
|
final merged = <String>{...lokal, ...server}.toList()..sort();
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feuer-und-Vergessen-Push eines Favoriten-Toggles an den Server.
|
||||||
|
/// Deterministisch via `set` (kein Doppel-Toggle-Risiko). Fehler werden
|
||||||
|
/// still geschluckt — der nächste vollständige Sync korrigiert den Zustand
|
||||||
|
/// (favoritenMerge-Vereinigung).
|
||||||
|
Future<void> pusheFavoritAnServer(
|
||||||
|
CloudService cloud,
|
||||||
|
String cloudId,
|
||||||
|
bool favorit,
|
||||||
|
) async {
|
||||||
|
await cloud.setFavorite(cloudId, favorit: favorit);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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';
|
||||||
|
import 'package:melo_app/services/favoriten_sync.dart';
|
||||||
|
|
||||||
|
/// Tests für den bidirektionalen Favoriten-Sync (Sprint D v2.52).
|
||||||
|
///
|
||||||
|
/// Root-Cause-Regression: `_syncAlles` spiegelte Server-Favoriten nur zurück
|
||||||
|
/// (Server → Server) — lokale ⭐-Toggles gingen verloren. Der Fix merge-t
|
||||||
|
/// lokal ∪ Server (favoritenMerge) und pusht Toggles deterministisch via
|
||||||
|
/// `set` (kein Doppel-Toggle-Risiko).
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
http.Response jsonOk(Map<String, dynamic> body) =>
|
||||||
|
http.Response(jsonEncode(body), 200,
|
||||||
|
headers: {'content-type': 'application/json'});
|
||||||
|
|
||||||
|
group('favoritenMerge (Vereinigung lokal ∪ Server)', () {
|
||||||
|
test('lokale Favoriten werden dem Server-Push hinzugefügt', () {
|
||||||
|
final merged = favoritenMerge(
|
||||||
|
lokal: {'cloud-1', 'cloud-2'},
|
||||||
|
server: {'cloud-9'},
|
||||||
|
);
|
||||||
|
expect(merged, containsAll(['cloud-1', 'cloud-2', 'cloud-9']));
|
||||||
|
expect(merged, hasLength(3));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Server-Favoriten bleiben erhalten (kein Datenverlust)', () {
|
||||||
|
final merged = favoritenMerge(
|
||||||
|
lokal: <String>{},
|
||||||
|
server: {'cloud-7', 'cloud-8'},
|
||||||
|
);
|
||||||
|
expect(merged, containsAll(['cloud-7', 'cloud-8']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Doppelte IDs (lokal == server) werden dedupliziert', () {
|
||||||
|
final merged = favoritenMerge(
|
||||||
|
lokal: {'cloud-1', 'cloud-2'},
|
||||||
|
server: {'cloud-2', 'cloud-3'},
|
||||||
|
);
|
||||||
|
expect(merged, hasLength(3));
|
||||||
|
expect(merged.where((id) => id == 'cloud-2'), hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leere Mengen → leere Liste', () {
|
||||||
|
expect(favoritenMerge(lokal: {}, server: {}), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deterministisch sortiert (für Tests/Stabilität)', () {
|
||||||
|
final a = favoritenMerge(lokal: {'b', 'a'}, server: {'c'});
|
||||||
|
final b = favoritenMerge(lokal: {'c'}, server: {'b', 'a'});
|
||||||
|
expect(a, b);
|
||||||
|
expect(a, ['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CloudService.setFavorite (deterministischer Push)', () {
|
||||||
|
test('sendet set:true und wertet status ok aus', () async {
|
||||||
|
late Map<String, dynamic> gesendeterBody;
|
||||||
|
String? pfad;
|
||||||
|
final cloud = CloudService(
|
||||||
|
client: MockClient((req) async {
|
||||||
|
pfad = req.url.path;
|
||||||
|
gesendeterBody = jsonDecode(req.body) as Map<String, dynamic>;
|
||||||
|
return jsonOk({'status': 'ok', 'favorited': true});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final ok = await cloud.setFavorite('cloud-1', favorit: true);
|
||||||
|
expect(ok, isTrue);
|
||||||
|
expect(pfad, '/api/v1/cloud/favorites/toggle');
|
||||||
|
expect(gesendeterBody['song_id'], 'cloud-1');
|
||||||
|
expect(gesendeterBody['set'], isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendet set:false beim Entfernen', () async {
|
||||||
|
late Map<String, dynamic> gesendeterBody;
|
||||||
|
final cloud = CloudService(
|
||||||
|
client: MockClient((req) async {
|
||||||
|
gesendeterBody = jsonDecode(req.body) as Map<String, dynamic>;
|
||||||
|
return jsonOk({'status': 'ok', 'favorited': false});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final ok = await cloud.setFavorite('cloud-2', favorit: false);
|
||||||
|
expect(ok, isTrue);
|
||||||
|
expect(gesendeterBody['set'], isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Fehlerantwort (kein status ok) → false', () async {
|
||||||
|
final cloud = CloudService(
|
||||||
|
client: MockClient((req) async => jsonOk({'status': 'error'})),
|
||||||
|
);
|
||||||
|
expect(await cloud.setFavorite('cloud-3', favorit: true), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Netzwerkfehler → false (kein Crash)', () async {
|
||||||
|
final cloud = CloudService(
|
||||||
|
client: MockClient(
|
||||||
|
(req) async => throw http.ClientException('offline')),
|
||||||
|
);
|
||||||
|
expect(await cloud.setFavorite('cloud-4', favorit: true), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('pusheFavoritAnServer (Feuer-und-Vergessen)', () {
|
||||||
|
test('leitet Toggle-Status an setFavorite weiter', () async {
|
||||||
|
final bodies = <Map<String, dynamic>>[];
|
||||||
|
final cloud = CloudService(
|
||||||
|
client: MockClient((req) async {
|
||||||
|
bodies.add(jsonDecode(req.body) as Map<String, dynamic>);
|
||||||
|
return jsonOk({'status': 'ok'});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await pusheFavoritAnServer(cloud, 'cloud-5', true);
|
||||||
|
await pusheFavoritAnServer(cloud, 'cloud-5', false);
|
||||||
|
expect(bodies, hasLength(2));
|
||||||
|
expect(bodies[0]['set'], isTrue);
|
||||||
|
expect(bodies[1]['set'], isFalse);
|
||||||
|
expect(bodies[0]['song_id'], 'cloud-5');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user