From e8d0dd818b19f70e26aa3ae935519dee972b22aa Mon Sep 17 00:00:00 2001 From: code3743 Date: Sun, 13 Sep 2026 20:46:12 -0500 Subject: [PATCH 1/6] refactor(library): remove debug print statement from getAccount method --- .../library/data/repositories/library_repository_impl.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/features/library/data/repositories/library_repository_impl.dart b/lib/features/library/data/repositories/library_repository_impl.dart index 3a6bc5c..456c067 100644 --- a/lib/features/library/data/repositories/library_repository_impl.dart +++ b/lib/features/library/data/repositories/library_repository_impl.dart @@ -24,7 +24,6 @@ class LibraryRepositoryImpl implements LibraryRepository { ); return Ok(account.toEntity()); } on AppException catch (e) { - print('LibraryRepositoryImpl.getAccount: $e'); return Err(mapExceptionToFailure(e)); } } From e110f38f2aaf2b38c15f3b95de24d0b713bba0b6 Mon Sep 17 00:00:00 2001 From: code3743 Date: Sun, 13 Sep 2026 20:46:32 -0500 Subject: [PATCH 2/6] feat: add GitHub Actions workflow for testing with coverage and implement mocktail dependency --- .github/workflows/test.yml | 47 +++++++++++++++++++ pubspec.lock | 8 ++++ pubspec.yaml | 1 + test/core/utils/date_formatter_test.dart | 35 ++++++++++++++ .../widgets/app_loading_indicator_test.dart | 23 +++++++++ test/helpers/container.dart | 9 ++++ test/helpers/pump_app.dart | 20 ++++++++ 7 files changed, 143 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 test/core/utils/date_formatter_test.dart create mode 100644 test/core/widgets/app_loading_indicator_test.dart create mode 100644 test/helpers/container.dart create mode 100644 test/helpers/pump_app.dart diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a94e942 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,47 @@ +name: Test + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + name: Run tests with coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: 3.47.3 + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Generate code (riverpod codegen) + run: dart run build_runner build + + - name: Run tests with coverage + run: flutter test --coverage + + - name: Filter out generated files from coverage + run: | + sudo apt-get update -y && sudo apt-get install -y lcov + lcov --remove coverage/lcov.info \ + '**/*.g.dart' \ + '**/*.freezed.dart' \ + -o coverage/lcov.info + + - name: Report coverage summary + run: | + lcov --summary coverage/lcov.info | tee coverage_summary.txt + { + echo "### Coverage summary" + echo '```' + cat coverage_summary.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/pubspec.lock b/pubspec.lock index 8da6485..34e9cec 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -565,6 +565,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.8.1" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa" + url: "https://pub.dev" + source: hosted + version: "1.0.5" objective_c: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 946de5f..a7931f9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,6 +32,7 @@ dev_dependencies: riverpod_generator: ^4.0.9 build_runner: ^2.16.1 flutter_native_splash: ^2.4.8 + mocktail: ^1.0.4 flutter: uses-material-design: true diff --git a/test/core/utils/date_formatter_test.dart b/test/core/utils/date_formatter_test.dart new file mode 100644 index 0000000..91ec90e --- /dev/null +++ b/test/core/utils/date_formatter_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:univalle_app/core/utils/date_formatter.dart'; + +void main() { + setUpAll(() async { + await initializeDateFormatting('es'); + }); + + group('AppDateFormatter.short', () { + test('formats as dd/MM/yyyy', () { + expect(AppDateFormatter.short(DateTime(2026, 3, 5)), '05/03/2026'); + }); + }); + + group('AppDateFormatter.long', () { + test('formats as "d de MMMM de y" in Spanish', () { + expect(AppDateFormatter.long(DateTime(2026, 3, 5)), '5 de marzo de 2026'); + }); + }); + + group('AppDateFormatter.tryParse', () { + test('parses a valid dd/MM/yyyy string', () { + expect(AppDateFormatter.tryParse('05/03/2026'), DateTime(2026, 3, 5)); + }); + + test('returns null for an invalid string', () { + expect(AppDateFormatter.tryParse('not-a-date'), isNull); + }); + + test('returns null when the string does not match the given pattern', () { + expect(AppDateFormatter.tryParse('2026-03-05'), isNull); + }); + }); +} diff --git a/test/core/widgets/app_loading_indicator_test.dart b/test/core/widgets/app_loading_indicator_test.dart new file mode 100644 index 0000000..05fe8de --- /dev/null +++ b/test/core/widgets/app_loading_indicator_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/core/widgets/app_loading_indicator.dart'; + +import '../../helpers/pump_app.dart'; + +void main() { + testWidgets('renders a CircularProgressIndicator at the given size', ( + tester, + ) async { + await tester.pumpApp(const AppLoadingIndicator(size: 48)); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + final sizedBox = tester.widget( + find.ancestor( + of: find.byType(CircularProgressIndicator), + matching: find.byType(SizedBox), + ), + ); + expect(sizedBox.width, 48); + expect(sizedBox.height, 48); + }); +} diff --git a/test/helpers/container.dart b/test/helpers/container.dart new file mode 100644 index 0000000..0fce24f --- /dev/null +++ b/test/helpers/container.dart @@ -0,0 +1,9 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; + +ProviderContainer createContainer({List overrides = const []}) { + final container = ProviderContainer(overrides: overrides); + addTearDown(container.dispose); + return container; +} diff --git a/test/helpers/pump_app.dart b/test/helpers/pump_app.dart new file mode 100644 index 0000000..d8105a6 --- /dev/null +++ b/test/helpers/pump_app.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:univalle_app/core/theme/app_theme.dart'; + +extension PumpApp on WidgetTester { + Future pumpApp(Widget widget, {List overrides = const []}) { + return pumpWidget( + ProviderScope( + overrides: overrides, + child: MaterialApp( + theme: AppTheme.light, + home: Scaffold(body: widget), + ), + ), + ); + } +} From 9491c7756910860355bc05a668007484c12c05dd Mon Sep 17 00:00:00 2001 From: code3743 Date: Sun, 13 Sep 2026 21:20:22 -0500 Subject: [PATCH 3/6] test: add unit tests for error handling and utility functions --- test/core/error/exception_mapper_test.dart | 56 ++++++++++++ test/core/error/failures_test.dart | 80 +++++++++++++++++ test/core/error/result_test.dart | 40 +++++++++ .../extensions/snackbar_extension_test.dart | 53 +++++++++++ .../network/dio_exception_mapper_test.dart | 89 +++++++++++++++++++ test/core/utils/name_formatter_test.dart | 44 +++++++++ .../utils/sira_period_formatter_test.dart | 73 +++++++++++++++ test/coverage_helper_test.dart | 8 ++ 8 files changed, 443 insertions(+) create mode 100644 test/core/error/exception_mapper_test.dart create mode 100644 test/core/error/failures_test.dart create mode 100644 test/core/error/result_test.dart create mode 100644 test/core/extensions/snackbar_extension_test.dart create mode 100644 test/core/network/dio_exception_mapper_test.dart create mode 100644 test/core/utils/name_formatter_test.dart create mode 100644 test/core/utils/sira_period_formatter_test.dart create mode 100644 test/coverage_helper_test.dart diff --git a/test/core/error/exception_mapper_test.dart b/test/core/error/exception_mapper_test.dart new file mode 100644 index 0000000..f395e27 --- /dev/null +++ b/test/core/error/exception_mapper_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/core/error/exception_mapper.dart'; +import 'package:univalle_app/core/error/exceptions.dart'; +import 'package:univalle_app/core/error/failures.dart'; + +void main() { + test( + 'maps ServerException to ServerFailure, preserving message and status code', + () { + final failure = mapExceptionToFailure( + const ServerException(message: 'boom', statusCode: 500), + ); + expect(failure, isA()); + expect(failure.message, 'boom'); + expect((failure as ServerFailure).statusCode, 500); + }, + ); + + test('maps NetworkException to NetworkFailure', () { + final failure = mapExceptionToFailure( + const NetworkException(message: 'offline'), + ); + expect(failure, isA()); + expect(failure.message, 'offline'); + }); + + test('maps CacheException to CacheFailure', () { + final failure = mapExceptionToFailure( + const CacheException(message: 'no local data'), + ); + expect(failure, isA()); + expect(failure.message, 'no local data'); + }); + + test('maps AuthException to AuthFailure', () { + final failure = mapExceptionToFailure( + const AuthException(message: 'bad credentials'), + ); + expect(failure, isA()); + expect(failure.message, 'bad credentials'); + }); + + test('maps BusinessException to BusinessFailure, preserving retryable', () { + final failure = mapExceptionToFailure( + const BusinessException(message: 'no survey', retryable: false), + ); + expect(failure, isA()); + expect((failure as BusinessFailure).retryable, isFalse); + }); + + test('maps any other error to UnknownFailure using its toString', () { + final failure = mapExceptionToFailure(StateError('unexpected')); + expect(failure, isA()); + expect(failure.message, StateError('unexpected').toString()); + }); +} diff --git a/test/core/error/failures_test.dart b/test/core/error/failures_test.dart new file mode 100644 index 0000000..749315b --- /dev/null +++ b/test/core/error/failures_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/core/constants/app_strings.dart'; +import 'package:univalle_app/core/error/failures.dart'; + +void main() { + group('ServerFailure', () { + test('maps 401 to the session-expired message', () { + final failure = ServerFailure(message: 'x', statusCode: 401); + expect(failure.userMessage, AppStrings.sessionExpired); + }); + + test('maps 403 to the forbidden message', () { + final failure = ServerFailure(message: 'x', statusCode: 403); + expect(failure.userMessage, AppStrings.forbidden); + }); + + test('maps 404 to the not-found message', () { + final failure = ServerFailure(message: 'x', statusCode: 404); + expect(failure.userMessage, AppStrings.notFound); + }); + + test('maps any 5xx to the server-error message', () { + expect( + ServerFailure(message: 'x', statusCode: 500).userMessage, + AppStrings.serverError, + ); + expect( + ServerFailure(message: 'x', statusCode: 503).userMessage, + AppStrings.serverError, + ); + }); + + test( + 'maps an unrecognized status code to the generic request-error message', + () { + expect( + ServerFailure(message: 'x', statusCode: 418).userMessage, + AppStrings.requestError, + ); + }, + ); + + test('maps a null status code to the generic request-error message', () { + expect( + ServerFailure(message: 'x', statusCode: null).userMessage, + AppStrings.requestError, + ); + }); + }); + + test('NetworkFailure uses the network-error message', () { + expect(NetworkFailure(message: 'x').userMessage, AppStrings.networkError); + }); + + test('CacheFailure uses the cache-error message', () { + expect(CacheFailure(message: 'x').userMessage, AppStrings.cacheError); + }); + + test('UnknownFailure uses the generic-error message', () { + expect(UnknownFailure(message: 'x').userMessage, AppStrings.genericError); + }); + + test('AuthFailure surfaces its own message as the user message', () { + expect( + AuthFailure(message: 'Sesión inválida').userMessage, + 'Sesión inválida', + ); + }); + + test('BusinessFailure surfaces its own message as the user message and defaults to retryable', () { + final failure = BusinessFailure(message: 'Encuesta no disponible'); + expect(failure.userMessage, 'Encuesta no disponible'); + expect(failure.retryable, isTrue); + }); + + test('BusinessFailure can be marked as non-retryable', () { + final failure = BusinessFailure(message: 'x', retryable: false); + expect(failure.retryable, isFalse); + }); +} diff --git a/test/core/error/result_test.dart b/test/core/error/result_test.dart new file mode 100644 index 0000000..b2b4c2f --- /dev/null +++ b/test/core/error/result_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; + +void main() { + group('Ok', () { + test('isOk is true and isErr is false', () { + const result = Ok(42); + expect(result.isOk, isTrue); + expect(result.isErr, isFalse); + }); + + test('fold calls onSuccess with the value', () { + const result = Ok(42); + final folded = result.fold( + onError: (_) => 'error', + onSuccess: (value) => 'ok:$value', + ); + expect(folded, 'ok:42'); + }); + }); + + group('Err', () { + test('isErr is true and isOk is false', () { + final result = Err(UnknownFailure(message: 'x')); + expect(result.isErr, isTrue); + expect(result.isOk, isFalse); + }); + + test('fold calls onError with the failure', () { + final failure = UnknownFailure(message: 'x'); + final result = Err(failure); + final folded = result.fold( + onError: (f) => 'error:${f.message}', + onSuccess: (_) => 'ok', + ); + expect(folded, 'error:x'); + }); + }); +} diff --git a/test/core/extensions/snackbar_extension_test.dart b/test/core/extensions/snackbar_extension_test.dart new file mode 100644 index 0000000..1aabac7 --- /dev/null +++ b/test/core/extensions/snackbar_extension_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/core/extensions/snackbar_extension.dart'; + +void main() { + testWidgets('shows a SnackBar with the given message', (tester) async { + late BuildContext capturedContext; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + capturedContext = context; + return const SizedBox(); + }, + ), + ), + ), + ); + + capturedContext.showSnack('Hola mundo'); + await tester.pump(); + + expect(find.text('Hola mundo'), findsOneWidget); + }); + + testWidgets( + 'hides the currently visible SnackBar before showing the new one', + (tester) async { + late BuildContext capturedContext; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + capturedContext = context; + return const SizedBox(); + }, + ), + ), + ), + ); + + capturedContext.showSnack('Primero'); + await tester.pump(); + capturedContext.showSnack('Segundo'); + await tester.pump(); + + expect(find.text('Primero'), findsNothing); + expect(find.text('Segundo'), findsOneWidget); + }, + ); +} diff --git a/test/core/network/dio_exception_mapper_test.dart b/test/core/network/dio_exception_mapper_test.dart new file mode 100644 index 0000000..248caef --- /dev/null +++ b/test/core/network/dio_exception_mapper_test.dart @@ -0,0 +1,89 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/core/error/exceptions.dart'; +import 'package:univalle_app/core/network/dio_exception_mapper.dart'; + +RequestOptions _options() => RequestOptions(path: '/test'); + +void main() { + group('timeout and connection errors', () { + for (final type in [ + DioExceptionType.connectionTimeout, + DioExceptionType.sendTimeout, + DioExceptionType.receiveTimeout, + DioExceptionType.connectionError, + ]) { + test('$type maps to NetworkException', () { + final result = mapDioException( + DioException( + requestOptions: _options(), + type: type, + message: 'timed out', + ), + ); + expect(result, isA()); + expect(result.message, 'timed out'); + }); + } + + test('falls back to a default message when none is provided', () { + final result = mapDioException( + DioException( + requestOptions: _options(), + type: DioExceptionType.connectionError, + ), + ); + expect(result.message, 'Connection error'); + }); + }); + + group('badResponse', () { + test('maps to ServerException using the response body and status code', () { + final result = mapDioException( + DioException( + requestOptions: _options(), + type: DioExceptionType.badResponse, + response: Response( + requestOptions: _options(), + statusCode: 404, + data: 'not found', + ), + ), + ); + expect(result, isA()); + expect(result.message, 'not found'); + expect((result as ServerException).statusCode, 404); + }); + + test( + 'falls back to the exception message when the response has no data', + () { + final result = mapDioException( + DioException( + requestOptions: _options(), + type: DioExceptionType.badResponse, + message: 'bad response', + response: Response(requestOptions: _options(), statusCode: 500), + ), + ); + expect(result.message, 'bad response'); + }, + ); + }); + + group('other exception types', () { + for (final type in [ + DioExceptionType.cancel, + DioExceptionType.badCertificate, + DioExceptionType.unknown, + ]) { + test('$type maps to ServerException', () { + final result = mapDioException( + DioException(requestOptions: _options(), type: type, message: 'oops'), + ); + expect(result, isA()); + expect(result.message, 'oops'); + }); + } + }); +} diff --git a/test/core/utils/name_formatter_test.dart b/test/core/utils/name_formatter_test.dart new file mode 100644 index 0000000..36181e1 --- /dev/null +++ b/test/core/utils/name_formatter_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/core/utils/name_formatter.dart'; + +void main() { + group('NameFormatter.firstName', () { + test('capitalizes only the first letter of the first word', () { + expect(NameFormatter.firstName('JUAN CAMILO PEREZ'), 'Juan'); + }); + + test('trims surrounding whitespace', () { + expect(NameFormatter.firstName(' ana maria '), 'Ana'); + }); + + test('returns an empty string for blank input', () { + expect(NameFormatter.firstName(' '), ''); + }); + + test('handles a single-word name', () { + expect(NameFormatter.firstName('maria'), 'Maria'); + }); + + test('collapses multiple spaces between words', () { + expect(NameFormatter.firstName('LUIS FERNANDO'), 'Luis'); + }); + }); + + group('NameFormatter.initial', () { + test('returns the uppercased first character', () { + expect(NameFormatter.initial('maria'), 'M'); + }); + + test('trims surrounding whitespace before taking the first character', () { + expect(NameFormatter.initial(' paula'), 'P'); + }); + + test('returns the fallback for blank input', () { + expect(NameFormatter.initial(' '), '?'); + }); + + test('accepts a custom fallback', () { + expect(NameFormatter.initial('', fallback: '-'), '-'); + }); + }); +} diff --git a/test/core/utils/sira_period_formatter_test.dart b/test/core/utils/sira_period_formatter_test.dart new file mode 100644 index 0000000..301aaac --- /dev/null +++ b/test/core/utils/sira_period_formatter_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/core/utils/sira_period_formatter.dart'; + +void main() { + group('SiraPeriodFormatter.format', () { + test('shortens a well-formed period', () { + expect( + SiraPeriodFormatter.format('FEBRERO/2022 - JUNIO/2022'), + 'Feb/22 – Jun/22', + ); + }); + + test('trims whitespace around each side', () { + expect( + SiraPeriodFormatter.format(' AGOSTO/2023 -DICIEMBRE/2023 '), + 'Ago/23 – Dic/23', + ); + }); + + test('returns the raw input when it has no dash-separated range', () { + expect(SiraPeriodFormatter.format('FEBRERO/2022'), 'FEBRERO/2022'); + }); + + test('returns the raw input when a month is not recognized', () { + const raw = 'FOO/2022 - JUNIO/2022'; + expect(SiraPeriodFormatter.format(raw), raw); + }); + + test('returns the raw input when a side has no year', () { + const raw = 'FEBRERO - JUNIO/2022'; + expect(SiraPeriodFormatter.format(raw), raw); + }); + }); + + group('SiraPeriodFormatter.shortCode', () { + test('derives semester 1 for a period starting Feb-Jun', () { + expect( + SiraPeriodFormatter.shortCode('FEBRERO/2022 - JUNIO/2022'), + '2022-1', + ); + expect( + SiraPeriodFormatter.shortCode('JUNIO/2022 - JUNIO/2022'), + '2022-1', + ); + }); + + test('derives semester 2 for a period starting Jul-Dec', () { + expect( + SiraPeriodFormatter.shortCode('AGOSTO/2023 - DICIEMBRE/2023'), + '2023-2', + ); + expect( + SiraPeriodFormatter.shortCode('JULIO/2023 - DICIEMBRE/2023'), + '2023-2', + ); + }); + + test('returns the raw input when the start month is not recognized', () { + const raw = 'FOO/2022 - JUNIO/2022'; + expect(SiraPeriodFormatter.shortCode(raw), raw); + }); + + test('returns the raw input when the year is malformed', () { + const raw = 'FEBRERO/22 - JUNIO/2022'; + expect(SiraPeriodFormatter.shortCode(raw), raw); + }); + + test('returns the raw input when there is no month/year split', () { + const raw = 'FEBRERO - JUNIO/2022'; + expect(SiraPeriodFormatter.shortCode(raw), raw); + }); + }); +} diff --git a/test/coverage_helper_test.dart b/test/coverage_helper_test.dart new file mode 100644 index 0000000..6b8b775 --- /dev/null +++ b/test/coverage_helper_test.dart @@ -0,0 +1,8 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/main.dart' as app; + +void main() { + test('coverage helper — pulls the whole app import graph into coverage', () { + expect(app.MainApp, isNotNull); + }); +} From 11ec7446767784e7667aa1577527850e2a0138ad Mon Sep 17 00:00:00 2001 From: code3743 Date: Mon, 14 Sep 2026 07:38:37 -0500 Subject: [PATCH 4/6] refactor(theme): remove dark theme support and simplify button styles --- lib/core/theme/app_theme.dart | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index f85e5fe..3bc731e 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -5,7 +5,6 @@ import 'app_colors.dart'; abstract final class AppTheme { static ThemeData get light => _build(Brightness.light); - static ThemeData get dark => _build(Brightness.dark); static ThemeData _build(Brightness brightness) { // ColorScheme.fromSeed tones down a pure-red seed into a muddy brick tone @@ -14,8 +13,7 @@ abstract final class AppTheme { seedColor: AppColors.univalleRed, brightness: brightness, ).copyWith(primary: AppColors.univalleRed); - final baseTextTheme = - brightness == Brightness.dark ? ThemeData.dark().textTheme : ThemeData.light().textTheme; + final baseTextTheme = ThemeData.light().textTheme; final textTheme = GoogleFonts.poppinsTextTheme(baseTextTheme).apply( bodyColor: colorScheme.onSurface, displayColor: colorScheme.onSurface, @@ -36,21 +34,31 @@ abstract final class AppTheme { filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(48), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + textStyle: textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), ), ), outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( minimumSize: const Size.fromHeight(48), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), side: BorderSide(color: colorScheme.primary), - textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), + textStyle: textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), ), ), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( - textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), + textStyle: textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), ), ), inputDecorationTheme: InputDecorationTheme( @@ -72,7 +80,10 @@ abstract final class AppTheme { borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: colorScheme.error, width: 1.5), ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), ), snackBarTheme: SnackBarThemeData( behavior: SnackBarBehavior.floating, From dea6b6772fd1dad2e79f1a78ecbb0d5ca2b71b84 Mon Sep 17 00:00:00 2001 From: code3743 Date: Mon, 14 Sep 2026 07:40:01 -0500 Subject: [PATCH 5/6] test: Add unit tests for various use cases and models in the application --- .github/workflows/test.yml | 11 + .gitignore | 1 + .../auth_repository_impl_test.dart | 239 ++++++++++++++++++ .../domain/usecases/login_usecase_test.dart | 43 ++++ .../domain/usecases/logout_usecase_test.dart | 37 +++ .../usecases/reset_password_usecase_test.dart | 43 ++++ .../restore_session_usecase_test.dart | 40 +++ .../models/library_account_model_test.dart | 27 ++ .../library_repository_impl_test.dart | 70 +++++ .../get_library_account_usecase_test.dart | 43 ++++ .../data/models/student_model_test.dart | 28 ++ .../profile_repository_impl_test.dart | 51 ++++ .../usecases/get_student_usecase_test.dart | 49 ++++ .../models/curriculum_subject_model_test.dart | 24 ++ .../resolution_repository_impl_test.dart | 51 ++++ .../domain/entities/curriculum_test.dart | 86 +++++++ .../usecases/get_curriculum_usecase_test.dart | 40 +++ .../data/models/lunch_payment_model_test.dart | 24 ++ .../models/restaurant_account_model_test.dart | 48 ++++ .../restaurant_repository_impl_test.dart | 121 +++++++++ .../usecases/buy_lunches_usecase_test.dart | 47 ++++ .../get_restaurant_account_usecase_test.dart | 45 ++++ .../models/schedule_class_model_test.dart | 50 ++++ .../schedule_repository_impl_test.dart | 61 +++++ .../domain/entities/weekday_test.dart | 31 +++ .../usecases/get_schedule_usecase_test.dart | 57 +++++ .../data/models/grades_model_test.dart | 36 +++ .../data/models/subject_model_test.dart | 26 ++ .../grades_repository_impl_test.dart | 51 ++++ .../usecases/get_grades_usecase_test.dart | 49 ++++ .../tabulate_repository_impl_test.dart | 43 ++++ .../usecases/get_tabulate_usecase_test.dart | 43 ++++ .../models/review_question_model_test.dart | 19 ++ .../models/teacher_review_model_test.dart | 30 +++ .../models/teacher_to_rate_model_test.dart | 52 ++++ .../teaching_rating_repository_impl_test.dart | 184 ++++++++++++++ .../get_teacher_review_usecase_test.dart | 62 +++++ .../get_teachers_to_rate_usecase_test.dart | 56 ++++ .../submit_teacher_review_usecase_test.dart | 96 +++++++ 39 files changed, 2114 insertions(+) create mode 100644 test/features/auth/data/repositories/auth_repository_impl_test.dart create mode 100644 test/features/auth/domain/usecases/login_usecase_test.dart create mode 100644 test/features/auth/domain/usecases/logout_usecase_test.dart create mode 100644 test/features/auth/domain/usecases/reset_password_usecase_test.dart create mode 100644 test/features/auth/domain/usecases/restore_session_usecase_test.dart create mode 100644 test/features/library/data/models/library_account_model_test.dart create mode 100644 test/features/library/data/repositories/library_repository_impl_test.dart create mode 100644 test/features/library/domain/usecases/get_library_account_usecase_test.dart create mode 100644 test/features/profile/data/models/student_model_test.dart create mode 100644 test/features/profile/data/repositories/profile_repository_impl_test.dart create mode 100644 test/features/profile/domain/usecases/get_student_usecase_test.dart create mode 100644 test/features/resolution/data/models/curriculum_subject_model_test.dart create mode 100644 test/features/resolution/data/repositories/resolution_repository_impl_test.dart create mode 100644 test/features/resolution/domain/entities/curriculum_test.dart create mode 100644 test/features/resolution/domain/usecases/get_curriculum_usecase_test.dart create mode 100644 test/features/restaurant/data/models/lunch_payment_model_test.dart create mode 100644 test/features/restaurant/data/models/restaurant_account_model_test.dart create mode 100644 test/features/restaurant/data/repositories/restaurant_repository_impl_test.dart create mode 100644 test/features/restaurant/domain/usecases/buy_lunches_usecase_test.dart create mode 100644 test/features/restaurant/domain/usecases/get_restaurant_account_usecase_test.dart create mode 100644 test/features/schedule/data/models/schedule_class_model_test.dart create mode 100644 test/features/schedule/data/repositories/schedule_repository_impl_test.dart create mode 100644 test/features/schedule/domain/entities/weekday_test.dart create mode 100644 test/features/schedule/domain/usecases/get_schedule_usecase_test.dart create mode 100644 test/features/student_grades/data/models/grades_model_test.dart create mode 100644 test/features/student_grades/data/models/subject_model_test.dart create mode 100644 test/features/student_grades/data/repositories/grades_repository_impl_test.dart create mode 100644 test/features/student_grades/domain/usecases/get_grades_usecase_test.dart create mode 100644 test/features/student_tabulate/data/repositories/tabulate_repository_impl_test.dart create mode 100644 test/features/student_tabulate/domain/usecases/get_tabulate_usecase_test.dart create mode 100644 test/features/teaching_rating/data/models/review_question_model_test.dart create mode 100644 test/features/teaching_rating/data/models/teacher_review_model_test.dart create mode 100644 test/features/teaching_rating/data/models/teacher_to_rate_model_test.dart create mode 100644 test/features/teaching_rating/data/repositories/teaching_rating_repository_impl_test.dart create mode 100644 test/features/teaching_rating/domain/usecases/get_teacher_review_usecase_test.dart create mode 100644 test/features/teaching_rating/domain/usecases/get_teachers_to_rate_usecase_test.dart create mode 100644 test/features/teaching_rating/domain/usecases/submit_teacher_review_usecase_test.dart diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a94e942..091e092 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,11 @@ jobs: test: name: Run tests with coverage runs-on: ubuntu-latest + env: + # Ratcheted up phase by phase as the testing plan progresses (target: 80%). + # test/coverage_helper_test.dart imports the whole app so every lib/ file + # is measured, not just the ones touched by tests. + COVERAGE_MIN: 10 steps: - uses: actions/checkout@v5 @@ -45,3 +50,9 @@ jobs: cat coverage_summary.txt echo '```' } >> "$GITHUB_STEP_SUMMARY" + + - name: Enforce minimum coverage + run: | + pct=$(grep -oP 'lines\.+: \K[0-9]+(\.[0-9]+)?' coverage_summary.txt) + echo "Line coverage: ${pct}% (minimum: ${COVERAGE_MIN}%)" + awk -v pct="$pct" -v min="$COVERAGE_MIN" 'BEGIN { exit !(pct >= min) }' diff --git a/.gitignore b/.gitignore index ea4bfe5..3374501 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ migrate_working_dir/ .pub/ /build/ /coverage/ +/coverage_summary.txt # Symbolication related app.*.symbols diff --git a/test/features/auth/data/repositories/auth_repository_impl_test.dart b/test/features/auth/data/repositories/auth_repository_impl_test.dart new file mode 100644 index 0000000..d843d00 --- /dev/null +++ b/test/features/auth/data/repositories/auth_repository_impl_test.dart @@ -0,0 +1,239 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/exceptions.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/core/storage/auth_local_datasource.dart'; +import 'package:univalle_app/features/auth/data/datasources/sira_auth_remote_datasource.dart'; +import 'package:univalle_app/features/auth/data/datasources/uplanner_remote_datasource.dart'; +import 'package:univalle_app/features/auth/data/repositories/auth_repository_impl.dart'; +import 'package:univalle_app/features/auth/domain/entities/auth_session.dart'; + +class MockSiraAuthRemoteDataSource extends Mock + implements SiraAuthRemoteDataSource {} + +class MockAuthLocalDataSource extends Mock implements AuthLocalDataSource {} + +class MockUplannerRemoteDataSource extends Mock + implements UplannerRemoteDataSource {} + +void main() { + late MockSiraAuthRemoteDataSource remote; + late MockAuthLocalDataSource local; + late MockUplannerRemoteDataSource uplanner; + late AuthRepositoryImpl repository; + + setUp(() { + remote = MockSiraAuthRemoteDataSource(); + local = MockAuthLocalDataSource(); + uplanner = MockUplannerRemoteDataSource(); + repository = AuthRepositoryImpl(remote, local, uplanner); + + when( + () => local.saveCredentials( + username: any(named: 'username'), + password: any(named: 'password'), + ), + ).thenAnswer((_) async {}); + when(() => local.savePhotoUrl(any())).thenAnswer((_) async {}); + when(() => local.clearCredentials()).thenAnswer((_) async {}); + }); + + group('login', () { + test('logs in, saves credentials and fetches a fresh photo url', () async { + when(() => remote.login(username: 'jperez', password: 'secret')) + .thenAnswer((_) async {}); + when(() => uplanner.fetchPhotoUrl(username: 'jperez', password: 'secret')) + .thenAnswer((_) async => 'https://x/y.jpg'); + + final result = await repository.login( + username: 'jperez', + password: 'secret', + ); + + final session = (result as Ok).value; + expect(session.username, 'jperez'); + expect(session.photoUrl, 'https://x/y.jpg'); + verify( + () => local.saveCredentials(username: 'jperez', password: 'secret'), + ).called(1); + verify(() => local.savePhotoUrl('https://x/y.jpg')).called(1); + }); + + test('succeeds with a null photo url when U-Planner has none', () async { + when(() => remote.login(username: 'jperez', password: 'secret')) + .thenAnswer((_) async {}); + when(() => uplanner.fetchPhotoUrl(username: 'jperez', password: 'secret')) + .thenAnswer((_) async => null); + + final result = await repository.login( + username: 'jperez', + password: 'secret', + ); + + expect((result as Ok).value.photoUrl, isNull); + verifyNever(() => local.savePhotoUrl(any())); + }); + + test('maps a thrown AppException to a Failure', () async { + when(() => remote.login(username: 'jperez', password: 'wrong')) + .thenThrow(const AuthException(message: 'Credenciales inválidas')); + + final result = await repository.login( + username: 'jperez', + password: 'wrong', + ); + + expect(result, isA>()); + expect((result as Err).failure, isA()); + verifyNever( + () => local.saveCredentials( + username: any(named: 'username'), + password: any(named: 'password'), + ), + ); + }); + }); + + group('logout', () { + test( + 'clears local credentials even when the remote logout throws', + () async { + when(() => remote.logout()) + .thenThrow(const NetworkException(message: 'offline')); + + final result = await repository.logout(); + + expect(result, isA>()); + verify(() => local.clearCredentials()).called(1); + }, + ); + + test('clears local credentials when the remote logout succeeds', () async { + when(() => remote.logout()).thenAnswer((_) async {}); + + final result = await repository.logout(); + + expect(result, isA>()); + verify(() => local.clearCredentials()).called(1); + }); + }); + + group('restoreSession', () { + test('returns Ok(null) when there are no saved credentials', () async { + when(() => local.getCredentials()).thenAnswer((_) async => null); + + final result = await repository.restoreSession(); + + expect((result as Ok).value, isNull); + verifyNever( + () => remote.login( + username: any(named: 'username'), + password: any(named: 'password'), + ), + ); + }); + + test( + 'reuses the cached photo url without calling U-Planner again', + () async { + when(() => local.getCredentials()).thenAnswer( + (_) async => const StoredCredentials( + username: 'jperez', + password: 'secret', + photoUrl: 'https://cached/photo.jpg', + ), + ); + when(() => remote.login(username: 'jperez', password: 'secret')) + .thenAnswer((_) async {}); + + final result = await repository.restoreSession(); + + expect( + (result as Ok).value?.photoUrl, + 'https://cached/photo.jpg', + ); + verifyNever( + () => uplanner.fetchPhotoUrl( + username: any(named: 'username'), + password: any(named: 'password'), + ), + ); + }, + ); + + test('fetches a fresh photo url when none is cached', () async { + when(() => local.getCredentials()).thenAnswer( + (_) async => + const StoredCredentials(username: 'jperez', password: 'secret'), + ); + when(() => remote.login(username: 'jperez', password: 'secret')) + .thenAnswer((_) async {}); + when(() => uplanner.fetchPhotoUrl(username: 'jperez', password: 'secret')) + .thenAnswer((_) async => 'https://fresh/photo.jpg'); + + final result = await repository.restoreSession(); + + expect( + (result as Ok).value?.photoUrl, + 'https://fresh/photo.jpg', + ); + verify(() => local.savePhotoUrl('https://fresh/photo.jpg')).called(1); + }); + + test( + 'clears stale credentials and returns Ok(null) on AuthException', + () async { + when(() => local.getCredentials()).thenAnswer( + (_) async => + const StoredCredentials(username: 'jperez', password: 'stale'), + ); + when(() => remote.login(username: 'jperez', password: 'stale')) + .thenThrow(const AuthException(message: 'expired')); + + final result = await repository.restoreSession(); + + expect((result as Ok).value, isNull); + verify(() => local.clearCredentials()).called(1); + }, + ); + + test( + 'keeps credentials and returns Ok(null) on any other AppException', + () async { + when(() => local.getCredentials()).thenAnswer( + (_) async => + const StoredCredentials(username: 'jperez', password: 'secret'), + ); + when(() => remote.login(username: 'jperez', password: 'secret')) + .thenThrow(const NetworkException(message: 'offline')); + + final result = await repository.restoreSession(); + + expect((result as Ok).value, isNull); + verifyNever(() => local.clearCredentials()); + }, + ); + }); + + group('resetPassword', () { + test('returns the email the repository reports', () async { + when(() => remote.resetPassword(username: 'jperez')) + .thenAnswer((_) async => 'j***@correounivalle.edu.co'); + + final result = await repository.resetPassword(username: 'jperez'); + + expect((result as Ok).value, 'j***@correounivalle.edu.co'); + }); + + test('maps a thrown AppException to a Failure', () async { + when( + () => remote.resetPassword(username: 'ghost'), + ).thenThrow(const ServerException(message: 'no existe', statusCode: 404)); + + final result = await repository.resetPassword(username: 'ghost'); + + expect((result as Err).failure, isA()); + }); + }); +} diff --git a/test/features/auth/domain/usecases/login_usecase_test.dart b/test/features/auth/domain/usecases/login_usecase_test.dart new file mode 100644 index 0000000..3b4ee28 --- /dev/null +++ b/test/features/auth/domain/usecases/login_usecase_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/auth/domain/entities/auth_session.dart'; +import 'package:univalle_app/features/auth/domain/repositories/auth_repository.dart'; +import 'package:univalle_app/features/auth/domain/usecases/login_usecase.dart'; + +class MockAuthRepository extends Mock implements AuthRepository {} + +void main() { + late MockAuthRepository repository; + late LoginUseCase useCase; + + setUp(() { + repository = MockAuthRepository(); + useCase = LoginUseCase(repository); + }); + + test('forwards username and password to the repository', () async { + const session = AuthSession(username: 'jperez'); + when(() => repository.login(username: 'jperez', password: 'secret')) + .thenAnswer((_) async => const Ok(session)); + + final result = await useCase.call(username: 'jperez', password: 'secret'); + + expect(result, isA>()); + expect((result as Ok).value, session); + verify(() => repository.login(username: 'jperez', password: 'secret')) + .called(1); + }); + + test('propagates a failure result unchanged', () async { + final failure = AuthFailure(message: 'Credenciales inválidas'); + when(() => repository.login(username: 'jperez', password: 'wrong')) + .thenAnswer((_) async => Err(failure)); + + final result = await useCase.call(username: 'jperez', password: 'wrong'); + + expect(result, isA>()); + expect((result as Err).failure, failure); + }); +} diff --git a/test/features/auth/domain/usecases/logout_usecase_test.dart b/test/features/auth/domain/usecases/logout_usecase_test.dart new file mode 100644 index 0000000..7f7af5b --- /dev/null +++ b/test/features/auth/domain/usecases/logout_usecase_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/auth/domain/repositories/auth_repository.dart'; +import 'package:univalle_app/features/auth/domain/usecases/logout_usecase.dart'; + +class MockAuthRepository extends Mock implements AuthRepository {} + +void main() { + late MockAuthRepository repository; + late LogoutUseCase useCase; + + setUp(() { + repository = MockAuthRepository(); + useCase = LogoutUseCase(repository); + }); + + test('delegates to the repository and returns its success result', () async { + when(() => repository.logout()).thenAnswer((_) async => const Ok(null)); + + final result = await useCase.call(); + + expect(result, isA>()); + verify(() => repository.logout()).called(1); + }); + + test('propagates a failure result unchanged', () async { + final failure = UnknownFailure(message: 'boom'); + when(() => repository.logout()).thenAnswer((_) async => Err(failure)); + + final result = await useCase.call(); + + expect(result, isA>()); + expect((result as Err).failure, failure); + }); +} diff --git a/test/features/auth/domain/usecases/reset_password_usecase_test.dart b/test/features/auth/domain/usecases/reset_password_usecase_test.dart new file mode 100644 index 0000000..cb8028f --- /dev/null +++ b/test/features/auth/domain/usecases/reset_password_usecase_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/auth/domain/repositories/auth_repository.dart'; +import 'package:univalle_app/features/auth/domain/usecases/reset_password_usecase.dart'; + +class MockAuthRepository extends Mock implements AuthRepository {} + +void main() { + late MockAuthRepository repository; + late ResetPasswordUseCase useCase; + + setUp(() { + repository = MockAuthRepository(); + useCase = ResetPasswordUseCase(repository); + }); + + test( + 'forwards the username to the repository and returns its success message', + () async { + when(() => repository.resetPassword(username: 'jperez')) + .thenAnswer((_) async => const Ok('Revisa tu correo institucional')); + + final result = await useCase.call(username: 'jperez'); + + expect(result, isA>()); + expect((result as Ok).value, 'Revisa tu correo institucional'); + verify(() => repository.resetPassword(username: 'jperez')).called(1); + }, + ); + + test('propagates a failure result unchanged', () async { + final failure = UnknownFailure(message: 'no existe'); + when(() => repository.resetPassword(username: 'ghost')) + .thenAnswer((_) async => Err(failure)); + + final result = await useCase.call(username: 'ghost'); + + expect(result, isA>()); + expect((result as Err).failure, failure); + }); +} diff --git a/test/features/auth/domain/usecases/restore_session_usecase_test.dart b/test/features/auth/domain/usecases/restore_session_usecase_test.dart new file mode 100644 index 0000000..8bdb739 --- /dev/null +++ b/test/features/auth/domain/usecases/restore_session_usecase_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/auth/domain/entities/auth_session.dart'; +import 'package:univalle_app/features/auth/domain/repositories/auth_repository.dart'; +import 'package:univalle_app/features/auth/domain/usecases/restore_session_usecase.dart'; + +class MockAuthRepository extends Mock implements AuthRepository {} + +void main() { + late MockAuthRepository repository; + late RestoreSessionUseCase useCase; + + setUp(() { + repository = MockAuthRepository(); + useCase = RestoreSessionUseCase(repository); + }); + + test('returns the restored session when one exists', () async { + const session = AuthSession( + username: 'jperez', + photoUrl: 'https://x/y.jpg', + ); + when(() => repository.restoreSession()) + .thenAnswer((_) async => const Ok(session)); + + final result = await useCase.call(); + + expect((result as Ok).value, session); + }); + + test('returns null when there is no saved session', () async { + when(() => repository.restoreSession()) + .thenAnswer((_) async => const Ok(null)); + + final result = await useCase.call(); + + expect((result as Ok).value, isNull); + }); +} diff --git a/test/features/library/data/models/library_account_model_test.dart b/test/features/library/data/models/library_account_model_test.dart new file mode 100644 index 0000000..aa7ad33 --- /dev/null +++ b/test/features/library/data/models/library_account_model_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/features/library/data/models/library_account_model.dart'; +import 'package:univalle_app/features/library/data/models/library_loan_record_model.dart'; + +void main() { + test('toEntity maps every field, including nested loan records', () { + const model = LibraryAccountModel( + currentFine: r'$5.000', + currentLoans: [ + LibraryLoanRecordModel( + code: '1', + title: 'Clean Code', + location: 'Sala general', + date: '05/03/2026', + ), + ], + history: [], + ); + + final entity = model.toEntity(); + + expect(entity.currentFine, r'$5.000'); + expect(entity.currentLoans, hasLength(1)); + expect(entity.currentLoans.single.title, 'Clean Code'); + expect(entity.history, isEmpty); + }); +} diff --git a/test/features/library/data/repositories/library_repository_impl_test.dart b/test/features/library/data/repositories/library_repository_impl_test.dart new file mode 100644 index 0000000..dc84fff --- /dev/null +++ b/test/features/library/data/repositories/library_repository_impl_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/exceptions.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/core/storage/auth_local_datasource.dart'; +import 'package:univalle_app/features/library/data/datasources/library_remote_datasource.dart'; +import 'package:univalle_app/features/library/data/models/library_account_model.dart'; +import 'package:univalle_app/features/library/data/repositories/library_repository_impl.dart'; +import 'package:univalle_app/features/library/domain/entities/library_account.dart'; + +class MockLibraryRemoteDataSource extends Mock + implements LibraryRemoteDataSource {} + +class MockAuthLocalDataSource extends Mock implements AuthLocalDataSource {} + +void main() { + late MockLibraryRemoteDataSource remote; + late MockAuthLocalDataSource credentials; + late LibraryRepositoryImpl repository; + + setUp(() { + remote = MockLibraryRemoteDataSource(); + credentials = MockAuthLocalDataSource(); + repository = LibraryRepositoryImpl(remote, credentials); + }); + + test( + 'returns the account mapped to an entity when credentials exist', + () async { + when(() => credentials.getCredentials()).thenAnswer( + (_) async => const StoredCredentials(username: 'jperez', password: 'x'), + ); + when(() => remote.fetchAccount(username: 'jperez')).thenAnswer( + (_) async => const LibraryAccountModel( + currentFine: r'$0', + currentLoans: [], + history: [], + ), + ); + + final result = await repository.getAccount(); + + expect(result, isA>()); + expect((result as Ok).value.currentFine, r'$0'); + }, + ); + + test('returns an AuthFailure when there are no saved credentials', () async { + when(() => credentials.getCredentials()).thenAnswer((_) async => null); + + final result = await repository.getAccount(); + + expect(result, isA>()); + expect((result as Err).failure, isA()); + verifyNever(() => remote.fetchAccount(username: any(named: 'username'))); + }); + + test('maps a thrown AppException to a Failure', () async { + when(() => credentials.getCredentials()).thenAnswer( + (_) async => const StoredCredentials(username: 'jperez', password: 'x'), + ); + when(() => remote.fetchAccount(username: 'jperez')) + .thenThrow(const ServerException(message: 'boom', statusCode: 500)); + + final result = await repository.getAccount(); + + expect((result as Err).failure, isA()); + }); +} diff --git a/test/features/library/domain/usecases/get_library_account_usecase_test.dart b/test/features/library/domain/usecases/get_library_account_usecase_test.dart new file mode 100644 index 0000000..b951470 --- /dev/null +++ b/test/features/library/domain/usecases/get_library_account_usecase_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/library/domain/entities/library_account.dart'; +import 'package:univalle_app/features/library/domain/repositories/library_repository.dart'; +import 'package:univalle_app/features/library/domain/usecases/get_library_account_usecase.dart'; + +class MockLibraryRepository extends Mock implements LibraryRepository {} + +void main() { + late MockLibraryRepository repository; + late GetLibraryAccountUseCase useCase; + + setUp(() { + repository = MockLibraryRepository(); + useCase = GetLibraryAccountUseCase(repository); + }); + + test('returns the account from the repository', () async { + const account = LibraryAccount( + currentFine: r'$0', + currentLoans: [], + history: [], + ); + when(() => repository.getAccount()) + .thenAnswer((_) async => const Ok(account)); + + final result = await useCase.call(); + + expect((result as Ok).value, account); + verify(() => repository.getAccount()).called(1); + }); + + test('propagates a failure result unchanged', () async { + final failure = UnknownFailure(message: 'boom'); + when(() => repository.getAccount()).thenAnswer((_) async => Err(failure)); + + final result = await useCase.call(); + + expect((result as Err).failure, failure); + }); +} diff --git a/test/features/profile/data/models/student_model_test.dart b/test/features/profile/data/models/student_model_test.dart new file mode 100644 index 0000000..2cc74d4 --- /dev/null +++ b/test/features/profile/data/models/student_model_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/features/profile/data/models/student_model.dart'; + +void main() { + test('toEntity maps every field', () { + const model = StudentModel( + documentId: '123', + firstName: 'Juan', + lastName: 'Perez', + email: 'jperez@correounivalle.edu.co', + programName: 'Ingeniería de Sistemas', + campus: 'Meléndez', + average: 4.2, + accumulatedCredits: 90, + ); + + final entity = model.toEntity(); + + expect(entity.documentId, '123'); + expect(entity.firstName, 'Juan'); + expect(entity.lastName, 'Perez'); + expect(entity.email, 'jperez@correounivalle.edu.co'); + expect(entity.programName, 'Ingeniería de Sistemas'); + expect(entity.campus, 'Meléndez'); + expect(entity.average, 4.2); + expect(entity.accumulatedCredits, 90); + }); +} diff --git a/test/features/profile/data/repositories/profile_repository_impl_test.dart b/test/features/profile/data/repositories/profile_repository_impl_test.dart new file mode 100644 index 0000000..4004f30 --- /dev/null +++ b/test/features/profile/data/repositories/profile_repository_impl_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/exceptions.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/profile/data/datasources/sira_profile_remote_datasource.dart'; +import 'package:univalle_app/features/profile/data/models/student_model.dart'; +import 'package:univalle_app/features/profile/data/repositories/profile_repository_impl.dart'; +import 'package:univalle_app/features/profile/domain/entities/student.dart'; + +class MockSiraProfileRemoteDataSource extends Mock + implements SiraProfileRemoteDataSource {} + +void main() { + late MockSiraProfileRemoteDataSource remote; + late ProfileRepositoryImpl repository; + + setUp(() { + remote = MockSiraProfileRemoteDataSource(); + repository = ProfileRepositoryImpl(remote); + }); + + test('returns the student mapped to an entity', () async { + when(() => remote.fetchStudent(username: 'jperez')).thenAnswer( + (_) async => const StudentModel( + documentId: '123', + firstName: 'Juan', + lastName: 'Perez', + email: 'jperez@correounivalle.edu.co', + programName: 'Ingeniería de Sistemas', + campus: 'Meléndez', + average: 4.2, + accumulatedCredits: 90, + ), + ); + + final result = await repository.getStudent(username: 'jperez'); + + expect(result, isA>()); + expect((result as Ok).value.firstName, 'Juan'); + }); + + test('maps a thrown AppException to a Failure', () async { + when(() => remote.fetchStudent(username: 'jperez')) + .thenThrow(const ServerException(message: 'boom', statusCode: 500)); + + final result = await repository.getStudent(username: 'jperez'); + + expect((result as Err).failure, isA()); + }); +} diff --git a/test/features/profile/domain/usecases/get_student_usecase_test.dart b/test/features/profile/domain/usecases/get_student_usecase_test.dart new file mode 100644 index 0000000..caef927 --- /dev/null +++ b/test/features/profile/domain/usecases/get_student_usecase_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/profile/domain/entities/student.dart'; +import 'package:univalle_app/features/profile/domain/repositories/profile_repository.dart'; +import 'package:univalle_app/features/profile/domain/usecases/get_student_usecase.dart'; + +class MockProfileRepository extends Mock implements ProfileRepository {} + +void main() { + late MockProfileRepository repository; + late GetStudentUseCase useCase; + + setUp(() { + repository = MockProfileRepository(); + useCase = GetStudentUseCase(repository); + }); + + test('forwards the username to the repository', () async { + const student = Student( + documentId: '123', + firstName: 'Juan', + lastName: 'Perez', + email: 'jperez@correounivalle.edu.co', + programName: 'Ingeniería de Sistemas', + campus: 'Meléndez', + average: 4.2, + accumulatedCredits: 90, + ); + when(() => repository.getStudent(username: 'jperez')) + .thenAnswer((_) async => const Ok(student)); + + final result = await useCase.call(username: 'jperez'); + + expect((result as Ok).value, student); + verify(() => repository.getStudent(username: 'jperez')).called(1); + }); + + test('propagates a failure result unchanged', () async { + final failure = UnknownFailure(message: 'boom'); + when(() => repository.getStudent(username: 'jperez')) + .thenAnswer((_) async => Err(failure)); + + final result = await useCase.call(username: 'jperez'); + + expect((result as Err).failure, failure); + }); +} diff --git a/test/features/resolution/data/models/curriculum_subject_model_test.dart b/test/features/resolution/data/models/curriculum_subject_model_test.dart new file mode 100644 index 0000000..280b99b --- /dev/null +++ b/test/features/resolution/data/models/curriculum_subject_model_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/features/resolution/data/models/curriculum_subject_model.dart'; + +void main() { + test('toEntity maps every field, including prerequisite codes', () { + final model = CurriculumSubjectModel( + code: '101', + name: 'Cálculo', + subjectType: 'Fundamentación', + credits: 4, + semester: 1, + prerequisiteCodes: ['090'], + ); + + final entity = model.toEntity(); + + expect(entity.code, '101'); + expect(entity.name, 'Cálculo'); + expect(entity.subjectType, 'Fundamentación'); + expect(entity.credits, 4); + expect(entity.semester, 1); + expect(entity.prerequisiteCodes, ['090']); + }); +} diff --git a/test/features/resolution/data/repositories/resolution_repository_impl_test.dart b/test/features/resolution/data/repositories/resolution_repository_impl_test.dart new file mode 100644 index 0000000..845eecf --- /dev/null +++ b/test/features/resolution/data/repositories/resolution_repository_impl_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/exceptions.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/resolution/data/datasources/sira_resolution_remote_datasource.dart'; +import 'package:univalle_app/features/resolution/data/models/curriculum_subject_model.dart'; +import 'package:univalle_app/features/resolution/data/repositories/resolution_repository_impl.dart'; +import 'package:univalle_app/features/resolution/domain/entities/curriculum.dart'; + +class MockSiraResolutionRemoteDataSource extends Mock + implements SiraResolutionRemoteDataSource {} + +void main() { + late MockSiraResolutionRemoteDataSource remote; + late ResolutionRepositoryImpl repository; + + setUp(() { + remote = MockSiraResolutionRemoteDataSource(); + repository = ResolutionRepositoryImpl(remote); + }); + + test('wraps the mapped subjects in a Curriculum', () async { + when(() => remote.fetchCurriculum(username: 'jperez')).thenAnswer( + (_) async => [ + CurriculumSubjectModel( + code: '101', + name: 'Cálculo', + subjectType: 'Fundamentación', + credits: 4, + semester: 1, + prerequisiteCodes: const [], + ), + ], + ); + + final result = await repository.getCurriculum(username: 'jperez'); + + expect(result, isA>()); + expect((result as Ok).value.subjects, hasLength(1)); + }); + + test('maps a thrown AppException to a Failure', () async { + when(() => remote.fetchCurriculum(username: 'jperez')) + .thenThrow(const ServerException(message: 'boom', statusCode: 500)); + + final result = await repository.getCurriculum(username: 'jperez'); + + expect((result as Err).failure, isA()); + }); +} diff --git a/test/features/resolution/domain/entities/curriculum_test.dart b/test/features/resolution/domain/entities/curriculum_test.dart new file mode 100644 index 0000000..c40c1c5 --- /dev/null +++ b/test/features/resolution/domain/entities/curriculum_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/features/resolution/domain/entities/curriculum.dart'; +import 'package:univalle_app/features/resolution/domain/entities/curriculum_subject.dart'; + +CurriculumSubject _subject({ + required String code, + int semester = 1, + int credits = 3, + List prerequisiteCodes = const [], +}) { + return CurriculumSubject( + code: code, + name: 'Subject $code', + subjectType: 'Fundamentación', + credits: credits, + semester: semester, + prerequisiteCodes: prerequisiteCodes, + ); +} + +void main() { + late CurriculumSubject algebra; + late CurriculumSubject calculus; + late CurriculumSubject physics; + late Curriculum curriculum; + + setUp(() { + algebra = _subject(code: '101', semester: 1, credits: 4); + calculus = _subject( + code: '102', + semester: 2, + credits: 5, + prerequisiteCodes: ['101'], + ); + physics = _subject( + code: '103', + semester: 2, + credits: 3, + prerequisiteCodes: ['101'], + ); + curriculum = Curriculum(subjects: [algebra, calculus, physics]); + }); + + test('semesters returns the distinct semester numbers, sorted', () { + expect(curriculum.semesters, [1, 2]); + }); + + test('subjectsInSemester filters subjects by semester', () { + expect(curriculum.subjectsInSemester(2), [calculus, physics]); + expect(curriculum.subjectsInSemester(3), isEmpty); + }); + + test('creditsInSemester sums the credits of that semester\'s subjects', () { + expect(curriculum.creditsInSemester(1), 4); + expect(curriculum.creditsInSemester(2), 8); + }); + + test('prerequisitesOf resolves prerequisite codes to their subjects', () { + expect(curriculum.prerequisitesOf(calculus), [algebra]); + expect(curriculum.prerequisitesOf(algebra), isEmpty); + }); + + test( + 'prerequisitesOf ignores a prerequisite code with no matching subject', + () { + final orphan = _subject( + code: '104', + semester: 3, + prerequisiteCodes: ['does-not-exist'], + ); + final curriculumWithOrphan = Curriculum( + subjects: [algebra, calculus, physics, orphan], + ); + + expect(curriculumWithOrphan.prerequisitesOf(orphan), isEmpty); + }, + ); + + test('unlockedBy returns every subject that lists it as a prerequisite', () { + expect(curriculum.unlockedBy(algebra), [calculus, physics]); + }); + + test('unlockedBy returns an empty list when nothing depends on it', () { + expect(curriculum.unlockedBy(calculus), isEmpty); + }); +} diff --git a/test/features/resolution/domain/usecases/get_curriculum_usecase_test.dart b/test/features/resolution/domain/usecases/get_curriculum_usecase_test.dart new file mode 100644 index 0000000..7f78e5d --- /dev/null +++ b/test/features/resolution/domain/usecases/get_curriculum_usecase_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/resolution/domain/entities/curriculum.dart'; +import 'package:univalle_app/features/resolution/domain/repositories/resolution_repository.dart'; +import 'package:univalle_app/features/resolution/domain/usecases/get_curriculum_usecase.dart'; + +class MockResolutionRepository extends Mock implements ResolutionRepository {} + +void main() { + late MockResolutionRepository repository; + late GetCurriculumUseCase useCase; + + setUp(() { + repository = MockResolutionRepository(); + useCase = GetCurriculumUseCase(repository); + }); + + test('forwards the username to the repository', () async { + final curriculum = Curriculum(subjects: []); + when(() => repository.getCurriculum(username: 'jperez')) + .thenAnswer((_) async => Ok(curriculum)); + + final result = await useCase.call(username: 'jperez'); + + expect((result as Ok).value, curriculum); + verify(() => repository.getCurriculum(username: 'jperez')).called(1); + }); + + test('propagates a failure result unchanged', () async { + final failure = UnknownFailure(message: 'boom'); + when(() => repository.getCurriculum(username: 'jperez')) + .thenAnswer((_) async => Err(failure)); + + final result = await useCase.call(username: 'jperez'); + + expect((result as Err).failure, failure); + }); +} diff --git a/test/features/restaurant/data/models/lunch_payment_model_test.dart b/test/features/restaurant/data/models/lunch_payment_model_test.dart new file mode 100644 index 0000000..527dd94 --- /dev/null +++ b/test/features/restaurant/data/models/lunch_payment_model_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/features/restaurant/data/models/lunch_payment_model.dart'; + +void main() { + test('toEntity maps every field', () { + const model = LunchPaymentModel( + quantity: 5, + total: 25000, + purchaseDate: '2026-03-05', + expirationDate: '2026-04-05', + message: 'Pago exitoso', + paymentUrl: 'https://pagos.univalle.edu.co/x', + ); + + final entity = model.toEntity(); + + expect(entity.quantity, 5); + expect(entity.total, 25000); + expect(entity.purchaseDate, '2026-03-05'); + expect(entity.expirationDate, '2026-04-05'); + expect(entity.message, 'Pago exitoso'); + expect(entity.paymentUrl, 'https://pagos.univalle.edu.co/x'); + }); +} diff --git a/test/features/restaurant/data/models/restaurant_account_model_test.dart b/test/features/restaurant/data/models/restaurant_account_model_test.dart new file mode 100644 index 0000000..918d4c9 --- /dev/null +++ b/test/features/restaurant/data/models/restaurant_account_model_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/features/restaurant/data/models/lunch_payment_model.dart'; +import 'package:univalle_app/features/restaurant/data/models/restaurant_account_model.dart'; + +void main() { + test('toEntity maps a null pendingPayment as null', () { + const model = RestaurantAccountModel( + membershipType: 'Regular', + lunchPrice: 5000, + accumulatedLunches: 3, + minPurchase: 1, + maxPurchase: 10, + ); + + expect(model.toEntity().pendingPayment, isNull); + }); + + test('toEntity maps a present pendingPayment', () { + const model = RestaurantAccountModel( + membershipType: 'Regular', + lunchPrice: 5000, + accumulatedLunches: 3, + minPurchase: 1, + maxPurchase: 10, + pendingPayment: LunchPaymentModel( + quantity: 5, + total: 25000, + purchaseDate: '2026-03-05', + expirationDate: '2026-04-05', + message: 'Pendiente', + paymentUrl: 'https://pagos.univalle.edu.co/x', + ), + ); + + final entity = model.toEntity(); + + expect(entity.membershipType, 'Regular'); + expect(entity.lunchPrice, 5000); + expect(entity.accumulatedLunches, 3); + expect(entity.minPurchase, 1); + expect(entity.maxPurchase, 10); + expect(entity.pendingPayment?.quantity, 5); + expect( + entity.pendingPayment?.paymentUrl, + 'https://pagos.univalle.edu.co/x', + ); + }); +} diff --git a/test/features/restaurant/data/repositories/restaurant_repository_impl_test.dart b/test/features/restaurant/data/repositories/restaurant_repository_impl_test.dart new file mode 100644 index 0000000..05ee148 --- /dev/null +++ b/test/features/restaurant/data/repositories/restaurant_repository_impl_test.dart @@ -0,0 +1,121 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/exceptions.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/core/storage/auth_local_datasource.dart'; +import 'package:univalle_app/features/restaurant/data/datasources/restaurant_remote_datasource.dart'; +import 'package:univalle_app/features/restaurant/data/models/lunch_payment_model.dart'; +import 'package:univalle_app/features/restaurant/data/models/restaurant_account_model.dart'; +import 'package:univalle_app/features/restaurant/data/repositories/restaurant_repository_impl.dart'; +import 'package:univalle_app/features/restaurant/domain/entities/lunch_payment.dart'; +import 'package:univalle_app/features/restaurant/domain/entities/restaurant_account.dart'; + +class MockRestaurantRemoteDataSource extends Mock + implements RestaurantRemoteDataSource {} + +class MockAuthLocalDataSource extends Mock implements AuthLocalDataSource {} + +void main() { + late MockRestaurantRemoteDataSource remote; + late MockAuthLocalDataSource credentials; + late RestaurantRepositoryImpl repository; + + setUp(() { + remote = MockRestaurantRemoteDataSource(); + credentials = MockAuthLocalDataSource(); + repository = RestaurantRepositoryImpl(remote, credentials); + }); + + group('getAccount', () { + test( + 'returns the account mapped to an entity when credentials exist', + () async { + when(() => credentials.getCredentials()).thenAnswer( + (_) async => + const StoredCredentials(username: 'jperez', password: 'x'), + ); + when(() => remote.fetchAccount(username: 'jperez', password: 'x')) + .thenAnswer( + (_) async => const RestaurantAccountModel( + membershipType: 'Regular', + lunchPrice: 5000, + accumulatedLunches: 3, + minPurchase: 1, + maxPurchase: 10, + ), + ); + + final result = await repository.getAccount(); + + expect(result, isA>()); + expect( + (result as Ok).value.membershipType, + 'Regular', + ); + }, + ); + + test( + 'returns an AuthFailure when there are no saved credentials', + () async { + when(() => credentials.getCredentials()).thenAnswer((_) async => null); + + final result = await repository.getAccount(); + + expect((result as Err).failure, isA()); + verifyNever( + () => remote.fetchAccount( + username: any(named: 'username'), + password: any(named: 'password'), + ), + ); + }, + ); + + test('maps a thrown AppException to a Failure', () async { + when(() => credentials.getCredentials()).thenAnswer( + (_) async => const StoredCredentials(username: 'jperez', password: 'x'), + ); + when(() => remote.fetchAccount(username: 'jperez', password: 'x')) + .thenThrow(const ServerException(message: 'boom', statusCode: 500)); + + final result = await repository.getAccount(); + + expect((result as Err).failure, isA()); + }); + }); + + group('buyLunches', () { + test( + 'returns the payment mapped to an entity, without requiring credentials', + () async { + when(() => remote.buyLunches(quantity: 5, total: 25000)).thenAnswer( + (_) async => const LunchPaymentModel( + quantity: 5, + total: 25000, + purchaseDate: '2026-03-05', + expirationDate: '2026-04-05', + message: 'Pago exitoso', + paymentUrl: 'https://pagos.univalle.edu.co/x', + ), + ); + + final result = await repository.buyLunches(quantity: 5, total: 25000); + + expect(result, isA>()); + expect((result as Ok).value.quantity, 5); + verifyNever(() => credentials.getCredentials()); + }, + ); + + test('maps a thrown AppException to a Failure', () async { + when(() => remote.buyLunches(quantity: 5, total: 25000)) + .thenThrow(const ServerException(message: 'boom', statusCode: 500)); + + final result = await repository.buyLunches(quantity: 5, total: 25000); + + expect((result as Err).failure, isA()); + }); + }); +} diff --git a/test/features/restaurant/domain/usecases/buy_lunches_usecase_test.dart b/test/features/restaurant/domain/usecases/buy_lunches_usecase_test.dart new file mode 100644 index 0000000..90172b2 --- /dev/null +++ b/test/features/restaurant/domain/usecases/buy_lunches_usecase_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/restaurant/domain/entities/lunch_payment.dart'; +import 'package:univalle_app/features/restaurant/domain/repositories/restaurant_repository.dart'; +import 'package:univalle_app/features/restaurant/domain/usecases/buy_lunches_usecase.dart'; + +class MockRestaurantRepository extends Mock implements RestaurantRepository {} + +void main() { + late MockRestaurantRepository repository; + late BuyLunchesUseCase useCase; + + setUp(() { + repository = MockRestaurantRepository(); + useCase = BuyLunchesUseCase(repository); + }); + + test('forwards quantity and total to the repository', () async { + const payment = LunchPayment( + quantity: 5, + total: 25000, + purchaseDate: '2026-03-05', + expirationDate: '2026-04-05', + message: 'Pago exitoso', + paymentUrl: 'https://pagos.univalle.edu.co/x', + ); + when(() => repository.buyLunches(quantity: 5, total: 25000)) + .thenAnswer((_) async => const Ok(payment)); + + final result = await useCase.call(quantity: 5, total: 25000); + + expect((result as Ok).value, payment); + verify(() => repository.buyLunches(quantity: 5, total: 25000)).called(1); + }); + + test('propagates a failure result unchanged', () async { + final failure = UnknownFailure(message: 'boom'); + when(() => repository.buyLunches(quantity: 5, total: 25000)) + .thenAnswer((_) async => Err(failure)); + + final result = await useCase.call(quantity: 5, total: 25000); + + expect((result as Err).failure, failure); + }); +} diff --git a/test/features/restaurant/domain/usecases/get_restaurant_account_usecase_test.dart b/test/features/restaurant/domain/usecases/get_restaurant_account_usecase_test.dart new file mode 100644 index 0000000..3b02247 --- /dev/null +++ b/test/features/restaurant/domain/usecases/get_restaurant_account_usecase_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/restaurant/domain/entities/restaurant_account.dart'; +import 'package:univalle_app/features/restaurant/domain/repositories/restaurant_repository.dart'; +import 'package:univalle_app/features/restaurant/domain/usecases/get_restaurant_account_usecase.dart'; + +class MockRestaurantRepository extends Mock implements RestaurantRepository {} + +void main() { + late MockRestaurantRepository repository; + late GetRestaurantAccountUseCase useCase; + + setUp(() { + repository = MockRestaurantRepository(); + useCase = GetRestaurantAccountUseCase(repository); + }); + + test('returns the account from the repository', () async { + const account = RestaurantAccount( + membershipType: 'Regular', + lunchPrice: 5000, + accumulatedLunches: 3, + minPurchase: 1, + maxPurchase: 10, + ); + when(() => repository.getAccount()) + .thenAnswer((_) async => const Ok(account)); + + final result = await useCase.call(); + + expect((result as Ok).value, account); + verify(() => repository.getAccount()).called(1); + }); + + test('propagates a failure result unchanged', () async { + final failure = UnknownFailure(message: 'boom'); + when(() => repository.getAccount()).thenAnswer((_) async => Err(failure)); + + final result = await useCase.call(); + + expect((result as Err).failure, failure); + }); +} diff --git a/test/features/schedule/data/models/schedule_class_model_test.dart b/test/features/schedule/data/models/schedule_class_model_test.dart new file mode 100644 index 0000000..c8e76b7 --- /dev/null +++ b/test/features/schedule/data/models/schedule_class_model_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/features/schedule/data/models/schedule_class_model.dart'; +import 'package:univalle_app/features/schedule/domain/entities/weekday.dart'; + +void main() { + test('toEntity maps every field, including an absent teacherEmail', () { + const model = ScheduleClassModel( + subjectCode: '101', + subjectName: 'Cálculo', + group: '1', + teacher: 'Ana Ríos', + day: Weekday.monday, + startTime: '07:00', + endTime: '09:00', + building: 'B13', + room: '101', + campus: 'Meléndez', + ); + + final entity = model.toEntity(); + + expect(entity.subjectCode, '101'); + expect(entity.teacher, 'Ana Ríos'); + expect(entity.teacherEmail, isNull); + expect(entity.day, Weekday.monday); + expect(entity.startTime, '07:00'); + expect(entity.endTime, '09:00'); + expect(entity.building, 'B13'); + expect(entity.room, '101'); + expect(entity.campus, 'Meléndez'); + }); + + test('toEntity maps a present teacherEmail', () { + const model = ScheduleClassModel( + subjectCode: '101', + subjectName: 'Cálculo', + group: '1', + teacher: 'Ana Ríos', + teacherEmail: 'ana.rios@correounivalle.edu.co', + day: Weekday.tuesday, + startTime: '07:00', + endTime: '09:00', + building: 'B13', + room: '101', + campus: 'Meléndez', + ); + + expect(model.toEntity().teacherEmail, 'ana.rios@correounivalle.edu.co'); + }); +} diff --git a/test/features/schedule/data/repositories/schedule_repository_impl_test.dart b/test/features/schedule/data/repositories/schedule_repository_impl_test.dart new file mode 100644 index 0000000..ff26797 --- /dev/null +++ b/test/features/schedule/data/repositories/schedule_repository_impl_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/exceptions.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/schedule/data/datasources/sira_schedule_remote_datasource.dart'; +import 'package:univalle_app/features/schedule/data/models/schedule_class_model.dart'; +import 'package:univalle_app/features/schedule/data/repositories/schedule_repository_impl.dart'; +import 'package:univalle_app/features/schedule/domain/entities/schedule_class.dart'; +import 'package:univalle_app/features/schedule/domain/entities/schedule_subject.dart'; +import 'package:univalle_app/features/schedule/domain/entities/weekday.dart'; + +class MockSiraScheduleRemoteDataSource extends Mock + implements SiraScheduleRemoteDataSource {} + +void main() { + late MockSiraScheduleRemoteDataSource remote; + late ScheduleRepositoryImpl repository; + + const subjects = [ + ScheduleSubject(code: '101', group: '1', campusId: 'M', name: 'Cálculo'), + ]; + + setUp(() { + remote = MockSiraScheduleRemoteDataSource(); + repository = ScheduleRepositoryImpl(remote); + }); + + test('returns the classes mapped to entities', () async { + when(() => remote.fetchSchedule(subjects: subjects)).thenAnswer( + (_) async => const [ + ScheduleClassModel( + subjectCode: '101', + subjectName: 'Cálculo', + group: '1', + teacher: 'Ana Ríos', + day: Weekday.monday, + startTime: '07:00', + endTime: '09:00', + building: 'B13', + room: '101', + campus: 'Meléndez', + ), + ], + ); + + final result = await repository.getSchedule(subjects: subjects); + + expect(result, isA>>()); + expect((result as Ok>).value, hasLength(1)); + }); + + test('maps a thrown AppException to a Failure', () async { + when(() => remote.fetchSchedule(subjects: subjects)) + .thenThrow(const ServerException(message: 'boom', statusCode: 500)); + + final result = await repository.getSchedule(subjects: subjects); + + expect((result as Err>).failure, isA()); + }); +} diff --git a/test/features/schedule/domain/entities/weekday_test.dart b/test/features/schedule/domain/entities/weekday_test.dart new file mode 100644 index 0000000..24393c4 --- /dev/null +++ b/test/features/schedule/domain/entities/weekday_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/features/schedule/domain/entities/weekday.dart'; + +void main() { + group('Weekday.fromSiraCode', () { + const cases = { + 'LUN': Weekday.monday, + 'MAR': Weekday.tuesday, + 'MIE': Weekday.wednesday, + 'MIÉ': Weekday.wednesday, + 'JUE': Weekday.thursday, + 'VIE': Weekday.friday, + 'SAB': Weekday.saturday, + 'DOM': Weekday.sunday, + }; + + cases.forEach((code, expected) { + test('parses "$code" as $expected', () { + expect(Weekday.fromSiraCode(code), expected); + }); + }); + + test('is case-insensitive and trims whitespace', () { + expect(Weekday.fromSiraCode(' lun '), Weekday.monday); + }); + + test('returns null for an unrecognized code', () { + expect(Weekday.fromSiraCode('XXX'), isNull); + }); + }); +} diff --git a/test/features/schedule/domain/usecases/get_schedule_usecase_test.dart b/test/features/schedule/domain/usecases/get_schedule_usecase_test.dart new file mode 100644 index 0000000..dedf417 --- /dev/null +++ b/test/features/schedule/domain/usecases/get_schedule_usecase_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/schedule/domain/entities/schedule_class.dart'; +import 'package:univalle_app/features/schedule/domain/entities/schedule_subject.dart'; +import 'package:univalle_app/features/schedule/domain/entities/weekday.dart'; +import 'package:univalle_app/features/schedule/domain/repositories/schedule_repository.dart'; +import 'package:univalle_app/features/schedule/domain/usecases/get_schedule_usecase.dart'; + +class MockScheduleRepository extends Mock implements ScheduleRepository {} + +void main() { + late MockScheduleRepository repository; + late GetScheduleUseCase useCase; + + const subjects = [ + ScheduleSubject(code: '101', group: '1', campusId: 'M', name: 'Cálculo'), + ]; + + setUp(() { + repository = MockScheduleRepository(); + useCase = GetScheduleUseCase(repository); + }); + + test('forwards the subjects to the repository', () async { + const scheduleClass = ScheduleClass( + subjectCode: '101', + subjectName: 'Cálculo', + group: '1', + teacher: 'Ana Ríos', + day: Weekday.monday, + startTime: '07:00', + endTime: '09:00', + building: 'B13', + room: '101', + campus: 'Meléndez', + ); + when(() => repository.getSchedule(subjects: subjects)) + .thenAnswer((_) async => const Ok([scheduleClass])); + + final result = await useCase.call(subjects: subjects); + + expect((result as Ok>).value, [scheduleClass]); + verify(() => repository.getSchedule(subjects: subjects)).called(1); + }); + + test('propagates a failure result unchanged', () async { + final failure = UnknownFailure(message: 'boom'); + when(() => repository.getSchedule(subjects: subjects)) + .thenAnswer((_) async => Err(failure)); + + final result = await useCase.call(subjects: subjects); + + expect((result as Err>).failure, failure); + }); +} diff --git a/test/features/student_grades/data/models/grades_model_test.dart b/test/features/student_grades/data/models/grades_model_test.dart new file mode 100644 index 0000000..1c4bee4 --- /dev/null +++ b/test/features/student_grades/data/models/grades_model_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/features/student_grades/data/models/grades_model.dart'; +import 'package:univalle_app/features/student_grades/data/models/subject_model.dart'; + +void main() { + test('toEntity maps every field, including nested subjects', () { + const model = GradesModel( + period: 'Feb/22 – Jun/22', + average: 4.1, + credits: 18, + approvedPercentage: '100%', + hasAcademicMerit: true, + subjects: [ + SubjectModel( + code: '101', + group: '1', + name: 'Cálculo', + credits: 4, + grade: '4.5', + isCanceled: false, + campusId: 'M', + ), + ], + ); + + final entity = model.toEntity(); + + expect(entity.period, 'Feb/22 – Jun/22'); + expect(entity.average, 4.1); + expect(entity.credits, 18); + expect(entity.approvedPercentage, '100%'); + expect(entity.hasAcademicMerit, isTrue); + expect(entity.subjects, hasLength(1)); + expect(entity.subjects.single.name, 'Cálculo'); + }); +} diff --git a/test/features/student_grades/data/models/subject_model_test.dart b/test/features/student_grades/data/models/subject_model_test.dart new file mode 100644 index 0000000..35d75b9 --- /dev/null +++ b/test/features/student_grades/data/models/subject_model_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/features/student_grades/data/models/subject_model.dart'; + +void main() { + test('toEntity maps every field', () { + const model = SubjectModel( + code: '101', + group: '1', + name: 'Cálculo', + credits: 4, + grade: '4.5', + isCanceled: true, + campusId: 'M', + ); + + final entity = model.toEntity(); + + expect(entity.code, '101'); + expect(entity.group, '1'); + expect(entity.name, 'Cálculo'); + expect(entity.credits, 4); + expect(entity.grade, '4.5'); + expect(entity.isCanceled, isTrue); + expect(entity.campusId, 'M'); + }); +} diff --git a/test/features/student_grades/data/repositories/grades_repository_impl_test.dart b/test/features/student_grades/data/repositories/grades_repository_impl_test.dart new file mode 100644 index 0000000..eaa00db --- /dev/null +++ b/test/features/student_grades/data/repositories/grades_repository_impl_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/exceptions.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/student_grades/data/datasources/sira_grades_remote_datasource.dart'; +import 'package:univalle_app/features/student_grades/data/models/grades_model.dart'; +import 'package:univalle_app/features/student_grades/data/repositories/grades_repository_impl.dart'; +import 'package:univalle_app/features/student_grades/domain/entities/grades.dart'; + +class MockSiraGradesRemoteDataSource extends Mock + implements SiraGradesRemoteDataSource {} + +void main() { + late MockSiraGradesRemoteDataSource remote; + late GradesRepositoryImpl repository; + + setUp(() { + remote = MockSiraGradesRemoteDataSource(); + repository = GradesRepositoryImpl(remote); + }); + + test('returns the periods mapped to entities', () async { + when(() => remote.fetchGrades(username: 'jperez')).thenAnswer( + (_) async => const [ + GradesModel( + period: 'Feb/22 – Jun/22', + average: 4.1, + credits: 18, + approvedPercentage: '100%', + hasAcademicMerit: false, + subjects: [], + ), + ], + ); + + final result = await repository.getGrades(username: 'jperez'); + + expect(result, isA>>()); + expect((result as Ok>).value, hasLength(1)); + }); + + test('maps a thrown AppException to a Failure', () async { + when(() => remote.fetchGrades(username: 'jperez')) + .thenThrow(const ServerException(message: 'boom', statusCode: 500)); + + final result = await repository.getGrades(username: 'jperez'); + + expect((result as Err>).failure, isA()); + }); +} diff --git a/test/features/student_grades/domain/usecases/get_grades_usecase_test.dart b/test/features/student_grades/domain/usecases/get_grades_usecase_test.dart new file mode 100644 index 0000000..ff94263 --- /dev/null +++ b/test/features/student_grades/domain/usecases/get_grades_usecase_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/student_grades/domain/entities/grades.dart'; +import 'package:univalle_app/features/student_grades/domain/repositories/grades_repository.dart'; +import 'package:univalle_app/features/student_grades/domain/usecases/get_grades_usecase.dart'; + +class MockGradesRepository extends Mock implements GradesRepository {} + +void main() { + late MockGradesRepository repository; + late GetGradesUseCase useCase; + + setUp(() { + repository = MockGradesRepository(); + useCase = GetGradesUseCase(repository); + }); + + test('forwards the username to the repository', () async { + const grades = [ + Grades( + period: 'Feb/22 – Jun/22', + average: 4.1, + credits: 18, + approvedPercentage: '100%', + hasAcademicMerit: false, + subjects: [], + ), + ]; + when(() => repository.getGrades(username: 'jperez')) + .thenAnswer((_) async => const Ok(grades)); + + final result = await useCase.call(username: 'jperez'); + + expect((result as Ok>).value, grades); + verify(() => repository.getGrades(username: 'jperez')).called(1); + }); + + test('propagates a failure result unchanged', () async { + final failure = UnknownFailure(message: 'boom'); + when(() => repository.getGrades(username: 'jperez')) + .thenAnswer((_) async => Err(failure)); + + final result = await useCase.call(username: 'jperez'); + + expect((result as Err>).failure, failure); + }); +} diff --git a/test/features/student_tabulate/data/repositories/tabulate_repository_impl_test.dart b/test/features/student_tabulate/data/repositories/tabulate_repository_impl_test.dart new file mode 100644 index 0000000..0889c4e --- /dev/null +++ b/test/features/student_tabulate/data/repositories/tabulate_repository_impl_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/constants/sira_constants.dart'; +import 'package:univalle_app/core/error/exceptions.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/student_tabulate/data/datasources/sira_tabulate_remote_datasource.dart'; +import 'package:univalle_app/features/student_tabulate/data/repositories/tabulate_repository_impl.dart'; +import 'package:univalle_app/features/student_tabulate/domain/entities/tabulate.dart'; + +class MockSiraTabulateRemoteDataSource extends Mock + implements SiraTabulateRemoteDataSource {} + +void main() { + late MockSiraTabulateRemoteDataSource remote; + late TabulateRepositoryImpl repository; + + setUp(() { + remote = MockSiraTabulateRemoteDataSource(); + repository = TabulateRepositoryImpl(remote); + }); + + test('wraps the raw html with SIRA\'s base url', () async { + when(() => remote.fetchTabulate(username: 'jperez')) + .thenAnswer((_) async => 'tabulado'); + + final result = await repository.getTabulate(username: 'jperez'); + + expect(result, isA>()); + final tabulate = (result as Ok).value; + expect(tabulate.html, 'tabulado'); + expect(tabulate.baseUrl, Uri.parse(SiraConstants.baseUrl)); + }); + + test('maps a thrown AppException to a Failure', () async { + when(() => remote.fetchTabulate(username: 'jperez')) + .thenThrow(const ServerException(message: 'boom', statusCode: 500)); + + final result = await repository.getTabulate(username: 'jperez'); + + expect((result as Err).failure, isA()); + }); +} diff --git a/test/features/student_tabulate/domain/usecases/get_tabulate_usecase_test.dart b/test/features/student_tabulate/domain/usecases/get_tabulate_usecase_test.dart new file mode 100644 index 0000000..44a7f71 --- /dev/null +++ b/test/features/student_tabulate/domain/usecases/get_tabulate_usecase_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/student_tabulate/domain/entities/tabulate.dart'; +import 'package:univalle_app/features/student_tabulate/domain/repositories/tabulate_repository.dart'; +import 'package:univalle_app/features/student_tabulate/domain/usecases/get_tabulate_usecase.dart'; + +class MockTabulateRepository extends Mock implements TabulateRepository {} + +void main() { + late MockTabulateRepository repository; + late GetTabulateUseCase useCase; + + setUp(() { + repository = MockTabulateRepository(); + useCase = GetTabulateUseCase(repository); + }); + + test('forwards the username to the repository', () async { + final tabulate = Tabulate( + html: '', + baseUrl: Uri.parse('https://sira.univalle.edu.co'), + ); + when(() => repository.getTabulate(username: 'jperez')) + .thenAnswer((_) async => Ok(tabulate)); + + final result = await useCase.call(username: 'jperez'); + + expect((result as Ok).value, tabulate); + verify(() => repository.getTabulate(username: 'jperez')).called(1); + }); + + test('propagates a failure result unchanged', () async { + final failure = UnknownFailure(message: 'boom'); + when(() => repository.getTabulate(username: 'jperez')) + .thenAnswer((_) async => Err(failure)); + + final result = await useCase.call(username: 'jperez'); + + expect((result as Err).failure, failure); + }); +} diff --git a/test/features/teaching_rating/data/models/review_question_model_test.dart b/test/features/teaching_rating/data/models/review_question_model_test.dart new file mode 100644 index 0000000..397b400 --- /dev/null +++ b/test/features/teaching_rating/data/models/review_question_model_test.dart @@ -0,0 +1,19 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/features/teaching_rating/data/models/review_question_model.dart'; +import 'package:univalle_app/features/teaching_rating/domain/entities/review_question.dart'; + +void main() { + test('toEntity maps every field', () { + final model = ReviewQuestionModel( + id: 'q1', + category: QuestionCategory.student, + question: '¿Consideras adecuada tu dedicación al curso?', + ); + + final entity = model.toEntity(); + + expect(entity.id, 'q1'); + expect(entity.category, QuestionCategory.student); + expect(entity.question, '¿Consideras adecuada tu dedicación al curso?'); + }); +} diff --git a/test/features/teaching_rating/data/models/teacher_review_model_test.dart b/test/features/teaching_rating/data/models/teacher_review_model_test.dart new file mode 100644 index 0000000..38c3f71 --- /dev/null +++ b/test/features/teaching_rating/data/models/teacher_review_model_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/features/teaching_rating/data/models/review_question_model.dart'; +import 'package:univalle_app/features/teaching_rating/data/models/teacher_review_model.dart'; +import 'package:univalle_app/features/teaching_rating/domain/entities/review_question.dart'; + +void main() { + test('toEntity maps every field, including nested questions', () { + final model = TeacherReviewModel( + formFields: const {'id_evaluacion': '42'}, + questions: [ + ReviewQuestionModel( + id: 'q1', + category: QuestionCategory.teacher, + question: '¿El docente explica con claridad?', + ), + ], + teacherName: 'Ana Ríos', + subjectName: 'Cálculo', + ); + + final entity = model.toEntity(); + + expect(entity.formFields, {'id_evaluacion': '42'}); + expect(entity.teacherName, 'Ana Ríos'); + expect(entity.subjectName, 'Cálculo'); + expect(entity.questions, hasLength(1)); + expect(entity.questions.single.id, 'q1'); + expect(entity.questions.single.category, QuestionCategory.teacher); + }); +} diff --git a/test/features/teaching_rating/data/models/teacher_to_rate_model_test.dart b/test/features/teaching_rating/data/models/teacher_to_rate_model_test.dart new file mode 100644 index 0000000..747b28b --- /dev/null +++ b/test/features/teaching_rating/data/models/teacher_to_rate_model_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/features/teaching_rating/data/models/teacher_to_rate_model.dart'; + +void main() { + test('toEntity maps every field, including an absent novelty', () { + final model = TeacherToRateModel( + id: '1', + teacherName: 'Ana Ríos', + subjectName: 'Cálculo', + subjectCode: '101', + group: '1', + campusId: 'M', + teacherId: 't1', + teacherDocument: 'd1', + programId: 'p1', + programName: 'Ing. Sistemas', + programCode: '752', + isQualified: true, + ); + + final entity = model.toEntity(); + + expect(entity.id, '1'); + expect(entity.teacherName, 'Ana Ríos'); + expect(entity.subjectCode, '101'); + expect(entity.isQualified, isTrue); + expect(entity.novelty, isNull); + }); + + test('toEntity maps a present novelty', () { + final model = TeacherToRateModel( + id: '1', + teacherName: 'Ana Ríos', + subjectName: 'Cálculo', + subjectCode: '101', + group: '1', + campusId: 'M', + teacherId: 't1', + teacherDocument: 'd1', + programId: 'p1', + programName: 'Ing. Sistemas', + programCode: '752', + isQualified: false, + novelty: 'Docente no calificable', + ); + + final entity = model.toEntity(); + + expect(entity.isQualified, isFalse); + expect(entity.novelty, 'Docente no calificable'); + }); +} diff --git a/test/features/teaching_rating/data/repositories/teaching_rating_repository_impl_test.dart b/test/features/teaching_rating/data/repositories/teaching_rating_repository_impl_test.dart new file mode 100644 index 0000000..a1b0876 --- /dev/null +++ b/test/features/teaching_rating/data/repositories/teaching_rating_repository_impl_test.dart @@ -0,0 +1,184 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/exceptions.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/core/storage/auth_local_datasource.dart'; +import 'package:univalle_app/features/teaching_rating/data/datasources/course_evaluation_remote_datasource.dart'; +import 'package:univalle_app/features/teaching_rating/data/models/review_question_model.dart'; +import 'package:univalle_app/features/teaching_rating/data/models/teacher_review_model.dart'; +import 'package:univalle_app/features/teaching_rating/data/models/teacher_to_rate_model.dart'; +import 'package:univalle_app/features/teaching_rating/data/repositories/teaching_rating_repository_impl.dart'; +import 'package:univalle_app/features/teaching_rating/domain/entities/rating_option.dart'; +import 'package:univalle_app/features/teaching_rating/domain/entities/review_question.dart'; +import 'package:univalle_app/features/teaching_rating/domain/entities/teacher_review.dart'; +import 'package:univalle_app/features/teaching_rating/domain/entities/teacher_to_rate.dart'; + +class MockCourseEvaluationRemoteDataSource extends Mock + implements CourseEvaluationRemoteDataSource {} + +class MockAuthLocalDataSource extends Mock implements AuthLocalDataSource {} + +void main() { + late MockCourseEvaluationRemoteDataSource remote; + late MockAuthLocalDataSource credentials; + late TeachingRatingRepositoryImpl repository; + + const teacher = TeacherToRate( + id: '1', + teacherName: 'Ana Ríos', + subjectName: 'Cálculo', + subjectCode: '101', + group: '1', + campusId: 'M', + teacherId: 't1', + teacherDocument: 'd1', + programId: 'p1', + programName: 'Ing. Sistemas', + programCode: '752', + isQualified: true, + ); + + setUp(() { + remote = MockCourseEvaluationRemoteDataSource(); + credentials = MockAuthLocalDataSource(); + repository = TeachingRatingRepositoryImpl(remote, credentials); + }); + + group('getTeachersToRate', () { + test( + 'returns the teachers mapped to entities when credentials exist', + () async { + when(() => credentials.getCredentials()).thenAnswer( + (_) async => + const StoredCredentials(username: 'jperez', password: 'x'), + ); + when( + () => remote.fetchTeachersToRate(username: 'jperez', password: 'x'), + ).thenAnswer( + (_) async => [ + TeacherToRateModel( + id: '1', + teacherName: 'Ana Ríos', + subjectName: 'Cálculo', + subjectCode: '101', + group: '1', + campusId: 'M', + teacherId: 't1', + teacherDocument: 'd1', + programId: 'p1', + programName: 'Ing. Sistemas', + programCode: '752', + isQualified: true, + ), + ], + ); + + final result = await repository.getTeachersToRate(); + + expect(result, isA>>()); + expect((result as Ok>).value, hasLength(1)); + }, + ); + + test( + 'returns an AuthFailure when there are no saved credentials', + () async { + when(() => credentials.getCredentials()).thenAnswer((_) async => null); + + final result = await repository.getTeachersToRate(); + + expect( + (result as Err>).failure, + isA(), + ); + }, + ); + }); + + group('getTeacherReview', () { + test( + 'returns the review mapped to an entity, without requiring credentials', + () async { + when(() => remote.fetchTeacherReview(teacher: teacher)).thenAnswer( + (_) async => TeacherReviewModel( + formFields: const {'id_evaluacion': '42'}, + questions: [ + ReviewQuestionModel( + id: 'q1', + category: QuestionCategory.teacher, + question: '¿El docente explica con claridad?', + ), + ], + teacherName: 'Ana Ríos', + subjectName: 'Cálculo', + ), + ); + + final result = await repository.getTeacherReview(teacher: teacher); + + expect(result, isA>()); + expect((result as Ok).value.questions, hasLength(1)); + verifyNever(() => credentials.getCredentials()); + }, + ); + + test('maps a thrown AppException to a Failure', () async { + when(() => remote.fetchTeacherReview(teacher: teacher)) + .thenThrow(const ServerException(message: 'boom', statusCode: 500)); + + final result = await repository.getTeacherReview(teacher: teacher); + + expect((result as Err).failure, isA()); + }); + }); + + group('submitTeacherReview', () { + const review = TeacherReview( + formFields: {'id_evaluacion': '42'}, + questions: [], + teacherName: 'Ana Ríos', + subjectName: 'Cálculo', + ); + const answers = {'q1': RatingOption.agree}; + + test( + 'returns Ok(null) on success, without requiring credentials', + () async { + when( + () => remote.submitTeacherReview( + review: review, + answers: answers, + feedback: 'Excelente', + ), + ).thenAnswer((_) async {}); + + final result = await repository.submitTeacherReview( + review: review, + answers: answers, + feedback: 'Excelente', + ); + + expect(result, isA>()); + verifyNever(() => credentials.getCredentials()); + }, + ); + + test('maps a thrown AppException to a Failure', () async { + when( + () => remote.submitTeacherReview( + review: review, + answers: answers, + feedback: null, + ), + ).thenThrow(const ServerException(message: 'boom', statusCode: 500)); + + final result = await repository.submitTeacherReview( + review: review, + answers: answers, + ); + + expect((result as Err).failure, isA()); + }); + }); +} diff --git a/test/features/teaching_rating/domain/usecases/get_teacher_review_usecase_test.dart b/test/features/teaching_rating/domain/usecases/get_teacher_review_usecase_test.dart new file mode 100644 index 0000000..8c3c8d3 --- /dev/null +++ b/test/features/teaching_rating/domain/usecases/get_teacher_review_usecase_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/teaching_rating/domain/entities/teacher_review.dart'; +import 'package:univalle_app/features/teaching_rating/domain/entities/teacher_to_rate.dart'; +import 'package:univalle_app/features/teaching_rating/domain/repositories/teaching_rating_repository.dart'; +import 'package:univalle_app/features/teaching_rating/domain/usecases/get_teacher_review_usecase.dart'; + +class MockTeachingRatingRepository extends Mock + implements TeachingRatingRepository {} + +void main() { + late MockTeachingRatingRepository repository; + late GetTeacherReviewUseCase useCase; + + const teacher = TeacherToRate( + id: '1', + teacherName: 'Ana Ríos', + subjectName: 'Cálculo', + subjectCode: '101', + group: '1', + campusId: 'M', + teacherId: 't1', + teacherDocument: 'd1', + programId: 'p1', + programName: 'Ing. Sistemas', + programCode: '752', + isQualified: true, + ); + + setUp(() { + repository = MockTeachingRatingRepository(); + useCase = GetTeacherReviewUseCase(repository); + }); + + test('forwards the teacher to the repository', () async { + const review = TeacherReview( + formFields: {'id_evaluacion': '42'}, + questions: [], + teacherName: 'Ana Ríos', + subjectName: 'Cálculo', + ); + when(() => repository.getTeacherReview(teacher: teacher)) + .thenAnswer((_) async => const Ok(review)); + + final result = await useCase.call(teacher: teacher); + + expect((result as Ok).value, review); + verify(() => repository.getTeacherReview(teacher: teacher)).called(1); + }); + + test('propagates a failure result unchanged', () async { + final failure = UnknownFailure(message: 'boom'); + when(() => repository.getTeacherReview(teacher: teacher)) + .thenAnswer((_) async => Err(failure)); + + final result = await useCase.call(teacher: teacher); + + expect((result as Err).failure, failure); + }); +} diff --git a/test/features/teaching_rating/domain/usecases/get_teachers_to_rate_usecase_test.dart b/test/features/teaching_rating/domain/usecases/get_teachers_to_rate_usecase_test.dart new file mode 100644 index 0000000..868f509 --- /dev/null +++ b/test/features/teaching_rating/domain/usecases/get_teachers_to_rate_usecase_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/teaching_rating/domain/entities/teacher_to_rate.dart'; +import 'package:univalle_app/features/teaching_rating/domain/repositories/teaching_rating_repository.dart'; +import 'package:univalle_app/features/teaching_rating/domain/usecases/get_teachers_to_rate_usecase.dart'; + +class MockTeachingRatingRepository extends Mock + implements TeachingRatingRepository {} + +void main() { + late MockTeachingRatingRepository repository; + late GetTeachersToRateUseCase useCase; + + setUp(() { + repository = MockTeachingRatingRepository(); + useCase = GetTeachersToRateUseCase(repository); + }); + + test('returns the list of teachers to rate from the repository', () async { + const teachers = [ + TeacherToRate( + id: '1', + teacherName: 'Ana Ríos', + subjectName: 'Cálculo', + subjectCode: '101', + group: '1', + campusId: 'M', + teacherId: 't1', + teacherDocument: 'd1', + programId: 'p1', + programName: 'Ing. Sistemas', + programCode: '752', + isQualified: true, + ), + ]; + when(() => repository.getTeachersToRate()) + .thenAnswer((_) async => const Ok(teachers)); + + final result = await useCase.call(); + + expect((result as Ok>).value, teachers); + verify(() => repository.getTeachersToRate()).called(1); + }); + + test('propagates a failure result unchanged', () async { + final failure = UnknownFailure(message: 'boom'); + when(() => repository.getTeachersToRate()) + .thenAnswer((_) async => Err(failure)); + + final result = await useCase.call(); + + expect((result as Err>).failure, failure); + }); +} diff --git a/test/features/teaching_rating/domain/usecases/submit_teacher_review_usecase_test.dart b/test/features/teaching_rating/domain/usecases/submit_teacher_review_usecase_test.dart new file mode 100644 index 0000000..da22b60 --- /dev/null +++ b/test/features/teaching_rating/domain/usecases/submit_teacher_review_usecase_test.dart @@ -0,0 +1,96 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/error/failures.dart'; +import 'package:univalle_app/core/error/result.dart'; +import 'package:univalle_app/features/teaching_rating/domain/entities/rating_option.dart'; +import 'package:univalle_app/features/teaching_rating/domain/entities/review_question.dart'; +import 'package:univalle_app/features/teaching_rating/domain/entities/teacher_review.dart'; +import 'package:univalle_app/features/teaching_rating/domain/repositories/teaching_rating_repository.dart'; +import 'package:univalle_app/features/teaching_rating/domain/usecases/submit_teacher_review_usecase.dart'; + +class MockTeachingRatingRepository extends Mock + implements TeachingRatingRepository {} + +void main() { + late MockTeachingRatingRepository repository; + late SubmitTeacherReviewUseCase useCase; + + const review = TeacherReview( + formFields: {'id_evaluacion': '42'}, + questions: [ + ReviewQuestion( + id: 'q1', + category: QuestionCategory.teacher, + question: '¿El docente explica con claridad?', + ), + ], + teacherName: 'Ana Ríos', + subjectName: 'Cálculo', + ); + const answers = {'q1': RatingOption.agree}; + + setUp(() { + repository = MockTeachingRatingRepository(); + useCase = SubmitTeacherReviewUseCase(repository); + }); + + test('forwards review, answers and feedback to the repository', () async { + when( + () => repository.submitTeacherReview( + review: review, + answers: answers, + feedback: 'Excelente docente', + ), + ).thenAnswer((_) async => const Ok(null)); + + final result = await useCase.call( + review: review, + answers: answers, + feedback: 'Excelente docente', + ); + + expect(result, isA>()); + verify( + () => repository.submitTeacherReview( + review: review, + answers: answers, + feedback: 'Excelente docente', + ), + ).called(1); + }); + + test('forwards a null feedback when none is given', () async { + when( + () => repository.submitTeacherReview( + review: review, + answers: answers, + feedback: null, + ), + ).thenAnswer((_) async => const Ok(null)); + + await useCase.call(review: review, answers: answers); + + verify( + () => repository.submitTeacherReview( + review: review, + answers: answers, + feedback: null, + ), + ).called(1); + }); + + test('propagates a failure result unchanged', () async { + final failure = UnknownFailure(message: 'boom'); + when( + () => repository.submitTeacherReview( + review: review, + answers: answers, + feedback: null, + ), + ).thenAnswer((_) async => Err(failure)); + + final result = await useCase.call(review: review, answers: answers); + + expect((result as Err).failure, failure); + }); +} From 6e0ad3d060ef02f3aaa282587668a6f07dffd6c8 Mon Sep 17 00:00:00 2001 From: code3743 Date: Mon, 14 Sep 2026 07:51:56 -0500 Subject: [PATCH 6/6] fix: update minimum coverage threshold and improve coverage report handling --- .github/workflows/test.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 091e092..68b087b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: # Ratcheted up phase by phase as the testing plan progresses (target: 80%). # test/coverage_helper_test.dart imports the whole app so every lib/ file # is measured, not just the ones touched by tests. - COVERAGE_MIN: 10 + COVERAGE_MIN: 14 steps: - uses: actions/checkout@v5 @@ -38,12 +38,12 @@ jobs: sudo apt-get update -y && sudo apt-get install -y lcov lcov --remove coverage/lcov.info \ '**/*.g.dart' \ - '**/*.freezed.dart' \ - -o coverage/lcov.info + -o coverage/lcov.info \ + --ignore-errors unused,empty - name: Report coverage summary run: | - lcov --summary coverage/lcov.info | tee coverage_summary.txt + lcov --summary coverage/lcov.info --ignore-errors empty | tee coverage_summary.txt { echo "### Coverage summary" echo '```'