feat: surface admin load errors

This commit is contained in:
Artem Kokos
2026-04-23 20:11:37 +07:00
parent 5d2d0ac4a7
commit 90a86e932d
4 changed files with 516 additions and 191 deletions

View File

@@ -622,36 +622,42 @@ class ScenesNotifier extends Notifier<List<dynamic>> {
// ─── Расписания ────────────────────────────────────────────── // ─── Расписания ──────────────────────────────────────────────
final tasksProvider = NotifierProvider<TasksNotifier, List<dynamic>>( final tasksProvider = NotifierProvider<TasksNotifier, LoadState<List<dynamic>>>(
() => TasksNotifier(), () => TasksNotifier(),
); );
class TasksNotifier extends Notifier<List<dynamic>> { class TasksNotifier extends Notifier<LoadState<List<dynamic>>> {
@override @override
List<dynamic> build() => []; LoadState<List<dynamic>> build() => const LoadState.idle(<dynamic>[]);
Future<void> load() async { Future<void> load() async {
state = LoadState.loading(state.data);
try { try {
final api = ref.read(apiProvider); final api = ref.read(apiProvider);
final res = await api.getTasks(); final res = await api.getTasks();
final data = res.data; final data = res.data;
late final List<dynamic> tasks;
if (data is List) { if (data is List) {
state = data; tasks = List<dynamic>.from(data);
} else if (data is Map) { } 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<dynamic>.from(value);
} else {
throw FormatException('tasks должен быть списком расписаний');
} }
state = tasks.isEmpty ? LoadState.empty(tasks) : LoadState.data(tasks);
} catch (e) { } catch (e) {
debugPrint("Ошибка загрузки расписаний: $e"); state = LoadState.error(state.data, describeLoadError(e));
} }
} }
Future<void> cancel(String jobId) async { Future<void> cancel(String jobId) async {
try { await ref.read(apiProvider).cancelTask(jobId);
await ref.read(apiProvider).cancelTask(jobId); await load();
await load();
} catch (e) {
debugPrint("Ошибка отмены задачи: $e");
}
} }
/// Создать одноразовый таймер /// Создать одноразовый таймер
@@ -765,60 +771,61 @@ class EventLogNotifier extends Notifier<LoadState<List<dynamic>>> {
// ─── API-ключи ─────────────────────────────────────────────── // ─── API-ключи ───────────────────────────────────────────────
final apiKeysProvider = NotifierProvider<ApiKeysNotifier, List<dynamic>>( final apiKeysProvider =
() => ApiKeysNotifier(), NotifierProvider<ApiKeysNotifier, LoadState<List<dynamic>>>(
); () => ApiKeysNotifier(),
);
class ApiKeysNotifier extends Notifier<List<dynamic>> { class ApiKeysNotifier extends Notifier<LoadState<List<dynamic>>> {
@override @override
List<dynamic> build() => []; LoadState<List<dynamic>> build() => const LoadState.idle(<dynamic>[]);
Future<void> load() async { Future<void> load() async {
state = LoadState.loading(state.data);
try { try {
final api = ref.read(apiProvider); final api = ref.read(apiProvider);
final res = await api.getApiKeys(); final res = await api.getApiKeys();
final data = res.data; final data = res.data;
late final List<dynamic> keys;
if (data is List) { if (data is List) {
state = data; keys = List<dynamic>.from(data);
} else if (data is Map) { } 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<dynamic>.from(value);
} else {
throw FormatException('api-keys должен быть списком ключей');
} }
state = keys.isEmpty ? LoadState.empty(keys) : LoadState.data(keys);
} catch (e) { } catch (e) {
debugPrint("Ошибка загрузки API-ключей: $e"); state = LoadState.error(state.data, describeLoadError(e));
} }
} }
Future<String?> create(String name, {bool isAdmin = false}) async { Future<String> create(String name, {bool isAdmin = false}) async {
try { final api = ref.read(apiProvider);
final api = ref.read(apiProvider); final res = await api.createApiKey(name, isAdmin: isAdmin);
final res = await api.createApiKey(name, isAdmin: isAdmin); await load();
await load(); if (res.data is Map) {
if (res.data is Map) { final key = res.data['key']?.toString();
return 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<void> revoke(String key) async { Future<void> revoke(String key) async {
try { await ref.read(apiProvider).revokeApiKey(key);
await ref.read(apiProvider).revokeApiKey(key); await load();
await load();
} catch (e) {
debugPrint("Ошибка отзыва ключа: $e");
}
} }
Future<void> activate(String key) async { Future<void> activate(String key) async {
try { await ref.read(apiProvider).activateApiKey(key);
await ref.read(apiProvider).activateApiKey(key); await load();
await load();
} catch (e) {
debugPrint("Ошибка активации ключа: $e");
}
} }
} }

View File

@@ -1,7 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/error_message.dart';
import '../app/load_state.dart';
import '../providers/providers.dart'; import '../providers/providers.dart';
import '../widgets/load_error_view.dart';
/// Экран управления гостевыми API-ключами. /// Экран управления гостевыми API-ключами.
/// Доступен только администраторам. /// Доступен только администраторам.
@@ -13,7 +16,6 @@ class ApiKeysScreen extends ConsumerStatefulWidget {
} }
class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> { class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
bool _loading = true;
String? _lastCreatedKey; String? _lastCreatedKey;
@override @override
@@ -24,110 +26,22 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
Future<void> _load() async { Future<void> _load() async {
await ref.read(apiKeysProvider.notifier).load(); await ref.read(apiKeysProvider.notifier).load();
if (mounted) setState(() => _loading = false);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final keys = ref.watch(apiKeysProvider); final keysState = ref.watch(apiKeysProvider);
final keys = keysState.data;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('API-КЛЮЧИ')), appBar: AppBar(title: const Text('API-КЛЮЧИ')),
body: _loading body: Column(
? const Center( children: [
child: CircularProgressIndicator(color: Colors.deepOrange), if (_lastCreatedKey != null)
) _LastCreatedKeyBanner(keyValue: _lastCreatedKey!),
: Column( Expanded(child: _buildContent(keysState, keys)),
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<String, dynamic>.from(k)
: <String, dynamic>{};
return _ApiKeyCard(
data: map,
onRevoke: () => _revokeKey(map),
onActivate: () => _activateKey(map),
);
},
),
),
),
],
),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
backgroundColor: Colors.deepOrange, backgroundColor: Colors.deepOrange,
onPressed: () => _showCreateDialog(context), onPressed: () => _showCreateDialog(context),
@@ -136,6 +50,72 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
); );
} }
Widget _buildContent(LoadState<List<dynamic>> keysState, List<dynamic> 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<String, dynamic>.from(key)
: <String, dynamic>{};
return _ApiKeyCard(
data: map,
onRevoke: () => _revokeKey(map),
onActivate: () => _activateKey(map),
);
},
),
);
}
void _showCreateDialog(BuildContext context) { void _showCreateDialog(BuildContext context) {
final nameCtrl = TextEditingController(); final nameCtrl = TextEditingController();
bool isAdmin = false; bool isAdmin = false;
@@ -179,11 +159,22 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
final name = nameCtrl.text.trim(); final name = nameCtrl.text.trim();
if (name.isEmpty) return; if (name.isEmpty) return;
Navigator.of(ctx).pop(); Navigator.of(ctx).pop();
final key = await ref final messenger = ScaffoldMessenger.of(context);
.read(apiKeysProvider.notifier) try {
.create(name, isAdmin: isAdmin); final key = await ref
if (key != null && mounted) { .read(apiKeysProvider.notifier)
.create(name, isAdmin: isAdmin);
if (!mounted) return;
setState(() => _lastCreatedKey = key); setState(() => _lastCreatedKey = key);
} catch (e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text(
'Ошибка создания ключа: ${describeLoadError(e)}',
),
),
);
} }
}, },
child: const Text('Создать'), child: const Text('Создать'),
@@ -218,24 +209,103 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
), ),
); );
if (confirmed == true) { 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<void> _activateKey(Map<String, dynamic> data) async { Future<void> _activateKey(Map<String, dynamic> data) async {
final key = (data['key'] ?? data['token'] ?? '').toString(); final key = (data['key'] ?? data['token'] ?? '').toString();
await ref.read(apiKeysProvider.notifier).activate(key); try {
if (mounted) { 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( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Ключ активирован'), content: Text('Ошибка активации ключа: ${describeLoadError(e)}'),
duration: Duration(seconds: 1),
), ),
); );
} }
} }
} }
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-ключа /// Карточка одного API-ключа
class _ApiKeyCard extends StatelessWidget { class _ApiKeyCard extends StatelessWidget {
final Map<String, dynamic> data; final Map<String, dynamic> data;

View File

@@ -1,6 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/error_message.dart';
import '../app/load_state.dart';
import '../providers/providers.dart'; import '../providers/providers.dart';
import '../widgets/load_error_view.dart';
/// Экран управления расписаниями. /// Экран управления расписаниями.
/// Показывает все задачи (one-shot и cron), позволяет создавать и удалять. /// Показывает все задачи (one-shot и cron), позволяет создавать и удалять.
@@ -12,8 +15,6 @@ class SchedulesScreen extends ConsumerStatefulWidget {
} }
class _SchedulesScreenState extends ConsumerState<SchedulesScreen> { class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
bool _loading = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -22,45 +23,16 @@ class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
Future<void> _load() async { Future<void> _load() async {
await ref.read(tasksProvider.notifier).load(); await ref.read(tasksProvider.notifier).load();
if (mounted) setState(() => _loading = false);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tasks = ref.watch(tasksProvider); final tasksState = ref.watch(tasksProvider);
final tasks = tasksState.data;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('РАСПИСАНИЯ')), appBar: AppBar(title: const Text('РАСПИСАНИЯ')),
body: _loading body: _buildContent(tasksState, tasks),
? 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);
},
),
),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
backgroundColor: Colors.deepOrange, backgroundColor: Colors.deepOrange,
onPressed: () => _showAddDialog(context), onPressed: () => _showAddDialog(context),
@@ -81,6 +53,74 @@ class _SchedulesScreenState extends ConsumerState<SchedulesScreen> {
builder: (ctx) => const _AddScheduleSheet(), builder: (ctx) => const _AddScheduleSheet(),
); );
} }
Widget _buildContent(
LoadState<List<dynamic>> tasksState,
List<dynamic> 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('Нет'), child: const Text('Нет'),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () async {
Navigator.of(ctx).pop(); 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)), child: const Text('Да', style: TextStyle(color: Colors.redAccent)),
), ),
@@ -363,9 +414,9 @@ class _AddScheduleSheetState extends ConsumerState<_AddScheduleSheet> {
if (mounted) Navigator.of(context).pop(); if (mounted) Navigator.of(context).pop();
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text('Ошибка: ${describeLoadError(e)}')),
).showSnackBar(SnackBar(content: Text('Ошибка: $e'))); );
} }
} }
} }

View File

@@ -6,14 +6,48 @@ import 'package:ignis_app/providers/providers.dart';
import 'package:ignis_app/services/api_client.dart'; import 'package:ignis_app/services/api_client.dart';
class FakeIgnisApi extends IgnisApi { class FakeIgnisApi extends IgnisApi {
Object? tasksData;
Object? statsData; Object? statsData;
Object? eventLogData; Object? eventLogData;
Object? apiKeysData;
Object? tasksError;
Object? statsError; Object? statsError;
Object? eventLogError; Object? eventLogError;
Object? apiKeysError;
Object? cancelTaskError;
Object? revokeApiKeyError;
int? requestedDays; int? requestedDays;
int? requestedLimit; int? requestedLimit;
String? cancelledJobId;
String? revokedApiKey;
FakeIgnisApi({this.statsData, this.eventLogData}); FakeIgnisApi({
this.tasksData,
this.statsData,
this.eventLogData,
this.apiKeysData,
});
@override
Future<Response> getTasks() async {
final error = tasksError;
if (error != null) throw error;
return Response(
requestOptions: RequestOptions(path: '/schedules/tasks'),
data: tasksData,
);
}
@override
Future<Response> cancelTask(String jobId) async {
cancelledJobId = jobId;
final error = cancelTaskError;
if (error != null) throw error;
return Response(
requestOptions: RequestOptions(path: '/schedules/$jobId'),
data: <String, dynamic>{'ok': true},
);
}
@override @override
Future<Response> getStatsSummary({int days = 7}) async { Future<Response> getStatsSummary({int days = 7}) async {
@@ -36,6 +70,27 @@ class FakeIgnisApi extends IgnisApi {
data: eventLogData, data: eventLogData,
); );
} }
@override
Future<Response> getApiKeys() async {
final error = apiKeysError;
if (error != null) throw error;
return Response(
requestOptions: RequestOptions(path: '/api-keys'),
data: apiKeysData,
);
}
@override
Future<Response> revokeApiKey(String key) async {
revokedApiKey = key;
final error = revokeApiKeyError;
if (error != null) throw error;
return Response(
requestOptions: RequestOptions(path: '/api-keys/revoke'),
data: <String, dynamic>{'ok': true},
);
}
} }
void main() { void main() {
@@ -65,6 +120,57 @@ void main() {
expect(api.requestedDays, 14); 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': <Object>[]});
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 { test('stats load exposes empty state for empty groups', () async {
final api = FakeIgnisApi(statsData: {'groups': <Object>[]}); final api = FakeIgnisApi(statsData: {'groups': <Object>[]});
final container = containerWith(api); final container = containerWith(api);
@@ -94,6 +200,57 @@ void main() {
expect(api.requestedLimit, 50); 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': <Object>[]});
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 { test('load error keeps previous stats data and exposes message', () async {
final api = FakeIgnisApi( final api = FakeIgnisApi(
statsData: { statsData: {
@@ -118,4 +275,44 @@ void main() {
expect(state.data['groups'], hasLength(1)); expect(state.data['groups'], hasLength(1));
expect(state.errorMessage, contains('Backend недоступен')); expect(state.errorMessage, contains('Backend недоступен'));
}); });
test('task cancel error is not swallowed', () async {
final api = FakeIgnisApi(tasksData: <Object>[]);
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: <Object>[]);
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');
});
} }