diff --git a/mobile/lib/src/features/schedules/services/expense/submit_expense_schedule_controller.dart b/mobile/lib/src/features/schedules/services/expense/submit_expense_schedule_controller.dart index 0c3abc4..cb1d32f 100644 --- a/mobile/lib/src/features/schedules/services/expense/submit_expense_schedule_controller.dart +++ b/mobile/lib/src/features/schedules/services/expense/submit_expense_schedule_controller.dart @@ -39,32 +39,23 @@ class SubmitExpenseScheduleController ..expenseLocationID = schedule.locationID))); Future submit(ExpenseSchedule expenseSchedule) async { + if (!expenseSchedule.isValid) return; state = const AsyncLoading(); final String timezone = await ref.read(localTimezoneProvider.future) ?? 'UTC'; - expenseSchedule.isNew - ? await ref - .read(registerExpenseScheduleRepositoryProvider) - .registerExpenseSchedule( - _registerRequest(expenseSchedule, timezone)) - : await ref - .read(updateExpenseScheduleRepositoryProvider) - .updateExpenseSchedule(_updateRequest(expenseSchedule, timezone)); - - ref.invalidate(fetchSchedulesProvider); - - // state = AsyncValue.data( - // state.copyWith(submissionStatus: FormzSubmissionStatus.success)); - // // } on CustomFailure catch (e) { - // // state = state.copyWith( - // // status: FormzSubmissionStatus.failure, errorMessage: e.code); - // // } - - // TODO エラーハンドリング - // if (!state.hasError) { - // ref.read(itemQuantityControllerProvider.notifier).updateQuantity(1); - // } + state = await AsyncValue.guard(() async { + expenseSchedule.isNew + ? await ref + .read(registerExpenseScheduleRepositoryProvider) + .registerExpenseSchedule( + _registerRequest(expenseSchedule, timezone)) + : await ref + .read(updateExpenseScheduleRepositoryProvider) + .updateExpenseSchedule(_updateRequest(expenseSchedule, timezone)); + + ref.invalidate(fetchSchedulesProvider); + }); } } diff --git a/mobile/lib/src/features/schedules/services/income/submit_income_schedule_controller.dart b/mobile/lib/src/features/schedules/services/income/submit_income_schedule_controller.dart index 733d29f..6eb0d6e 100644 --- a/mobile/lib/src/features/schedules/services/income/submit_income_schedule_controller.dart +++ b/mobile/lib/src/features/schedules/services/income/submit_income_schedule_controller.dart @@ -35,31 +35,23 @@ class SubmitIncomeScheduleController extends _$SubmitIncomeScheduleController { ..incomeTypeId = inc.incomeTypeID))); Future submit(IncomeSchedule incomeSchedule) async { + if (!incomeSchedule.isValid) return; state = const AsyncLoading(); final String timezone = await ref.read(localTimezoneProvider.future) ?? 'UTC'; - incomeSchedule.isNew - ? await ref - .read(registerIncomeScheduleRepositoryProvider) - .registerIncomeSchedule(_registerRequest(incomeSchedule, timezone)) - : await ref - .read(updateIncomeScheduleRepositoryProvider) - .updateIncomeSchedule(_updateRequest(incomeSchedule, timezone)); - - ref.invalidate(fetchSchedulesProvider); - - // state = AsyncValue.data( - // state.copyWith(submissionStatus: FormzSubmissionStatus.success)); - // // } on CustomFailure catch (e) { - // // state = state.copyWith( - // // status: FormzSubmissionStatus.failure, errorMessage: e.code); - // // } - - // TODO エラーハンドリング - // if (!state.hasError) { - // ref.read(itemQuantityControllerProvider.notifier).updateQuantity(1); - // } + state = await AsyncValue.guard(() async { + incomeSchedule.isNew + ? await ref + .read(registerIncomeScheduleRepositoryProvider) + .registerIncomeSchedule( + _registerRequest(incomeSchedule, timezone)) + : await ref + .read(updateIncomeScheduleRepositoryProvider) + .updateIncomeSchedule(_updateRequest(incomeSchedule, timezone)); + + ref.invalidate(fetchSchedulesProvider); + }); } } diff --git a/mobile/lib/src/features/transactions/repositories/expense/register_expense_repository.dart b/mobile/lib/src/features/transactions/repositories/expense/register_expense_repository.dart index fbc9a12..f059ed9 100644 --- a/mobile/lib/src/features/transactions/repositories/expense/register_expense_repository.dart +++ b/mobile/lib/src/features/transactions/repositories/expense/register_expense_repository.dart @@ -11,7 +11,7 @@ class RegisterExpenseRepository { Future registerExpense(RegisterExpenseReq request) async { final api = _openapi.getSuitoExpenseApi(); final response = await api.registerExpense(request: request); - return response.data?.newExpense ?? ModelExpense(); + return response.data!.newExpense; } } diff --git a/mobile/lib/src/features/transactions/services/expense/submit_expense_controller.dart b/mobile/lib/src/features/transactions/services/expense/submit_expense_controller.dart index 07c51fb..3919809 100644 --- a/mobile/lib/src/features/transactions/services/expense/submit_expense_controller.dart +++ b/mobile/lib/src/features/transactions/services/expense/submit_expense_controller.dart @@ -41,30 +41,19 @@ class SubmitExpenseController extends _$SubmitExpenseController { } Future submit(Expense expense) async { + if (!expense.isValid) return; state = const AsyncLoading(); - expense.isNew - ? await ref - .read(registerExpenseRepositoryProvider) - .registerExpense(_registerRequest(expense)) - : await ref - .read(updateExpenseRepositoryProvider) - .updateExpense(_updateRequest(expense)); - - ref.invalidate(fetchTransactionsProvider); - - // ref.read(reloadTransactionsProvider.notifier).reload(); - - // state = AsyncValue.data( - // state.copyWith(submissionStatus: FormzSubmissionStatus.success)); - // // } on CustomFailure catch (e) { - // // state = state.copyWith( - // // status: FormzSubmissionStatus.failure, errorMessage: e.code); - // // } - - // TODO エラーハンドリング - // if (!state.hasError) { - // ref.read(itemQuantityControllerProvider.notifier).updateQuantity(1); - // } + state = await AsyncValue.guard(() async { + expense.isNew + ? await ref + .read(registerExpenseRepositoryProvider) + .registerExpense(_registerRequest(expense)) + : await ref + .read(updateExpenseRepositoryProvider) + .updateExpense(_updateRequest(expense)); + + ref.invalidate(fetchTransactionsProvider); + }); } } diff --git a/mobile/lib/src/features/transactions/services/income/submit_income_controller.dart b/mobile/lib/src/features/transactions/services/income/submit_income_controller.dart index 4665a7c..c74f01b 100644 --- a/mobile/lib/src/features/transactions/services/income/submit_income_controller.dart +++ b/mobile/lib/src/features/transactions/services/income/submit_income_controller.dart @@ -37,30 +37,19 @@ class SubmitIncomeController extends _$SubmitIncomeController { } Future submit(Income income) async { + if (!income.isValid) return; state = const AsyncLoading(); - income.isNew - ? await ref - .read(registerIncomeRepositoryProvider) - .registerIncome(_registerRequest(income)) - : await ref - .read(updateIncomeRepositoryProvider) - .updateIncome(_updateRequest(income)); - - ref.invalidate(fetchTransactionsProvider); - - // ref.read(reloadTransactionsProvider.notifier).reload(); - - // state = AsyncValue.data( - // state.copyWith(submissionStatus: FormzSubmissionStatus.success)); - // // } on CustomFailure catch (e) { - // // state = state.copyWith( - // // status: FormzSubmissionStatus.failure, errorMessage: e.code); - // // } - - // TODO エラーハンドリング - // if (!state.hasError) { - // ref.read(itemQuantityControllerProvider.notifier).updateQuantity(1); - // } + state = await AsyncValue.guard(() async { + income.isNew + ? await ref + .read(registerIncomeRepositoryProvider) + .registerIncome(_registerRequest(income)) + : await ref + .read(updateIncomeRepositoryProvider) + .updateIncome(_updateRequest(income)); + + ref.invalidate(fetchTransactionsProvider); + }); } } diff --git a/mobile/test/src/features/schedules/services/expense/submit_expense_schedule_controller_test.dart b/mobile/test/src/features/schedules/services/expense/submit_expense_schedule_controller_test.dart new file mode 100644 index 0000000..4611732 --- /dev/null +++ b/mobile/test/src/features/schedules/services/expense/submit_expense_schedule_controller_test.dart @@ -0,0 +1,273 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:openapi/openapi.dart'; +import 'package:suito/src/features/schedules/repositories/expense/register_expense_schedule_repository.dart'; +import 'package:suito/src/features/schedules/repositories/expense/update_expense_schedule_repository.dart'; +import 'package:suito/src/features/schedules/services/expense/expense_schedule.dart'; +import 'package:suito/src/features/schedules/services/expense/submit_expense_schedule_controller.dart'; +import 'package:suito/src/formz/amount.dart'; +import 'package:suito/src/formz/title.dart'; +import 'package:suito/src/utils/timezone_provider.dart'; + +import '../../../../mocks.dart'; + +void main() { + ProviderContainer makeProviderContainer( + {MockRegisterExpenseScheduleRepository? registerRepo, + MockUpdateExpenseScheduleRepository? updateRepo}) { + final container = ProviderContainer( + overrides: [ + localTimezoneProvider.overrideWith((ref) => 'Asia/Tokyo'), + if (registerRepo != null) + registerExpenseScheduleRepositoryProvider + .overrideWithValue(registerRepo), + if (updateRepo != null) + updateExpenseScheduleRepositoryProvider.overrideWithValue(updateRepo) + ], + ); + addTearDown(container.dispose); + return container; + } + + setUpAll(() { + registerFallbackValue(const AsyncLoading()); + }); + + group('submitExpenseScheduleController', () { + test('register new expenseSchedule, success', () async { + // setup + final registerRepo = MockRegisterExpenseScheduleRepository(); + const expenseSchedule = ExpenseSchedule( + id: '', + title: Title.dirty('A title'), + amount: Amount.dirty(400), + categoryID: 'category_id', + category: 'A category', + locationID: 'location_id', + location: 'A locatin', + memo: 'memo', + isValid: true); + final resExpenseSchedule = ModelExpenseSchedule((e) => e + ..id = 'new_expenseSchedule_id' + ..timezone = 'Asia/Tokyo' + ..title = expenseSchedule.title.value + ..amount = expenseSchedule.amount.value + ..expenseCategoryID = expenseSchedule.categoryID + ..expenseLocationID = expenseSchedule.locationID + ..memo = expenseSchedule.memo); + final req = RegisterExpenseScheduleReq( + (r) => r.expenseSchedule.replace(ModelExpenseSchedule((e) => e + ..id = expenseSchedule.id + ..title = expenseSchedule.title.value + ..amount = expenseSchedule.amount.value + ..timezone = 'Asia/Tokyo' + ..expenseCategoryID = expenseSchedule.categoryID + ..expenseLocationID = expenseSchedule.locationID + ..memo = expenseSchedule.memo))); + registerFallbackValue(req); + when(() => registerRepo.registerExpenseSchedule(any())).thenAnswer( + (_) => Future.value(resExpenseSchedule), + ); + final container = makeProviderContainer(registerRepo: registerRepo); + final controller = + container.read(submitExpenseScheduleControllerProvider.notifier); + final listener = Listener>(); + container.listen( + submitExpenseScheduleControllerProvider, + listener, + fireImmediately: true, + ); + const data = AsyncData(null); + verify(() => listener(null, data)); + // run + await controller.submit(expenseSchedule); + // verify + verifyInOrder([ + () => listener(data, any(that: isA())), + () => listener(any(that: isA()), data), + ]); + verifyNoMoreInteractions(listener); + verify(() => registerRepo.registerExpenseSchedule(any())).called(1); + }); + + test('register new expenseSchedule, failure', () async { + // setup + final registerRepo = MockRegisterExpenseScheduleRepository(); + const expenseSchedule = ExpenseSchedule( + id: '', + title: Title.dirty('A title'), + amount: Amount.dirty(400), + categoryID: 'category_id', + category: 'A category', + locationID: 'location_id', + location: 'A locatin', + memo: 'memo', + isValid: true); + final req = RegisterExpenseScheduleReq( + (r) => r.expenseSchedule.replace(ModelExpenseSchedule((e) => e + ..id = expenseSchedule.id + ..title = expenseSchedule.title.value + ..amount = expenseSchedule.amount.value + ..timezone = 'Asia/Tokyo' + ..expenseCategoryID = expenseSchedule.categoryID + ..expenseLocationID = expenseSchedule.locationID + ..memo = expenseSchedule.memo))); + registerFallbackValue(req); + when(() => registerRepo.registerExpenseSchedule(any())).thenThrow( + (_) => Exception("Network error"), + ); + final container = makeProviderContainer(registerRepo: registerRepo); + final controller = + container.read(submitExpenseScheduleControllerProvider.notifier); + final listener = Listener>(); + container.listen( + submitExpenseScheduleControllerProvider, + listener, + fireImmediately: true, + ); + const data = AsyncData(null); + verify(() => listener(null, data)); + // run + await controller.submit(expenseSchedule); + // verify + verifyInOrder([ + () => listener(data, any(that: isA())), + () => listener( + any(that: isA()), any(that: isA())), + ]); + verifyNoMoreInteractions(listener); + verify(() => registerRepo.registerExpenseSchedule(any())).called(1); + }); + + test('update expenseSchedule, success', () async { + // setup + final updateRepo = MockUpdateExpenseScheduleRepository(); + const expenseSchedule = ExpenseSchedule( + id: 'expenseSchedule_id', + title: Title.dirty('A title'), + amount: Amount.dirty(400), + categoryID: 'category_id', + category: 'A category', + locationID: 'location_id', + location: 'A locatin', + memo: 'memo', + isValid: true); + final resExpenseSchedule = ModelExpenseSchedule((e) => e + ..id = expenseSchedule.id + ..title = expenseSchedule.title.value + ..amount = expenseSchedule.amount.value + ..timezone = 'Asia/Tokyo' + ..expenseCategoryID = expenseSchedule.categoryID + ..expenseLocationID = expenseSchedule.locationID + ..memo = expenseSchedule.memo); + final req = UpdateExpenseScheduleReq( + (r) => r.expenseSchedule.replace(ModelExpenseSchedule((e) => e + ..id = expenseSchedule.id + ..title = expenseSchedule.title.value + ..amount = expenseSchedule.amount.value + ..timezone = 'Asia/Tokyo' + ..expenseCategoryID = expenseSchedule.categoryID + ..expenseLocationID = expenseSchedule.locationID + ..memo = expenseSchedule.memo))); + registerFallbackValue(req); + when(() => updateRepo.updateExpenseSchedule(any())).thenAnswer( + (_) => Future.value(resExpenseSchedule), + ); + final container = makeProviderContainer(updateRepo: updateRepo); + final controller = + container.read(submitExpenseScheduleControllerProvider.notifier); + final listener = Listener>(); + container.listen( + submitExpenseScheduleControllerProvider, + listener, + fireImmediately: true, + ); + const data = AsyncData(null); + verify(() => listener(null, data)); + // run + await controller.submit(expenseSchedule); + // verify + verifyInOrder([ + () => listener(data, any(that: isA())), + () => listener(any(that: isA()), data), + ]); + verifyNoMoreInteractions(listener); + verify(() => updateRepo.updateExpenseSchedule(any())).called(1); + }); + + test('update expenseSchedule, failure', () async { + // setup + final updateRepo = MockUpdateExpenseScheduleRepository(); + const expenseSchedule = ExpenseSchedule( + id: 'expenseSchedule_id', + title: Title.dirty('A title'), + amount: Amount.dirty(400), + categoryID: 'category_id', + category: 'A category', + locationID: 'location_id', + location: 'A locatin', + memo: 'memo', + isValid: true); + final req = UpdateExpenseScheduleReq( + (r) => r.expenseSchedule.replace(ModelExpenseSchedule((e) => e + ..id = expenseSchedule.id + ..title = expenseSchedule.title.value + ..amount = expenseSchedule.amount.value + ..timezone = 'Asia/Tokyo' + ..expenseCategoryID = expenseSchedule.categoryID + ..expenseLocationID = expenseSchedule.locationID + ..memo = expenseSchedule.memo))); + registerFallbackValue(req); + when(() => updateRepo.updateExpenseSchedule(any())).thenThrow( + (_) => Exception("Network error"), + ); + final container = makeProviderContainer(updateRepo: updateRepo); + final controller = + container.read(submitExpenseScheduleControllerProvider.notifier); + final listener = Listener>(); + container.listen( + submitExpenseScheduleControllerProvider, + listener, + fireImmediately: true, + ); + const data = AsyncData(null); + verify(() => listener(null, data)); + // run + await controller.submit(expenseSchedule); + // verify + verifyInOrder([ + () => listener(data, any(that: isA())), + () => listener( + any(that: isA()), any(that: isA())), + ]); + verifyNoMoreInteractions(listener); + verify(() => updateRepo.updateExpenseSchedule(any())).called(1); + }); + + test('repository is not called if expenseSchedule form value is invalid', + () async { + // setup + final registerRepo = MockRegisterExpenseScheduleRepository(); + final updateRepo = MockUpdateExpenseScheduleRepository(); + const expenseSchedule = ExpenseSchedule( + id: '', + title: Title.dirty('A title'), + amount: Amount.dirty(400), + categoryID: 'category_id', + category: 'A category', + locationID: 'location_id', + location: 'A locatin', + memo: 'memo', + isValid: false); + final container = makeProviderContainer(registerRepo: registerRepo); + final controller = + container.read(submitExpenseScheduleControllerProvider.notifier); + // run + await controller.submit(expenseSchedule); + // verify + verifyNever(() => registerRepo.registerExpenseSchedule(any())); + verifyNever(() => updateRepo.updateExpenseSchedule(any())); + }); + }); +} diff --git a/mobile/test/src/features/schedules/services/income/submit_income_schedule_controller_test.dart b/mobile/test/src/features/schedules/services/income/submit_income_schedule_controller_test.dart new file mode 100644 index 0000000..6c0ccef --- /dev/null +++ b/mobile/test/src/features/schedules/services/income/submit_income_schedule_controller_test.dart @@ -0,0 +1,246 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:openapi/openapi.dart'; +import 'package:suito/src/features/schedules/repositories/income/register_income_schedule_repository.dart'; +import 'package:suito/src/features/schedules/repositories/income/update_income_schedule_repository.dart'; +import 'package:suito/src/features/schedules/services/income/income_schedule.dart'; +import 'package:suito/src/features/schedules/services/income/submit_income_schedule_controller.dart'; +import 'package:suito/src/formz/amount.dart'; +import 'package:suito/src/formz/title.dart'; +import 'package:suito/src/utils/timezone_provider.dart'; + +import '../../../../mocks.dart'; + +void main() { + ProviderContainer makeProviderContainer( + {MockRegisterIncomeScheduleRepository? registerRepo, + MockUpdateIncomeScheduleRepository? updateRepo}) { + final container = ProviderContainer( + overrides: [ + localTimezoneProvider.overrideWith((ref) => 'Asia/Tokyo'), + if (registerRepo != null) + registerIncomeScheduleRepositoryProvider + .overrideWithValue(registerRepo), + if (updateRepo != null) + updateIncomeScheduleRepositoryProvider.overrideWithValue(updateRepo) + ], + ); + addTearDown(container.dispose); + return container; + } + + setUpAll(() { + registerFallbackValue(const AsyncLoading()); + }); + + group('submitIncomeScheduleController', () { + test('register new incomeSchedule, success', () async { + // setup + final registerRepo = MockRegisterIncomeScheduleRepository(); + const incomeSchedule = IncomeSchedule( + id: '', + title: Title.dirty('A title'), + amount: Amount.dirty(400), + incomeTypeID: 'income_type_id', + memo: 'memo', + isValid: true); + final resIncomeSchedule = ModelIncomeSchedule((e) => e + ..id = 'new_incomeSchedule_id' + ..timezone = 'Asia/Tokyo' + ..amount = incomeSchedule.amount.value + ..incomeTypeId = incomeSchedule.incomeTypeID + ..memo = incomeSchedule.memo); + final req = RegisterIncomeScheduleReq( + (r) => r.incomeSchedule.replace(ModelIncomeSchedule((e) => e + ..id = incomeSchedule.id + ..amount = incomeSchedule.amount.value + ..timezone = 'Asia/Tokyo' + ..incomeTypeId = incomeSchedule.incomeTypeID + ..memo = incomeSchedule.memo))); + registerFallbackValue(req); + when(() => registerRepo.registerIncomeSchedule(any())).thenAnswer( + (_) => Future.value(resIncomeSchedule), + ); + final container = makeProviderContainer(registerRepo: registerRepo); + final controller = + container.read(submitIncomeScheduleControllerProvider.notifier); + final listener = Listener>(); + container.listen( + submitIncomeScheduleControllerProvider, + listener, + fireImmediately: true, + ); + const data = AsyncData(null); + verify(() => listener(null, data)); + // run + await controller.submit(incomeSchedule); + // verify + verifyInOrder([ + () => listener(data, any(that: isA())), + () => listener(any(that: isA()), data), + ]); + verifyNoMoreInteractions(listener); + verify(() => registerRepo.registerIncomeSchedule(any())).called(1); + }); + + test('register new incomeSchedule, failure', () async { + // setup + final registerRepo = MockRegisterIncomeScheduleRepository(); + const incomeSchedule = IncomeSchedule( + id: '', + title: Title.dirty('A title'), + amount: Amount.dirty(400), + incomeTypeID: 'income_type_id', + memo: 'memo', + isValid: true); + final req = RegisterIncomeScheduleReq( + (r) => r.incomeSchedule.replace(ModelIncomeSchedule((e) => e + ..id = incomeSchedule.id + ..amount = incomeSchedule.amount.value + ..timezone = 'Asia/Tokyo' + ..incomeTypeId = incomeSchedule.incomeTypeID + ..memo = incomeSchedule.memo))); + registerFallbackValue(req); + when(() => registerRepo.registerIncomeSchedule(any())).thenThrow( + (_) => Exception("Network error"), + ); + final container = makeProviderContainer(registerRepo: registerRepo); + final controller = + container.read(submitIncomeScheduleControllerProvider.notifier); + final listener = Listener>(); + container.listen( + submitIncomeScheduleControllerProvider, + listener, + fireImmediately: true, + ); + const data = AsyncData(null); + verify(() => listener(null, data)); + // run + await controller.submit(incomeSchedule); + // verify + verifyInOrder([ + () => listener(data, any(that: isA())), + () => listener( + any(that: isA()), any(that: isA())), + ]); + verifyNoMoreInteractions(listener); + verify(() => registerRepo.registerIncomeSchedule(any())).called(1); + }); + + test('update incomeSchedule, success', () async { + // setup + final updateRepo = MockUpdateIncomeScheduleRepository(); + const incomeSchedule = IncomeSchedule( + id: 'incomeSchedule_id', + title: Title.dirty('A title'), + amount: Amount.dirty(400), + incomeTypeID: 'income_type_id', + memo: 'memo', + isValid: true); + final resIncomeSchedule = ModelIncomeSchedule((e) => e + ..id = incomeSchedule.id + ..amount = incomeSchedule.amount.value + ..timezone = 'Asia/Tokyo' + ..incomeTypeId = incomeSchedule.incomeTypeID + ..memo = incomeSchedule.memo); + final req = UpdateIncomeScheduleReq( + (r) => r.incomeSchedule.replace(ModelIncomeSchedule((e) => e + ..id = incomeSchedule.id + ..amount = incomeSchedule.amount.value + ..timezone = 'Asia/Tokyo' + ..incomeTypeId = incomeSchedule.incomeTypeID + ..memo = incomeSchedule.memo))); + registerFallbackValue(req); + when(() => updateRepo.updateIncomeSchedule(any())).thenAnswer( + (_) => Future.value(resIncomeSchedule), + ); + final container = makeProviderContainer(updateRepo: updateRepo); + final controller = + container.read(submitIncomeScheduleControllerProvider.notifier); + final listener = Listener>(); + container.listen( + submitIncomeScheduleControllerProvider, + listener, + fireImmediately: true, + ); + const data = AsyncData(null); + verify(() => listener(null, data)); + // run + await controller.submit(incomeSchedule); + // verify + verifyInOrder([ + () => listener(data, any(that: isA())), + () => listener(any(that: isA()), data), + ]); + verifyNoMoreInteractions(listener); + verify(() => updateRepo.updateIncomeSchedule(any())).called(1); + }); + + test('update incomeSchedule, failure', () async { + // setup + final updateRepo = MockUpdateIncomeScheduleRepository(); + const incomeSchedule = IncomeSchedule( + id: 'incomeSchedule_id', + title: Title.dirty('A title'), + amount: Amount.dirty(400), + incomeTypeID: 'income_type_id', + memo: 'memo', + isValid: true); + final req = UpdateIncomeScheduleReq( + (r) => r.incomeSchedule.replace(ModelIncomeSchedule((e) => e + ..id = incomeSchedule.id + ..amount = incomeSchedule.amount.value + ..timezone = 'Asia/Tokyo' + ..incomeTypeId = incomeSchedule.incomeTypeID + ..memo = incomeSchedule.memo))); + registerFallbackValue(req); + when(() => updateRepo.updateIncomeSchedule(any())).thenThrow( + (_) => Exception("Network error"), + ); + final container = makeProviderContainer(updateRepo: updateRepo); + final controller = + container.read(submitIncomeScheduleControllerProvider.notifier); + final listener = Listener>(); + container.listen( + submitIncomeScheduleControllerProvider, + listener, + fireImmediately: true, + ); + const data = AsyncData(null); + verify(() => listener(null, data)); + // run + await controller.submit(incomeSchedule); + // verify + verifyInOrder([ + () => listener(data, any(that: isA())), + () => listener( + any(that: isA()), any(that: isA())), + ]); + verifyNoMoreInteractions(listener); + verify(() => updateRepo.updateIncomeSchedule(any())).called(1); + }); + + test('repository is not called if incomeSchedule form value is invalid', + () async { + // setup + final registerRepo = MockRegisterIncomeScheduleRepository(); + final updateRepo = MockUpdateIncomeScheduleRepository(); + const incomeSchedule = IncomeSchedule( + id: '', + title: Title.dirty('A title'), + amount: Amount.dirty(400), + incomeTypeID: 'income_type_id', + memo: 'memo', + isValid: false); + final container = makeProviderContainer(registerRepo: registerRepo); + final controller = + container.read(submitIncomeScheduleControllerProvider.notifier); + // run + await controller.submit(incomeSchedule); + // verify + verifyNever(() => registerRepo.registerIncomeSchedule(any())); + verifyNever(() => updateRepo.updateIncomeSchedule(any())); + }); + }); +} diff --git a/mobile/test/src/features/transactions/services/expense/submit_expense_controller_test.dart b/mobile/test/src/features/transactions/services/expense/submit_expense_controller_test.dart new file mode 100644 index 0000000..a08eb07 --- /dev/null +++ b/mobile/test/src/features/transactions/services/expense/submit_expense_controller_test.dart @@ -0,0 +1,274 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:openapi/openapi.dart'; +import 'package:suito/src/features/transactions/repositories/expense/register_expense_repository.dart'; +import 'package:suito/src/features/transactions/repositories/expense/update_expense_repository.dart'; +import 'package:suito/src/features/transactions/services/expense/expense.dart'; +import 'package:suito/src/features/transactions/services/expense/submit_expense_controller.dart'; +import 'package:suito/src/formz/amount.dart'; +import 'package:suito/src/formz/title.dart'; + +import '../../../../mocks.dart'; + +void main() { + ProviderContainer makeProviderContainer( + {MockRegisterExpenseRepository? registerRepo, + MockUpdateExpenseRepository? updateRepo}) { + final container = ProviderContainer( + overrides: [ + if (registerRepo != null) + registerExpenseRepositoryProvider.overrideWithValue(registerRepo), + if (updateRepo != null) + updateExpenseRepositoryProvider.overrideWithValue(updateRepo) + ], + ); + addTearDown(container.dispose); + return container; + } + + setUpAll(() { + registerFallbackValue(const AsyncLoading()); + }); + + group('submitExpenseController', () { + test('register new expense, success', () async { + // setup + final registerRepo = MockRegisterExpenseRepository(); + const expense = Expense( + id: '', + title: Title.dirty('A title'), + amount: Amount.dirty(400), + date: '2023-01-05', + categoryID: 'category_id', + category: 'A category', + locationID: 'location_id', + location: 'A locatin', + memo: 'memo', + isValid: true); + final resExpense = ModelExpense((e) => e + ..id = 'new_expense_id' + ..title = expense.title.value + ..amount = expense.amount.value + ..localDate = expense.date + ..expenseCategoryID = expense.categoryID + ..expenseLocationID = expense.locationID + ..memo = expense.memo); + final req = + RegisterExpenseReq((r) => r.expense.replace(ModelExpense((e) => e + ..id = expense.id + ..title = expense.title.value + ..amount = expense.amount.value + ..localDate = expense.date + ..expenseCategoryID = expense.categoryID + ..expenseLocationID = expense.locationID + ..memo = expense.memo))); + registerFallbackValue(req); + when(() => registerRepo.registerExpense(any())).thenAnswer( + (_) => Future.value(resExpense), + ); + final container = makeProviderContainer(registerRepo: registerRepo); + final controller = + container.read(submitExpenseControllerProvider.notifier); + final listener = Listener>(); + container.listen( + submitExpenseControllerProvider, + listener, + fireImmediately: true, + ); + const data = AsyncData(null); + verify(() => listener(null, data)); + // run + await controller.submit(expense); + // verify + verifyInOrder([ + () => listener(data, any(that: isA())), + () => listener(any(that: isA()), data), + ]); + verifyNoMoreInteractions(listener); + verify(() => registerRepo.registerExpense(any())).called(1); + }); + + test('register new expense, failure', () async { + // setup + final registerRepo = MockRegisterExpenseRepository(); + const expense = Expense( + id: '', + title: Title.dirty('A title'), + amount: Amount.dirty(400), + date: '2023-01-05', + categoryID: 'category_id', + category: 'A category', + locationID: 'location_id', + location: 'A locatin', + memo: 'memo', + isValid: true); + final req = + RegisterExpenseReq((r) => r.expense.replace(ModelExpense((e) => e + ..id = expense.id + ..title = expense.title.value + ..amount = expense.amount.value + ..localDate = expense.date + ..expenseCategoryID = expense.categoryID + ..expenseLocationID = expense.locationID + ..memo = expense.memo))); + registerFallbackValue(req); + when(() => registerRepo.registerExpense(any())).thenThrow( + (_) => Exception("Network error"), + ); + final container = makeProviderContainer(registerRepo: registerRepo); + final controller = + container.read(submitExpenseControllerProvider.notifier); + final listener = Listener>(); + container.listen( + submitExpenseControllerProvider, + listener, + fireImmediately: true, + ); + const data = AsyncData(null); + verify(() => listener(null, data)); + // run + await controller.submit(expense); + // verify + verifyInOrder([ + () => listener(data, any(that: isA())), + () => listener( + any(that: isA()), any(that: isA())), + ]); + verifyNoMoreInteractions(listener); + verify(() => registerRepo.registerExpense(any())).called(1); + }); + + test('update expense, success', () async { + // setup + final updateRepo = MockUpdateExpenseRepository(); + const expense = Expense( + id: 'expense_id', + title: Title.dirty('A title'), + amount: Amount.dirty(400), + date: '2023-01-05', + categoryID: 'category_id', + category: 'A category', + locationID: 'location_id', + location: 'A locatin', + memo: 'memo', + isValid: true); + final resExpense = ModelExpense((e) => e + ..id = expense.id + ..title = expense.title.value + ..amount = expense.amount.value + ..localDate = expense.date + ..expenseCategoryID = expense.categoryID + ..expenseLocationID = expense.locationID + ..memo = expense.memo); + final req = + UpdateExpenseReq((r) => r.expense.replace(ModelExpense((e) => e + ..id = expense.id + ..title = expense.title.value + ..amount = expense.amount.value + ..localDate = expense.date + ..expenseCategoryID = expense.categoryID + ..expenseLocationID = expense.locationID + ..memo = expense.memo))); + registerFallbackValue(req); + when(() => updateRepo.updateExpense(any())).thenAnswer( + (_) => Future.value(resExpense), + ); + final container = makeProviderContainer(updateRepo: updateRepo); + final controller = + container.read(submitExpenseControllerProvider.notifier); + final listener = Listener>(); + container.listen( + submitExpenseControllerProvider, + listener, + fireImmediately: true, + ); + const data = AsyncData(null); + verify(() => listener(null, data)); + // run + await controller.submit(expense); + // verify + verifyInOrder([ + () => listener(data, any(that: isA())), + () => listener(any(that: isA()), data), + ]); + verifyNoMoreInteractions(listener); + verify(() => updateRepo.updateExpense(any())).called(1); + }); + + test('update expense, failure', () async { + // setup + final updateRepo = MockUpdateExpenseRepository(); + const expense = Expense( + id: 'expense_id', + title: Title.dirty('A title'), + amount: Amount.dirty(400), + date: '2023-01-05', + categoryID: 'category_id', + category: 'A category', + locationID: 'location_id', + location: 'A locatin', + memo: 'memo', + isValid: true); + final req = + UpdateExpenseReq((r) => r.expense.replace(ModelExpense((e) => e + ..id = expense.id + ..title = expense.title.value + ..amount = expense.amount.value + ..localDate = expense.date + ..expenseCategoryID = expense.categoryID + ..expenseLocationID = expense.locationID + ..memo = expense.memo))); + registerFallbackValue(req); + when(() => updateRepo.updateExpense(any())).thenThrow( + (_) => Exception("Network error"), + ); + final container = makeProviderContainer(updateRepo: updateRepo); + final controller = + container.read(submitExpenseControllerProvider.notifier); + final listener = Listener>(); + container.listen( + submitExpenseControllerProvider, + listener, + fireImmediately: true, + ); + const data = AsyncData(null); + verify(() => listener(null, data)); + // run + await controller.submit(expense); + // verify + verifyInOrder([ + () => listener(data, any(that: isA())), + () => listener( + any(that: isA()), any(that: isA())), + ]); + verifyNoMoreInteractions(listener); + verify(() => updateRepo.updateExpense(any())).called(1); + }); + + test('repository is not called if expense form value is invalid', () async { + // setup + final registerRepo = MockRegisterExpenseRepository(); + final updateRepo = MockUpdateExpenseRepository(); + const expense = Expense( + id: '', + title: Title.dirty('A title'), + amount: Amount.dirty(400), + date: '2023-01-05', + categoryID: 'category_id', + category: 'A category', + locationID: 'location_id', + location: 'A locatin', + memo: 'memo', + isValid: false); + final container = makeProviderContainer(registerRepo: registerRepo); + final controller = + container.read(submitExpenseControllerProvider.notifier); + // run + await controller.submit(expense); + // verify + verifyNever(() => registerRepo.registerExpense(any())); + verifyNever(() => updateRepo.updateExpense(any())); + }); + }); +} diff --git a/mobile/test/src/features/transactions/services/income/submit_income_controller_test.dart b/mobile/test/src/features/transactions/services/income/submit_income_controller_test.dart new file mode 100644 index 0000000..e9e2653 --- /dev/null +++ b/mobile/test/src/features/transactions/services/income/submit_income_controller_test.dart @@ -0,0 +1,243 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:openapi/openapi.dart'; +import 'package:suito/src/features/transactions/repositories/income/register_income_repository.dart'; +import 'package:suito/src/features/transactions/repositories/income/update_income_repository.dart'; +import 'package:suito/src/features/transactions/services/income/income.dart'; +import 'package:suito/src/features/transactions/services/income/submit_income_controller.dart'; +import 'package:suito/src/formz/amount.dart'; +import 'package:suito/src/formz/title.dart'; + +import '../../../../mocks.dart'; + +void main() { + ProviderContainer makeProviderContainer( + {MockRegisterIncomeRepository? registerRepo, + MockUpdateIncomeRepository? updateRepo}) { + final container = ProviderContainer( + overrides: [ + if (registerRepo != null) + registerIncomeRepositoryProvider.overrideWithValue(registerRepo), + if (updateRepo != null) + updateIncomeRepositoryProvider.overrideWithValue(updateRepo) + ], + ); + addTearDown(container.dispose); + return container; + } + + setUpAll(() { + registerFallbackValue(const AsyncLoading()); + }); + + group('submitIncomeController', () { + test('register new income, success', () async { + // setup + final registerRepo = MockRegisterIncomeRepository(); + const income = Income( + id: '', + title: Title.dirty('A title'), + amount: Amount.dirty(400), + date: '2023-01-05', + incomeTypeID: 'income_type_id', + memo: 'memo', + isValid: true); + final resIncome = ModelIncome((e) => e + ..id = 'new_income_id' + ..amount = income.amount.value + ..localDate = income.date + ..incomeTypeId = income.incomeTypeID + ..memo = income.memo); + final req = RegisterIncomeReq((r) => r.income.replace(ModelIncome((e) => e + ..id = income.id + ..incomeTypeId = income.incomeTypeID + ..amount = income.amount.value + ..localDate = income.date + ..memo = income.memo))); + registerFallbackValue(req); + when(() => registerRepo.registerIncome(any())).thenAnswer( + (_) => Future.value(resIncome), + ); + final container = makeProviderContainer(registerRepo: registerRepo); + final controller = + container.read(submitIncomeControllerProvider.notifier); + final listener = Listener>(); + container.listen( + submitIncomeControllerProvider, + listener, + fireImmediately: true, + ); + const data = AsyncData(null); + verify(() => listener(null, data)); + // run + await controller.submit(income); + // verify + verifyInOrder([ + () => listener(data, any(that: isA())), + () => listener(any(that: isA()), data), + ]); + verifyNoMoreInteractions(listener); + verify(() => registerRepo.registerIncome(any())).called(1); + }); + + test('register new income, failure', () async { + // setup + final registerRepo = MockRegisterIncomeRepository(); + const income = Income( + id: '', + title: Title.dirty('A title'), + amount: Amount.dirty(400), + date: '2023-01-05', + incomeTypeID: 'income_type_id', + memo: 'memo', + isValid: true); + final req = RegisterIncomeReq((r) => r.income.replace(ModelIncome((e) => e + ..id = income.id + ..amount = income.amount.value + ..localDate = income.date + ..incomeTypeId = income.incomeTypeID + ..memo = income.memo))); + registerFallbackValue(req); + when(() => registerRepo.registerIncome(any())).thenThrow( + (_) => Exception("Network error"), + ); + final container = makeProviderContainer(registerRepo: registerRepo); + final controller = + container.read(submitIncomeControllerProvider.notifier); + final listener = Listener>(); + container.listen( + submitIncomeControllerProvider, + listener, + fireImmediately: true, + ); + const data = AsyncData(null); + verify(() => listener(null, data)); + // run + await controller.submit(income); + // verify + verifyInOrder([ + () => listener(data, any(that: isA())), + () => listener( + any(that: isA()), any(that: isA())), + ]); + verifyNoMoreInteractions(listener); + verify(() => registerRepo.registerIncome(any())).called(1); + }); + + test('update income, success', () async { + // setup + final updateRepo = MockUpdateIncomeRepository(); + const income = Income( + id: 'income_id', + title: Title.dirty('A title'), + amount: Amount.dirty(400), + date: '2023-01-05', + incomeTypeID: 'income_type_id', + memo: 'memo', + isValid: true); + final resIncome = ModelIncome((e) => e + ..id = income.id + ..amount = income.amount.value + ..localDate = income.date + ..incomeTypeId = income.incomeTypeID + ..memo = income.memo); + final req = UpdateIncomeReq((r) => r.income.replace(ModelIncome((e) => e + ..id = income.id + ..amount = income.amount.value + ..localDate = income.date + ..incomeTypeId = income.incomeTypeID + ..memo = income.memo))); + registerFallbackValue(req); + when(() => updateRepo.updateIncome(any())).thenAnswer( + (_) => Future.value(resIncome), + ); + final container = makeProviderContainer(updateRepo: updateRepo); + final controller = + container.read(submitIncomeControllerProvider.notifier); + final listener = Listener>(); + container.listen( + submitIncomeControllerProvider, + listener, + fireImmediately: true, + ); + const data = AsyncData(null); + verify(() => listener(null, data)); + // run + await controller.submit(income); + // verify + verifyInOrder([ + () => listener(data, any(that: isA())), + () => listener(any(that: isA()), data), + ]); + verifyNoMoreInteractions(listener); + verify(() => updateRepo.updateIncome(any())).called(1); + }); + + test('update income, failure', () async { + // setup + final updateRepo = MockUpdateIncomeRepository(); + const income = Income( + id: 'income_id', + title: Title.dirty('A title'), + amount: Amount.dirty(400), + date: '2023-01-05', + incomeTypeID: 'income_type_id', + memo: 'memo', + isValid: true); + final req = UpdateIncomeReq((r) => r.income.replace(ModelIncome((e) => e + ..id = income.id + ..amount = income.amount.value + ..localDate = income.date + ..incomeTypeId = income.incomeTypeID + ..memo = income.memo))); + registerFallbackValue(req); + when(() => updateRepo.updateIncome(any())).thenThrow( + (_) => Exception("Network error"), + ); + final container = makeProviderContainer(updateRepo: updateRepo); + final controller = + container.read(submitIncomeControllerProvider.notifier); + final listener = Listener>(); + container.listen( + submitIncomeControllerProvider, + listener, + fireImmediately: true, + ); + const data = AsyncData(null); + verify(() => listener(null, data)); + // run + await controller.submit(income); + // verify + verifyInOrder([ + () => listener(data, any(that: isA())), + () => listener( + any(that: isA()), any(that: isA())), + ]); + verifyNoMoreInteractions(listener); + verify(() => updateRepo.updateIncome(any())).called(1); + }); + + test('repository is not called if income form value is invalid', () async { + // setup + final registerRepo = MockRegisterIncomeRepository(); + final updateRepo = MockUpdateIncomeRepository(); + const income = Income( + id: '', + title: Title.dirty('A title'), + amount: Amount.dirty(400), + date: '2023-01-05', + incomeTypeID: 'income_type_id', + memo: 'memo', + isValid: false); + final container = makeProviderContainer(registerRepo: registerRepo); + final controller = + container.read(submitIncomeControllerProvider.notifier); + // run + await controller.submit(income); + // verify + verifyNever(() => registerRepo.registerIncome(any())); + verifyNever(() => updateRepo.updateIncome(any())); + }); + }); +} diff --git a/mobile/test/src/mocks.dart b/mobile/test/src/mocks.dart index 2617c22..7546371 100644 --- a/mobile/test/src/mocks.dart +++ b/mobile/test/src/mocks.dart @@ -1,14 +1,22 @@ import 'package:mocktail/mocktail.dart'; import 'package:suito/src/features/schedules/repositories/expense/delete_expense_schedule_repository.dart'; import 'package:suito/src/features/schedules/repositories/expense/expense_schedule_detail_repository.dart'; +import 'package:suito/src/features/schedules/repositories/expense/register_expense_schedule_repository.dart'; +import 'package:suito/src/features/schedules/repositories/expense/update_expense_schedule_repository.dart'; import 'package:suito/src/features/schedules/repositories/income/delete_income_schedule_repository.dart'; import 'package:suito/src/features/schedules/repositories/income/income_schedule_detail_repository.dart'; +import 'package:suito/src/features/schedules/repositories/income/register_income_schedule_repository.dart'; +import 'package:suito/src/features/schedules/repositories/income/update_income_schedule_repository.dart'; import 'package:suito/src/features/transactions/repositories/expense/delete_expense_repository.dart'; import 'package:suito/src/features/transactions/repositories/expense/expense_categories_repository.dart'; import 'package:suito/src/features/transactions/repositories/expense/expense_detail_repository.dart'; import 'package:suito/src/features/transactions/repositories/expense/expense_locations_repository.dart'; +import 'package:suito/src/features/transactions/repositories/expense/register_expense_repository.dart'; +import 'package:suito/src/features/transactions/repositories/expense/update_expense_repository.dart'; import 'package:suito/src/features/transactions/repositories/income/income_detail_repository.dart'; import 'package:suito/src/features/transactions/repositories/income/income_types_repository.dart'; +import 'package:suito/src/features/transactions/repositories/income/register_income_repository.dart'; +import 'package:suito/src/features/transactions/repositories/income/update_income_repository.dart'; class MockExpenseDetailRepository extends Mock implements ExpenseDetailRepository {} @@ -39,6 +47,30 @@ class MockExpenseScheduleDetailRepository extends Mock class MockIncomeScheduleDetailRepository extends Mock implements IncomeScheduleDetailRepository {} +class MockRegisterExpenseRepository extends Mock + implements RegisterExpenseRepository {} + +class MockUpdateExpenseRepository extends Mock + implements UpdateExpenseRepository {} + +class MockRegisterIncomeRepository extends Mock + implements RegisterIncomeRepository {} + +class MockUpdateIncomeRepository extends Mock + implements UpdateIncomeRepository {} + +class MockRegisterExpenseScheduleRepository extends Mock + implements RegisterExpenseScheduleRepository {} + +class MockUpdateExpenseScheduleRepository extends Mock + implements UpdateExpenseScheduleRepository {} + +class MockRegisterIncomeScheduleRepository extends Mock + implements RegisterIncomeScheduleRepository {} + +class MockUpdateIncomeScheduleRepository extends Mock + implements UpdateIncomeScheduleRepository {} + class Listener extends Mock { void call(T? previous, T next); }