diff --git a/lib/services/melo_cloud_service.dart b/lib/services/melo_cloud_service.dart index 4c158d4..6d22621 100644 --- a/lib/services/melo_cloud_service.dart +++ b/lib/services/melo_cloud_service.dart @@ -42,6 +42,14 @@ class CloudSong { ); } +/// Eine Playlist, wie sie der Server kennt. Mehr als Name und ID braucht die +/// einseitige Sicherung nicht. +class CloudPlaylist { + const CloudPlaylist({required this.id, required this.name}); + final String id; + final String name; +} + /// Ein Wiedergabe-Ereignis, das zum Server gemeldet wird. class CloudVerlauf { const CloudVerlauf({ @@ -293,6 +301,128 @@ class MeloCloudService { _pruefeStatus(antwort); } + /// Der Körper einer Playlisten-Antwort, oder `CloudException`. + /// + /// Der Server meldet Fehler im 200er-Körper unter `error`. Ein bloßes + /// `{"status":"not_found"}` **ohne** `error` (so antworten + /// `handle_playlist_remove_song` und `handle_playlist_update_positions`) + /// geht hier bewusst durch: die Sicherung ist einseitig und + /// fire-and-forget, sie verwirft jeden Fehler ohnehin. + Map _json(http.Response antwort) { + final daten = jsonDecode(antwort.body) as Map; + final fehler = daten['error'] as String?; + if (fehler != null) throw CloudException(fehler); + return daten; + } + + /// Legt eine Playlist am Server an und gibt deren ID zurück. + /// + /// Die Identität stammt **immer** von hier: `user_playlists.id` ist + /// AUTOINCREMENT und damit stabil. Eine Zuordnung über den Namen gibt es + /// nicht — sie zerbräche beim ersten Umbenennen. + Future legePlaylistAn(String name) async { + _pruefeAnmeldung(); + final antwort = await _client + .post( + Uri.parse('$basisUrl/playlists'), + headers: {..._kopf, 'Content-Type': 'application/json'}, + body: jsonEncode({'name': name}), + ) + .timeout(const Duration(seconds: 30)); + _pruefeStatus(antwort); + // handle_playlist_create antwortet {"status":"ok","playlist":{"id":…}} — + // die ID liegt eine Ebene tiefer, nicht auf oberster Ebene. + final playlist = _json(antwort)['playlist']; + if (playlist is! Map) { + throw CloudException('Antwort ohne Playlist'); + } + return '${playlist['id']}'; + } + + Future fuegePlaylistSongsHinzu( + String playlistCloudId, List songCloudIds) async { + if (songCloudIds.isEmpty) return; + _pruefeAnmeldung(); + final antwort = await _client + .post( + Uri.parse('$basisUrl/playlists/$playlistCloudId/songs'), + headers: {..._kopf, 'Content-Type': 'application/json'}, + body: jsonEncode({'song_ids': songCloudIds}), + ) + .timeout(const Duration(seconds: 30)); + _pruefeStatus(antwort); + _json(antwort); + } + + Future entfernePlaylistSong( + String playlistCloudId, String songCloudId) async { + _pruefeAnmeldung(); + final antwort = await _client + .delete( + Uri.parse('$basisUrl/playlists/$playlistCloudId/songs/$songCloudId'), + headers: _kopf, + ) + .timeout(const Duration(seconds: 30)); + _pruefeStatus(antwort); + _json(antwort); + } + + /// Schreibt die Reihenfolge einer Playlist am Server fest. + /// + /// Der Körper heißt `positions` und trägt Paare aus `id` und `position`: + /// Der Router liest `d.get('positions',[])`, der Handler greift je Eintrag + /// auf beide Schlüssel zu. Eine blanke ID-Liste unter `song_ids` käme als + /// leere Liste an — der Server antwortete stumm `{"status":"ok"}` und + /// änderte nichts. + Future setzePlaylistReihenfolge( + String playlistCloudId, List songCloudIds) async { + _pruefeAnmeldung(); + final antwort = await _client + .put( + Uri.parse('$basisUrl/playlists/$playlistCloudId/positions'), + headers: {..._kopf, 'Content-Type': 'application/json'}, + body: jsonEncode({ + 'positions': [ + for (var i = 0; i < songCloudIds.length; i++) + {'id': songCloudIds[i], 'position': i}, + ], + }), + ) + .timeout(const Duration(seconds: 30)); + _pruefeStatus(antwort); + _json(antwort); + } + + Future> playlisten() async { + _pruefeAnmeldung(); + final antwort = await _client + .get(Uri.parse('$basisUrl/playlists'), headers: _kopf) + .timeout(const Duration(seconds: 30)); + _pruefeStatus(antwort); + final liste = _json(antwort)['playlists']; + if (liste is! List) throw CloudException('Antwort ohne Playlisten'); + return [ + for (final j in liste) + CloudPlaylist( + id: '${(j as Map)['id']}', + name: j['name'] as String? ?? 'Ohne Namen', + ), + ]; + } + + Future> playlistSongs(String playlistCloudId) async { + _pruefeAnmeldung(); + final antwort = await _client + .get(Uri.parse('$basisUrl/playlists/$playlistCloudId'), headers: _kopf) + .timeout(const Duration(seconds: 30)); + _pruefeStatus(antwort); + final liste = _json(antwort)['songs']; + if (liste is! List) throw CloudException('Antwort ohne Titel'); + return [ + for (final j in liste) '${(j as Map)['id']}', + ]; + } + /// Meldet Wiedergaben. Der Server nimmt höchstens 100 je Aufruf an und /// verwirft Doppelmeldungen desselben Titels innerhalb einer Stunde. Future meldeVerlauf(List eintraege) async { diff --git a/test/services/melo_cloud_playlists_test.dart b/test/services/melo_cloud_playlists_test.dart new file mode 100644 index 0000000..b105fe8 --- /dev/null +++ b/test/services/melo_cloud_playlists_test.dart @@ -0,0 +1,150 @@ +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 baue( + Future Function(http.Request) antwort) async { + final auth = BakaAuth( + speicher: _MemorySpeicher({'baka_token': 'tok', 'baka_user': 'Baka'}), + ); + await auth.laden(); + return MeloCloudService(auth: auth, client: MockClient(antwort)); +} + +void main() { + test('legePlaylistAn liefert die Server-ID', () async { + final dienst = await baue((anfrage) async { + expect(anfrage.method, 'POST'); + expect(anfrage.url.path, endsWith('/playlists')); + expect(jsonDecode(anfrage.body), {'name': 'Road Trip'}); + // handle_playlist_create verpackt die ID unter „playlist" — genau so + // antwortet der echte Server, und genau daran ist der Vertrag geknüpft. + return http.Response( + jsonEncode({ + 'status': 'ok', + 'playlist': {'id': 7, 'name': 'Road Trip', 'song_count': 0} + }), + 200, + ); + }); + + expect(await dienst.legePlaylistAn('Road Trip'), '7'); + }); + + test('eine Antwort ohne playlist-Block ist ein Fehler', () async { + // Die ID auf oberster Ebene zu suchen wäre der naheliegende Fehler; er + // fiele am echten Server als „null" auf und sonst nirgends. + final dienst = await baue( + (_) async => http.Response(jsonEncode({'status': 'ok'}), 200)); + + expect(() => dienst.legePlaylistAn('Road Trip'), + throwsA(isA())); + }); + + test('ein Fehler im 200er-Körper wird geworfen', () async { + final dienst = await baue((_) async => + http.Response(jsonEncode({'error': 'kein Name'}), 200)); + + expect(() => dienst.legePlaylistAn(''), + throwsA(isA())); + }); + + test('fuegePlaylistSongsHinzu meldet die Song-IDs', () async { + Map? gesendet; + final dienst = await baue((anfrage) async { + expect(anfrage.url.path, endsWith('/playlists/7/songs')); + gesendet = jsonDecode(anfrage.body) as Map; + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await dienst.fuegePlaylistSongsHinzu('7', ['c1', 'c2']); + + expect(gesendet, { + 'song_ids': ['c1', 'c2'] + }); + }); + + test('entfernePlaylistSong benutzt DELETE auf dem Song-Pfad', () async { + String? pfad; + String? methode; + final dienst = await baue((anfrage) async { + pfad = anfrage.url.path; + methode = anfrage.method; + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await dienst.entfernePlaylistSong('7', 'c1'); + + expect(methode, 'DELETE'); + expect(pfad, endsWith('/playlists/7/songs/c1')); + }); + + test('setzePlaylistReihenfolge benutzt PUT auf /positions', () async { + String? methode; + Map? gesendet; + final dienst = await baue((anfrage) async { + methode = anfrage.method; + expect(anfrage.url.path, endsWith('/playlists/7/positions')); + gesendet = jsonDecode(anfrage.body) as Map; + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await dienst.setzePlaylistReihenfolge('7', ['c2', 'c1']); + + expect(methode, 'PUT'); + // Der Router liest „positions", der Handler erwartet Paare. Unter + // „song_ids" bekäme der Server eine leere Liste und antwortete stumm + // „ok" — die Reihenfolge käme nie an, ohne jede Fehlermeldung. + expect(gesendet, { + 'positions': [ + {'id': 'c2', 'position': 0}, + {'id': 'c1', 'position': 1}, + ] + }); + }); + + test('playlisten liest Name und ID', () async { + final dienst = await baue((_) async => http.Response( + jsonEncode({ + 'playlists': [ + {'id': 7, 'name': 'Road Trip'} + ] + }), + 200, + )); + + final listen = await dienst.playlisten(); + + expect(listen.single.id, '7'); + expect(listen.single.name, 'Road Trip'); + }); + + test('playlistSongs liefert die Song-IDs in Reihenfolge', () async { + final dienst = await baue((_) async => http.Response( + jsonEncode({ + 'songs': [ + {'id': 'c1'}, + {'id': 'c2'}, + ] + }), + 200, + )); + + expect(await dienst.playlistSongs('7'), ['c1', 'c2']); + }); +}