feat: surface admin load errors
This commit is contained in:
@@ -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<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
|
||||
Future<Response> getStatsSummary({int days = 7}) async {
|
||||
@@ -36,6 +70,27 @@ class FakeIgnisApi extends IgnisApi {
|
||||
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() {
|
||||
@@ -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': <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 {
|
||||
final api = FakeIgnisApi(statsData: {'groups': <Object>[]});
|
||||
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': <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 {
|
||||
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: <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');
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user