jellyfin_controller_test.dart 8.1 KB

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