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

735 lines
22 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/models/home_config.dart';
import 'package:ignis_app/models/ignis_group.dart';
import 'package:ignis_app/providers/providers.dart';
import 'package:ignis_app/services/credentials_storage.dart';
import 'package:ignis_app/services/api_client.dart';
import 'package:ignis_app/services/settings_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
class InMemoryCredentialsStorage implements CredentialsStorage {
final Map<String, String> _apiKeys = {};
@override
Future<String?> getApiKey(String homeId) async => _apiKeys[homeId];
@override
Future<void> setApiKey(String homeId, String apiKey) async {
_apiKeys[homeId] = apiKey;
}
@override
Future<void> 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? devicesError;
Object? scenesError;
Object? tasksError;
Object? statsError;
Object? eventLogError;
Object? apiKeysError;
Object? authError;
Object? groupsError;
Object? groupStatusError;
Object? controlGroupError;
Object? cancelTaskError;
Object? revokeApiKeyError;
String? controlledGroupId;
Map<String, dynamic>? controlGroupParams;
int? requestedDays;
int? requestedLimit;
String? cancelledJobId;
String? revokedApiKey;
FakeIgnisApi({
this.groupsData,
this.groupStatusData,
this.devicesData,
this.scenesData,
this.tasksData,
this.statsData,
this.eventLogData,
this.apiKeysData,
this.authData,
});
@override
Future<Response> getAuthMe() async {
final error = authError;
if (error != null) throw error;
return Response(
requestOptions: RequestOptions(path: '/auth/me'),
data: authData ?? <String, dynamic>{'is_admin': false},
);
}
@override
Future<Response> getDevices() async {
final error = devicesError;
if (error != null) throw error;
return Response(
requestOptions: RequestOptions(path: '/devices'),
data: devicesData,
);
}
@override
Future<Response> getScenes() async {
final error = scenesError;
if (error != null) throw error;
return Response(
requestOptions: RequestOptions(path: '/devices/scenes'),
data: scenesData,
);
}
@override
Future<Response> getGroups() async {
final error = groupsError;
if (error != null) throw error;
return Response(
requestOptions: RequestOptions(path: '/devices/groups'),
data: groupsData ?? <Object>[],
);
}
@override
Future<Response> 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<Response> controlGroup(String id, Map<String, dynamic> params) async {
controlledGroupId = id;
controlGroupParams = params;
final error = controlGroupError;
if (error != null) throw error;
return Response(
requestOptions: RequestOptions(path: '/control/group/$id'),
data: <String, dynamic>{'ok': true},
);
}
@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() {
TestWidgetsFlutterBinding.ensureInitialized();
ProviderContainer containerWith(
FakeIgnisApi api, {
SettingsService? settingsService,
}) {
final overrides = [apiProvider.overrideWithValue(api)];
if (settingsService != null) {
overrides.add(settingsServiceProvider.overrideWithValue(settingsService));
}
final container = ProviderContainer(
overrides: overrides,
);
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(state.data.groups.single.targetId, 'kitchen');
expect(state.data.groups.single.totalCommands, 3);
expect(api.requestedDays, 14);
});
test('group status parser maps backend status shape', () {
final state = IgnisGroupState.firstFromStatusResponse({
'results': [
{
'status': {'state': true, 'dimming': 42, 'temp': 3000},
},
],
});
expect(state?.isOn, isTrue);
expect(state?.brightness, 42);
expect(state?.temp, 3000);
});
test('devices load exposes data state', () async {
final api = FakeIgnisApi(
devicesData: {
'devices': [
{'mac': 'AA:BB', 'name': 'Kitchen bulb'},
],
},
);
final container = containerWith(api);
await container.read(devicesProvider.notifier).load();
final state = container.read(devicesProvider);
expect(state.status, LoadStatus.data);
expect(state.data, hasLength(1));
expect(state.data.single.groupMemberId, 'AA:BB');
expect(state.data.single.name, 'Kitchen bulb');
});
test('devices load exposes empty state', () async {
final api = FakeIgnisApi(devicesData: {'devices': <Object>[]});
final container = containerWith(api);
await container.read(devicesProvider.notifier).load();
final state = container.read(devicesProvider);
expect(state.status, LoadStatus.empty);
expect(state.data, isEmpty);
});
test('devices load error exposes message', () async {
final api = FakeIgnisApi(
devicesData: [
{'mac': 'AA:BB'},
],
);
final container = containerWith(api);
await container.read(devicesProvider.notifier).load();
api.devicesError = DioException(
requestOptions: RequestOptions(path: '/devices'),
type: DioExceptionType.connectionError,
message: 'No route to host',
);
await container.read(devicesProvider.notifier).load();
final state = container.read(devicesProvider);
expect(state.status, LoadStatus.error);
expect(state.data, hasLength(1));
expect(state.errorMessage, contains('Backend недоступен'));
});
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));
expect(state.data.single.jobId, 'job-1');
expect(state.data.single.targetId, 'kitchen');
});
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(state.data.single.action, 'toggle');
expect(state.data.single.targetId, 'kitchen');
expect(api.requestedLimit, 50);
});
test('scenes load accepts id-name map and exposes data state', () async {
final api = FakeIgnisApi(scenesData: {'party': 'Party', 'relax': 'Relax'});
final container = containerWith(api);
await container.read(scenesProvider.notifier).load();
final state = container.read(scenesProvider);
expect(state.status, LoadStatus.data);
expect(state.data, hasLength(2));
expect(state.data.first.id, 'party');
expect(state.data.first.displayName, 'Party');
});
test('scenes load maps numeric scene ids to display names', () async {
final api = FakeIgnisApi(
scenesData: {
'scenes': ['1', '4'],
},
);
final container = containerWith(api);
await container.read(scenesProvider.notifier).load();
final state = container.read(scenesProvider);
expect(state.status, LoadStatus.data);
expect(state.data.first.id, '1');
expect(state.data.first.displayName, 'Океан');
expect(state.data.last.displayName, 'Вечеринка');
});
test('scenes load exposes empty state', () async {
final api = FakeIgnisApi(scenesData: {'scenes': <Object>[]});
final container = containerWith(api);
await container.read(scenesProvider.notifier).load();
final state = container.read(scenesProvider);
expect(state.status, LoadStatus.empty);
expect(state.data, isEmpty);
});
test('scenes load error exposes message', () async {
final api = FakeIgnisApi(scenesData: ['Party']);
final container = containerWith(api);
await container.read(scenesProvider.notifier).load();
api.scenesError = DioException(
requestOptions: RequestOptions(path: '/devices/scenes'),
type: DioExceptionType.connectionError,
message: 'No route to host',
);
await container.read(scenesProvider.notifier).load();
final state = container.read(scenesProvider);
expect(state.status, LoadStatus.error);
expect(state.data, hasLength(1));
expect(state.errorMessage, contains('Backend недоступен'));
});
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));
expect(state.data.single.key, 'secret');
expect(state.data.single.name, 'guest');
});
test('auth info load maps admin flag', () async {
final api = FakeIgnisApi(authData: {'is_admin': 'true', 'name': 'owner'});
final container = containerWith(api);
await container.read(authInfoProvider.notifier).load();
final state = container.read(authInfoProvider);
expect(state.status, LoadStatus.data);
expect(state.data?.isAdmin, isTrue);
expect(state.data?.name, 'owner');
expect(container.read(authInfoProvider.notifier).isAdmin, isTrue);
});
test('auth info load error clears stale auth state and exposes message', () async {
final api = FakeIgnisApi(authData: {'is_admin': true, 'name': 'owner'});
final container = containerWith(api);
await container.read(authInfoProvider.notifier).load();
api.authError = DioException(
requestOptions: RequestOptions(path: '/auth/me'),
type: DioExceptionType.connectionError,
message: 'No route to host',
);
await container.read(authInfoProvider.notifier).load();
final state = container.read(authInfoProvider);
expect(state.status, LoadStatus.error);
expect(state.data, isNull);
expect(state.errorMessage, contains('Backend недоступен'));
expect(container.read(authInfoProvider.notifier).isAdmin, isFalse);
});
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', 'key': 'secret'},
],
);
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('groups refresh maps groups and status to typed state', () async {
final api = FakeIgnisApi(
groupsData: {
'kitchen': {
'name': 'Kitchen',
'macs': ['AA:BB'],
},
},
groupStatusData: {
'results': [
{
'status': {
'state': true,
'dimming': 42,
'temp': 3000,
'r': 1,
'g': 2,
'b': 3,
'scene': '4',
},
},
],
},
);
final container = containerWith(api);
await container.read(groupsProvider.notifier).refresh();
final groups = container.read(groupsProvider);
expect(groups, hasLength(1));
expect(groups.single.id, 'kitchen');
expect(groups.single.name, 'Kitchen');
expect(groups.single.macs, ['AA:BB']);
expect(groups.single.state.isOn, isTrue);
expect(groups.single.state.brightness, 42);
expect(groups.single.state.sceneId, '4');
});
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');
});
test('set scene error is not swallowed', () async {
final api = FakeIgnisApi();
final container = containerWith(api);
final error = DioException(
requestOptions: RequestOptions(path: '/control/group/kitchen'),
type: DioExceptionType.badResponse,
response: Response(
requestOptions: RequestOptions(path: '/control/group/kitchen'),
statusCode: 500,
),
);
api.controlGroupError = error;
await expectLater(
container.read(groupsProvider.notifier).setScene('kitchen', 'party'),
throwsA(same(error)),
);
expect(api.controlledGroupId, 'kitchen');
expect(api.controlGroupParams, containsPair('scene', 'party'));
});
test('debounced group control error is reported', () async {
final api = FakeIgnisApi();
final container = containerWith(api);
api.controlGroupError = DioException(
requestOptions: RequestOptions(path: '/control/group/kitchen'),
type: DioExceptionType.connectionError,
message: 'No route to host',
);
container.read(groupsProvider.notifier).setBrightness('kitchen', 42);
await Future<void>.delayed(const Duration(milliseconds: 400));
final error = container.read(groupControlErrorProvider);
expect(error, isNotNull);
expect(error!.groupId, 'kitchen');
expect(error.action, 'яркость');
expect(error.message, contains('Backend недоступен'));
expect(api.controlGroupParams, containsPair('brightness', 42));
});
test('home selection rolls back current home and auth state on auth failure', () async {
SharedPreferences.setMockInitialValues({
'ignis_homes':
'[{"id":"home-1","name":"Home 1","url":"https://one.example","geofenceEnabled":false},'
'{"id":"home-2","name":"Home 2","url":"https://two.example","geofenceEnabled":false}]',
'ignis_current_home_id': 'home-1',
});
final settingsService = SettingsService(
credentialsStorage: InMemoryCredentialsStorage(),
);
await settingsService.setHomeApiKey('home-1', 'key-1');
await settingsService.setHomeApiKey('home-2', 'key-2');
final api = FakeIgnisApi(authData: {'is_admin': true, 'name': 'owner'});
final container = containerWith(api, settingsService: settingsService);
await container.read(currentHomeProvider.notifier).load();
await container.read(authInfoProvider.notifier).load();
final secondHome = HomeConfig(
id: 'home-2',
name: 'Home 2',
url: 'https://two.example',
);
final authError = DioException(
requestOptions: RequestOptions(path: '/auth/me'),
type: DioExceptionType.badResponse,
response: Response(
requestOptions: RequestOptions(path: '/auth/me'),
statusCode: 403,
),
);
api.authError = authError;
await expectLater(
container.read(currentHomeProvider.notifier).select(secondHome),
throwsA(same(authError)),
);
final currentHome = container.read(currentHomeProvider);
final authState = container.read(authInfoProvider);
final prefs = await SharedPreferences.getInstance();
expect(currentHome?.id, 'home-1');
expect(authState.status, LoadStatus.data);
expect(authState.data?.isAdmin, isTrue);
expect(prefs.getString('ignis_current_home_id'), 'home-1');
});
}