From 09d24abf4ddca48ac30cc71e9ea36b1509d6a4e9 Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Thu, 27 Aug 2026 10:47:46 +0200 Subject: [PATCH] Favoriten-GET gehaertet + deterministisches setzeFavorit --- lib/services/melo_cloud_service.dart | 31 +++++++- test/services/melo_cloud_service_test.dart | 84 +++++++++++++++++++++- 2 files changed, 111 insertions(+), 4 deletions(-) diff --git a/lib/services/melo_cloud_service.dart b/lib/services/melo_cloud_service.dart index ff851ed..21dab35 100644 --- a/lib/services/melo_cloud_service.dart +++ b/lib/services/melo_cloud_service.dart @@ -114,10 +114,22 @@ class MeloCloudService { } /// 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 static List parseFavoriten(String body) { final daten = jsonDecode(body) as Map; - 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 [ for (final j in liste) (j as Map)['id'] as String, ]; @@ -264,6 +276,23 @@ class MeloCloudService { 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 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]. Future setzeFavoriten(List cloudIds) async { _pruefeAnmeldung(); diff --git a/test/services/melo_cloud_service_test.dart b/test/services/melo_cloud_service_test.dart index 603ede6..212d6cf 100644 --- a/test/services/melo_cloud_service_test.dart +++ b/test/services/melo_cloud_service_test.dart @@ -1,8 +1,30 @@ 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/services/baka_auth.dart'; import 'package:melo/services/melo_cloud_service.dart'; +class _MemorySpeicher implements TokenSpeicher { + _MemorySpeicher(this.werte); + final Map werte; + @override + Future lesen(String key) async => werte[key]; + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + @override + Future loeschen(String key) async => werte.remove(key); +} + +Future _angemeldeteAuth() async { + final auth = BakaAuth( + speicher: _MemorySpeicher({'baka_token': 'tok', 'baka_user': 'Baka'}), + ); + await auth.laden(); + return auth; +} + void main() { group('parseListe', () { test('liest Titel inklusive Grabsteinen', () { @@ -95,9 +117,30 @@ void main() { expect(MeloCloudService.parseFavoriten(body), ['a', 'b']); }); - test('ohne Favoriten leere Liste', () { - expect(MeloCloudService.parseFavoriten(jsonEncode({'status': 'ok'})), - isEmpty); + test('eine leere Favoritenliste ist kein Fehler', () { + expect( + 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()), + ); + }); + + test('Server-Fehler im 200er-Körper wird als CloudException gemeldet', () { + expect( + () => MeloCloudService.parseFavoriten( + jsonEncode({'status': 'error', 'error': 'Auth required'})), + throwsA(isA()), + ); }); }); @@ -134,4 +177,39 @@ void main() { }); }); }); + + group('setzeFavorit', () { + test('meldet den Wunsch deterministisch, nicht als Umschalten', () async { + Map? gesendet; + String? pfad; + final dienst = MeloCloudService( + auth: await _angemeldeteAuth(), + client: MockClient((anfrage) async { + pfad = anfrage.url.path; + gesendet = jsonDecode(anfrage.body) as Map; + 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? gesendet; + final dienst = MeloCloudService( + auth: await _angemeldeteAuth(), + client: MockClient((anfrage) async { + gesendet = jsonDecode(anfrage.body) as Map; + return http.Response(jsonEncode({'status': 'ok'}), 200); + }), + ); + + await dienst.setzeFavorit('c5', false); + + expect(gesendet, {'song_id': 'c5', 'set': false}); + }); + }); }