diff --git a/ecommerce_app/integration_test/purchase_flow_test.dart b/ecommerce_app/integration_test/purchase_flow_test.dart index e3445857..c8513642 100644 --- a/ecommerce_app/integration_test/purchase_flow_test.dart +++ b/ecommerce_app/integration_test/purchase_flow_test.dart @@ -17,7 +17,7 @@ void main() { r.cart.expectFindNCartItems(1); // checkout await r.checkout.startCheckout(); - await r.auth.signInWithEmailAndPassword(); + await r.auth.enterAndSubmitEmailAndPassword(); r.cart.expectFindNCartItems(1); await r.checkout.startPayment(); // when a payment is complete, user is taken to the orders page diff --git a/ecommerce_app/lib/src/exceptions/app_exception.dart b/ecommerce_app/lib/src/exceptions/app_exception.dart new file mode 100644 index 00000000..bb7def2c --- /dev/null +++ b/ecommerce_app/lib/src/exceptions/app_exception.dart @@ -0,0 +1,50 @@ +import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; + +/// Base class for all all client-side errors that can be generated by the app +sealed class AppException implements Exception { + AppException(this.code, this.message); + final String code; + final String message; +} + +/// Auth +class EmailAlreadyInUseException extends AppException { + EmailAlreadyInUseException() + : super('email-already-in-use', 'Email already in use'.hardcoded); +} + +class WeakPasswordException extends AppException { + WeakPasswordException() + : super('weak-password', 'Password is too weak'.hardcoded); +} + +class WrongPasswordException extends AppException { + WrongPasswordException() + : super('wrong-password', 'Wrong password'.hardcoded); +} + +class UserNotFoundException extends AppException { + UserNotFoundException() : super('user-not-found', 'User not found'.hardcoded); +} + +/// Cart +class CartSyncFailedException extends AppException { + CartSyncFailedException() + : super('cart-sync-failed', + 'An error has occurred while updating the shopping cart'.hardcoded); +} + +/// Checkout +class PaymentFailureEmptyCartException extends AppException { + PaymentFailureEmptyCartException() + : super('payment-failure-empty-cart', + 'Can\'t place an order if the cart is empty'.hardcoded); +} + +/// Orders +class ParseOrderFailureException extends AppException { + ParseOrderFailureException(this.status) + : super('parse-order-failure', + 'Could not parse order status: $status'.hardcoded); + final String status; +} diff --git a/ecommerce_app/lib/src/exceptions/app_exception_enum.dart b/ecommerce_app/lib/src/exceptions/app_exception_enum.dart new file mode 100644 index 00000000..1a135aba --- /dev/null +++ b/ecommerce_app/lib/src/exceptions/app_exception_enum.dart @@ -0,0 +1,51 @@ +import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; + +/// An exception type to represent all client-side errors that can be generated +/// by the app +enum AppExceptionEnum { + // Auth + emailAlreadyInUse('email-already-in-use'), + weakPassword('weak-password'), + wrongPassword('wrong-password'), + userNotFound('user-not-found'), + // Cart + cartSyncFailed('cart-sync-failed'), + // Checkout + paymentFailureEmptyCart('payment-failure-empty-cart'), + // Orders + parseOrderFailure('parse-order-failure'); + + const AppExceptionEnum(this.code); + + /// A value that can be sent to the backend when logging the error + final String code; + + /// A user-friendly message that can be shown in the UI. + // * This needs to be a getter variable or a method since the error message + // * can't be declared as const if it's localized + String get message { + switch (this) { + // Auth + case emailAlreadyInUse: + return 'Email already in use'.hardcoded; + case weakPassword: + return 'Password is too weak'.hardcoded; + case wrongPassword: + return 'Wrong password'.hardcoded; + case userNotFound: + return 'User not found'.hardcoded; + // Cart + case cartSyncFailed: + return 'An error has occurred while updating the shopping cart' + .hardcoded; + // Checkout + case paymentFailureEmptyCart: + return 'Can\'t place an order if the cart is empty'.hardcoded; + // Orders + case parseOrderFailure: + return 'Could not parse order status'.hardcoded; + default: + return 'Unknown error'.hardcoded; + } + } +} diff --git a/ecommerce_app/lib/src/features/authentication/data/fake_auth_repository.dart b/ecommerce_app/lib/src/features/authentication/data/fake_auth_repository.dart index 4c6414d5..bc5d3a63 100644 --- a/ecommerce_app/lib/src/features/authentication/data/fake_auth_repository.dart +++ b/ecommerce_app/lib/src/features/authentication/data/fake_auth_repository.dart @@ -1,4 +1,6 @@ import 'package:ecommerce_app/src/features/authentication/domain/app_user.dart'; +import 'package:ecommerce_app/src/features/authentication/domain/fake_app_user.dart'; +import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; import 'package:ecommerce_app/src/utils/delay.dart'; import 'package:ecommerce_app/src/utils/in_memory_store.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -11,15 +13,41 @@ class FakeAuthRepository { Stream authStateChanges() => _authState.stream; AppUser? get currentUser => _authState.value; + // List to keep track of all user accounts + final List _users = []; + Future signInWithEmailAndPassword(String email, String password) async { await delay(addDelay); - _createNewUser(email); + // check the given credentials agains each registered user + for (final u in _users) { + // matching email and password + if (u.email == email && u.password == password) { + _authState.value = u; + return; + } + // same email, wrong password + if (u.email == email && u.password != password) { + throw Exception('Wrong password'.hardcoded); + } + } + throw Exception('User not found'.hardcoded); } Future createUserWithEmailAndPassword( String email, String password) async { await delay(addDelay); - _createNewUser(email); + // check if the email is already in use + for (final u in _users) { + if (u.email == email) { + throw Exception('Email already in use'.hardcoded); + } + } + // minimum password length requirement + if (password.length < 8) { + throw Exception('Password is too weak'.hardcoded); + } + // create new user + _createNewUser(email, password); } Future signOut() async { @@ -28,11 +56,17 @@ class FakeAuthRepository { void dispose() => _authState.close(); - void _createNewUser(String email) { - _authState.value = AppUser( + void _createNewUser(String email, String password) { + // create new user + final user = FakeAppUser( uid: email.split('').reversed.join(), email: email, + password: password, ); + // register it + _users.add(user); + // update the auth state + _authState.value = user; } } diff --git a/ecommerce_app/lib/src/features/authentication/domain/app_user.dart b/ecommerce_app/lib/src/features/authentication/domain/app_user.dart index 70dad071..ba81b44c 100644 --- a/ecommerce_app/lib/src/features/authentication/domain/app_user.dart +++ b/ecommerce_app/lib/src/features/authentication/domain/app_user.dart @@ -2,10 +2,10 @@ class AppUser { const AppUser({ required this.uid, - this.email, + required this.email, }); final String uid; - final String? email; + final String email; @override bool operator ==(Object other) { diff --git a/ecommerce_app/lib/src/features/authentication/domain/fake_app_user.dart b/ecommerce_app/lib/src/features/authentication/domain/fake_app_user.dart new file mode 100644 index 00000000..25296c4b --- /dev/null +++ b/ecommerce_app/lib/src/features/authentication/domain/fake_app_user.dart @@ -0,0 +1,11 @@ +import 'package:ecommerce_app/src/features/authentication/domain/app_user.dart'; + +/// Fake user class used to simulate a user account on the backend +class FakeAppUser extends AppUser { + FakeAppUser({ + required super.uid, + required super.email, + required this.password, + }); + final String password; +} diff --git a/ecommerce_app/lib/src/features/orders/domain/order.dart b/ecommerce_app/lib/src/features/orders/domain/order.dart index 5d35240f..65de68a6 100644 --- a/ecommerce_app/lib/src/features/orders/domain/order.dart +++ b/ecommerce_app/lib/src/features/orders/domain/order.dart @@ -1,4 +1,4 @@ -import 'package:ecommerce_app/src/localization/string_hardcoded.dart'; +import 'package:ecommerce_app/src/exceptions/app_exception.dart'; /// Order status enum OrderStatus { confirmed, shipped, delivered } @@ -9,7 +9,7 @@ extension OrderStatusString on OrderStatus { if (string == 'confirmed') return OrderStatus.confirmed; if (string == 'shipped') return OrderStatus.shipped; if (string == 'delivered') return OrderStatus.delivered; - throw Exception('Could not parse order status: $string'.hardcoded); + throw ParseOrderFailureException(string); } } diff --git a/ecommerce_app/test/src/features/authentication/auth_flow_test.dart b/ecommerce_app/test/src/features/authentication/auth_flow_test.dart index e32afdf7..ddb60ded 100644 --- a/ecommerce_app/test/src/features/authentication/auth_flow_test.dart +++ b/ecommerce_app/test/src/features/authentication/auth_flow_test.dart @@ -9,7 +9,8 @@ void main() { r.products.expectFindAllProductCards(); await r.openPopupMenu(); await r.auth.openEmailPasswordSignInScreen(); - await r.auth.signInWithEmailAndPassword(); + await r.auth.tapFormToggleButton(); + await r.auth.enterAndSubmitEmailAndPassword(); r.products.expectFindAllProductCards(); await r.openPopupMenu(); await r.auth.openAccountScreen(); diff --git a/ecommerce_app/test/src/features/authentication/auth_robot.dart b/ecommerce_app/test/src/features/authentication/auth_robot.dart index 89e0704b..c163b3a0 100644 --- a/ecommerce_app/test/src/features/authentication/auth_robot.dart +++ b/ecommerce_app/test/src/features/authentication/auth_robot.dart @@ -86,7 +86,7 @@ class AuthRobot { expect(dialogTitle, findsNothing); } - Future signInWithEmailAndPassword() async { + Future enterAndSubmitEmailAndPassword() async { await enterEmail('test@test.com'); await tester.pump(); await enterPassword('test1234'); diff --git a/ecommerce_app/test/src/features/authentication/data/fake_auth_repository_test.dart b/ecommerce_app/test/src/features/authentication/data/fake_auth_repository_test.dart index e37b005c..7b89cafc 100644 --- a/ecommerce_app/test/src/features/authentication/data/fake_auth_repository_test.dart +++ b/ecommerce_app/test/src/features/authentication/data/fake_auth_repository_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; void main() { const testEmail = 'test@test.com'; - const testPassword = '1234'; + const testPassword = 'test1234'; final testUser = AppUser( uid: testEmail.split('').reversed.join(), email: testEmail, @@ -19,15 +19,19 @@ void main() { expect(authRepository.currentUser, null); expect(authRepository.authStateChanges(), emits(null)); }); - test('currentUser is not null after sign in', () async { + + test('sign in throws when user not found', () async { final authRepository = makeAuthRepository(); addTearDown(authRepository.dispose); - await authRepository.signInWithEmailAndPassword( - testEmail, - testPassword, + await expectLater( + () => authRepository.signInWithEmailAndPassword( + testEmail, + testPassword, + ), + throwsA(isA()), ); - expect(authRepository.currentUser, testUser); - expect(authRepository.authStateChanges(), emits(testUser)); + expect(authRepository.currentUser, null); + expect(authRepository.authStateChanges(), emits(null)); }); test('currentUser is not null after registration', () async { @@ -44,7 +48,7 @@ void main() { test('currentUser is null after sign out', () async { final authRepository = makeAuthRepository(); addTearDown(authRepository.dispose); - await authRepository.signInWithEmailAndPassword( + await authRepository.createUserWithEmailAndPassword( testEmail, testPassword, ); @@ -56,11 +60,11 @@ void main() { expect(authRepository.authStateChanges(), emits(null)); }); - test('sign in after dispose throws exception', () { + test('create user after dispose throws exception', () { final authRepository = makeAuthRepository(); authRepository.dispose(); expect( - () => authRepository.signInWithEmailAndPassword( + () => authRepository.createUserWithEmailAndPassword( testEmail, testPassword, ), diff --git a/ecommerce_app/test/src/features/cart/application/cart_service_test.dart b/ecommerce_app/test/src/features/cart/application/cart_service_test.dart index eee0b058..59202bb9 100644 --- a/ecommerce_app/test/src/features/cart/application/cart_service_test.dart +++ b/ecommerce_app/test/src/features/cart/application/cart_service_test.dart @@ -15,7 +15,7 @@ void main() { setUpAll(() { registerFallbackValue(const Cart()); }); - const testUser = AppUser(uid: 'abc'); + const testUser = AppUser(uid: 'abc', email: 'abc@test.com'); late MockAuthRepository authRepository; late MockRemoteCartRepository remoteCartRepository; diff --git a/ecommerce_app/test/src/features/checkout/application/fake_checkout_service_test.dart b/ecommerce_app/test/src/features/checkout/application/fake_checkout_service_test.dart index 9947ce67..74e07f98 100644 --- a/ecommerce_app/test/src/features/checkout/application/fake_checkout_service_test.dart +++ b/ecommerce_app/test/src/features/checkout/application/fake_checkout_service_test.dart @@ -12,7 +12,7 @@ import 'package:mocktail/mocktail.dart'; import '../../../mocks.dart'; void main() { - const testUser = AppUser(uid: 'abc'); + const testUser = AppUser(uid: 'abc', email: 'abc@test.com'); setUpAll(() { // needed for MockOrdersRepository registerFallbackValue(Order( diff --git a/ecommerce_app/test/src/features/checkout/presentation/checkout_screen/checkout_screen_test.dart b/ecommerce_app/test/src/features/checkout/presentation/checkout_screen/checkout_screen_test.dart index a71fadc3..1b4fbd9e 100644 --- a/ecommerce_app/test/src/features/checkout/presentation/checkout_screen/checkout_screen_test.dart +++ b/ecommerce_app/test/src/features/checkout/presentation/checkout_screen/checkout_screen_test.dart @@ -13,7 +13,7 @@ void main() { await r.checkout.startCheckout(); // sign in from checkout screen r.auth.expectEmailAndPasswordFieldsFound(); - await r.auth.signInWithEmailAndPassword(); + await r.auth.enterAndSubmitEmailAndPassword(); // check that we move to the payment page r.checkout.expectPayButtonFound(); }); @@ -21,9 +21,10 @@ void main() { testWidgets('checkout when previously signed in', (tester) async { final r = Robot(tester); await r.pumpMyApp(); - // sign in first + // create an account first await r.auth.openEmailPasswordSignInScreen(); - await r.auth.signInWithEmailAndPassword(); + await r.auth.tapFormToggleButton(); + await r.auth.enterAndSubmitEmailAndPassword(); // then add a product and start checkout await r.products.selectProduct(); await r.cart.addToCart(); diff --git a/ecommerce_app/test/src/features/purchase_flow_test.dart b/ecommerce_app/test/src/features/purchase_flow_test.dart index 1310d6e0..3f7cca40 100644 --- a/ecommerce_app/test/src/features/purchase_flow_test.dart +++ b/ecommerce_app/test/src/features/purchase_flow_test.dart @@ -15,7 +15,7 @@ void main() { r.cart.expectFindNCartItems(1); // checkout await r.checkout.startCheckout(); - await r.auth.signInWithEmailAndPassword(); + await r.auth.enterAndSubmitEmailAndPassword(); r.cart.expectFindNCartItems(1); await r.checkout.startPayment(); // when a payment is complete, user is taken to the orders page