v2.55 — Echtzeit-Cloud-Sync (SSE) mit 3 Modi: Echtzeit/Manuell/Zeit

This commit is contained in:
Dustin
2026-08-06 18:01:02 +02:00
parent 2ce24398e4
commit 3f169fa1d9
4 changed files with 935 additions and 19 deletions
@@ -0,0 +1,283 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:melo_app/services/realtime_sync_service.dart';
import 'package:melo_app/services/sync_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// RealtimeSyncService-Tests (v2.55 Echtzeit-Cloud-Sync).
///
/// Abgedeckt:
/// - Backoff-Berechnung (exponentiell, clamped auf max 8s)
/// - SSE-Event-Parsing (heartbeat ignoriert, song_favorite verarbeitet)
/// - Tombstone-Respektierung bei Song-Löschung
/// - Leere song_id wird ignoriert
/// - Sync-Modus-Prüfung (manual/interval/realtime Enum-Werte)
///
/// KEINE neuen Packages — reine Unit-Tests mit einfachen Hand-Mocks.
/// Verwendet nur flutter_test + bestehende Projekt-Abhängigkeiten.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
SharedPreferences.setMockInitialValues({});
// ── Gruppe 1: Backoff-Berechnung ──
group('Backoff-Berechnung (exponentiell)', () {
test('Versuch 1 → 2 Sekunden', () {
final service = RealtimeSyncService();
final backoff = service.backoffBerechnen(1);
expect(backoff.inSeconds, 2);
});
test('Versuch 2 → 4 Sekunden', () {
final service = RealtimeSyncService();
final backoff = service.backoffBerechnen(2);
expect(backoff.inSeconds, 4);
});
test('Versuch 3 → 8 Sekunden', () {
final service = RealtimeSyncService();
final backoff = service.backoffBerechnen(3);
expect(backoff.inSeconds, 8);
});
test('Versuch 4+ → clamped auf max 8 Sekunden', () {
final service = RealtimeSyncService();
expect(service.backoffBerechnen(4).inSeconds, 8);
expect(service.backoffBerechnen(5).inSeconds, 8);
expect(service.backoffBerechnen(10).inSeconds, 8);
});
test('Versuch 0 → 0 Sekunden (Clamp)', () {
final service = RealtimeSyncService();
final backoff = service.backoffBerechnen(0);
expect(backoff.inSeconds, 0);
});
});
// ── Gruppe 2: SSE-Event-Parsing ──
group('SSE-Event-Parsing', () {
test('Heartbeat wird ignoriert (event: heartbeat)', () {
final service = RealtimeSyncService();
// Heartbeat-Event → sollte keinen Fehler werfen
service.verarbeiteSseZeile('event: heartbeat');
service.verarbeiteSseZeile('data: {}');
// Kein Fehler → Test bestanden
expect(service.fehlerNotifier.value, isNull);
});
test('Heartbeat data:-Zeile wird still verarbeitet', () {
final service = RealtimeSyncService();
// Nach einem Heartbeat-Event darf keine Exception fliegen
service.verarbeiteSseZeile('event: heartbeat');
expect(() => service.verarbeiteSseZeile('data: {}'), returnsNormally);
});
test('song_favorite Event wird erkannt (kein Parse-Fehler)', () {
final service = RealtimeSyncService();
// Gültiges song_favorite-Event — darf keinen Parse-Fehler werfen
service.verarbeiteSseZeile('event: song_favorite');
expect(
() => service.verarbeiteSseZeile(
'data: {"song_id":"abc123","is_favorite":true}'),
returnsNormally,
);
});
test('song_delete mit leerer song_id verarbeitet still', () {
final service = RealtimeSyncService();
// Leere song_id → Event wird ignoriert (kein Crash)
service.verarbeiteSseZeile('event: song_delete');
expect(
() => service.verarbeiteSseZeile(
'data: {"song_id":"","deleted_at":"2026-08-01T12:00:00"}'),
returnsNormally,
);
});
test('song_update Event wird erkannt (kein Parse-Fehler)', () {
final service = RealtimeSyncService();
service.verarbeiteSseZeile('event: song_update');
expect(
() => service.verarbeiteSseZeile(
'data: {"song_id":"abc123","title":"Neuer Titel","artist":"Künstler"}'),
returnsNormally,
);
});
test('playlist_update wird ignoriert (kein Fehler)', () {
final service = RealtimeSyncService();
service.verarbeiteSseZeile('event: playlist_update');
expect(
() => service.verarbeiteSseZeile(
'data: {"playlist_id":42}'),
returnsNormally,
);
});
});
// ── Gruppe 3: Tombstone-Respektierung ──
group('Tombstone-Respektierung bei Song-Löschung', () {
final letzterSync = DateTime(2026, 8, 4, 12, 0, 0);
test('deletedAt neuer als letzterSync → anwenden', () {
expect(
SyncService.tombstoneAnwenden(
letzterSync: letzterSync,
deletedAt: '2026-08-05T10:00:00'),
isTrue,
);
});
test('deletedAt älter als letzterSync → NICHT anwenden', () {
expect(
SyncService.tombstoneAnwenden(
letzterSync: letzterSync,
deletedAt: '2026-08-03T10:00:00'),
isFalse,
);
});
test('deletedAt null (Alt-Tombstone ohne Zeitstempel) → anwenden', () {
expect(
SyncService.tombstoneAnwenden(
letzterSync: letzterSync, deletedAt: null),
isTrue,
);
});
test('letzterSync null (Erst-Sync) → anwenden', () {
expect(
SyncService.tombstoneAnwenden(
letzterSync: null, deletedAt: '2026-08-05T10:00:00'),
isTrue,
);
});
test('deletedAt == letzterSync (Gleichstand) → NICHT anwenden', () {
expect(
SyncService.tombstoneAnwenden(
letzterSync: letzterSync,
deletedAt: '2026-08-04T12:00:00'),
isFalse,
);
});
test('ungültiger deletedAt-String → anwenden (sicherer Default)', () {
expect(
SyncService.tombstoneAnwenden(
letzterSync: letzterSync, deletedAt: 'kein-iso-timestamp'),
isTrue,
);
});
});
// ── Gruppe 4: Leere song_id ──
group('Leere song_id wird ignoriert', () {
test('song_favorite mit leerer song_id → kein Fehler', () {
final service = RealtimeSyncService();
service.verarbeiteSseZeile('event: song_favorite');
expect(
() => service.verarbeiteSseZeile(
'data: {"song_id":"","is_favorite":true}'),
returnsNormally,
);
});
test('song_delete mit null song_id → kein Fehler', () {
final service = RealtimeSyncService();
service.verarbeiteSseZeile('event: song_delete');
expect(
() => service.verarbeiteSseZeile('data: {"deleted_at":"2026-08-01T12:00:00"}'),
returnsNormally,
);
});
test('song_update ohne song_id → kein Fehler', () {
final service = RealtimeSyncService();
service.verarbeiteSseZeile('event: song_update');
expect(
() => service.verarbeiteSseZeile(
'data: {"title":"Nur Titel"}'),
returnsNormally,
);
});
test('Ungültiges JSON in data:-Zeile → kein Crash', () {
final service = RealtimeSyncService();
expect(
() => service.verarbeiteSseZeile('data: kein-json'),
returnsNormally,
);
});
test('Leere data:-Zeile → kein Fehler', () {
final service = RealtimeSyncService();
expect(
() => service.verarbeiteSseZeile('data: '),
returnsNormally,
);
});
});
// ── Gruppe 5: Sync-Modus-Prüfung ──
group('Sync-Modus (Enum + Werte)', () {
test('SyncModus hat genau 3 Werte', () {
const alle = SyncModus.values;
expect(alle.length, 3);
});
test('SyncModus-Werte sind eindeutig', () {
const alle = SyncModus.values;
expect(alle.toSet().length, 3);
});
test('aktuellerModus() liefert immer einen gültigen Modus', () async {
final modus = await RealtimeSyncService.aktuellerModus();
// Default ist 'realtime' (SharedPreferences ohne Mock = leer)
expect(modus, SyncModus.echtzeit);
});
test('cloudIntervallStunden() liefert Default 48h', () async {
final intervall = await RealtimeSyncService.cloudIntervallStunden();
// Ohne gespeicherte Werte = Default 48 (SharedPreferences leer)
expect(intervall, greaterThanOrEqualTo(48));
});
test('verschiedene Modi sind unterscheidbar', () {
expect(SyncModus.manuell, isNot(SyncModus.echtzeit));
expect(SyncModus.manuell, isNot(SyncModus.intervall));
expect(SyncModus.echtzeit, isNot(SyncModus.intervall));
});
});
// ── Gruppe 6: Notifier-Verhalten ──
group('Notifier-Verhalten', () {
test('istVerbundenNotifier startet mit false', () {
final service = RealtimeSyncService();
expect(service.istVerbundenNotifier.value, false);
});
test('fehlerNotifier startet mit null', () {
final service = RealtimeSyncService();
expect(service.fehlerNotifier.value, isNull);
});
test('stoppe() setzt istVerbunden auf false', () {
final service = RealtimeSyncService();
service.istVerbundenNotifier.value = true;
service.stoppe();
expect(service.istVerbundenNotifier.value, false);
});
test('pausiere() setzt istVerbunden auf false', () {
final service = RealtimeSyncService();
service.istVerbundenNotifier.value = true;
service.pausiere();
expect(service.istVerbundenNotifier.value, false);
});
});
}