355 lines
13 KiB
Dart
355 lines
13 KiB
Dart
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']);
|
||
});
|
||
});
|
||
}
|