From 90a86e932d9eeb747ae961e95aec7f70cc953b04 Mon Sep 17 00:00:00 2001 From: Artem Kokos Date: Thu, 23 Apr 2026 20:11:37 +0700 Subject: [PATCH] feat: surface admin load errors --- lib/providers/providers.dart | 93 ++++----- lib/screens/api_keys_screen.dart | 286 +++++++++++++++++----------- lib/screens/schedules_screen.dart | 129 +++++++++---- test/read_only_load_state_test.dart | 199 ++++++++++++++++++- 4 files changed, 516 insertions(+), 191 deletions(-) diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart index 09477cf..42324ee 100644 --- a/lib/providers/providers.dart +++ b/lib/providers/providers.dart @@ -622,36 +622,42 @@ class ScenesNotifier extends Notifier> { // ─── Расписания ────────────────────────────────────────────── -final tasksProvider = NotifierProvider>( +final tasksProvider = NotifierProvider>>( () => TasksNotifier(), ); -class TasksNotifier extends Notifier> { +class TasksNotifier extends Notifier>> { @override - List build() => []; + LoadState> build() => const LoadState.idle([]); Future load() async { + state = LoadState.loading(state.data); try { final api = ref.read(apiProvider); final res = await api.getTasks(); final data = res.data; + late final List tasks; if (data is List) { - state = data; + tasks = List.from(data); } else if (data is Map) { - state = data['tasks'] ?? data['data'] ?? data.values.toList(); + final value = data['tasks'] ?? data['data'] ?? data.values.toList(); + if (value is! List) { + throw FormatException('tasks должен быть списком расписаний'); + } + tasks = List.from(value); + } else { + throw FormatException('tasks должен быть списком расписаний'); } + + state = tasks.isEmpty ? LoadState.empty(tasks) : LoadState.data(tasks); } catch (e) { - debugPrint("Ошибка загрузки расписаний: $e"); + state = LoadState.error(state.data, describeLoadError(e)); } } Future cancel(String jobId) async { - try { - await ref.read(apiProvider).cancelTask(jobId); - await load(); - } catch (e) { - debugPrint("Ошибка отмены задачи: $e"); - } + await ref.read(apiProvider).cancelTask(jobId); + await load(); } /// Создать одноразовый таймер @@ -765,60 +771,61 @@ class EventLogNotifier extends Notifier>> { // ─── API-ключи ─────────────────────────────────────────────── -final apiKeysProvider = NotifierProvider>( - () => ApiKeysNotifier(), -); +final apiKeysProvider = + NotifierProvider>>( + () => ApiKeysNotifier(), + ); -class ApiKeysNotifier extends Notifier> { +class ApiKeysNotifier extends Notifier>> { @override - List build() => []; + LoadState> build() => const LoadState.idle([]); Future load() async { + state = LoadState.loading(state.data); try { final api = ref.read(apiProvider); final res = await api.getApiKeys(); final data = res.data; + late final List keys; if (data is List) { - state = data; + keys = List.from(data); } else if (data is Map) { - state = data['data'] ?? data['keys'] ?? data.values.toList(); + final value = data['data'] ?? data['keys'] ?? data.values.toList(); + if (value is! List) { + throw FormatException('api-keys должен быть списком ключей'); + } + keys = List.from(value); + } else { + throw FormatException('api-keys должен быть списком ключей'); } + + state = keys.isEmpty ? LoadState.empty(keys) : LoadState.data(keys); } catch (e) { - debugPrint("Ошибка загрузки API-ключей: $e"); + state = LoadState.error(state.data, describeLoadError(e)); } } - Future create(String name, {bool isAdmin = false}) async { - try { - final api = ref.read(apiProvider); - final res = await api.createApiKey(name, isAdmin: isAdmin); - await load(); - if (res.data is Map) { - return res.data['key']?.toString(); + Future create(String name, {bool isAdmin = false}) async { + final api = ref.read(apiProvider); + final res = await api.createApiKey(name, isAdmin: isAdmin); + await load(); + if (res.data is Map) { + final key = res.data['key']?.toString(); + if (key != null && key.isNotEmpty) { + return key; } - return null; - } catch (e) { - debugPrint("Ошибка создания ключа: $e"); - return null; } + throw const FormatException('backend не вернул созданный API key'); } Future revoke(String key) async { - try { - await ref.read(apiProvider).revokeApiKey(key); - await load(); - } catch (e) { - debugPrint("Ошибка отзыва ключа: $e"); - } + await ref.read(apiProvider).revokeApiKey(key); + await load(); } Future activate(String key) async { - try { - await ref.read(apiProvider).activateApiKey(key); - await load(); - } catch (e) { - debugPrint("Ошибка активации ключа: $e"); - } + await ref.read(apiProvider).activateApiKey(key); + await load(); } } diff --git a/lib/screens/api_keys_screen.dart b/lib/screens/api_keys_screen.dart index 0fa3871..be9cb9d 100644 --- a/lib/screens/api_keys_screen.dart +++ b/lib/screens/api_keys_screen.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../app/error_message.dart'; +import '../app/load_state.dart'; import '../providers/providers.dart'; +import '../widgets/load_error_view.dart'; /// Экран управления гостевыми API-ключами. /// Доступен только администраторам. @@ -13,7 +16,6 @@ class ApiKeysScreen extends ConsumerStatefulWidget { } class _ApiKeysScreenState extends ConsumerState { - bool _loading = true; String? _lastCreatedKey; @override @@ -24,110 +26,22 @@ class _ApiKeysScreenState extends ConsumerState { Future _load() async { await ref.read(apiKeysProvider.notifier).load(); - if (mounted) setState(() => _loading = false); } @override Widget build(BuildContext context) { - final keys = ref.watch(apiKeysProvider); + final keysState = ref.watch(apiKeysProvider); + final keys = keysState.data; return Scaffold( appBar: AppBar(title: const Text('API-КЛЮЧИ')), - body: _loading - ? const Center( - child: CircularProgressIndicator(color: Colors.deepOrange), - ) - : Column( - children: [ - // ─── Последний созданный ключ (для копирования) ─── - if (_lastCreatedKey != null) - Container( - margin: const EdgeInsets.all(12), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.deepOrange.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.deepOrange.withValues(alpha: 0.3), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Новый ключ создан! Скопируйте его сейчас:', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - color: Colors.deepOrange, - ), - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: Text( - _lastCreatedKey!, - style: const TextStyle( - fontSize: 12, - fontFamily: 'monospace', - color: Colors.white70, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - IconButton( - icon: const Icon(Icons.copy, size: 20), - onPressed: () { - Clipboard.setData( - ClipboardData(text: _lastCreatedKey!), - ); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Ключ скопирован'), - duration: Duration(seconds: 1), - ), - ); - }, - ), - ], - ), - ], - ), - ), - - // ─── Список ключей ─── - Expanded( - child: keys.isEmpty - ? const Center( - child: Text( - 'Нет гостевых ключей', - style: TextStyle(color: Colors.white54), - ), - ) - : RefreshIndicator( - color: Colors.deepOrange, - onRefresh: _load, - child: ListView.builder( - padding: const EdgeInsets.all(12), - itemCount: keys.length, - itemBuilder: (context, index) { - final k = keys[index]; - final map = k is Map - ? Map.from(k) - : {}; - return _ApiKeyCard( - data: map, - onRevoke: () => _revokeKey(map), - onActivate: () => _activateKey(map), - ); - }, - ), - ), - ), - ], - ), + body: Column( + children: [ + if (_lastCreatedKey != null) + _LastCreatedKeyBanner(keyValue: _lastCreatedKey!), + Expanded(child: _buildContent(keysState, keys)), + ], + ), floatingActionButton: FloatingActionButton( backgroundColor: Colors.deepOrange, onPressed: () => _showCreateDialog(context), @@ -136,6 +50,72 @@ class _ApiKeysScreenState extends ConsumerState { ); } + Widget _buildContent(LoadState> keysState, List keys) { + if ((keysState.isIdle || keysState.isLoading) && keys.isEmpty) { + return const Center( + child: CircularProgressIndicator(color: Colors.deepOrange), + ); + } + + if (keysState.hasError && keys.isEmpty) { + return LoadErrorView( + title: 'Не удалось загрузить API-ключи', + message: keysState.errorMessage, + icon: Icons.vpn_key, + onRetry: _load, + ); + } + + if (keys.isEmpty) { + return const Center( + child: Text( + 'Нет гостевых ключей', + style: TextStyle(color: Colors.white54), + ), + ); + } + + final hasStatusHeader = keysState.isLoading || keysState.hasError; + final statusHeaderCount = hasStatusHeader ? 1 : 0; + + return RefreshIndicator( + color: Colors.deepOrange, + onRefresh: _load, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(12), + itemCount: keys.length + statusHeaderCount, + itemBuilder: (context, index) { + if (hasStatusHeader && index == 0) { + if (keysState.isLoading) { + return const Padding( + padding: EdgeInsets.only(bottom: 12), + child: LinearProgressIndicator(color: Colors.deepOrange), + ); + } + + return LoadErrorBanner( + title: 'Не удалось обновить API-ключи', + message: keysState.errorMessage, + onRetry: _load, + ); + } + + final keyIndex = index - statusHeaderCount; + final key = keys[keyIndex]; + final map = key is Map + ? Map.from(key) + : {}; + return _ApiKeyCard( + data: map, + onRevoke: () => _revokeKey(map), + onActivate: () => _activateKey(map), + ); + }, + ), + ); + } + void _showCreateDialog(BuildContext context) { final nameCtrl = TextEditingController(); bool isAdmin = false; @@ -179,11 +159,22 @@ class _ApiKeysScreenState extends ConsumerState { final name = nameCtrl.text.trim(); if (name.isEmpty) return; Navigator.of(ctx).pop(); - final key = await ref - .read(apiKeysProvider.notifier) - .create(name, isAdmin: isAdmin); - if (key != null && mounted) { + final messenger = ScaffoldMessenger.of(context); + try { + final key = await ref + .read(apiKeysProvider.notifier) + .create(name, isAdmin: isAdmin); + if (!mounted) return; setState(() => _lastCreatedKey = key); + } catch (e) { + if (!mounted) return; + messenger.showSnackBar( + SnackBar( + content: Text( + 'Ошибка создания ключа: ${describeLoadError(e)}', + ), + ), + ); } }, child: const Text('Создать'), @@ -218,24 +209,103 @@ class _ApiKeysScreenState extends ConsumerState { ), ); if (confirmed == true) { - await ref.read(apiKeysProvider.notifier).revoke(key); + try { + await ref.read(apiKeysProvider.notifier).revoke(key); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка отзыва ключа: ${describeLoadError(e)}'), + ), + ); + } } } Future _activateKey(Map data) async { final key = (data['key'] ?? data['token'] ?? '').toString(); - await ref.read(apiKeysProvider.notifier).activate(key); - if (mounted) { + try { + await ref.read(apiKeysProvider.notifier).activate(key); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Ключ активирован'), + duration: Duration(seconds: 1), + ), + ); + } + } catch (e) { + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Ключ активирован'), - duration: Duration(seconds: 1), + SnackBar( + content: Text('Ошибка активации ключа: ${describeLoadError(e)}'), ), ); } } } +class _LastCreatedKeyBanner extends StatelessWidget { + final String keyValue; + + const _LastCreatedKeyBanner({required this.keyValue}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.all(12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.deepOrange.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.deepOrange.withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Новый ключ создан! Скопируйте его сейчас:', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Colors.deepOrange, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Text( + keyValue, + style: const TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: Colors.white70, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.copy, size: 20), + onPressed: () { + Clipboard.setData(ClipboardData(text: keyValue)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Ключ скопирован'), + duration: Duration(seconds: 1), + ), + ); + }, + ), + ], + ), + ], + ), + ); + } +} + /// Карточка одного API-ключа class _ApiKeyCard extends StatelessWidget { final Map data; diff --git a/lib/screens/schedules_screen.dart b/lib/screens/schedules_screen.dart index 1d723f2..0c0ac7b 100644 --- a/lib/screens/schedules_screen.dart +++ b/lib/screens/schedules_screen.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../app/error_message.dart'; +import '../app/load_state.dart'; import '../providers/providers.dart'; +import '../widgets/load_error_view.dart'; /// Экран управления расписаниями. /// Показывает все задачи (one-shot и cron), позволяет создавать и удалять. @@ -12,8 +15,6 @@ class SchedulesScreen extends ConsumerStatefulWidget { } class _SchedulesScreenState extends ConsumerState { - bool _loading = true; - @override void initState() { super.initState(); @@ -22,45 +23,16 @@ class _SchedulesScreenState extends ConsumerState { Future _load() async { await ref.read(tasksProvider.notifier).load(); - if (mounted) setState(() => _loading = false); } @override Widget build(BuildContext context) { - final tasks = ref.watch(tasksProvider); + final tasksState = ref.watch(tasksProvider); + final tasks = tasksState.data; return Scaffold( appBar: AppBar(title: const Text('РАСПИСАНИЯ')), - body: _loading - ? const Center( - child: CircularProgressIndicator(color: Colors.deepOrange), - ) - : tasks.isEmpty - ? const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.schedule, size: 64, color: Colors.white24), - SizedBox(height: 16), - Text( - 'Нет активных расписаний', - style: TextStyle(color: Colors.white54, fontSize: 16), - ), - ], - ), - ) - : RefreshIndicator( - color: Colors.deepOrange, - onRefresh: () => ref.read(tasksProvider.notifier).load(), - child: ListView.builder( - padding: const EdgeInsets.all(12), - itemCount: tasks.length, - itemBuilder: (context, index) { - final task = tasks[index]; - return _TaskCard(task: task); - }, - ), - ), + body: _buildContent(tasksState, tasks), floatingActionButton: FloatingActionButton( backgroundColor: Colors.deepOrange, onPressed: () => _showAddDialog(context), @@ -81,6 +53,74 @@ class _SchedulesScreenState extends ConsumerState { builder: (ctx) => const _AddScheduleSheet(), ); } + + Widget _buildContent( + LoadState> tasksState, + List tasks, + ) { + if ((tasksState.isIdle || tasksState.isLoading) && tasks.isEmpty) { + return const Center( + child: CircularProgressIndicator(color: Colors.deepOrange), + ); + } + + if (tasksState.hasError && tasks.isEmpty) { + return LoadErrorView( + title: 'Не удалось загрузить расписания', + message: tasksState.errorMessage, + icon: Icons.schedule, + onRetry: _load, + ); + } + + if (tasks.isEmpty) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.schedule, size: 64, color: Colors.white24), + SizedBox(height: 16), + Text( + 'Нет активных расписаний', + style: TextStyle(color: Colors.white54, fontSize: 16), + ), + ], + ), + ); + } + + final hasStatusHeader = tasksState.isLoading || tasksState.hasError; + final statusHeaderCount = hasStatusHeader ? 1 : 0; + + return RefreshIndicator( + color: Colors.deepOrange, + onRefresh: _load, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(12), + itemCount: tasks.length + statusHeaderCount, + itemBuilder: (context, index) { + if (hasStatusHeader && index == 0) { + if (tasksState.isLoading) { + return const Padding( + padding: EdgeInsets.only(bottom: 12), + child: LinearProgressIndicator(color: Colors.deepOrange), + ); + } + + return LoadErrorBanner( + title: 'Не удалось обновить расписания', + message: tasksState.errorMessage, + onRetry: _load, + ); + } + + final taskIndex = index - statusHeaderCount; + return _TaskCard(task: tasks[taskIndex]); + }, + ), + ); + } } /// Карточка одной задачи расписания @@ -151,9 +191,20 @@ class _TaskCard extends ConsumerWidget { child: const Text('Нет'), ), TextButton( - onPressed: () { + onPressed: () async { Navigator.of(ctx).pop(); - ref.read(tasksProvider.notifier).cancel(jobId); + try { + await ref.read(tasksProvider.notifier).cancel(jobId); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Ошибка отмены задачи: ${describeLoadError(e)}', + ), + ), + ); + } }, child: const Text('Да', style: TextStyle(color: Colors.redAccent)), ), @@ -363,9 +414,9 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> { if (mounted) Navigator.of(context).pop(); } catch (e) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Ошибка: $e'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Ошибка: ${describeLoadError(e)}')), + ); } } } diff --git a/test/read_only_load_state_test.dart b/test/read_only_load_state_test.dart index dc573d2..4856a83 100644 --- a/test/read_only_load_state_test.dart +++ b/test/read_only_load_state_test.dart @@ -6,14 +6,48 @@ import 'package:ignis_app/providers/providers.dart'; import 'package:ignis_app/services/api_client.dart'; class FakeIgnisApi extends IgnisApi { + Object? tasksData; Object? statsData; Object? eventLogData; + Object? apiKeysData; + Object? tasksError; Object? statsError; Object? eventLogError; + Object? apiKeysError; + Object? cancelTaskError; + Object? revokeApiKeyError; int? requestedDays; int? requestedLimit; + String? cancelledJobId; + String? revokedApiKey; - FakeIgnisApi({this.statsData, this.eventLogData}); + FakeIgnisApi({ + this.tasksData, + this.statsData, + this.eventLogData, + this.apiKeysData, + }); + + @override + Future getTasks() async { + final error = tasksError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/schedules/tasks'), + data: tasksData, + ); + } + + @override + Future cancelTask(String jobId) async { + cancelledJobId = jobId; + final error = cancelTaskError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/schedules/$jobId'), + data: {'ok': true}, + ); + } @override Future getStatsSummary({int days = 7}) async { @@ -36,6 +70,27 @@ class FakeIgnisApi extends IgnisApi { data: eventLogData, ); } + + @override + Future getApiKeys() async { + final error = apiKeysError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/api-keys'), + data: apiKeysData, + ); + } + + @override + Future revokeApiKey(String key) async { + revokedApiKey = key; + final error = revokeApiKeyError; + if (error != null) throw error; + return Response( + requestOptions: RequestOptions(path: '/api-keys/revoke'), + data: {'ok': true}, + ); + } } void main() { @@ -65,6 +120,57 @@ void main() { expect(api.requestedDays, 14); }); + test('tasks load exposes data state', () async { + final api = FakeIgnisApi( + tasksData: { + 'tasks': [ + {'id': 'job-1', 'target_id': 'kitchen'}, + ], + }, + ); + final container = containerWith(api); + + await container.read(tasksProvider.notifier).load(); + + final state = container.read(tasksProvider); + expect(state.status, LoadStatus.data); + expect(state.data, hasLength(1)); + }); + + test('tasks load exposes empty state', () async { + final api = FakeIgnisApi(tasksData: {'tasks': []}); + final container = containerWith(api); + + await container.read(tasksProvider.notifier).load(); + + final state = container.read(tasksProvider); + expect(state.status, LoadStatus.empty); + expect(state.data, isEmpty); + }); + + test('tasks load error exposes message', () async { + final api = FakeIgnisApi( + tasksData: [ + {'id': 'job-1'}, + ], + ); + final container = containerWith(api); + + await container.read(tasksProvider.notifier).load(); + api.tasksError = DioException( + requestOptions: RequestOptions(path: '/schedules/tasks'), + type: DioExceptionType.connectionError, + message: 'No route to host', + ); + + await container.read(tasksProvider.notifier).load(); + + final state = container.read(tasksProvider); + expect(state.status, LoadStatus.error); + expect(state.data, hasLength(1)); + expect(state.errorMessage, contains('Backend недоступен')); + }); + test('stats load exposes empty state for empty groups', () async { final api = FakeIgnisApi(statsData: {'groups': []}); final container = containerWith(api); @@ -94,6 +200,57 @@ void main() { expect(api.requestedLimit, 50); }); + test('api keys load exposes data state', () async { + final api = FakeIgnisApi( + apiKeysData: { + 'keys': [ + {'name': 'guest', 'key': 'secret'}, + ], + }, + ); + final container = containerWith(api); + + await container.read(apiKeysProvider.notifier).load(); + + final state = container.read(apiKeysProvider); + expect(state.status, LoadStatus.data); + expect(state.data, hasLength(1)); + }); + + test('api keys load exposes empty state', () async { + final api = FakeIgnisApi(apiKeysData: {'keys': []}); + final container = containerWith(api); + + await container.read(apiKeysProvider.notifier).load(); + + final state = container.read(apiKeysProvider); + expect(state.status, LoadStatus.empty); + expect(state.data, isEmpty); + }); + + test('api keys load error exposes message', () async { + final api = FakeIgnisApi( + apiKeysData: [ + {'name': 'guest'}, + ], + ); + final container = containerWith(api); + + await container.read(apiKeysProvider.notifier).load(); + api.apiKeysError = DioException( + requestOptions: RequestOptions(path: '/api-keys'), + type: DioExceptionType.connectionError, + message: 'No route to host', + ); + + await container.read(apiKeysProvider.notifier).load(); + + final state = container.read(apiKeysProvider); + expect(state.status, LoadStatus.error); + expect(state.data, hasLength(1)); + expect(state.errorMessage, contains('Backend недоступен')); + }); + test('load error keeps previous stats data and exposes message', () async { final api = FakeIgnisApi( statsData: { @@ -118,4 +275,44 @@ void main() { expect(state.data['groups'], hasLength(1)); expect(state.errorMessage, contains('Backend недоступен')); }); + + test('task cancel error is not swallowed', () async { + final api = FakeIgnisApi(tasksData: []); + final container = containerWith(api); + final error = DioException( + requestOptions: RequestOptions(path: '/schedules/job-1'), + type: DioExceptionType.badResponse, + response: Response( + requestOptions: RequestOptions(path: '/schedules/job-1'), + statusCode: 500, + ), + ); + api.cancelTaskError = error; + + await expectLater( + container.read(tasksProvider.notifier).cancel('job-1'), + throwsA(same(error)), + ); + expect(api.cancelledJobId, 'job-1'); + }); + + test('api key revoke error is not swallowed', () async { + final api = FakeIgnisApi(apiKeysData: []); + final container = containerWith(api); + final error = DioException( + requestOptions: RequestOptions(path: '/api-keys/revoke'), + type: DioExceptionType.badResponse, + response: Response( + requestOptions: RequestOptions(path: '/api-keys/revoke'), + statusCode: 500, + ), + ); + api.revokeApiKeyError = error; + + await expectLater( + container.read(apiKeysProvider.notifier).revoke('secret'), + throwsA(same(error)), + ); + expect(api.revokedApiKey, 'secret'); + }); }