## MED-3 (Review-Fix) - test/sync_service_test.dart NEU (14 Tests): tombstoneAnwenden-Matrix (letzterSync null → true, deletedAt null → true, älter → false, neuer → true, Gleichstand → false, ungültiger Timestamp → true), SyncBericht (hatAenderungen, zusammenfassung kombiniert/leer, details), songAusServerMap (cloud_id/Titel/Künstler/Dauer/Größe/istKorrupt-Mapping, Defaults bei fehlenden Feldern, String-Robustheit) — deckt die MED-1-Heuristik + den MED-2-DB-Insert ab (123→140 Tests) - test/cloud_service_test.dart +3: delete mit deletedAt sendet deleted_at im Body, delete ohne deletedAt sendet KEIN deleted_at-Feld, delete Netzwerkfehler → false
359 lines
12 KiB
Dart
359 lines
12 KiB
Dart
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';
|
|
|
|
/// CloudService-Tests mit http-Mocks (package:http/testing.dart — Teil von
|
|
/// http, kein neues Package). Abgedeckt: LWW-Sync-Konflikt-Pfade (updated_at
|
|
/// bleibt für den Client-Vergleich erhalten), Auth-Fehler (401) und
|
|
/// Netzwerkfehler (alle Pfade failen sauber statt zu crashen).
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
http.Response jsonOk(Map<String, dynamic> body) =>
|
|
http.Response(jsonEncode(body), 200,
|
|
headers: {'content-type': 'application/json'});
|
|
|
|
group('login (Auth)', () {
|
|
test('200 → verbunden (true)', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async => http.Response('{}', 200)),
|
|
);
|
|
expect(await cloud.login('dustin'), isTrue);
|
|
});
|
|
|
|
test('401 (Auth-Fehler) → false', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async => http.Response('{}', 401)),
|
|
);
|
|
expect(await cloud.login('dustin'), isFalse);
|
|
});
|
|
|
|
test('Netzwerkfehler → false (kein Crash)', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient(
|
|
(req) async => throw http.ClientException('Verbindung weg')),
|
|
);
|
|
expect(await cloud.login('dustin'), isFalse);
|
|
});
|
|
});
|
|
|
|
group('verbinde()', () {
|
|
test('ohne Benutzer → Status fehler (kein Request)', () async {
|
|
var requests = 0;
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async {
|
|
requests++;
|
|
return http.Response('{}', 200);
|
|
}),
|
|
);
|
|
await cloud.verbinde();
|
|
expect(cloud.verbindungGestartet, isTrue);
|
|
expect(cloud.status, CloudStatus.fehler);
|
|
expect(cloud.istVerbunden, isFalse);
|
|
expect(requests, 0);
|
|
});
|
|
});
|
|
|
|
group('statusDaten (Auth-Fehler)', () {
|
|
test('200 → JSON-Map', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient(
|
|
(req) async => jsonOk({'songs': 5, 'users': 2})),
|
|
);
|
|
final d = await cloud.statusDaten();
|
|
expect(d, isNotNull);
|
|
expect(d!['songs'], 5);
|
|
});
|
|
|
|
test('401 → null (Token abgelaufen)', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async => http.Response('{}', 401)),
|
|
);
|
|
expect(await cloud.statusDaten(), isNull);
|
|
});
|
|
|
|
test('Netzwerkfehler → null', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient(
|
|
(req) async => throw http.ClientException('offline')),
|
|
);
|
|
expect(await cloud.statusDaten(), isNull);
|
|
});
|
|
});
|
|
|
|
group('syncChanges — LWW-Sync-Konflikt-Pfade', () {
|
|
test('parst Changes inkl. updated_at (Grundlage für LWW-Vergleich)',
|
|
() async {
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async => jsonOk({
|
|
'changes': [
|
|
{
|
|
'id': 'song-1',
|
|
'title': 'Neu vom Server',
|
|
'updated_at': '2026-08-04T10:00:00Z',
|
|
},
|
|
{
|
|
'id': 'song-2',
|
|
'title': 'Älterer Eintrag',
|
|
'updated_at': '2026-08-04T09:00:00Z',
|
|
},
|
|
],
|
|
})),
|
|
);
|
|
final changes = await cloud.syncChanges('2026-08-04T08:00:00Z');
|
|
expect(changes, hasLength(2));
|
|
// LWW-Vertrag: Timestamps bleiben unverändert erhalten, damit der
|
|
// Client lokal neueren Stand (Upload via syncAll) von server-neuerem
|
|
// Stand (Übernahme) unterscheiden kann.
|
|
expect(changes[0]['updated_at'], '2026-08-04T10:00:00Z');
|
|
expect(changes[1]['updated_at'], '2026-08-04T09:00:00Z');
|
|
expect(changes[0]['title'], 'Neu vom Server');
|
|
});
|
|
|
|
test('leere Changes-Liste → []', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async => jsonOk({'changes': []})),
|
|
);
|
|
expect(await cloud.syncChanges('2026-08-04T08:00:00Z'), isEmpty);
|
|
});
|
|
|
|
test('fehlendes changes-Feld → []', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async => jsonOk({})),
|
|
);
|
|
expect(await cloud.syncChanges('2026-08-04T08:00:00Z'), isEmpty);
|
|
});
|
|
|
|
test('401 (Auth-Fehler) → []', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async => http.Response('{}', 401)),
|
|
);
|
|
expect(await cloud.syncChanges('2026-08-04T08:00:00Z'), isEmpty);
|
|
});
|
|
|
|
test('Netzwerkfehler → []', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient(
|
|
(req) async => throw http.ClientException('offline')),
|
|
);
|
|
expect(await cloud.syncChanges('2026-08-04T08:00:00Z'), isEmpty);
|
|
});
|
|
|
|
test('sendet since-Timestamp als Body', () async {
|
|
late Map<String, dynamic> gesendeterBody;
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async {
|
|
gesendeterBody = jsonDecode(req.body) as Map<String, dynamic>;
|
|
return jsonOk({'changes': []});
|
|
}),
|
|
);
|
|
await cloud.syncChanges('2026-08-04T08:00:00Z');
|
|
expect(gesendeterBody['since'], '2026-08-04T08:00:00Z');
|
|
});
|
|
});
|
|
|
|
group('syncAll (LWW-Schreibpfad)', () {
|
|
test('200 → Status-Map', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async => jsonOk({'status': 'ok'})),
|
|
);
|
|
final d = await cloud.syncAll();
|
|
expect(d?['status'], 'ok');
|
|
});
|
|
|
|
test('401 → null (Auth-Fehler)', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async => http.Response('{}', 401)),
|
|
);
|
|
expect(await cloud.syncAll(), isNull);
|
|
});
|
|
});
|
|
|
|
group('Playlisten', () {
|
|
test('getPlaylists parst Liste', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async => jsonOk({
|
|
'playlists': [
|
|
{'id': 1, 'name': 'Workout'},
|
|
],
|
|
})),
|
|
);
|
|
final list = await cloud.getPlaylists();
|
|
expect(list, hasLength(1));
|
|
expect(list.first['name'], 'Workout');
|
|
});
|
|
|
|
test('createPlaylist 200 → Playlist-Objekt', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async =>
|
|
jsonOk({'playlist': {'id': 7, 'name': 'Neu'}})),
|
|
);
|
|
final p = await cloud.createPlaylist('Neu');
|
|
expect(p?['id'], 7);
|
|
});
|
|
|
|
test('deletePlaylist 200 ohne status → false', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async => jsonOk({'status': 'ok'})),
|
|
);
|
|
expect(await cloud.deletePlaylist(3), isTrue);
|
|
});
|
|
});
|
|
|
|
group('Favoriten', () {
|
|
test('getFavorites parst Liste', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async => jsonOk({
|
|
'favorites': [
|
|
{'id': 's1'},
|
|
],
|
|
})),
|
|
);
|
|
final list = await cloud.getFavorites();
|
|
expect(list, hasLength(1));
|
|
expect(list.first['id'], 's1');
|
|
});
|
|
|
|
test('syncFavorites mit status ok → true', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async => jsonOk({'status': 'ok'})),
|
|
);
|
|
expect(await cloud.syncFavorites(['s1', 's2']), isTrue);
|
|
});
|
|
|
|
test('syncFavorites 401 → false (Auth-Fehler)', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async => http.Response('{}', 401)),
|
|
);
|
|
expect(await cloud.syncFavorites(['s1']), isFalse);
|
|
});
|
|
});
|
|
|
|
group('ytUrlSetzen (F2: YT-Link → Server)', () {
|
|
test('POST /yt-url mit song_id + yt_url, status ok → true', () async {
|
|
http.Request? anfrage;
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async {
|
|
anfrage = req;
|
|
return jsonOk({'status': 'ok'});
|
|
}),
|
|
);
|
|
final ok = await cloud.ytUrlSetzen('s1', 'https://youtu.be/abc');
|
|
expect(ok, isTrue);
|
|
expect(anfrage!.url.path, '/api/v1/cloud/yt-url');
|
|
final body = jsonDecode(anfrage!.body) as Map<String, dynamic>;
|
|
expect(body['song_id'], 's1');
|
|
expect(body['yt_url'], 'https://youtu.be/abc');
|
|
});
|
|
|
|
test('ytUrlSetzen ohne status ok → false', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async => jsonOk({'status': 'error'})),
|
|
);
|
|
expect(await cloud.ytUrlSetzen('s1', 'https://youtu.be/abc'), isFalse);
|
|
});
|
|
|
|
test('ytUrlSetzen 401 → false (Auth-Fehler)', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async => http.Response('{}', 401)),
|
|
);
|
|
expect(await cloud.ytUrlSetzen('s1', 'https://youtu.be/abc'), isFalse);
|
|
});
|
|
});
|
|
|
|
group('History & Songs', () {
|
|
test('getHistory parst history-Liste', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async => jsonOk({
|
|
'history': [
|
|
{'song_id': 's1', 'played_at': '2026-08-04T10:00:00Z'},
|
|
],
|
|
})),
|
|
);
|
|
final list = await cloud.getHistory();
|
|
expect(list, hasLength(1));
|
|
expect(list.first['song_id'], 's1');
|
|
});
|
|
|
|
test('listSongs parst songs-Liste', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async => jsonOk({
|
|
'songs': [
|
|
{'id': 's1', 'title': 'Titel'},
|
|
],
|
|
})),
|
|
);
|
|
final list = await cloud.listSongs();
|
|
expect(list, hasLength(1));
|
|
expect(list.first['title'], 'Titel');
|
|
});
|
|
});
|
|
|
|
group('delete / globalList (Fehlerpfade)', () {
|
|
test('delete 200 → true, 401 → false', () async {
|
|
final cloudOk = CloudService(
|
|
client: MockClient((req) async => http.Response('{}', 200)),
|
|
);
|
|
expect(await cloudOk.delete('s1'), isTrue);
|
|
|
|
final cloud401 = CloudService(
|
|
client: MockClient((req) async => http.Response('{}', 401)),
|
|
);
|
|
expect(await cloud401.delete('s1'), isFalse);
|
|
});
|
|
|
|
test('delete mit deletedAt sendet deleted_at im Body (Tombstone, Sprint E)',
|
|
() async {
|
|
late Map<String, dynamic> gesendeterBody;
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async {
|
|
gesendeterBody = jsonDecode(req.body) as Map<String, dynamic>;
|
|
return http.Response('{}', 200);
|
|
}),
|
|
);
|
|
final ok = await cloud.delete('s1', deletedAt: '2026-08-05T10:00:00');
|
|
expect(ok, isTrue);
|
|
expect(gesendeterBody['song_id'], 's1');
|
|
expect(gesendeterBody['deleted_at'], '2026-08-05T10:00:00');
|
|
});
|
|
|
|
test('delete ohne deletedAt sendet KEIN deleted_at-Feld', () async {
|
|
late Map<String, dynamic> gesendeterBody;
|
|
final cloud = CloudService(
|
|
client: MockClient((req) async {
|
|
gesendeterBody = jsonDecode(req.body) as Map<String, dynamic>;
|
|
return http.Response('{}', 200);
|
|
}),
|
|
);
|
|
final ok = await cloud.delete('s1');
|
|
expect(ok, isTrue);
|
|
expect(gesendeterBody['song_id'], 's1');
|
|
expect(gesendeterBody.containsKey('deleted_at'), isFalse);
|
|
});
|
|
|
|
test('delete Netzwerkfehler → false (kein Crash)', () async {
|
|
final cloud = CloudService(
|
|
client: MockClient(
|
|
(req) async => throw http.ClientException('offline')),
|
|
);
|
|
expect(await cloud.delete('s1', deletedAt: '2026-08-05T10:00:00'),
|
|
isFalse);
|
|
});
|
|
|
|
test('globalList 200 → Songs, 401 → []', () async {
|
|
final cloudOk = CloudService(
|
|
client: MockClient((req) async =>
|
|
jsonOk({'songs': [{'id': 'g1'}]})),
|
|
);
|
|
expect(await cloudOk.globalList(), hasLength(1));
|
|
|
|
final cloud401 = CloudService(
|
|
client: MockClient((req) async => http.Response('{}', 401)),
|
|
);
|
|
expect(await cloud401.globalList(), isEmpty);
|
|
});
|
|
});
|
|
}
|