import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ignis_app/features/settings/models/app_theme_preset.dart'; import 'package:ignis_app/features/settings/providers/settings_providers.dart'; import 'package:ignis_app/providers/providers.dart'; import 'package:ignis_app/services/api_client.dart'; import 'package:ignis_app/services/credentials_storage.dart'; import 'package:ignis_app/services/settings_service.dart'; class InMemoryCredentialsStorage implements CredentialsStorage { final Map _apiKeys = {}; @override Future getApiKey(String homeId) async => _apiKeys[homeId]; @override Future setApiKey(String homeId, String apiKey) async { _apiKeys[homeId] = apiKey; } @override Future deleteApiKey(String homeId) async { _apiKeys.remove(homeId); } } class FakeIgnisApi extends IgnisApi { Object? groupsData; Object? groupStatusData; Object? devicesData; Object? scenesData; Object? tasksData; Object? statsData; Object? eventLogData; Object? apiKeysData; Object? authData; Object? rescanNetworkData; Object? devicesError; Object? scenesError; Object? tasksError; Object? statsError; Object? eventLogError; Object? apiKeysError; Object? authError; Object? groupsError; Object? groupStatusError; Object? controlGroupError; Object? cancelTaskError; Object? revokeApiKeyError; Object? activateApiKeyError; Object? createApiKeyError; Object? createGroupError; Object? deleteGroupError; Object? scheduleOnceError; Object? scheduleCronError; Object? rescanNetworkError; String? controlledGroupId; Map? controlGroupParams; int? requestedDays; int? requestedLimit; String? cancelledJobId; String? revokedApiKey; String? activatedApiKey; String? createdApiKeyName; bool? createdApiKeyIsAdmin; String? createdGroupId; String? createdGroupName; List? createdGroupMacs; String? deletedGroupId; Map? scheduledOnceParams; Map? scheduledCronParams; int rescanCalls = 0; FakeIgnisApi({ this.groupsData, this.groupStatusData, this.devicesData, this.scenesData, this.tasksData, this.statsData, this.eventLogData, this.apiKeysData, this.authData, }); @override Future getAuthMe() async { final error = authError; if (error != null) throw error; return Response( requestOptions: RequestOptions(path: '/auth/me'), data: authData ?? {'is_admin': false}, ); } @override Future validateCredentials(String baseUrl, String apiKey) async { final error = authError; if (error != null) throw error; } @override Future getDevices() async { final error = devicesError; if (error != null) throw error; return Response( requestOptions: RequestOptions(path: '/devices'), data: devicesData ?? {'devices': []}, ); } @override Future getScenes() async { final error = scenesError; if (error != null) throw error; return Response( requestOptions: RequestOptions(path: '/devices/scenes'), data: scenesData ?? [], ); } @override Future getGroups() async { final error = groupsError; if (error != null) throw error; return Response( requestOptions: RequestOptions(path: '/devices/groups'), data: groupsData ?? [], ); } @override Future getGroupStatus(String id) async { final error = groupStatusError; if (error != null) throw error; return Response( requestOptions: RequestOptions(path: '/control/group/$id/status'), data: groupStatusData ?? { 'results': [ { 'status': {'state': false, 'dimming': 100, 'temp': 4000}, }, ], }, ); } @override Future controlGroup(String id, Map params) async { controlledGroupId = id; controlGroupParams = params; final error = controlGroupError; if (error != null) throw error; return Response( requestOptions: RequestOptions(path: '/control/group/$id'), data: {'ok': true}, ); } @override Future getTasks() async { final error = tasksError; if (error != null) throw error; return Response( requestOptions: RequestOptions(path: '/schedules/tasks'), data: tasksData ?? {'tasks': []}, ); } @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 scheduleOnce(Map params) async { scheduledOnceParams = Map.from(params); final error = scheduleOnceError; if (error != null) throw error; return Response( requestOptions: RequestOptions(path: '/schedules/once'), data: {'ok': true}, ); } @override Future scheduleCron(Map params) async { scheduledCronParams = Map.from(params); final error = scheduleCronError; if (error != null) throw error; return Response( requestOptions: RequestOptions(path: '/schedules/cron'), 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 ?? {'keys': []}, ); } @override Future createApiKey(String name, {bool isAdmin = false}) async { createdApiKeyName = name; createdApiKeyIsAdmin = isAdmin; final error = createApiKeyError; if (error != null) throw error; final newKey = '${name}_token'; apiKeysData = { 'keys': [ ...(apiKeysData is Map ? List>.from( (apiKeysData as Map)['keys'] as List? ?? const [], ) : const >[]), {'name': name, 'key': newKey, 'is_admin': isAdmin, 'is_active': true}, ], }; return Response( requestOptions: RequestOptions(path: '/api-keys'), data: {'key': newKey}, ); } @override Future revokeApiKey(String key) async { revokedApiKey = key; final error = revokeApiKeyError; if (error != null) throw error; apiKeysData = _mapApiKeys( apiKeysData, (item) => item['key'] == key ? {...item, 'is_active': false} : item, ); return Response( requestOptions: RequestOptions(path: '/api-keys/revoke'), data: {'ok': true}, ); } @override Future activateApiKey(String key) async { activatedApiKey = key; final error = activateApiKeyError; if (error != null) throw error; apiKeysData = _mapApiKeys( apiKeysData, (item) => item['key'] == key ? {...item, 'is_active': true} : item, ); return Response( requestOptions: RequestOptions(path: '/api-keys/activate'), data: {'ok': true}, ); } @override Future createGroup( String id, String name, List macs, ) async { createdGroupId = id; createdGroupName = name; createdGroupMacs = List.from(macs); final error = createGroupError; if (error != null) throw error; groupsData = _addGroup(groupsData, id, name, macs); return Response( requestOptions: RequestOptions(path: '/devices/groups'), data: {'ok': true}, ); } @override Future deleteGroup(String groupId) async { deletedGroupId = groupId; final error = deleteGroupError; if (error != null) throw error; groupsData = _removeGroup(groupsData, groupId); return Response( requestOptions: RequestOptions(path: '/devices/groups/$groupId'), data: {'ok': true}, ); } @override Future rescanNetwork() async { rescanCalls += 1; final error = rescanNetworkError; if (error != null) throw error; return Response( requestOptions: RequestOptions(path: '/devices/rescan'), data: rescanNetworkData ?? { 'status': 'ok', 'found': 0, 'added': 0, 'updated': 0, 'removed_offline': 0, 'pending_removal': 0, 'online': 0, }, ); } } ProviderContainer createTestContainer( FakeIgnisApi api, { SettingsService? settingsService, bool remotePollingEnabled = true, AppThemePreset initialThemePreset = AppThemePreset.fallback, List extraOverrides = const [], }) { final overrides = [ apiProvider.overrideWithValue(api), remotePollingEnabledProvider.overrideWithValue(remotePollingEnabled), initialAppThemePresetProvider.overrideWithValue(initialThemePreset), ]; if (settingsService != null) { overrides.add(settingsServiceProvider.overrideWithValue(settingsService)); } overrides.addAll(extraOverrides.cast()); final container = ProviderContainer(overrides: overrides); addTearDown(container.dispose); return container; } Future pumpTestApp( WidgetTester tester, { required Widget child, FakeIgnisApi? api, SettingsService? settingsService, bool remotePollingEnabled = true, AppThemePreset initialThemePreset = AppThemePreset.fallback, List extraOverrides = const [], }) async { final container = createTestContainer( api ?? FakeIgnisApi(), settingsService: settingsService, remotePollingEnabled: remotePollingEnabled, initialThemePreset: initialThemePreset, extraOverrides: extraOverrides, ); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: MaterialApp(home: child), ), ); await tester.pump(); return container; } Object _mapApiKeys( Object? source, Map Function(Map) transform, ) { final current = source is Map ? List>.from(source['keys'] as List? ?? const []) : >[]; return { 'keys': current .map((item) => transform(Map.from(item))) .toList(), }; } Object _addGroup(Object? source, String id, String name, List macs) { if (source is Map) { final next = Map.from(source); next[id] = {'name': name, 'macs': macs}; return next; } final list = source is List ? List.from(source) : []; list.add({'id': id, 'name': name, 'macs': macs}); return list; } Object _removeGroup(Object? source, String id) { if (source is Map) { final next = Map.from(source); next.remove(id); return next; } if (source is List) { return List.from(source) ..removeWhere((item) => item is Map && item['id']?.toString() == id); } return []; }