Sync-Ausbau: Favoriten-Fix, Auswahl-Upload, Einzel-Offline, Playlist-Sicherung #5
@@ -114,10 +114,22 @@ class MeloCloudService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Liest die Favoriten-IDs aus einer Server-Antwort.
|
/// Liest die Favoriten-IDs aus einer Server-Antwort.
|
||||||
|
///
|
||||||
|
/// Ein fehlender `favorites`-Schlüssel ist ein **Fehler, keine leere
|
||||||
|
/// Menge**: der Router verdrahtet für `GET /favorites` hart HTTP 200, und
|
||||||
|
/// mehrere Handler desselben Servers melden Fehler im 200er-Körper. Eine
|
||||||
|
/// fälschlich leere Antwort wäre sonst von einer echten nicht zu
|
||||||
|
/// unterscheiden — genau wie bei [parseListe] und [parseUpload] wird
|
||||||
|
/// deshalb geworfen.
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
static List<String> parseFavoriten(String body) {
|
static List<String> parseFavoriten(String body) {
|
||||||
final daten = jsonDecode(body) as Map<String, dynamic>;
|
final daten = jsonDecode(body) as Map<String, dynamic>;
|
||||||
final liste = daten['favorites'] as List? ?? const [];
|
final fehler = daten['error'] as String?;
|
||||||
|
if (fehler != null) throw CloudException(fehler);
|
||||||
|
final liste = daten['favorites'];
|
||||||
|
if (liste is! List) {
|
||||||
|
throw CloudException('Antwort ohne Favoritenliste');
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
for (final j in liste) (j as Map<String, dynamic>)['id'] as String,
|
for (final j in liste) (j as Map<String, dynamic>)['id'] as String,
|
||||||
];
|
];
|
||||||
@@ -264,6 +276,23 @@ class MeloCloudService {
|
|||||||
return parseFavoriten(antwort.body);
|
return parseFavoriten(antwort.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Setzt einen einzelnen Favoriten am Server — additiv oder entfernend,
|
||||||
|
/// aber immer **deterministisch**.
|
||||||
|
///
|
||||||
|
/// Bewusst kein Umschalten: hätte der Server einen abweichenden Stand,
|
||||||
|
/// kehrte ein Toggle den Wunsch des Nutzers um.
|
||||||
|
Future<void> setzeFavorit(String cloudId, bool gesetzt) async {
|
||||||
|
_pruefeAnmeldung();
|
||||||
|
final antwort = await _client
|
||||||
|
.post(
|
||||||
|
Uri.parse('$basisUrl/favorites/toggle'),
|
||||||
|
headers: {..._kopf, 'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({'song_id': cloudId, 'set': gesetzt}),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
|
_pruefeStatus(antwort);
|
||||||
|
}
|
||||||
|
|
||||||
/// Ersetzt die Favoriten am Server durch [cloudIds].
|
/// Ersetzt die Favoriten am Server durch [cloudIds].
|
||||||
Future<void> setzeFavoriten(List<String> cloudIds) async {
|
Future<void> setzeFavoriten(List<String> cloudIds) async {
|
||||||
_pruefeAnmeldung();
|
_pruefeAnmeldung();
|
||||||
|
|||||||
@@ -1,8 +1,30 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
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/melo_cloud_service.dart';
|
import 'package:melo/services/melo_cloud_service.dart';
|
||||||
|
|
||||||
|
class _MemorySpeicher implements TokenSpeicher {
|
||||||
|
_MemorySpeicher(this.werte);
|
||||||
|
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(
|
||||||
|
speicher: _MemorySpeicher({'baka_token': 'tok', 'baka_user': 'Baka'}),
|
||||||
|
);
|
||||||
|
await auth.laden();
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('parseListe', () {
|
group('parseListe', () {
|
||||||
test('liest Titel inklusive Grabsteinen', () {
|
test('liest Titel inklusive Grabsteinen', () {
|
||||||
@@ -95,9 +117,30 @@ void main() {
|
|||||||
expect(MeloCloudService.parseFavoriten(body), ['a', 'b']);
|
expect(MeloCloudService.parseFavoriten(body), ['a', 'b']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ohne Favoriten leere Liste', () {
|
test('eine leere Favoritenliste ist kein Fehler', () {
|
||||||
expect(MeloCloudService.parseFavoriten(jsonEncode({'status': 'ok'})),
|
expect(
|
||||||
isEmpty);
|
MeloCloudService.parseFavoriten(jsonEncode({'favorites': []})),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fehlender Schlüssel ist ein Fehler, keine leere Menge', () {
|
||||||
|
// Der Router verdrahtet für GET /favorites hart HTTP 200; sechs Handler
|
||||||
|
// desselben Servers melden Fehler im 200er-Körper. Ein fälschlich
|
||||||
|
// leeres Ergebnis wäre von einer echten Leerantwort nicht zu
|
||||||
|
// unterscheiden — wie parseListe und parseUpload wird deshalb geworfen.
|
||||||
|
expect(
|
||||||
|
() => MeloCloudService.parseFavoriten(jsonEncode({'status': 'ok'})),
|
||||||
|
throwsA(isA<CloudException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Server-Fehler im 200er-Körper wird als CloudException gemeldet', () {
|
||||||
|
expect(
|
||||||
|
() => MeloCloudService.parseFavoriten(
|
||||||
|
jsonEncode({'status': 'error', 'error': 'Auth required'})),
|
||||||
|
throwsA(isA<CloudException>()),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,4 +177,39 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('setzeFavorit', () {
|
||||||
|
test('meldet den Wunsch deterministisch, nicht als Umschalten', () async {
|
||||||
|
Map<String, dynamic>? gesendet;
|
||||||
|
String? pfad;
|
||||||
|
final dienst = MeloCloudService(
|
||||||
|
auth: await _angemeldeteAuth(),
|
||||||
|
client: MockClient((anfrage) async {
|
||||||
|
pfad = anfrage.url.path;
|
||||||
|
gesendet = jsonDecode(anfrage.body) as Map<String, dynamic>;
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await dienst.setzeFavorit('c5', true);
|
||||||
|
|
||||||
|
expect(pfad, endsWith('/favorites/toggle'));
|
||||||
|
expect(gesendet, {'song_id': 'c5', 'set': true});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kann einen Favoriten auch ausdrücklich entfernen', () async {
|
||||||
|
Map<String, dynamic>? gesendet;
|
||||||
|
final dienst = MeloCloudService(
|
||||||
|
auth: await _angemeldeteAuth(),
|
||||||
|
client: MockClient((anfrage) async {
|
||||||
|
gesendet = jsonDecode(anfrage.body) as Map<String, dynamic>;
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await dienst.setzeFavorit('c5', false);
|
||||||
|
|
||||||
|
expect(gesendet, {'song_id': 'c5', 'set': false});
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user