jellyfin_controller_test.dart 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. import 'package:cardash_launcher/data/models/jellyfin_library.dart';
  2. import 'package:cardash_launcher/data/models/jellyfin_models.dart';
  3. import 'package:cardash_launcher/data/repositories/settings_repository.dart';
  4. import 'package:cardash_launcher/data/services/jellyfin_service.dart';
  5. import 'package:cardash_launcher/data/services/secure_store.dart';
  6. import 'package:cardash_launcher/features/settings/jellyfin_controller.dart';
  7. import 'package:flutter_test/flutter_test.dart';
  8. import 'package:shared_preferences/shared_preferences.dart';
  9. class _MemoryStore implements SecureStore {
  10. final values = <String, String>{};
  11. @override
  12. Future<String?> read(String key) async => values[key];
  13. @override
  14. Future<void> write(String key, String value) async {
  15. values[key] = value;
  16. }
  17. @override
  18. Future<void> delete(String key) async {
  19. values.remove(key);
  20. }
  21. }
  22. class _FakeGateway implements JellyfinGateway {
  23. bool unreachable = false;
  24. bool approved = false;
  25. bool rejectToken = false;
  26. int initiateCalls = 0;
  27. int userCalls = 0;
  28. String? lastDeviceId;
  29. @override
  30. Future<JellyfinServerInfo> serverInfo(Uri baseUrl) async {
  31. if (unreachable) {
  32. throw JellyfinUnreachable('Could not reach the server.');
  33. }
  34. return const JellyfinServerInfo(name: 'DarkTower', version: '12.2.0');
  35. }
  36. @override
  37. Future<JellyfinQuickConnect> startQuickConnect(
  38. Uri baseUrl, {
  39. required String deviceId,
  40. }) async {
  41. if (unreachable) {
  42. throw JellyfinUnreachable('Could not reach the server.');
  43. }
  44. initiateCalls++;
  45. lastDeviceId = deviceId;
  46. return const JellyfinQuickConnect(code: '491006', secret: 'secret');
  47. }
  48. @override
  49. Future<bool> isQuickConnectApproved(Uri baseUrl, String secret) async {
  50. if (unreachable) {
  51. throw JellyfinUnreachable('Could not reach the server.');
  52. }
  53. return approved;
  54. }
  55. @override
  56. Future<JellyfinSession> authenticateQuickConnect(
  57. Uri baseUrl,
  58. String secret, {
  59. required String deviceId,
  60. }) async {
  61. return const JellyfinSession(
  62. accessToken: 'token-1',
  63. userId: 'user-1',
  64. userName: 'wasp',
  65. );
  66. }
  67. @override
  68. Future<JellyfinUser> currentUser(Uri baseUrl, String token) async {
  69. userCalls++;
  70. if (unreachable) {
  71. throw JellyfinUnreachable('Could not reach the server.');
  72. }
  73. if (rejectToken) {
  74. throw JellyfinAuthException('The saved Jellyfin pairing is not valid.');
  75. }
  76. return const JellyfinUser(id: 'user-1', name: 'wasp');
  77. }
  78. @override
  79. void close() {}
  80. }
  81. Future<SettingsRepository> _settings([
  82. Map<String, Object> values = const {},
  83. ]) async {
  84. SharedPreferences.setMockInitialValues(values);
  85. final prefs = await SharedPreferences.getInstance();
  86. return SettingsRepository(prefs);
  87. }
  88. void main() {
  89. TestWidgetsFlutterBinding.ensureInitialized();
  90. test('enabling without a URL waits for setup', () async {
  91. final settings = await _settings();
  92. final gateway = _FakeGateway();
  93. final controller = JellyfinController(
  94. settings,
  95. gateway,
  96. _MemoryStore(),
  97. pollInterval: const Duration(milliseconds: 10),
  98. retryDelays: const [Duration(milliseconds: 10)],
  99. );
  100. addTearDown(controller.dispose);
  101. await controller.initialize();
  102. expect(controller.status, JellyfinStatus.disabled);
  103. await controller.setEnabled(true);
  104. expect(controller.status, JellyfinStatus.needsSetup);
  105. expect(controller.enabled, isTrue);
  106. expect(settings.jellyfinEnabled, isTrue);
  107. });
  108. test(
  109. 'Quick Connect code is shown and the approved token is stored',
  110. () async {
  111. final settings = await _settings();
  112. final gateway = _FakeGateway();
  113. final store = _MemoryStore();
  114. final controller = JellyfinController(
  115. settings,
  116. gateway,
  117. store,
  118. pollInterval: const Duration(milliseconds: 10),
  119. pairingLifetime: const Duration(seconds: 2),
  120. retryDelays: const [Duration(milliseconds: 10)],
  121. );
  122. addTearDown(controller.dispose);
  123. await controller.initialize();
  124. await controller.setEnabled(true);
  125. await controller.connect('jellyfin.example.com');
  126. expect(controller.status, JellyfinStatus.pairing);
  127. expect(controller.quickConnectCode, '491006');
  128. expect(settings.jellyfinServerUrl, 'https://jellyfin.example.com');
  129. expect(gateway.lastDeviceId, isNotEmpty);
  130. gateway.approved = true;
  131. await Future<void>.delayed(const Duration(milliseconds: 80));
  132. expect(controller.status, JellyfinStatus.connected);
  133. expect(controller.quickConnectCode, isNull);
  134. expect(store.values[JellyfinController.tokenKey], 'token-1');
  135. expect(settings.jellyfinUserName, 'wasp');
  136. },
  137. );
  138. test('a saved token is reused on startup without Quick Connect', () async {
  139. final settings = await _settings({
  140. 'jellyfin.enabled': true,
  141. 'jellyfin.serverUrl': 'https://jellyfin.example.com',
  142. });
  143. final store = _MemoryStore()
  144. ..values[JellyfinController.tokenKey] = 'token-1';
  145. final gateway = _FakeGateway();
  146. final controller = JellyfinController(
  147. settings,
  148. gateway,
  149. store,
  150. pollInterval: const Duration(milliseconds: 10),
  151. retryDelays: const [Duration(milliseconds: 10)],
  152. );
  153. addTearDown(controller.dispose);
  154. await controller.initialize();
  155. expect(controller.status, JellyfinStatus.connecting);
  156. controller.start();
  157. await Future<void>.delayed(const Duration(milliseconds: 40));
  158. expect(controller.status, JellyfinStatus.connected);
  159. expect(controller.serverName, 'DarkTower');
  160. expect(controller.userName, 'wasp');
  161. expect(gateway.initiateCalls, 0);
  162. expect(gateway.userCalls, 1);
  163. });
  164. test(
  165. 'a rejected token disables Jellyfin, clears the token, and asks to pair',
  166. () async {
  167. final settings = await _settings({
  168. 'jellyfin.enabled': true,
  169. 'jellyfin.serverUrl': 'https://jellyfin.example.com',
  170. });
  171. final store = _MemoryStore()
  172. ..values[JellyfinController.tokenKey] = 'stale-token';
  173. final gateway = _FakeGateway()..rejectToken = true;
  174. final controller = JellyfinController(
  175. settings,
  176. gateway,
  177. store,
  178. retryDelays: const [Duration(milliseconds: 10)],
  179. );
  180. addTearDown(controller.dispose);
  181. await controller.initialize();
  182. controller.start();
  183. await Future<void>.delayed(const Duration(milliseconds: 40));
  184. expect(controller.enabled, isFalse);
  185. expect(controller.needsRepair, isTrue);
  186. expect(controller.status, JellyfinStatus.error);
  187. expect(controller.error, isNotNull);
  188. expect(store.values.containsKey(JellyfinController.tokenKey), isFalse);
  189. expect(settings.jellyfinEnabled, isFalse);
  190. expect(settings.jellyfinServerUrl, 'https://jellyfin.example.com');
  191. },
  192. );
  193. test('an unreachable server is retried and reconnects', () async {
  194. final settings = await _settings({
  195. 'jellyfin.enabled': true,
  196. 'jellyfin.serverUrl': 'https://jellyfin.example.com',
  197. });
  198. final store = _MemoryStore()
  199. ..values[JellyfinController.tokenKey] = 'token-1';
  200. final gateway = _FakeGateway()..unreachable = true;
  201. final controller = JellyfinController(
  202. settings,
  203. gateway,
  204. store,
  205. retryDelays: const [Duration(milliseconds: 20)],
  206. );
  207. addTearDown(controller.dispose);
  208. await controller.initialize();
  209. controller.start();
  210. await Future<void>.delayed(const Duration(milliseconds: 30));
  211. expect(controller.status, JellyfinStatus.offline);
  212. expect(controller.enabled, isTrue);
  213. expect(store.values[JellyfinController.tokenKey], 'token-1');
  214. gateway.unreachable = false;
  215. await Future<void>.delayed(const Duration(milliseconds: 80));
  216. expect(controller.status, JellyfinStatus.connected);
  217. expect(controller.userName, 'wasp');
  218. });
  219. test('audio quality defaults to direct and persists', () async {
  220. final settings = await _settings();
  221. final controller = JellyfinController(
  222. settings,
  223. _FakeGateway(),
  224. _MemoryStore(),
  225. );
  226. addTearDown(controller.dispose);
  227. await controller.initialize();
  228. expect(controller.audioQuality, JellyfinAudioQuality.direct);
  229. await controller.setAudioQuality(JellyfinAudioQuality.kbps320);
  230. expect(controller.audioQuality, JellyfinAudioQuality.kbps320);
  231. expect(settings.jellyfinAudioQuality, JellyfinAudioQuality.kbps320);
  232. final second = JellyfinController(settings, _FakeGateway(), _MemoryStore());
  233. addTearDown(second.dispose);
  234. await second.initialize();
  235. expect(second.audioQuality, JellyfinAudioQuality.kbps320);
  236. });
  237. test('urls are normalized with a scheme and without a trailing slash', () {
  238. expect(
  239. normalizeJellyfinUrl('jellyfin.example.com')?.toString(),
  240. 'https://jellyfin.example.com/',
  241. );
  242. expect(
  243. normalizeJellyfinUrl('http://host:8096/library')?.toString(),
  244. 'http://host:8096/library/',
  245. );
  246. expect(normalizeJellyfinUrl(''), isNull);
  247. expect(normalizeJellyfinUrl('ftp://host'), isNull);
  248. expect(displayJellyfinUrl('https://host/'), 'https://host');
  249. });
  250. }