Favoriten additiv abgleichen statt ersetzen; Upload-Timeout ist Einzelfehler

This commit is contained in:
Hermes (Server)
2026-08-27 10:55:00 +02:00
parent 09d24abf4d
commit 5dc80931e2
3 changed files with 246 additions and 26 deletions
+184 -5
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@@ -163,7 +164,7 @@ void main() {
expect(await db.watchSongs().first, isEmpty);
});
test('Favoriten werden mit ihren Server-IDs gemeldet', () async {
test('lokale Favoriten werden additiv gepusht, nie als Voll-Ersatz', () async {
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
@@ -176,7 +177,54 @@ void main() {
await db.setCloudId('lokal-1', 'c5');
await db.setFavorite('lokal-1', true);
List<String>? gemeldet;
final gepusht = <Map<String, dynamic>>[];
var vollErsatz = 0;
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(
jsonEncode({
'songs': [
{'id': 'c5', 'title': 'Lieblingslied'}
]
}),
200,
);
}
if (pfad.endsWith('/favorites/toggle')) {
gepusht.add(jsonDecode(anfrage.body) as Map<String, dynamic>);
return http.Response(jsonEncode({'status': 'ok'}), 200);
}
if (pfad.endsWith('/favorites')) {
if (anfrage.method == 'POST') vollErsatz++;
return http.Response(jsonEncode({'favorites': []}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(gepusht, [
{'song_id': 'c5', 'set': true}
]);
// Der Datenverlust-Bug ist strukturell weg: es gibt keinen Aufruf mehr,
// der den Server-Stand ersetzen könnte.
expect(vollErsatz, 0);
expect(sync.fehler, isNull);
});
test('ein Server-Favorit wird lokal nachgezogen', () async {
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
path: '${tempDir.path}/fav.mp3',
title: 'Lieblingslied',
dateAddedMs: 0,
updatedAtMs: 0,
),
]);
await db.setCloudId('lokal-1', 'c5');
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
@@ -190,15 +238,146 @@ void main() {
);
}
if (pfad.endsWith('/favorites')) {
final d = jsonDecode(anfrage.body) as Map<String, dynamic>;
gemeldet = (d['song_ids'] as List).cast<String>();
return http.Response(
jsonEncode({
'favorites': [
{'id': 'c5'}
]
}),
200,
);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(gemeldet, ['c5']);
expect(await db.favoriteSongIds(), ['lokal-1']);
});
test('200 mit Fehlerkörper überspringt die Favoriten-Phase ohne Push',
() async {
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
path: '${tempDir.path}/fav.mp3',
title: 'Lieblingslied',
dateAddedMs: 0,
updatedAtMs: 0,
),
]);
await db.setCloudId('lokal-1', 'c5');
await db.setFavorite('lokal-1', true);
var pushes = 0;
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(
jsonEncode({
'songs': [
{'id': 'c5', 'title': 'Lieblingslied'}
]
}),
200,
);
}
if (pfad.endsWith('/favorites/toggle')) {
pushes++;
return http.Response(jsonEncode({'status': 'ok'}), 200);
}
if (pfad.endsWith('/favorites')) {
return http.Response(
jsonEncode({'status': 'error', 'error': 'kaputt'}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(pushes, 0);
// Nur diese Phase fällt aus, der Lauf geht weiter.
expect(sync.fehler, isNull);
});
test('200 mit leerer Favoritenliste läuft normal durch', () async {
// Gegenprobe zum Test darüber: eine echte Leerantwort darf NICHT als
// Fehler gelten, sonst wäre die Sicherheitsregel trivial erfüllt.
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
path: '${tempDir.path}/fav.mp3',
title: 'Lieblingslied',
dateAddedMs: 0,
updatedAtMs: 0,
),
]);
await db.setCloudId('lokal-1', 'c5');
await db.setFavorite('lokal-1', true);
var pushes = 0;
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(
jsonEncode({
'songs': [
{'id': 'c5', 'title': 'Lieblingslied'}
]
}),
200,
);
}
if (pfad.endsWith('/favorites/toggle')) {
pushes++;
return http.Response(jsonEncode({'status': 'ok'}), 200);
}
if (pfad.endsWith('/favorites')) {
return http.Response(jsonEncode({'favorites': []}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(pushes, 1);
});
test('eine Zeitüberschreitung beim Hochladen reißt den Lauf nicht ab',
() async {
final datei = File('${tempDir.path}/haengt.mp3');
await datei.writeAsBytes([1]);
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
path: datei.path,
title: 'Hängt',
dateAddedMs: 0,
updatedAtMs: 0,
),
]);
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(jsonEncode({'songs': []}), 200);
}
if (pfad.endsWith('/upload')) {
// Der 120-s-Timeout in melo_cloud_service wirft TimeoutException,
// nicht CloudException — ohne eigenen Zweig riss ein einziger
// hängender Upload den ganzen Lauf ab.
throw TimeoutException('zu lang');
}
if (pfad.endsWith('/favorites')) {
return http.Response(jsonEncode({'favorites': []}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(sync.fehler, isNull);
expect((await db.songById('lokal-1'))!.cloudId, isNull);
});
test('eine Löschwelle wird nicht zum Server durchgereicht', () async {