diff --git a/lib/features/schedule/data/datasources/sira_schedule_remote_datasource.dart b/lib/features/schedule/data/datasources/sira_schedule_remote_datasource.dart index 9f2a345..2801d50 100644 --- a/lib/features/schedule/data/datasources/sira_schedule_remote_datasource.dart +++ b/lib/features/schedule/data/datasources/sira_schedule_remote_datasource.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:html/dom.dart'; -import 'package:html/parser.dart' show parse; +import 'package:html/parser.dart' show parse, parseFragment; import '../../../../core/constants/schedule_constants.dart'; import '../../../../core/error/exceptions.dart'; @@ -17,10 +17,12 @@ class SiraScheduleRemoteDataSource { const SiraScheduleRemoteDataSource(this._dio); // Matches a session's day/time/building line when a room is assigned, - // e.g. "JUE: 14:00-17:00 , Edf. B13". Hours may be one or two digits - // ("8:00" as well as "14:00"). + // e.g. "JUE: 14:00-17:00 , Edf. B13 (B13) -> SALA 1 -- MG -- MELENDEZ". + // Hours may be one or two digits ("8:00" as well as "14:00"). The + // location detail after the building code is optional and, when + // present, captured whole in group 5 for `_locationLine` to parse. static final _sessionWithBuilding = RegExp( - r'^(LUN|MAR|MI[EÉ]|JUE|VIE|SAB|DOM):\s*(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})\s*,\s*Edf\.\s*(.+)$', + r'^(LUN|MAR|MI[EÉ]|JUE|VIE|SAB|DOM):\s*(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})\s*,\s*Edf\.\s*(\S+)\s*(\(.*)?$', caseSensitive: false, ); @@ -32,8 +34,9 @@ class SiraScheduleRemoteDataSource { caseSensitive: false, ); - // Matches the location line that follows a `_sessionWithBuilding` line, - // e.g. "(B13) -> SALA 1 -- MG -- MELENDEZ". + // Matches the location detail, e.g. "(B13) -> SALA 1 -- MG -- MELENDEZ" — + // usually on the same line as `_sessionWithBuilding` right after the + // building code, occasionally (defensively handled) on the next line. static final _locationLine = RegExp( r'^\([^)]*\)\s*->\s*(.+?)\s*--\s*(.+?)\s*--\s*(.+)$', ); @@ -76,11 +79,11 @@ class SiraScheduleRemoteDataSource { if (cells.length != 9) return const []; if (cells[2].text.trim() != subject.group) return const []; - final scheduleText = cells[4].text.trim(); - if (scheduleText.isEmpty) return const []; + final scheduleLines = _splitByBreaks(cells[4]); + if (scheduleLines.isEmpty) return const []; final teacher = _parseTeacher(cells[5].text.trim()); - return _parseSessions(scheduleText) + return _parseSessions(scheduleLines) .map( (session) => ScheduleClassModel( subjectCode: subject.code, @@ -99,18 +102,22 @@ class SiraScheduleRemoteDataSource { .toList(); } - // The schedule cell packs one or more sessions. When a room is assigned - // it's an alternating pair of lines: "JUE: 14:00-17:00 , Edf. B13" then - // "(B13) -> SALA 1 -- MG -- MELENDEZ" (the middle "--" segment is an - // internal room-type code that isn't meaningful to show the student). - // When it isn't, it's a single line: "JUE: 8:00-12:00 ,SIN ESPACIO -- MG". - List<_ParsedSession> _parseSessions(String rawText) { - final lines = rawText - .split('\n') - .map((line) => line.trim()) - .where((line) => line.isNotEmpty) - .toList(); - + // The schedule cell packs one or more sessions separated by tags + // rather than actual newlines in the source HTML, so lines must be split + // on the markup itself — splitting the extracted text on '\n' leaves + // every session glued into one string with no separator to find. + List _splitByBreaks(Element cell) => cell.innerHtml + .split(RegExp(r'', caseSensitive: false)) + .map((fragment) => parseFragment(fragment).text?.trim() ?? '') + .where((line) => line.isNotEmpty) + .toList(); + + // The schedule cell packs one or more sessions, each its own - + // separated line: "JUE: 14:00-17:00 , Edf. B13 (B13) -> SALA 1 -- MG -- + // MELENDEZ" (the middle "--" segment is an internal room-type code that + // isn't meaningful to show the student). When no room is assigned yet, + // it's just "JUE: 8:00-12:00 ,SIN ESPACIO -- MG" with no location detail. + List<_ParsedSession> _parseSessions(List lines) { final sessions = <_ParsedSession>[]; var i = 0; while (i < lines.length) { @@ -118,7 +125,14 @@ class SiraScheduleRemoteDataSource { if (withBuilding != null) { var room = ''; var campus = ''; - if (i + 1 < lines.length) { + final sameLineLocation = withBuilding.group(5); + if (sameLineLocation != null) { + final locationMatch = _locationLine.firstMatch(sameLineLocation); + if (locationMatch != null) { + room = locationMatch.group(1)!.trim(); + campus = locationMatch.group(3)!.trim(); + } + } else if (i + 1 < lines.length) { final locationMatch = _locationLine.firstMatch(lines[i + 1]); if (locationMatch != null) { room = locationMatch.group(1)!.trim(); diff --git a/test/core/error/exceptions_test.dart b/test/core/error/exceptions_test.dart new file mode 100644 index 0000000..ff498a3 --- /dev/null +++ b/test/core/error/exceptions_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:univalle_app/core/error/exceptions.dart'; + +void main() { + test('ServerException carries an optional status code', () { + final exception = ServerException(message: 'boom', statusCode: 500); + expect(exception.message, 'boom'); + expect(exception.statusCode, 500); + }); + + test('NetworkException carries its message', () { + expect(NetworkException(message: 'boom').message, 'boom'); + }); + + test('CacheException carries its message', () { + expect(CacheException(message: 'boom').message, 'boom'); + }); + + test('AuthException carries its message', () { + expect(AuthException(message: 'boom').message, 'boom'); + }); + + test('BusinessException defaults to retryable', () { + final exception = BusinessException(message: 'boom'); + expect(exception.retryable, isTrue); + }); + + test('BusinessException can be marked as non-retryable', () { + final exception = BusinessException(message: 'boom', retryable: false); + expect(exception.retryable, isFalse); + }); + + test('toString returns the runtime type', () { + expect(NetworkException(message: 'boom').toString(), 'NetworkException'); + }); +} diff --git a/test/core/error/failures_test.dart b/test/core/error/failures_test.dart index 749315b..1b53019 100644 --- a/test/core/error/failures_test.dart +++ b/test/core/error/failures_test.dart @@ -77,4 +77,11 @@ void main() { final failure = BusinessFailure(message: 'x', retryable: false); expect(failure.retryable, isFalse); }); + + test('toString includes the internal message', () { + expect( + NetworkFailure(message: 'boom').toString(), + 'Failure(message: boom)', + ); + }); } diff --git a/test/core/storage/shared_preferences_service_test.dart b/test/core/storage/shared_preferences_service_test.dart new file mode 100644 index 0000000..179d175 --- /dev/null +++ b/test/core/storage/shared_preferences_service_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:univalle_app/core/error/exceptions.dart'; +import 'package:univalle_app/core/storage/shared_preferences_service.dart'; + +class MockSharedPreferences extends Mock implements SharedPreferences {} + +void main() { + late MockSharedPreferences preferences; + late SharedPreferencesService service; + + setUp(() { + preferences = MockSharedPreferences(); + service = SharedPreferencesService(preferences); + }); + + test('getString reads through to SharedPreferences', () async { + when(() => preferences.getString('k')).thenReturn('v'); + expect(await service.getString('k'), 'v'); + }); + + test('setString writes through to SharedPreferences', () async { + when(() => preferences.setString('k', 'v')).thenAnswer((_) async => true); + await service.setString('k', 'v'); + verify(() => preferences.setString('k', 'v')).called(1); + }); + + test('getBool reads through to SharedPreferences', () async { + when(() => preferences.getBool('k')).thenReturn(true); + expect(await service.getBool('k'), isTrue); + }); + + test('setBool writes through to SharedPreferences', () async { + when(() => preferences.setBool('k', true)).thenAnswer((_) async => true); + await service.setBool('k', true); + verify(() => preferences.setBool('k', true)).called(1); + }); + + test('getInt reads through to SharedPreferences', () async { + when(() => preferences.getInt('k')).thenReturn(42); + expect(await service.getInt('k'), 42); + }); + + test('setInt writes through to SharedPreferences', () async { + when(() => preferences.setInt('k', 42)).thenAnswer((_) async => true); + await service.setInt('k', 42); + verify(() => preferences.setInt('k', 42)).called(1); + }); + + test('getDouble reads through to SharedPreferences', () async { + when(() => preferences.getDouble('k')).thenReturn(4.2); + expect(await service.getDouble('k'), 4.2); + }); + + test('setDouble writes through to SharedPreferences', () async { + when(() => preferences.setDouble('k', 4.2)).thenAnswer((_) async => true); + await service.setDouble('k', 4.2); + verify(() => preferences.setDouble('k', 4.2)).called(1); + }); + + test('getStringList reads through to SharedPreferences', () async { + when(() => preferences.getStringList('k')).thenReturn(['a', 'b']); + expect(await service.getStringList('k'), ['a', 'b']); + }); + + test('setStringList writes through to SharedPreferences', () async { + when(() => preferences.setStringList('k', ['a', 'b'])) + .thenAnswer((_) async => true); + await service.setStringList('k', ['a', 'b']); + verify(() => preferences.setStringList('k', ['a', 'b'])).called(1); + }); + + test('remove delegates to SharedPreferences', () async { + when(() => preferences.remove('k')).thenAnswer((_) async => true); + await service.remove('k'); + verify(() => preferences.remove('k')).called(1); + }); + + test('clear delegates to SharedPreferences', () async { + when(() => preferences.clear()).thenAnswer((_) async => true); + await service.clear(); + verify(() => preferences.clear()).called(1); + }); + + test('wraps any thrown error as a CacheException', () async { + when(() => preferences.getString('k')).thenThrow(Exception('disk full')); + + await expectLater( + () => service.getString('k'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('disk full'), + ), + ), + ); + }); +} diff --git a/test/features/auth/data/datasources/sira_auth_remote_datasource_test.dart b/test/features/auth/data/datasources/sira_auth_remote_datasource_test.dart new file mode 100644 index 0000000..c25da17 --- /dev/null +++ b/test/features/auth/data/datasources/sira_auth_remote_datasource_test.dart @@ -0,0 +1,275 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +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/features/auth/auth_strings.dart'; +import 'package:univalle_app/features/auth/data/datasources/sira_auth_remote_datasource.dart'; + +class MockDio extends Mock implements Dio {} + +Response> _bytesResponse(String html, {int statusCode = 200}) => + Response( + requestOptions: RequestOptions(path: ''), + statusCode: statusCode, + data: latin1.encode(html), + ); + +Response _plainResponse(String text, {int statusCode = 200}) => + Response( + requestOptions: RequestOptions(path: SiraConstants.resetPasswordUrl), + statusCode: statusCode, + data: text, + ); + +void main() { + late MockDio dio; + late SiraAuthRemoteDataSource dataSource; + + setUpAll(() { + registerFallbackValue({}); + registerFallbackValue(Options()); + }); + + setUp(() { + dio = MockDio(); + dataSource = SiraAuthRemoteDataSource(dio); + }); + + group('login', () { + test( + 'throws AuthException without a request for empty credentials', + () async { + await expectLater( + () => dataSource.login(username: '', password: ''), + throwsA(isA()), + ); + verifyNever(() => dio.post(any(), data: any(named: 'data'))); + }, + ); + + test('succeeds silently when SIRA redirects (valid credentials)', () async { + when( + () => dio.post( + any(), + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).thenAnswer((_) async => _bytesResponse('', statusCode: 302)); + + await expectLater( + dataSource.login(username: 'jperez-3743', password: 'secret'), + completes, + ); + }); + + test('parses the failure message from a real login error page', () async { + final html = File('test/fixtures/auth/login_error.html') + .readAsStringSync(); + when( + () => dio.post( + any(), + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).thenAnswer((_) async => _bytesResponse(html, statusCode: 200)); + + await expectLater( + dataSource.login(username: 'jperez-3743', password: 'wrong'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Verifique su LOGIN y CONTRASEÑA.', + ), + ), + ); + }); + + test( + 'accepts redirects and rejects hard failures as valid status', + () async { + when( + () => dio.post( + any(), + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).thenAnswer((_) async => _bytesResponse('', statusCode: 302)); + + await dataSource.login(username: 'jperez-3743', password: 'secret'); + + final options = + verify( + () => dio.post( + any(), + data: any(named: 'data'), + options: captureAny(named: 'options'), + ), + ).captured.single + as Options; + expect(options.validateStatus!(200), isTrue); + expect(options.validateStatus!(302), isTrue); + expect(options.validateStatus!(399), isTrue); + expect(options.validateStatus!(400), isFalse); + expect(options.validateStatus!(500), isFalse); + expect(options.validateStatus!(null), isFalse); + }, + ); + }); + + group('logout', () { + test('requests the SIRA logout endpoint', () async { + when(() => dio.get(any())).thenAnswer((_) async => _bytesResponse('')); + + await dataSource.logout(); + + verify(() => dio.get(SiraConstants.logoutPath)).called(1); + }); + + test('maps a DioException to an AppException', () async { + when(() => dio.get(any())).thenThrow( + DioException( + requestOptions: RequestOptions(path: SiraConstants.logoutPath), + type: DioExceptionType.connectionTimeout, + ), + ); + + expect(() => dataSource.logout(), throwsA(isA())); + }); + }); + + group('resetPassword', () { + test( + 'throws AuthException without a request for an empty username', + () async { + await expectLater( + () => dataSource.resetPassword(username: ''), + throwsA(isA()), + ); + verifyNever(() => dio.get(any(), options: any(named: 'options'))); + }, + ); + + test( + 'fetches the csrf token and returns the masked confirmation email', + () async { + final formHtml = File('test/fixtures/auth/reset_password_form.html') + .readAsStringSync(); + final successHtml = File( + 'test/fixtures/auth/reset_password_success.html', + ).readAsStringSync(); + + when(() => dio.get(any(), options: any(named: 'options'))) + .thenAnswer((_) async => _plainResponse(formHtml)); + when( + () => dio.post( + any(), + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).thenAnswer((_) async => _plainResponse(successHtml)); + + final email = await dataSource.resetPassword(username: 'jperez-3743'); + + expect(email, 'usu***ario@correounivalle.edu.co'); + final captured = + verify( + () => dio.post( + any(), + data: captureAny(named: 'data'), + options: any(named: 'options'), + ), + ).captured.single + as Map; + expect( + captured['parametros[_csrf_token]'], + 'b81f83ee4b3ea839b0a0d2f822f60b56', + ); + expect(captured['parametros[usuario]'], 'jperez-3743'); + }, + ); + + test('throws when the reset form request does not return 200', () async { + when(() => dio.get(any(), options: any(named: 'options'))) + .thenAnswer((_) async => _plainResponse('', statusCode: 500)); + + expect( + () => dataSource.resetPassword(username: 'jperez-3743'), + throwsA( + isA().having( + (e) => e.message, + 'message', + AuthStrings.serverNotResponding, + ), + ), + ); + }); + + test('throws when the reset form has no csrf token', () async { + when( + () => dio.get(any(), options: any(named: 'options')), + ).thenAnswer((_) async => _plainResponse('')); + + expect( + () => dataSource.resetPassword(username: 'jperez-3743'), + throwsA( + isA().having( + (e) => e.message, + 'message', + AuthStrings.resetServiceUnavailable, + ), + ), + ); + }); + + test( + 'throws AuthException when the reset result page has an error', + () async { + final formHtml = File('test/fixtures/auth/reset_password_form.html') + .readAsStringSync(); + when(() => dio.get(any(), options: any(named: 'options'))) + .thenAnswer((_) async => _plainResponse(formHtml)); + when( + () => dio.post( + any(), + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).thenAnswer( + (_) async => _plainResponse( + 'Usuario no encontrado', + ), + ); + + expect( + () => dataSource.resetPassword(username: 'jperez-3743'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Usuario no encontrado', + ), + ), + ); + }, + ); + + test('maps a DioException to an AppException', () async { + when(() => dio.get(any(), options: any(named: 'options'))).thenThrow( + DioException( + requestOptions: RequestOptions(path: SiraConstants.resetPasswordUrl), + type: DioExceptionType.connectionTimeout, + ), + ); + + expect( + () => dataSource.resetPassword(username: 'jperez-3743'), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/features/library/data/datasources/library_remote_datasource_test.dart b/test/features/library/data/datasources/library_remote_datasource_test.dart new file mode 100644 index 0000000..420438c --- /dev/null +++ b/test/features/library/data/datasources/library_remote_datasource_test.dart @@ -0,0 +1,178 @@ +import 'dart:io'; + +import 'package:cookie_jar/cookie_jar.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/constants/library_constants.dart'; +import 'package:univalle_app/core/error/exceptions.dart'; +import 'package:univalle_app/features/library/data/datasources/library_remote_datasource.dart'; +import 'package:univalle_app/features/library/library_strings.dart'; + +class MockDio extends Mock implements Dio {} + +Response _stringResponse(String html, {int statusCode = 200}) => + Response( + requestOptions: RequestOptions(path: ''), + statusCode: statusCode, + data: html, + ); + +void main() { + late MockDio dio; + late CookieJar cookieJar; + late LibraryRemoteDataSource dataSource; + late String sessionHtml; + late String accountHtml; + + setUpAll(() { + registerFallbackValue({}); + registerFallbackValue(Options()); + sessionHtml = File('test/fixtures/library/opac_session.html') + .readAsStringSync(); + accountHtml = File('test/fixtures/library/opac_account.html') + .readAsStringSync(); + }); + + setUp(() { + dio = MockDio(); + cookieJar = CookieJar(); + dataSource = LibraryRemoteDataSource(dio, cookieJar); + }); + + void stubAuthAndAccount({ + String? sessionResponse, + String? loginResponse, + String? accountResponse, + }) { + // A single stub routes by path: the home page GET (`_dio.get('')`) + // passes no `options` while the account page GET does, and mocktail's + // `any(named: 'options')` matches both shapes — registering them as two + // separate stubs makes whichever is registered last silently win for + // every call, home page included. + when(() => dio.get(any(), options: any(named: 'options'))) + .thenAnswer((invocation) async { + final path = invocation.positionalArguments[0] as String; + if (path == LibraryConstants.accountPath) { + return _stringResponse(accountResponse ?? accountHtml); + } + return _stringResponse(sessionResponse ?? sessionHtml); + }); + when( + () => dio.post( + any(), + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).thenAnswer( + (_) async => + _stringResponse(loginResponse ?? ''), + ); + } + + group('fetchAccount', () { + test('parses the fine and loan history from a real OPAC account', () async { + stubAuthAndAccount(); + + final account = await dataSource.fetchAccount(username: '0000000-3743'); + + expect(account.currentFine, '0.00'); + expect(account.currentLoans, isEmpty); + expect(account.history, hasLength(4)); + + final first = account.history.first; + expect(first.code, '0579289'); + // OPAC's own title data has this stray space before the accented + // vowel ("Ingenier ía" instead of "Ingeniería") — captured verbatim. + expect(first.title, 'Ingenier ía de software'); + expect(first.location, 'Biblioteca Sede Tulua'); + expect(first.date, '20-Mar-2026 16:53'); + }); + + test( + 'accepts redirects and rejects hard failures as valid status', + () async { + stubAuthAndAccount(); + + await dataSource.fetchAccount(username: '0000000-3743'); + + final options = + verify( + () => dio.post( + any(), + data: any(named: 'data'), + options: captureAny(named: 'options'), + ), + ).captured.single + as Options; + expect(options.validateStatus!(200), isTrue); + expect(options.validateStatus!(399), isTrue); + expect(options.validateStatus!(400), isFalse); + expect(options.validateStatus!(null), isFalse); + }, + ); + + test('throws when the home page has no SessionID script', () async { + stubAuthAndAccount(sessionResponse: ''); + + expect( + () => dataSource.fetchAccount(username: '0000000-3743'), + throwsA( + isA().having( + (e) => e.message, + 'message', + LibraryStrings.noSessionId, + ), + ), + ); + }); + + test('throws AuthException when the login response has an error', () async { + stubAuthAndAccount( + loginResponse: 'Credenciales inválidas', + ); + + expect( + () => dataSource.fetchAccount(username: '0000000-3743'), + throwsA(isA()), + ); + }); + + test('throws when the account page has no fine field', () async { + stubAuthAndAccount(accountResponse: ''); + + expect( + () => dataSource.fetchAccount(username: '0000000-3743'), + throwsA(isA()), + ); + }); + + test('clears cookies and rethrows when a step fails', () async { + stubAuthAndAccount(accountResponse: ''); + + await expectLater( + () => dataSource.fetchAccount(username: '0000000-3743'), + throwsA(isA()), + ); + + final cookies = await cookieJar.loadForRequest( + Uri.parse(LibraryConstants.baseUrl), + ); + expect(cookies, isEmpty); + }); + + test('maps a DioException to an AppException', () async { + when(() => dio.get(any(), options: any(named: 'options'))).thenThrow( + DioException( + requestOptions: RequestOptions(path: ''), + type: DioExceptionType.connectionTimeout, + ), + ); + + expect( + () => dataSource.fetchAccount(username: '0000000-3743'), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/features/profile/data/datasources/sira_profile_remote_datasource_test.dart b/test/features/profile/data/datasources/sira_profile_remote_datasource_test.dart new file mode 100644 index 0000000..46a8fbc --- /dev/null +++ b/test/features/profile/data/datasources/sira_profile_remote_datasource_test.dart @@ -0,0 +1,137 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +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/features/profile/data/datasources/sira_profile_remote_datasource.dart'; + +class MockDio extends Mock implements Dio {} + +// The academic-record half of fetchStudent hits the same "Generar Carpeta" +// report as student_grades, so that already-captured fixture is reused here +// instead of recapturing the same page. +const _academicRecordFixture = + 'test/fixtures/student_grades/academic_record_report.html'; +const _contactInfoFixture = 'test/fixtures/profile/contact_info.html'; + +const _academicRecordFormHtml = ''' + + + + + +'''; + +Response> _htmlResponse(String html, {int statusCode = 200}) => + Response( + requestOptions: RequestOptions(path: SiraConstants.academicRecordPath), + statusCode: statusCode, + data: latin1.encode(html), + ); + +void main() { + late MockDio dio; + late SiraProfileRemoteDataSource dataSource; + late String contactInfoHtml; + late String academicRecordHtml; + + setUpAll(() { + registerFallbackValue({}); + contactInfoHtml = File(_contactInfoFixture).readAsStringSync(); + academicRecordHtml = File(_academicRecordFixture).readAsStringSync(); + }); + + setUp(() { + dio = MockDio(); + dataSource = SiraProfileRemoteDataSource(dio); + }); + + void stubFetchStudent({int? contactInfoStatusCode, int? recordStatusCode}) { + when(() => dio.post(any(), data: any(named: 'data'))).thenAnswer(( + invocation, + ) async { + final data = invocation.namedArguments[#data]; + if (data is Map && data['accion'] == 'desplegarFmInformacionDeContacto') { + return _htmlResponse( + contactInfoHtml, + statusCode: contactInfoStatusCode ?? 200, + ); + } + if (data is Map && data['accion'] == 'Generar Carpeta') { + return _htmlResponse( + academicRecordHtml, + statusCode: recordStatusCode ?? 200, + ); + } + return _htmlResponse(_academicRecordFormHtml); + }); + } + + group('fetchStudent', () { + test( + 'combines the contact info and academic record pages into a student', + () async { + stubFetchStudent(); + + final student = await dataSource.fetchStudent(username: '0000000-3743'); + + expect(student.documentId, '000000000'); + expect(student.firstName, 'PRUEBA'); + expect(student.lastName, 'ESTUDIANTE'); + expect(student.email, 'estudiante.prueba@correounivalle.edu.co'); + expect(student.programName, 'INGENIERÍA DE SISTEMAS'); + expect(student.campus, 'Tuluá'); + expect(student.average, 4.38); + expect(student.accumulatedCredits, 157); + }, + ); + + test('throws when the contact info request does not return 200', () async { + stubFetchStudent(contactInfoStatusCode: 500); + + expect( + () => dataSource.fetchStudent(username: '0000000-3743'), + throwsA(isA()), + ); + }); + + test( + 'throws when the academic record request does not return 200', + () async { + stubFetchStudent(recordStatusCode: 500); + + expect( + () => dataSource.fetchStudent(username: '0000000-3743'), + throwsA(isA()), + ); + }, + ); + + test('throws an AuthException when the contact info page has no name (expired session)', () async { + when(() => dio.post(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _htmlResponse('')); + + expect( + () => dataSource.fetchStudent(username: '0000000-3743'), + throwsA(isA()), + ); + }); + + test('maps a DioException to an AppException', () async { + when(() => dio.post(any(), data: any(named: 'data'))).thenThrow( + DioException( + requestOptions: RequestOptions(path: SiraConstants.studentPath), + type: DioExceptionType.connectionTimeout, + ), + ); + + expect( + () => dataSource.fetchStudent(username: '0000000-3743'), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/features/resolution/data/datasources/sira_resolution_remote_datasource_test.dart b/test/features/resolution/data/datasources/sira_resolution_remote_datasource_test.dart new file mode 100644 index 0000000..fd9d365 --- /dev/null +++ b/test/features/resolution/data/datasources/sira_resolution_remote_datasource_test.dart @@ -0,0 +1,110 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +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/features/resolution/data/datasources/sira_resolution_remote_datasource.dart'; + +class MockDio extends Mock implements Dio {} + +Response> _htmlResponse(String html) => Response( + requestOptions: RequestOptions(path: SiraConstants.resolutionPath), + statusCode: 200, + data: latin1.encode(html), +); + +void main() { + late MockDio dio; + late SiraResolutionRemoteDataSource dataSource; + + setUpAll(() { + registerFallbackValue({}); + }); + + setUp(() { + dio = MockDio(); + dataSource = SiraResolutionRemoteDataSource(dio); + }); + + group('fetchCurriculum', () { + test('parses every subject in a real curriculum resolution', () async { + final html = File('test/fixtures/resolution/curriculum.html') + .readAsStringSync(); + when(() => dio.post(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _htmlResponse(html)); + + final subjects = await dataSource.fetchCurriculum( + username: '0000000-3743', + ); + + expect(subjects, hasLength(44)); + }); + + test('parses a subject with no prerequisites', () async { + final html = File('test/fixtures/resolution/curriculum.html') + .readAsStringSync(); + when(() => dio.post(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _htmlResponse(html)); + + final subjects = await dataSource.fetchCurriculum( + username: '0000000-3743', + ); + + final subject = subjects.firstWhere((s) => s.code == '111023C'); + expect(subject.semester, 1); + expect(subject.name, 'MATEMÁTICAS BÁSICAS'); + expect(subject.subjectType, 'AB'); + expect(subject.credits, 3); + expect(subject.prerequisiteCodes, isEmpty); + }); + + test( + 'merges a subject repeated once per prerequisite into one entry', + () async { + final html = File('test/fixtures/resolution/curriculum.html') + .readAsStringSync(); + when(() => dio.post(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _htmlResponse(html)); + + final subjects = await dataSource.fetchCurriculum( + username: '0000000-3743', + ); + + final subject = subjects.firstWhere((s) => s.code == '750016C'); + expect(subject.semester, 5); + expect(subject.name, 'SIMULACIÓN Y COMPUTACIÓN NUMÉRICA'); + expect( + subject.prerequisiteCodes, + unorderedEquals(['106012C', '111026C', '111048M', '761001C']), + ); + }, + ); + + test('throws when the resolution table is empty', () async { + when(() => dio.post(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _htmlResponse('')); + + expect( + () => dataSource.fetchCurriculum(username: '0000000-3743'), + throwsA(isA()), + ); + }); + + test('maps a DioException to an AppException', () async { + when(() => dio.post(any(), data: any(named: 'data'))).thenThrow( + DioException( + requestOptions: RequestOptions(path: SiraConstants.resolutionPath), + type: DioExceptionType.connectionTimeout, + ), + ); + + expect( + () => dataSource.fetchCurriculum(username: '0000000-3743'), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/features/schedule/data/datasources/sira_schedule_remote_datasource_test.dart b/test/features/schedule/data/datasources/sira_schedule_remote_datasource_test.dart new file mode 100644 index 0000000..f18c966 --- /dev/null +++ b/test/features/schedule/data/datasources/sira_schedule_remote_datasource_test.dart @@ -0,0 +1,220 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:univalle_app/core/constants/schedule_constants.dart'; +import 'package:univalle_app/core/error/exceptions.dart'; +import 'package:univalle_app/features/schedule/data/datasources/sira_schedule_remote_datasource.dart'; +import 'package:univalle_app/features/schedule/domain/entities/schedule_subject.dart'; +import 'package:univalle_app/features/schedule/domain/entities/weekday.dart'; + +class MockDio extends Mock implements Dio {} + +Response> _htmlResponse(String html, {int statusCode = 200}) => + Response( + requestOptions: RequestOptions(path: ScheduleConstants.path), + statusCode: statusCode, + data: latin1.encode(html), + ); + +const _subjectWithSchedule = ScheduleSubject( + code: '806010M', + group: '01', + campusId: '02', + name: 'INGENIERÍA DE SOFTWARE', +); + +const _subjectOtherGroup = ScheduleSubject( + code: '806010M', + group: '02', + campusId: '02', + name: 'INGENIERÍA DE SOFTWARE', +); + +const _subjectUnknownGroup = ScheduleSubject( + code: '806010M', + group: '99', + campusId: '02', + name: 'INGENIERÍA DE SOFTWARE', +); + +void main() { + late MockDio dio; + late SiraScheduleRemoteDataSource dataSource; + late String scheduleHtml; + + setUpAll(() { + registerFallbackValue({}); + scheduleHtml = File('test/fixtures/schedule/subject_schedule.html') + .readAsStringSync(); + }); + + setUp(() { + dio = MockDio(); + dataSource = SiraScheduleRemoteDataSource(dio); + }); + + group('fetchSchedule', () { + test( + 'parses every weekly session for a group with two class days', + () async { + when(() => dio.post(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _htmlResponse(scheduleHtml)); + + final classes = await dataSource.fetchSchedule( + subjects: [_subjectWithSchedule], + ); + + expect(classes, hasLength(2)); + + final wednesday = classes.firstWhere((c) => c.day == Weekday.wednesday); + expect(wednesday.startTime, '14:00'); + expect(wednesday.endTime, '16:00'); + expect(wednesday.building, 'E23'); + expect(wednesday.room, '1020'); + expect(wednesday.campus, 'MELENDEZ'); + expect(wednesday.teacher, 'DOCENTE PRUEBA UNO'); + expect(wednesday.teacherEmail, 'docente.uno@correounivalle.edu.co'); + expect(wednesday.subjectCode, _subjectWithSchedule.code); + expect(wednesday.subjectName, _subjectWithSchedule.name); + expect(wednesday.group, _subjectWithSchedule.group); + + final monday = classes.firstWhere((c) => c.day == Weekday.monday); + expect(monday.startTime, '14:00'); + expect(monday.endTime, '16:00'); + expect(monday.building, 'E26'); + expect(monday.room, '1017'); + expect(monday.campus, 'MELENDEZ'); + }, + ); + + test( + 'parses a different group\'s own two sessions independently', + () async { + when(() => dio.post(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _htmlResponse(scheduleHtml)); + + final classes = await dataSource.fetchSchedule( + subjects: [_subjectOtherGroup], + ); + + expect(classes, hasLength(2)); + final monday = classes.firstWhere((c) => c.day == Weekday.monday); + expect(monday.building, 'E23'); + expect(monday.room, '1011'); + final wednesday = classes.firstWhere((c) => c.day == Weekday.wednesday); + expect(wednesday.building, 'E20'); + expect(wednesday.room, 'SC2'); + }, + ); + + test( + 'returns no sessions for a group that has no row in the table', + () async { + when(() => dio.post(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _htmlResponse(scheduleHtml)); + + final classes = await dataSource.fetchSchedule( + subjects: [_subjectUnknownGroup], + ); + + expect(classes, isEmpty); + }, + ); + + test('throws when the response does not return 200', () async { + when( + () => dio.post(any(), data: any(named: 'data')), + ).thenAnswer((_) async => _htmlResponse(scheduleHtml, statusCode: 500)); + + expect( + () => dataSource.fetchSchedule(subjects: [_subjectWithSchedule]), + throwsA(isA()), + ); + }); + + test('maps a DioException to an AppException', () async { + when(() => dio.post(any(), data: any(named: 'data'))).thenThrow( + DioException( + requestOptions: RequestOptions(path: ScheduleConstants.path), + type: DioExceptionType.connectionTimeout, + ), + ); + + expect( + () => dataSource.fetchSchedule(subjects: [_subjectWithSchedule]), + throwsA(isA()), + ); + }); + + // Neither format below appears in the real captured fixture, so these + // use small synthetic rows matching the same table shape to exercise + // the defensive/legacy parsing branches instead. + test( + 'falls back to a next-line location when none is on the same line', + () async { + const subject = ScheduleSubject( + code: '806010M', + group: 'X1', + campusId: '02', + name: 'INGENIERÍA DE SOFTWARE', + ); + when(() => dio.post(any(), data: any(named: 'data'))).thenAnswer( + (_) async => _htmlResponse(''' + + +1PERIODOX110 + JUE: 14:00-17:00 , Edf. B13(B13) -> SALA 1 -- MG -- MELENDEZ + PROFESOR SIN CORREO +Programa + + +'''), + ); + + final classes = await dataSource.fetchSchedule(subjects: [subject]); + + expect(classes, hasLength(1)); + expect(classes.single.building, 'B13'); + expect(classes.single.room, 'SALA 1'); + expect(classes.single.campus, 'MELENDEZ'); + expect(classes.single.teacher, 'PROFESOR SIN CORREO'); + expect(classes.single.teacherEmail, isNull); + }, + ); + + test('parses a session with no room assigned yet', () async { + const subject = ScheduleSubject( + code: '806010M', + group: 'X2', + campusId: '02', + name: 'INGENIERÍA DE SOFTWARE', + ); + when(() => dio.post(any(), data: any(named: 'data'))).thenAnswer( + (_) async => _htmlResponse(''' + + +1PERIODOX210 + JUE: 8:00-12:00 ,SIN ESPACIO -- MG + DOCENTE PRUEBA UNO docente.uno@correounivalle.edu.co +Programa + + +'''), + ); + + final classes = await dataSource.fetchSchedule(subjects: [subject]); + + expect(classes, hasLength(1)); + final session = classes.single; + expect(session.day, Weekday.thursday); + expect(session.startTime, '08:00'); + expect(session.endTime, '12:00'); + expect(session.building, ''); + expect(session.room, ''); + expect(session.campus, ''); + }); + }); +} diff --git a/test/features/student_grades/data/datasources/sira_grades_remote_datasource_test.dart b/test/features/student_grades/data/datasources/sira_grades_remote_datasource_test.dart new file mode 100644 index 0000000..5867d23 --- /dev/null +++ b/test/features/student_grades/data/datasources/sira_grades_remote_datasource_test.dart @@ -0,0 +1,170 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +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/constants/sira_constants.dart'; +import 'package:univalle_app/features/student_grades/data/datasources/sira_grades_remote_datasource.dart'; +import 'package:univalle_app/features/student_grades/student_grades_strings.dart'; + +class MockDio extends Mock implements Dio {} + +// The academic-record flow is a two-step SIRA form dance: a first POST +// returns a page whose hidden inputs must be echoed back verbatim in +// a second POST to actually generate the report. Only the report's content +// is parsing-relevant, so the intermediate form page is a small synthetic +// stub rather than a captured fixture. +const _formPageHtml = ''' + + + + + +'''; + +Response> _htmlResponse(String html, {int statusCode = 200}) => + Response( + requestOptions: RequestOptions(path: SiraConstants.academicRecordPath), + statusCode: statusCode, + data: latin1.encode(html), + ); + +void main() { + late MockDio dio; + late SiraGradesRemoteDataSource dataSource; + late String reportHtml; + + setUpAll(() { + registerFallbackValue({}); + reportHtml = File( + 'test/fixtures/student_grades/academic_record_report.html', + ).readAsStringSync(); + }); + + setUp(() { + dio = MockDio(); + dataSource = SiraGradesRemoteDataSource(dio); + }); + + void stubReport({int statusCode = 200}) { + when(() => dio.post(any(), data: any(named: 'data'))) + .thenAnswer((invocation) async { + final data = invocation.namedArguments[#data]; + if (data is Map && data['accion'] == 'Generar Carpeta') { + return _htmlResponse(reportHtml, statusCode: statusCode); + } + return _htmlResponse(_formPageHtml); + }); + } + + group('fetchGrades', () { + test('parses every period from a real SIRA academic record', () async { + stubReport(); + + final grades = await dataSource.fetchGrades(username: '0000000-3743'); + + expect(grades, hasLength(10)); + }); + + test('parses a completed period\'s summary and subjects', () async { + stubReport(); + + final grades = await dataSource.fetchGrades(username: '0000000-3743'); + + final first = grades.first; + expect(first.period, 'FEBRERO/2022 - JUNIO/2022'); + expect(first.average, 4.5); + expect(first.credits, 18); + expect(first.approvedPercentage, '100%'); + expect(first.hasAcademicMerit, isFalse); + + final subject = first.subjects.firstWhere((s) => s.code == '111007M'); + expect(subject.group, '50'); + expect(subject.campusId, '06'); + expect(subject.name, 'MATEMÁTICA FUNDAMENTAL'); + expect(subject.credits, 3); + expect(subject.grade, '4.9'); + expect(subject.isCanceled, isFalse); + }); + + test('flags a period with an academic merit distinction', () async { + stubReport(); + + final grades = await dataSource.fetchGrades(username: '0000000-3743'); + + expect(grades[1].period, 'AGOSTO/2022 - DICIEMBRE/2022'); + expect(grades[1].hasAcademicMerit, isTrue); + }); + + test('marks a subject canceled mid-semester with a blank grade', () async { + stubReport(); + + final grades = await dataSource.fetchGrades(username: '0000000-3743'); + + final canceledPeriod = grades[5]; + expect(canceledPeriod.period, 'AGOSTO/2024 - DICIEMBRE/2024'); + + final canceled = canceledPeriod.subjects.firstWhere( + (s) => s.code == '750023C' && s.group == '51', + ); + expect(canceled.grade, ''); + expect(canceled.isCanceled, isTrue); + }); + + test( + 'parses the in-progress current period with no final average', + () async { + stubReport(); + + final grades = await dataSource.fetchGrades(username: '0000000-3743'); + + final current = grades.last; + expect(current.period, 'AGOSTO/2026 - DICIEMBRE/2026'); + expect(current.average, 0); + // SIRA renders this cell without a trailing '%' for the still-open + // in-progress period, unlike completed periods (e.g. "100%"). + expect(current.approvedPercentage, '0'); + + final inProgress = current.subjects.firstWhere( + (s) => s.code == '750039C', + ); + expect(inProgress.name, 'TRABAJO DE GRADO II'); + expect(inProgress.grade, ''); + expect(inProgress.isCanceled, isFalse); + }, + ); + + test('throws when the report request does not return 200', () async { + stubReport(statusCode: 500); + + expect( + () => dataSource.fetchGrades(username: '0000000-3743'), + throwsA( + isA().having( + (e) => e.message, + 'message', + StudentGradesStrings.gradesUnavailable, + ), + ), + ); + }); + + test('maps a DioException to an AppException', () async { + when(() => dio.post(any(), data: any(named: 'data'))).thenThrow( + DioException( + requestOptions: RequestOptions( + path: SiraConstants.academicRecordPath, + ), + type: DioExceptionType.connectionTimeout, + ), + ); + + expect( + () => dataSource.fetchGrades(username: '0000000-3743'), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/features/student_tabulate/data/datasources/sira_tabulate_remote_datasource_test.dart b/test/features/student_tabulate/data/datasources/sira_tabulate_remote_datasource_test.dart new file mode 100644 index 0000000..f4f3876 --- /dev/null +++ b/test/features/student_tabulate/data/datasources/sira_tabulate_remote_datasource_test.dart @@ -0,0 +1,101 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +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/features/student_tabulate/data/datasources/sira_tabulate_remote_datasource.dart'; + +class MockDio extends Mock implements Dio {} + +Response> _htmlResponse(String html) => Response( + requestOptions: RequestOptions(path: SiraConstants.tabulatedPath), + statusCode: 200, + data: latin1.encode(html), +); + +void main() { + late MockDio dio; + late SiraTabulateRemoteDataSource dataSource; + late String reportHtml; + + setUpAll(() { + registerFallbackValue({}); + reportHtml = File('test/fixtures/student_tabulate/tabulate_report.html') + .readAsStringSync(); + }); + + setUp(() { + dio = MockDio(); + dataSource = SiraTabulateRemoteDataSource(dio); + }); + + group('fetchTabulate', () { + test( + 'returns a mobile-ready document scaled from a real tabulado', + () async { + when(() => dio.post(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _htmlResponse(reportHtml)); + + final result = await dataSource.fetchTabulate(username: '0000000-3743'); + + expect(result, contains('id="mobileWrapper"')); + expect(result, contains('name="viewport"')); + // The real fixture's frame table is 765px wide. + expect(result, contains('calc(100vw / 765px)')); + }, + ); + + test('strips dead interactive elements and HTML comments', () async { + when(() => dio.post(any(), data: any(named: 'data'))) + .thenAnswer((_) async => _htmlResponse(reportHtml)); + + final result = await dataSource.fetchTabulate(username: '0000000-3743'); + + expect(result, isNot(contains('