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 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 { requestedDays = days; final error = statsError; if (error != null) throw error; return Response( requestOptions: RequestOptions(path: '/stats/summary'), data: statsData, ); } @override Future 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 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() { 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': []}); 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); 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': []}); 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: []); 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'); }); }