Files
ignis_app/test/read_only_load_state_test.dart
2026-04-23 20:11:37 +07:00

319 lines
8.9 KiB
Dart

import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ignis_app/app/load_state.dart';
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.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 {
requestedDays = days;
final error = statsError;
if (error != null) throw error;
return Response(
requestOptions: RequestOptions(path: '/stats/summary'),
data: statsData,
);
}
@override
Future<Response> getStatsLog({int limit = 100}) async {
requestedLimit = limit;
final error = eventLogError;
if (error != null) throw error;
return Response(
requestOptions: RequestOptions(path: '/stats/log'),
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() {
ProviderContainer containerWith(FakeIgnisApi api) {
final container = ProviderContainer(
overrides: [apiProvider.overrideWithValue(api)],
);
addTearDown(container.dispose);
return container;
}
test('stats load exposes data state', () async {
final api = FakeIgnisApi(
statsData: {
'groups': [
{'id': 'kitchen', 'total_commands': 3},
],
},
);
final container = containerWith(api);
await container.read(statsProvider.notifier).load(days: 14);
final state = container.read(statsProvider);
expect(state.status, LoadStatus.data);
expect(state.data['groups'], hasLength(1));
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);
await container.read(statsProvider.notifier).load();
final state = container.read(statsProvider);
expect(state.status, LoadStatus.empty);
expect(state.data['groups'], isEmpty);
});
test('event log load accepts map response and exposes data state', () async {
final api = FakeIgnisApi(
eventLogData: {
'events': [
{'action': 'toggle', 'target_id': 'kitchen'},
],
},
);
final container = containerWith(api);
await container.read(eventLogProvider.notifier).load(limit: 50);
final state = container.read(eventLogProvider);
expect(state.status, LoadStatus.data);
expect(state.data, hasLength(1));
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: {
'groups': [
{'id': 'kitchen'},
],
},
);
final container = containerWith(api);
await container.read(statsProvider.notifier).load();
api.statsError = DioException(
requestOptions: RequestOptions(path: '/stats/summary'),
type: DioExceptionType.connectionError,
message: 'No route to host',
);
await container.read(statsProvider.notifier).load();
final state = container.read(statsProvider);
expect(state.status, LoadStatus.error);
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');
});
}