Files
OnBudget/test/features/user/users_controller_test.dart
T
2026-05-27 17:46:56 +03:00

355 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:new_budget/src/features/accounts/domain/repositories/account_repository.dart';
import 'package:new_budget/src/features/categories/domain/repositories/category_repository.dart';
import 'package:new_budget/src/features/transactions/domain/repositories/transaction_repository.dart';
import 'package:new_budget/src/features/user/application/user_providers.dart';
import 'package:new_budget/src/features/user/application/user_seeder.dart';
import 'package:new_budget/src/features/user/application/users_controller.dart';
import 'package:new_budget/src/features/user/domain/entities/user.dart';
import 'package:new_budget/src/features/user/domain/repositories/user_repository.dart';
// ─── Fakes ────────────────────────────────────────────────────────────────────
//
// Фейкаем границу контроллера с внешним миром (репозиторий + сидер), но НЕ сам
// контроллер. Тогда тесты прогоняют реальный код UsersController.createUser
// и ловят такие баги как auto-dispose между awaits.
class FakeUserRepository implements UserRepository {
final List<User> _users = [];
final List<String> createdNames = [];
final List<String> deletedIds = [];
final List<(String, String)> renames = [];
Object? createError;
Object? renameError;
Object? deleteError;
/// Контролируемая задержка create — позволяет проверить, что между
/// state=AsyncLoading и финальным state notifier остаётся жив.
Completer<void>? createGate;
@override
Stream<List<User>> watchAll() => Stream.value(List.unmodifiable(_users));
@override
Future<User?> findById(String id) async =>
_users.cast<User?>().firstWhere((u) => u!.id == id, orElse: () => null);
@override
Future<User> create(String name) async {
createdNames.add(name);
if (createGate != null) await createGate!.future;
if (createError != null) throw createError!;
final user = User(
id: 'uid-${_users.length}',
name: name,
createdAt: DateTime(2024, 1, 1),
);
_users.add(user);
return user;
}
@override
Future<User> rename(String id, String newName) async {
renames.add((id, newName));
if (renameError != null) throw renameError!;
final idx = _users.indexWhere((u) => u.id == id);
final updated = User(id: id, name: newName, createdAt: _users[idx].createdAt);
_users[idx] = updated;
return updated;
}
@override
Future<void> delete(String id) async {
deletedIds.add(id);
if (deleteError != null) throw deleteError!;
_users.removeWhere((u) => u.id == id);
}
}
/// Сидер фейкаем целиком — нам важно только, что его позвали и с каким id.
class FakeUserSeeder extends UserSeeder {
FakeUserSeeder()
: super(
accountRepo: _UnusedAccountRepo(),
categoryRepo: _UnusedCategoryRepo(),
txRepo: _UnusedTransactionRepo(),
);
final List<String> seededFor = [];
Object? seedError;
@override
Future<void> seedForNewUser(String userId) async {
if (seedError != null) throw seedError!;
seededFor.add(userId);
}
}
// Заглушки для конструктора UserSeeder — методы вызываться не должны.
class _UnusedAccountRepo implements AccountRepository {
@override
dynamic noSuchMethod(Invocation i) =>
throw StateError('AccountRepository should not be called in this test');
}
class _UnusedCategoryRepo implements CategoryRepository {
@override
dynamic noSuchMethod(Invocation i) =>
throw StateError('CategoryRepository should not be called in this test');
}
class _UnusedTransactionRepo implements TransactionRepository {
@override
dynamic noSuchMethod(Invocation i) =>
throw StateError('TransactionRepository should not be called in this test');
}
// ─── Helper ───────────────────────────────────────────────────────────────────
ProviderContainer _makeContainer({
required FakeUserRepository repo,
required FakeUserSeeder seeder,
}) {
return ProviderContainer(
overrides: [
userRepositoryProvider.overrideWithValue(repo),
userSeederProvider.overrideWithValue(seeder),
],
);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
void main() {
group('UsersController.createUser', () {
late FakeUserRepository repo;
late FakeUserSeeder seeder;
late ProviderContainer container;
setUp(() {
repo = FakeUserRepository();
seeder = FakeUserSeeder();
container = _makeContainer(repo: repo, seeder: seeder);
});
tearDown(() => container.dispose());
test('создаёт пользователя через репозиторий и сидит начальные данные',
() async {
final user = await container
.read(usersControllerProvider.notifier)
.createUser('Alice');
expect(user.name, 'Alice');
expect(repo.createdNames, ['Alice']);
expect(seeder.seededFor, [user.id]);
});
test('после успеха состояние = AsyncData(null)', () async {
await container
.read(usersControllerProvider.notifier)
.createUser('Alice');
final state = container.read(usersControllerProvider);
expect(state, isA<AsyncData<void>>());
expect(state.hasError, isFalse);
});
// Регрессия: UsersController должен переживать await-границы.
// Изначально был @riverpod (auto-dispose) — после первого await
// notifier удалялся, и второй ref.read падал с "Ref … has been disposed".
test('переживает несколько await-границ при вызове через .read(.notifier)',
() async {
// Никто не watch'ит провайдер — типичная ситуация при вызове из
// обработчика onPressed экрана.
final gate = Completer<void>();
repo.createGate = gate;
final notifier = container.read(usersControllerProvider.notifier);
final future = notifier.createUser('Alice');
// Уступаем event loop, чтобы auto-dispose (если бы он был) сработал.
await Future<void>.delayed(Duration.zero);
gate.complete();
final user = await future;
expect(user.name, 'Alice');
expect(seeder.seededFor, [user.id], reason: 'сидер должен быть вызван');
});
test('во время выполнения состояние = AsyncLoading', () async {
final gate = Completer<void>();
repo.createGate = gate;
final future = container
.read(usersControllerProvider.notifier)
.createUser('Alice');
// Даём контроллеру дойти до первого await.
await Future<void>.delayed(Duration.zero);
expect(container.read(usersControllerProvider), isA<AsyncLoading<void>>());
gate.complete();
await future;
});
test('ошибка в репозитории пробрасывается и пишется в state', () async {
final boom = Exception('db is on fire');
repo.createError = boom;
await expectLater(
container.read(usersControllerProvider.notifier).createUser('Alice'),
throwsA(same(boom)),
);
final state = container.read(usersControllerProvider);
expect(state, isA<AsyncError<void>>());
expect(state.error, same(boom));
expect(state.stackTrace, isNotNull);
expect(seeder.seededFor, isEmpty, reason: 'сидер не должен вызываться');
});
test('ошибка в сидере пробрасывается и пишется в state', () async {
final boom = Exception('seed failed');
seeder.seedError = boom;
await expectLater(
container.read(usersControllerProvider.notifier).createUser('Alice'),
throwsA(same(boom)),
);
final state = container.read(usersControllerProvider);
expect(state, isA<AsyncError<void>>());
expect(state.error, same(boom));
expect(repo.createdNames, ['Alice'],
reason: 'создание уже произошло до падения сидера');
});
// Если после неудачи пользователь жмёт "Continue" второй раз — должно
// получиться. Это проверка, что notifier (и его ref) живы между вызовами.
test('после ошибки можно вызвать createUser ещё раз и преуспеть', () async {
repo.createError = Exception('first attempt fails');
final notifier = container.read(usersControllerProvider.notifier);
await expectLater(notifier.createUser('Alice'), throwsA(isA<Exception>()));
repo.createError = null;
final user = await notifier.createUser('Alice');
expect(user.name, 'Alice');
expect(repo.createdNames, ['Alice', 'Alice']);
});
});
group('UsersController.renameUser', () {
late FakeUserRepository repo;
late FakeUserSeeder seeder;
late ProviderContainer container;
setUp(() async {
repo = FakeUserRepository();
seeder = FakeUserSeeder();
container = _makeContainer(repo: repo, seeder: seeder);
// Создаём пользователя для последующего rename.
await container
.read(usersControllerProvider.notifier)
.createUser('Alice');
});
tearDown(() => container.dispose());
test('переименовывает пользователя через репозиторий', () async {
await container
.read(usersControllerProvider.notifier)
.renameUser('uid-0', 'Alice II');
expect(repo.renames, [('uid-0', 'Alice II')]);
expect(container.read(usersControllerProvider), isA<AsyncData<void>>());
});
test('ошибка пробрасывается и пишется в state', () async {
final boom = Exception('rename failed');
repo.renameError = boom;
await expectLater(
container
.read(usersControllerProvider.notifier)
.renameUser('uid-0', 'X'),
throwsA(same(boom)),
);
final state = container.read(usersControllerProvider);
expect(state, isA<AsyncError<void>>());
expect(state.error, same(boom));
});
});
group('UsersController.deleteUser', () {
late FakeUserRepository repo;
late FakeUserSeeder seeder;
late ProviderContainer container;
setUp(() async {
repo = FakeUserRepository();
seeder = FakeUserSeeder();
container = _makeContainer(repo: repo, seeder: seeder);
await container
.read(usersControllerProvider.notifier)
.createUser('Alice');
});
tearDown(() => container.dispose());
test('удаляет пользователя через репозиторий', () async {
await container
.read(usersControllerProvider.notifier)
.deleteUser('uid-0');
expect(repo.deletedIds, ['uid-0']);
expect(container.read(usersControllerProvider), isA<AsyncData<void>>());
});
test('ошибка пробрасывается и пишется в state', () async {
final boom = Exception('delete failed');
repo.deleteError = boom;
await expectLater(
container
.read(usersControllerProvider.notifier)
.deleteUser('uid-0'),
throwsA(same(boom)),
);
final state = container.read(usersControllerProvider);
expect(state, isA<AsyncError<void>>());
expect(state.error, same(boom));
});
});
group('usersStreamProvider', () {
test('пробрасывает значения из репозитория', () async {
final repo = FakeUserRepository();
final container = _makeContainer(repo: repo, seeder: FakeUserSeeder());
addTearDown(container.dispose);
await container
.read(usersControllerProvider.notifier)
.createUser('Alice');
await container
.read(usersControllerProvider.notifier)
.createUser('Bob');
// listen, а не read(.future): иначе auto-dispose снесёт провайдер
// до того, как future завершится.
final sub = container.listen(usersStreamProvider.future, (_, __) {});
addTearDown(sub.close);
final users = await sub.read();
expect(users.map((u) => u.name), ['Alice', 'Bob']);
});
});
}