Files
Melo/test/services/sync_service_test.dart
Hermes (Server)andClaude Opus 5 cd79213bb3 Serverseitig: Uploads landen in der Navidrome-Bibliothek
Auf Wunsch: was vom Handy hochgeladen wird, soll auch in Navidrome
auftauchen. Die Subsonic-API kennt keinen Upload — also erledigt es die
Melo-Cloud selbst.

Server (/home/dustin/scripts/melo_cloud.py, nicht versioniert — Backup als
melo_cloud.py.bak-20260821-084506):
- verknuepfe_navidrome(): Hardlink der Registry-Datei nach
  /home/dustin/navidrome/music (Bind-Mount des Containers). Gleiche
  Partition -> kein zusaetzlicher Speicher. Fallback: Kopie.
- rescan_navidrome(): Subsonic startScan.view ueber Navidromes
  AuthProxyHeader (TrustedSources 127.0.0.1) — kein Passwort im Code.
  Nur zur Beschleunigung, der Watcher findet neue Dateien ohnehin.
- upload() verknuepft neue UND bereits bekannte Titel (heilt Bestand).
- handle_delete() entfernt die Datei aus Registry + Navidrome, sobald kein
  Konto sie mehr aktiv hat. Der Registry-EINTRAG bleibt stehen: handle_list
  haengt die Grabsteine daran (JOIN registry) — ohne ihn erfuehren die
  anderen Geraete nie von der Loeschung (Zombie-Song).
- registry_pfad(): gemeinsame Dateisuche ueber alle Audio-Endungen, mit
  Rueckfall auf den Navidrome-Ordner.

Nebenbei repariert:
- Alle 325 Cloud-Dateien lagen nur noch im Navidrome-Ordner, REG war leer:
  jeder Download antwortete "File missing". Per Hardlink zurueckverknuepft
  (scripts/melo_cloud_migriere_registry.py).
- upload() legte jede Datei als ".mp3" ab, auch m4a/flac. Jetzt echte
  Endung; _download liefert passenden Content-Type und einen Dateinamen
  ohne doppelte Endung.

App:
- MeloCloudService.herunterladen() gibt die geschriebene Datei zurueck und
  leitet die Endung aus dem Content-Type ab (endungFuer) — sonst landet
  eine M4A als .mp3 auf dem Handy und Android ordnet sie falsch ein.
- loeschBremseGreift(): der Abgleich reicht keine Loeschwelle mehr zum
  Server durch (>10 Titel UND >1/3 des Serverbestands). Ohne die Bremse
  haette eine nicht eingehaengte Speicherkarte die Sammlung auf allen
  Geraeten geloescht.

276 Tests gruen (1 uebersprungen), flutter analyze ohne Befund, plus ein
Durchlauf gegen den echten Server (scripts/test_melo_cloud_navidrome.py):
Upload -> Registry + Navidrome (ein Hardlink) -> Navidrome liest ein ->
Loeschen entfernt beides, Grabstein bleibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpPu4nuKjKKeX1RpdDeX81
2026-08-21 08:56:44 +02:00

310 lines
8.7 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:drift/drift.dart' show Value;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:melo/library/database.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/melo_cloud_service.dart';
import 'package:melo/services/sync_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _MemorySpeicher implements TokenSpeicher {
_MemorySpeicher(this.werte);
final Map<String, String> werte;
@override
Future<String?> lesen(String key) async => werte[key];
@override
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
@override
Future<void> loeschen(String key) async => werte.remove(key);
}
Future<BakaAuth> _angemeldeteAuth() async {
final auth = BakaAuth(
speicher: _MemorySpeicher({'baka_token': 'tok', 'baka_user': 'Baka'}),
);
await auth.laden();
return auth;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
late MeloDb db;
setUp(() async {
SharedPreferences.setMockInitialValues({});
tempDir = await Directory.systemTemp.createTemp('melo_sync');
db = MeloDb(NativeDatabase.memory());
});
tearDown(() async {
await db.close();
await tempDir.delete(recursive: true);
});
Future<SyncService> baue(
Future<http.Response> Function(http.Request) antwort,
) async {
return SyncService(
db: db,
cloud: MeloCloudService(
auth: await _angemeldeteAuth(),
client: MockClient(antwort),
),
musikOrdner: () async => tempDir,
);
}
test('ein Titel vom Server landet als Datei und in der Bibliothek',
() async {
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(
jsonEncode({
'songs': [
{'id': 'c1', 'title': 'Nachtpuls', 'artist': 'Rotklang', 'duration': 200}
]
}),
200,
);
}
if (pfad.contains('/download/')) {
return http.Response.bytes([1, 2, 3, 4], 200,
headers: {'content-type': 'audio/mp4'});
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(sync.fehler, isNull);
final songs = await db.allSongs();
expect(songs, hasLength(1));
expect(songs.single.title, 'Nachtpuls');
expect(songs.single.artist, 'Rotklang');
expect(songs.single.cloudId, 'c1');
expect(songs.single.durationMs, 200000);
expect(await File(songs.single.path).readAsBytes(), [1, 2, 3, 4]);
// Endung kommt vom echten Dateityp des Servers, nicht pauschal .mp3.
expect(songs.single.path, endsWith('.m4a'));
});
test('eine eigene Datei geht zum Server und bekommt die Server-ID', () async {
final datei = File('${tempDir.path}/eigen.mp3');
await datei.writeAsBytes([9, 9, 9]);
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
path: datei.path,
title: 'Eigenes Lied',
dateAddedMs: 0,
updatedAtMs: 0,
),
]);
var hochgeladen = 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')) {
hochgeladen++;
return http.Response(
jsonEncode({'status': 'ok', 'song_id': 'neu-42'}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(sync.fehler, isNull);
expect(hochgeladen, 1);
expect((await db.allSongs()).single.cloudId, 'neu-42');
});
test('ein am Server gelöschter Titel verschwindet auch auf dem Gerät',
() async {
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
path: '${tempDir.path}/weg.mp3',
title: 'Weg',
dateAddedMs: 0,
updatedAtMs: 0,
),
]);
await db.setCloudId('lokal-1', 'c9');
final sync = await baue((anfrage) async {
if (anfrage.url.path.endsWith('/list')) {
return http.Response(
jsonEncode({
'songs': [
{'id': 'c9', 'title': 'Weg', 'deleted': true}
]
}),
200,
);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect((await db.allSongs()).single.deleted, isTrue);
expect(await db.watchSongs().first, isEmpty);
});
test('Favoriten werden mit ihren Server-IDs gemeldet', () 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);
List<String>? gemeldet;
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')) {
final d = jsonDecode(anfrage.body) as Map<String, dynamic>;
gemeldet = (d['song_ids'] as List).cast<String>();
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(gemeldet, ['c5']);
});
test('eine Löschwelle wird nicht zum Server durchgereicht', () async {
// 12 lokal verschwundene Titel bei 12 am Server: sieht nach einem
// Speicherkarten-Unfall aus, nicht nach 12 Einzel-Löschungen.
final amServer = <Map<String, dynamic>>[];
for (var i = 0; i < 12; i++) {
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-$i',
path: '${tempDir.path}/weg$i.mp3',
title: 'Titel $i',
dateAddedMs: 0,
updatedAtMs: 0,
deleted: const Value(true),
),
]);
await db.setCloudId('lokal-$i', 'c$i');
amServer.add({'id': 'c$i', 'title': 'Titel $i'});
}
var geloescht = 0;
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(jsonEncode({'songs': amServer}), 200);
}
if (pfad.endsWith('/delete')) geloescht++;
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(geloescht, 0);
expect(sync.fehler, contains('Sicherheitsbremse'));
});
test('einzelne Löschungen laufen weiterhin durch', () async {
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
path: '${tempDir.path}/weg.mp3',
title: 'Weg',
dateAddedMs: 0,
updatedAtMs: 0,
deleted: const Value(true),
),
]);
await db.setCloudId('lokal-1', 'c1');
final gemeldet = <String>[];
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(
jsonEncode({
'songs': [
{'id': 'c1', 'title': 'Weg'},
{'id': 'c2', 'title': 'Bleibt'},
]
}),
200,
);
}
if (pfad.endsWith('/delete')) {
gemeldet.add(
(jsonDecode(anfrage.body) as Map<String, dynamic>)['song_id'] as String);
}
if (pfad.contains('/download/')) {
return http.Response.bytes([1], 200,
headers: {'content-type': 'audio/mpeg'});
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(gemeldet, ['c1']);
expect(sync.fehler, isNull);
});
test('ohne Anmeldung passiert nichts und der Grund steht da', () async {
final sync = SyncService(
db: db,
cloud: MeloCloudService(
auth: BakaAuth(speicher: _MemorySpeicher({})),
client: MockClient((_) async => http.Response('{}', 200)),
),
musikOrdner: () async => tempDir,
);
await sync.synchronisiere();
expect(sync.fehler, contains('anmelden'));
expect(sync.laeuft, isFalse);
});
test('ein Server-Fehler beendet den Lauf mit einer lesbaren Meldung',
() async {
final sync = await baue(
(_) async => http.Response(jsonEncode({'error': 'Auth required'}), 401));
await sync.synchronisiere();
expect(sync.fehler, contains('Anmeldung abgelaufen'));
expect(sync.laeuft, isFalse);
});
}