import 'package:cardash_launcher/data/models/jellyfin_models.dart'; import 'package:cardash_launcher/data/repositories/settings_repository.dart'; import 'package:cardash_launcher/data/services/jellyfin_service.dart'; import 'package:cardash_launcher/data/services/secure_store.dart'; import 'package:cardash_launcher/features/settings/jellyfin_controller.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; class _MemoryStore implements SecureStore { final values = {}; @override Future read(String key) async => values[key]; @override Future write(String key, String value) async { values[key] = value; } @override Future delete(String key) async { values.remove(key); } } class _FakeGateway implements JellyfinGateway { bool unreachable = false; bool approved = false; bool rejectToken = false; int initiateCalls = 0; int userCalls = 0; String? lastDeviceId; @override Future serverInfo(Uri baseUrl) async { if (unreachable) { throw JellyfinUnreachable('Could not reach the server.'); } return const JellyfinServerInfo(name: 'DarkTower', version: '12.2.0'); } @override Future startQuickConnect( Uri baseUrl, { required String deviceId, }) async { if (unreachable) { throw JellyfinUnreachable('Could not reach the server.'); } initiateCalls++; lastDeviceId = deviceId; return const JellyfinQuickConnect(code: '491006', secret: 'secret'); } @override Future isQuickConnectApproved(Uri baseUrl, String secret) async { if (unreachable) { throw JellyfinUnreachable('Could not reach the server.'); } return approved; } @override Future authenticateQuickConnect( Uri baseUrl, String secret, { required String deviceId, }) async { return const JellyfinSession( accessToken: 'token-1', userId: 'user-1', userName: 'wasp', ); } @override Future currentUser(Uri baseUrl, String token) async { userCalls++; if (unreachable) { throw JellyfinUnreachable('Could not reach the server.'); } if (rejectToken) { throw JellyfinAuthException('The saved Jellyfin pairing is not valid.'); } return const JellyfinUser(id: 'user-1', name: 'wasp'); } @override void close() {} } Future _settings([ Map values = const {}, ]) async { SharedPreferences.setMockInitialValues(values); final prefs = await SharedPreferences.getInstance(); return SettingsRepository(prefs); } void main() { TestWidgetsFlutterBinding.ensureInitialized(); test('enabling without a URL waits for setup', () async { final settings = await _settings(); final gateway = _FakeGateway(); final controller = JellyfinController( settings, gateway, _MemoryStore(), pollInterval: const Duration(milliseconds: 10), retryDelays: const [Duration(milliseconds: 10)], ); addTearDown(controller.dispose); await controller.initialize(); expect(controller.status, JellyfinStatus.disabled); await controller.setEnabled(true); expect(controller.status, JellyfinStatus.needsSetup); expect(controller.enabled, isTrue); expect(settings.jellyfinEnabled, isTrue); }); test( 'Quick Connect code is shown and the approved token is stored', () async { final settings = await _settings(); final gateway = _FakeGateway(); final store = _MemoryStore(); final controller = JellyfinController( settings, gateway, store, pollInterval: const Duration(milliseconds: 10), pairingLifetime: const Duration(seconds: 2), retryDelays: const [Duration(milliseconds: 10)], ); addTearDown(controller.dispose); await controller.initialize(); await controller.setEnabled(true); await controller.connect('jellyfin.example.com'); expect(controller.status, JellyfinStatus.pairing); expect(controller.quickConnectCode, '491006'); expect(settings.jellyfinServerUrl, 'https://jellyfin.example.com'); expect(gateway.lastDeviceId, isNotEmpty); gateway.approved = true; await Future.delayed(const Duration(milliseconds: 80)); expect(controller.status, JellyfinStatus.connected); expect(controller.quickConnectCode, isNull); expect(store.values[JellyfinController.tokenKey], 'token-1'); expect(settings.jellyfinUserName, 'wasp'); }, ); test('a saved token is reused on startup without Quick Connect', () async { final settings = await _settings({ 'jellyfin.enabled': true, 'jellyfin.serverUrl': 'https://jellyfin.example.com', }); final store = _MemoryStore() ..values[JellyfinController.tokenKey] = 'token-1'; final gateway = _FakeGateway(); final controller = JellyfinController( settings, gateway, store, pollInterval: const Duration(milliseconds: 10), retryDelays: const [Duration(milliseconds: 10)], ); addTearDown(controller.dispose); await controller.initialize(); expect(controller.status, JellyfinStatus.connecting); controller.start(); await Future.delayed(const Duration(milliseconds: 40)); expect(controller.status, JellyfinStatus.connected); expect(controller.serverName, 'DarkTower'); expect(controller.userName, 'wasp'); expect(gateway.initiateCalls, 0); expect(gateway.userCalls, 1); }); test( 'a rejected token disables Jellyfin, clears the token, and asks to pair', () async { final settings = await _settings({ 'jellyfin.enabled': true, 'jellyfin.serverUrl': 'https://jellyfin.example.com', }); final store = _MemoryStore() ..values[JellyfinController.tokenKey] = 'stale-token'; final gateway = _FakeGateway()..rejectToken = true; final controller = JellyfinController( settings, gateway, store, retryDelays: const [Duration(milliseconds: 10)], ); addTearDown(controller.dispose); await controller.initialize(); controller.start(); await Future.delayed(const Duration(milliseconds: 40)); expect(controller.enabled, isFalse); expect(controller.needsRepair, isTrue); expect(controller.status, JellyfinStatus.error); expect(controller.error, isNotNull); expect(store.values.containsKey(JellyfinController.tokenKey), isFalse); expect(settings.jellyfinEnabled, isFalse); expect(settings.jellyfinServerUrl, 'https://jellyfin.example.com'); }, ); test('an unreachable server is retried and reconnects', () async { final settings = await _settings({ 'jellyfin.enabled': true, 'jellyfin.serverUrl': 'https://jellyfin.example.com', }); final store = _MemoryStore() ..values[JellyfinController.tokenKey] = 'token-1'; final gateway = _FakeGateway()..unreachable = true; final controller = JellyfinController( settings, gateway, store, retryDelays: const [Duration(milliseconds: 20)], ); addTearDown(controller.dispose); await controller.initialize(); controller.start(); await Future.delayed(const Duration(milliseconds: 30)); expect(controller.status, JellyfinStatus.offline); expect(controller.enabled, isTrue); expect(store.values[JellyfinController.tokenKey], 'token-1'); gateway.unreachable = false; await Future.delayed(const Duration(milliseconds: 80)); expect(controller.status, JellyfinStatus.connected); expect(controller.userName, 'wasp'); }); test('urls are normalized with a scheme and without a trailing slash', () { expect( normalizeJellyfinUrl('jellyfin.example.com')?.toString(), 'https://jellyfin.example.com/', ); expect( normalizeJellyfinUrl('http://host:8096/library')?.toString(), 'http://host:8096/library/', ); expect(normalizeJellyfinUrl(''), isNull); expect(normalizeJellyfinUrl('ftp://host'), isNull); expect(displayJellyfinUrl('https://host/'), 'https://host'); }); }