diff --git a/.github/workflows/api_verify_and_test.yaml b/.github/workflows/api_verify_and_test.yaml index 8a031f1..7fade8b 100644 --- a/.github/workflows/api_verify_and_test.yaml +++ b/.github/workflows/api_verify_and_test.yaml @@ -14,6 +14,7 @@ on: - "api/**" - "api/packages/fluttercon_data_source/**" - "api/packages/fluttercon_shared_models/**" + - "api/packages/speakers_repository/**" - "api/packages/talks_repository/**" - "api/packages/user_repository/**" push: @@ -25,6 +26,7 @@ on: - "api/**" - "api/packages/fluttercon_data_source/**" - "api/packages/fluttercon_shared_models/**" + - "api/packages/speakers_repository/**" - "api/packages/talks_repository/**" - "api/packages/user_repository/**" diff --git a/.github/workflows/speakers_repository_verify_and_test.yaml b/.github/workflows/speakers_repository_verify_and_test.yaml new file mode 100644 index 0000000..fd65507 --- /dev/null +++ b/.github/workflows/speakers_repository_verify_and_test.yaml @@ -0,0 +1,34 @@ +name: Speakers Repository + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + branches: + - master + - main + paths: + - ".github/workflows/speakers_repository_verify_and_test.yaml" + - "api/packages/speakers_repository/**" + - "api/packages/fluttercon_data_source/**" + - "api/packages/fluttercon_shared_models/**" + push: + branches: + - master + - main + paths: + - ".github/workflows/**" + - "api/packages/speakers_repository/**" + - "api/packages/fluttercon_data_source/**" + - "api/packages/fluttercon_shared_models/**" + +jobs: + verify_and_test: + name: Verify and Test + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/dart_package.yml@main + with: + working_directory: api/packages/speakers_repository + dart_sdk: stable + min_coverage: 100 diff --git a/api/packages/speakers_repository/README.md b/api/packages/speakers_repository/README.md new file mode 100644 index 0000000..5a6dd91 --- /dev/null +++ b/api/packages/speakers_repository/README.md @@ -0,0 +1,62 @@ +# Speakers Repository + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![Powered by Mason](https://img.shields.io/endpoint?url=https%3A%2F%2Ftinyurl.com%2Fmason-badge)](https://github.com/felangel/mason) +[![License: MIT][license_badge]][license_link] + +A package encapsulating model manipulation for speaker entities for a demo app for [FlutterCon USA 2024][https://flutterconusa.dev/]. + +## Installation ๐Ÿ’ป + +**โ— In order to start using Speakers Repository you must have the [Dart SDK][dart_install_link] installed on your machine.** + +Install via `dart pub add`: + +```sh +dart pub add speakers_repository +``` + +--- + +## Continuous Integration ๐Ÿค– + +Speakers Repository comes with a built-in [GitHub Actions workflow][github_actions_link] powered by [Very Good Workflows][very_good_workflows_link] but you can also add your preferred CI/CD solution. + +Out of the box, on each pull request and push, the CI `formats`, `lints`, and `tests` the code. This ensures the code remains consistent and behaves correctly as you add functionality or make changes. The project uses [Very Good Analysis][very_good_analysis_link] for a strict set of analysis options used by our team. Code coverage is enforced using the [Very Good Workflows][very_good_coverage_link]. + +--- + +## Running Tests ๐Ÿงช + +To run all unit tests: + +```sh +dart pub global activate coverage 1.2.0 +dart test --coverage=coverage +dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info +``` + +To view the generated coverage report you can use [lcov](https://github.com/linux-test-project/lcov). + +```sh +# Generate Coverage Report +genhtml coverage/lcov.info -o coverage/ + +# Open Coverage Report +open coverage/index.html +``` + +[dart_install_link]: https://dart.dev/get-dart +[github_actions_link]: https://docs.github.com/en/actions/learn-github-actions +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[logo_black]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_black.png#gh-light-mode-only +[logo_white]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_white.png#gh-dark-mode-only +[mason_link]: https://github.com/felangel/mason +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis +[very_good_coverage_link]: https://github.com/marketplace/actions/very-good-coverage +[very_good_ventures_link]: https://verygood.ventures +[very_good_ventures_link_light]: https://verygood.ventures#gh-light-mode-only +[very_good_ventures_link_dark]: https://verygood.ventures#gh-dark-mode-only +[very_good_workflows_link]: https://github.com/VeryGoodOpenSource/very_good_workflows diff --git a/api/packages/speakers_repository/analysis_options.yaml b/api/packages/speakers_repository/analysis_options.yaml new file mode 100644 index 0000000..bb72091 --- /dev/null +++ b/api/packages/speakers_repository/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.6.0.0.yaml diff --git a/api/packages/speakers_repository/lib/speakers_repository.dart b/api/packages/speakers_repository/lib/speakers_repository.dart new file mode 100644 index 0000000..46b286f --- /dev/null +++ b/api/packages/speakers_repository/lib/speakers_repository.dart @@ -0,0 +1 @@ +export 'src/speakers_repository.dart'; diff --git a/api/packages/speakers_repository/lib/src/speakers_repository.dart b/api/packages/speakers_repository/lib/src/speakers_repository.dart new file mode 100644 index 0000000..0885baf --- /dev/null +++ b/api/packages/speakers_repository/lib/src/speakers_repository.dart @@ -0,0 +1,39 @@ +import 'package:fluttercon_data_source/fluttercon_data_source.dart'; +import 'package:fluttercon_shared_models/fluttercon_shared_models.dart'; + +/// {@template speakers_repository} +/// A repository to cache and prepare speaker data retrieved from the api. +/// {@endtemplate} +class SpeakersRepository { + /// {@macro speakers_repository} + const SpeakersRepository({ + required FlutterconDataSource dataSource, + }) : _dataSource = dataSource; + + final FlutterconDataSource _dataSource; + + /// Fetches a paginated list of speakers from the data source. + Future> getSpeakers() async { + final speakersResponse = await _dataSource.getSpeakers(); + + final speakerPreviews = speakersResponse.items + .map( + (speaker) => SpeakerPreview( + id: speaker?.id ?? '', + name: speaker?.name ?? '', + title: speaker?.title ?? '', + imageUrl: speaker?.imageUrl ?? '', + ), + ) + .toList(); + + final sortedSpeakerPreviews = [...speakerPreviews] + ..sort((a, b) => a.name.compareTo(b.name)); + + return PaginatedData( + items: sortedSpeakerPreviews, + limit: speakersResponse.limit, + nextToken: speakersResponse.nextToken, + ); + } +} diff --git a/api/packages/speakers_repository/pubspec.yaml b/api/packages/speakers_repository/pubspec.yaml new file mode 100644 index 0000000..a19665b --- /dev/null +++ b/api/packages/speakers_repository/pubspec.yaml @@ -0,0 +1,18 @@ +name: speakers_repository +description: Model manipulation for talk entities for FlutterCon demo app. +version: 0.1.0+1 +publish_to: none + +environment: + sdk: ^3.4.0 + +dev_dependencies: + mocktail: ^1.0.4 + test: ^1.25.7 + very_good_analysis: ^6.0.0 + +dependencies: + fluttercon_data_source: + path: ../fluttercon_data_source + fluttercon_shared_models: + path: ../fluttercon_shared_models diff --git a/api/packages/speakers_repository/test/helpers/test_helpers.dart b/api/packages/speakers_repository/test/helpers/test_helpers.dart new file mode 100644 index 0000000..5a0c244 --- /dev/null +++ b/api/packages/speakers_repository/test/helpers/test_helpers.dart @@ -0,0 +1,55 @@ +import 'package:fluttercon_data_source/fluttercon_data_source.dart'; +import 'package:fluttercon_shared_models/fluttercon_shared_models.dart'; + +class TestHelpers { + static final speakers = PaginatedResult( + [ + Speaker( + id: '1', + name: 'John Doe', + title: 'Test Title 1', + imageUrl: 'Test Image Url 1', + ), + Speaker( + id: '2', + name: 'Jane Doe', + title: 'Test Title 2', + imageUrl: 'Test Image Url 2', + ), + Speaker( + id: '3', + name: 'John Smith', + title: 'Test Title 3', + imageUrl: 'Test Image Url 3', + ), + ], + null, + null, + null, + Speaker.classType, + null, + ); + + static const speakerPreviews = PaginatedData( + items: [ + SpeakerPreview( + id: '2', + name: 'Jane Doe', + title: 'Test Title 2', + imageUrl: 'Test Image Url 2', + ), + SpeakerPreview( + id: '1', + name: 'John Doe', + title: 'Test Title 1', + imageUrl: 'Test Image Url 1', + ), + SpeakerPreview( + id: '3', + name: 'John Smith', + title: 'Test Title 3', + imageUrl: 'Test Image Url 3', + ), + ], + ); +} diff --git a/api/packages/speakers_repository/test/src/speakers_repository_test.dart b/api/packages/speakers_repository/test/src/speakers_repository_test.dart new file mode 100644 index 0000000..4e7a55d --- /dev/null +++ b/api/packages/speakers_repository/test/src/speakers_repository_test.dart @@ -0,0 +1,37 @@ +// ignore_for_file: prefer_const_constructors +import 'package:fluttercon_data_source/fluttercon_data_source.dart'; +import 'package:fluttercon_shared_models/fluttercon_shared_models.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:speakers_repository/speakers_repository.dart'; +import 'package:test/test.dart'; + +import '../helpers/test_helpers.dart'; + +class _MockFlutterconDataSource extends Mock implements FlutterconDataSource {} + +void main() { + group('SpeakersRepository', () { + late FlutterconDataSource dataSource; + late SpeakersRepository speakersRepository; + + setUp(() { + dataSource = _MockFlutterconDataSource(); + speakersRepository = SpeakersRepository(dataSource: dataSource); + }); + + test('can be instantiated', () { + expect(speakersRepository, isNotNull); + }); + + group('getTalks', () { + test('returns sorted ${PaginatedData} when successful', + () async { + when(() => dataSource.getSpeakers()) + .thenAnswer((_) async => TestHelpers.speakers); + + final result = await speakersRepository.getSpeakers(); + expect(result, equals(TestHelpers.speakerPreviews)); + }); + }); + }); +} diff --git a/api/packages/talks_repository/pubspec.yaml b/api/packages/talks_repository/pubspec.yaml index 992edfb..650c4c5 100644 --- a/api/packages/talks_repository/pubspec.yaml +++ b/api/packages/talks_repository/pubspec.yaml @@ -14,9 +14,7 @@ dev_dependencies: very_good_analysis: ^6.0.0 dependencies: - equatable: ^2.0.5 fluttercon_data_source: path: ../fluttercon_data_source fluttercon_shared_models: path: ../fluttercon_shared_models - json_annotation: ^4.8.1 diff --git a/api/pubspec.yaml b/api/pubspec.yaml index a6cdd6c..8611249 100644 --- a/api/pubspec.yaml +++ b/api/pubspec.yaml @@ -18,6 +18,8 @@ dependencies: fluttercon_shared_models: path: packages/fluttercon_shared_models shelf_cors_headers: ^0.1.5 + speakers_repository: + path: packages/speakers_repository talks_repository: path: packages/talks_repository user_repository: diff --git a/api/routes/_middleware.dart b/api/routes/_middleware.dart index e4b40c5..fcbf0fc 100644 --- a/api/routes/_middleware.dart +++ b/api/routes/_middleware.dart @@ -3,12 +3,20 @@ import 'package:dart_frog/dart_frog.dart'; import 'package:dart_frog_auth/dart_frog_auth.dart'; import 'package:fluttercon_data_source/fluttercon_data_source.dart'; import 'package:shelf_cors_headers/shelf_cors_headers.dart'; +import 'package:speakers_repository/speakers_repository.dart'; import 'package:talks_repository/talks_repository.dart'; import 'package:user_repository/user_repository.dart'; Handler middleware(Handler handler) { return handler .use(requestLogger()) + .use( + provider( + (context) => SpeakersRepository( + dataSource: context.read(), + ), + ), + ) .use( provider( (context) => TalksRepository( diff --git a/api/routes/speakers/index.dart b/api/routes/speakers/index.dart index ea683ef..36032af 100644 --- a/api/routes/speakers/index.dart +++ b/api/routes/speakers/index.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:dart_frog/dart_frog.dart'; import 'package:fluttercon_data_source/fluttercon_data_source.dart'; +import 'package:speakers_repository/speakers_repository.dart'; Future onRequest(RequestContext context) async { return switch (context.request.method) { @@ -12,14 +13,17 @@ Future onRequest(RequestContext context) async { } Future _get(RequestContext context) async { - final dataSource = context.read(); + final speakersRepo = context.read(); try { - final data = await dataSource.getSpeakers(); - return Response(body: jsonEncode(data.toJson())); + final data = await speakersRepo.getSpeakers(); + final json = data.toJson( + (value) => value.toJson(), + ); + return Response(body: jsonEncode(json)); } on AmplifyApiException catch (e) { return Response( statusCode: HttpStatus.internalServerError, - body: jsonEncode(e.exception), + body: jsonEncode(e.exception.toString()), ); } } diff --git a/api/test/routes/speakers/index_test.dart b/api/test/routes/speakers/index_test.dart index ae50024..2427982 100644 --- a/api/test/routes/speakers/index_test.dart +++ b/api/test/routes/speakers/index_test.dart @@ -4,7 +4,9 @@ import 'dart:io'; import 'package:dart_frog/dart_frog.dart'; import 'package:fluttercon_data_source/fluttercon_data_source.dart'; +import 'package:fluttercon_shared_models/fluttercon_shared_models.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:speakers_repository/speakers_repository.dart'; import 'package:test/test.dart'; import '../../../routes/speakers/index.dart' as route; @@ -12,42 +14,46 @@ import '../../helpers/method_not_allowed.dart'; class _MockRequestContext extends Mock implements RequestContext {} -class _MockFlutterconDataSource extends Mock implements FlutterconDataSource {} +class _MockSpeakersRepository extends Mock implements SpeakersRepository {} void main() { - late FlutterconDataSource dataSource; + late SpeakersRepository speakersRepository; setUp(() { - dataSource = _MockFlutterconDataSource(); + speakersRepository = _MockSpeakersRepository(); }); group('GET /speakers', () { - final responseData = PaginatedResult( - [ - Speaker( - id: '1', - name: 'John Doe', - bio: 'A bio', + const responseData = PaginatedData( + items: [ + SpeakerPreview( + id: 'id', + name: 'name', + title: 'title', + imageUrl: 'imageUrl', ), ], - null, - null, - null, - Speaker.classType, - null, ); test('responds with a 200 and a list of speakers when successful', () async { final context = _MockRequestContext(); final request = Request('GET', Uri.parse('http://127.0.0.1/')); when(() => context.request).thenReturn(request); - when(() => context.read()).thenReturn(dataSource); - when(() => dataSource.getSpeakers()) + when(() => context.read()) + .thenReturn(speakersRepository); + when(() => speakersRepository.getSpeakers()) .thenAnswer((_) async => responseData); final response = await route.onRequest(context); expect(response.statusCode, equals(HttpStatus.ok)); - expect(await response.body(), equals(jsonEncode(responseData.toJson()))); + expect( + await response.body(), + equals( + jsonEncode( + responseData.toJson((value) => value.toJson()), + ), + ), + ); }); test('responds with a 500 and exception when there is a failure', () async { @@ -55,8 +61,9 @@ void main() { final request = Request('GET', Uri.parse('http://127.0.0.1/')); const amplifyException = AmplifyApiException(exception: 'oops'); when(() => context.request).thenReturn(request); - when(() => context.read()).thenReturn(dataSource); - when(() => dataSource.getSpeakers()).thenThrow(amplifyException); + when(() => context.read()) + .thenReturn(speakersRepository); + when(() => speakersRepository.getSpeakers()).thenThrow(amplifyException); final response = await route.onRequest(context); expect(response.statusCode, equals(HttpStatus.internalServerError)); @@ -69,8 +76,8 @@ void main() { group('Unsupported methods', () { test('respond with 405', () async { final context = _MockRequestContext(); - when(() => context.read()).thenReturn( - dataSource, + when(() => context.read()).thenReturn( + speakersRepository, ); FutureOr action() => route.onRequest(context); await testMethodNotAllowed(context, action, 'POST'); diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 49b10a5..9c2fb6e 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -2,7 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttercon_api/fluttercon_api.dart'; import 'package:fluttercon_usa_2024/l10n/l10n.dart'; -import 'package:fluttercon_usa_2024/talks/view/talks_page.dart'; +import 'package:fluttercon_usa_2024/speakers/speakers.dart'; +import 'package:fluttercon_usa_2024/talks/talks.dart'; import 'package:fluttercon_usa_2024/user/cubit/user_cubit.dart'; class App extends StatelessWidget { @@ -71,9 +72,7 @@ class _HomePageState extends State { ), body: const [ TalksPage(), - Center( - child: Text('Speakers coming soon!'), - ), + SpeakersPage(), Center( child: Text('Favorites coming soon!'), ), diff --git a/lib/speakers/bloc/speakers_bloc.dart b/lib/speakers/bloc/speakers_bloc.dart new file mode 100644 index 0000000..d4bf899 --- /dev/null +++ b/lib/speakers/bloc/speakers_bloc.dart @@ -0,0 +1,32 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:fluttercon_api/fluttercon_api.dart'; +import 'package:fluttercon_shared_models/fluttercon_shared_models.dart'; + +part 'speakers_event.dart'; +part 'speakers_state.dart'; + +class SpeakersBloc extends Bloc { + SpeakersBloc({required FlutterconApi api}) + : _api = api, + super(const SpeakersInitial()) { + on(_speakersRequested); + } + + final FlutterconApi _api; + + FutureOr _speakersRequested( + SpeakersRequested event, + Emitter emit, + ) async { + try { + emit(const SpeakersLoading()); + final speakers = await _api.getSpeakers(); + emit(SpeakersLoaded(speakers: speakers.items)); + } catch (e) { + emit(SpeakersError(error: e)); + } + } +} diff --git a/lib/speakers/bloc/speakers_event.dart b/lib/speakers/bloc/speakers_event.dart new file mode 100644 index 0000000..3dcc765 --- /dev/null +++ b/lib/speakers/bloc/speakers_event.dart @@ -0,0 +1,12 @@ +part of 'speakers_bloc.dart'; + +sealed class SpeakersEvent extends Equatable { + const SpeakersEvent(); +} + +final class SpeakersRequested extends SpeakersEvent { + const SpeakersRequested(); + + @override + List get props => []; +} diff --git a/lib/speakers/bloc/speakers_state.dart b/lib/speakers/bloc/speakers_state.dart new file mode 100644 index 0000000..1e21b59 --- /dev/null +++ b/lib/speakers/bloc/speakers_state.dart @@ -0,0 +1,37 @@ +part of 'speakers_bloc.dart'; + +sealed class SpeakersState extends Equatable { + const SpeakersState(); +} + +final class SpeakersInitial extends SpeakersState { + const SpeakersInitial(); + + @override + List get props => []; +} + +final class SpeakersLoading extends SpeakersState { + const SpeakersLoading(); + + @override + List get props => []; +} + +final class SpeakersLoaded extends SpeakersState { + const SpeakersLoaded({required this.speakers}); + + final List speakers; + + @override + List get props => [speakers]; +} + +final class SpeakersError extends SpeakersState { + const SpeakersError({required this.error}); + + final Object error; + + @override + List get props => [error]; +} diff --git a/lib/speakers/speakers.dart b/lib/speakers/speakers.dart new file mode 100644 index 0000000..4e8cb3d --- /dev/null +++ b/lib/speakers/speakers.dart @@ -0,0 +1,2 @@ +export 'bloc/speakers_bloc.dart'; +export 'view/speakers_page.dart'; diff --git a/lib/speakers/view/speakers_page.dart b/lib/speakers/view/speakers_page.dart new file mode 100644 index 0000000..d8541f2 --- /dev/null +++ b/lib/speakers/view/speakers_page.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fluttercon_api/fluttercon_api.dart'; +import 'package:fluttercon_shared_models/fluttercon_shared_models.dart'; +import 'package:fluttercon_usa_2024/speakers/speakers.dart'; + +class SpeakersPage extends StatelessWidget { + const SpeakersPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => SpeakersBloc(api: context.read()) + ..add(const SpeakersRequested()), + child: const SpeakersView(), + ); + } +} + +@visibleForTesting +class SpeakersView extends StatelessWidget { + const SpeakersView({super.key}); + + @override + Widget build(BuildContext context) { + final state = context.watch().state; + return switch (state) { + SpeakersInitial() || SpeakersLoading() => const Center( + child: CircularProgressIndicator(), + ), + SpeakersError(error: final e) => Center( + child: Text('$e'), + ), + SpeakersLoaded(speakers: final speakers) => SpeakersList( + speakers: speakers, + ), + }; + } +} + +@visibleForTesting +class SpeakersList extends StatelessWidget { + const SpeakersList({ + required this.speakers, + super.key, + }); + + final List speakers; + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemCount: speakers.length, + itemBuilder: (context, index) { + final speaker = speakers[index]; + return ListTile( + leading: CircleAvatar( + backgroundImage: NetworkImage(speaker.imageUrl), + ), + title: Text(speaker.name), + subtitle: Text(speaker.title), + onTap: () {}, + ); + }, + ); + } +} diff --git a/packages/fluttercon_api/lib/src/fluttercon_api.dart b/packages/fluttercon_api/lib/src/fluttercon_api.dart index 9b456b6..a70e8dd 100644 --- a/packages/fluttercon_api/lib/src/fluttercon_api.dart +++ b/packages/fluttercon_api/lib/src/fluttercon_api.dart @@ -62,6 +62,20 @@ class FlutterconApi { return _currentUser!; } + /// GET /speakers + /// Fetches a paginated list of speakers. + Future> getSpeakers() async => _sendRequest( + uri: Uri.parse('$_baseUrl/speakers'), + method: HttpMethod.get, + fromJson: (json) => PaginatedData.fromJson( + json, + // ignoring line length to fix coverage gap + // ignore: lines_longer_than_80_chars + (item) => + SpeakerPreview.fromJson((item ?? {}) as Map), + ), + ); + /// GET /talks /// Fetches a paginated list of talks. Future> getTalks() async => _sendRequest( diff --git a/packages/fluttercon_api/test/helpers/test_helpers.dart b/packages/fluttercon_api/test/helpers/test_helpers.dart index 58f32e2..f9c14aa 100644 --- a/packages/fluttercon_api/test/helpers/test_helpers.dart +++ b/packages/fluttercon_api/test/helpers/test_helpers.dart @@ -17,4 +17,15 @@ class TestHelpers { }, ], }; + + static final speakersResponse = { + 'items': [ + { + 'id': 'id', + 'name': 'name', + 'title': 'title', + 'imageUrl': 'imageUrl', + }, + ], + }; } diff --git a/packages/fluttercon_api/test/src/fluttercon_api_test.dart b/packages/fluttercon_api/test/src/fluttercon_api_test.dart index 8420947..fd58e4f 100644 --- a/packages/fluttercon_api/test/src/fluttercon_api_test.dart +++ b/packages/fluttercon_api/test/src/fluttercon_api_test.dart @@ -180,6 +180,78 @@ void main() { ); }); + group('getSpeakers', () { + final url = Uri.parse('$baseUrl/speakers'); + + test( + 'returns ${PaginatedData} on successful response', + () async { + whenHttpClientSend(url: url, response: TestHelpers.speakersResponse); + + final talks = await flutterconApi.getSpeakers(); + + expect(talks, isA>()); + }, + ); + + test( + 'throws $FlutterconApiMalformedResponseException ' + 'when body is malformed', + () async { + whenHttpClientSend(url: url, response: ''); + + expect( + () async => flutterconApi.getSpeakers(), + throwsA(isA()), + ); + }, + ); + + test( + 'throws $FlutterconApiClientException ' + 'when response is not successful', + () async { + whenHttpClientSend( + url: url, + // ignore: inference_failure_on_collection_literal + response: {}, + httpStatus: HttpStatus.notFound, + ); + + expect( + () async => flutterconApi.getSpeakers(), + throwsA( + isA().having( + (e) => e.statusCode, + 'status code', + equals( + HttpStatus.notFound, + ), + ), + ), + ); + }, + ); + + test( + 'throws $FlutterconApiClientException ' + 'when an unexpected error occurs', + () async { + whenHttpClientSend( + url: url, + exception: Exception('oops'), + ); + + expect( + () async => flutterconApi.getSpeakers(), + throwsA( + isA(), + ), + ); + }, + ); + }); + group('getTalks', () { final url = Uri.parse('$baseUrl/talks'); diff --git a/pubspec.lock b/pubspec.lock index c489c03..bfd9052 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -121,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + build: + dependency: transitive + description: + name: build + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + url: "https://pub.dev" + source: hosted + version: "2.4.1" built_collection: dependency: transitive description: @@ -153,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 + url: "https://pub.dev" + source: hosted + version: "4.10.0" collection: dependency: transitive description: @@ -193,6 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" + url: "https://pub.dev" + source: hosted + version: "2.3.6" diff_match_patch: dependency: transitive description: @@ -445,6 +469,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + mockito: + dependency: transitive + description: + name: mockito + sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" + url: "https://pub.dev" + source: hosted + version: "5.4.4" mocktail: dependency: "direct dev" description: @@ -461,6 +493,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + network_image_mock: + dependency: "direct main" + description: + name: network_image_mock + sha256: "855cdd01d42440e0cffee0d6c2370909fc31b3bcba308a59829f24f64be42db7" + url: "https://pub.dev" + source: hosted + version: "2.1.1" node_preamble: dependency: transitive description: @@ -602,6 +642,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.2" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + url: "https://pub.dev" + source: hosted + version: "1.5.0" source_map_stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2cf183d..d4d20cd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: path: api/packages/fluttercon_shared_models intl: ^0.19.0 meta: ^1.12.0 + network_image_mock: ^2.1.1 dev_dependencies: bloc_test: ^9.1.7 diff --git a/test/app/view/app_test.dart b/test/app/view/app_test.dart index aca99bd..0aa723f 100644 --- a/test/app/view/app_test.dart +++ b/test/app/view/app_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fluttercon_api/fluttercon_api.dart'; import 'package:fluttercon_usa_2024/app/app.dart'; +import 'package:fluttercon_usa_2024/speakers/view/speakers_page.dart'; import 'package:mocktail/mocktail.dart'; import '../../helpers/test_data.dart'; @@ -35,7 +36,7 @@ void main() { await tester.tap(find.byIcon(Icons.people_outlined)); await tester.pumpAndSettle(); - expect(find.text('Speakers coming soon!'), findsOneWidget); + expect(find.byType(SpeakersPage), findsOneWidget); }); }); } diff --git a/test/helpers/pump_app.dart b/test/helpers/pump_app.dart index 307012b..0f3e906 100644 --- a/test/helpers/pump_app.dart +++ b/test/helpers/pump_app.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fluttercon_api/fluttercon_api.dart'; import 'package:fluttercon_usa_2024/l10n/l10n.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:network_image_mock/network_image_mock.dart'; class _MockFlutterconApi extends Mock implements FlutterconApi {} @@ -12,13 +13,17 @@ extension PumpApp on WidgetTester { Widget widget, { FlutterconApi? api, }) { - return pumpWidget( - RepositoryProvider.value( - value: api ?? _MockFlutterconApi(), - child: MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: widget, + return mockNetworkImagesFor( + () => pumpWidget( + RepositoryProvider.value( + value: api ?? _MockFlutterconApi(), + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Material( + child: widget, + ), + ), ), ), ); diff --git a/test/helpers/test_data.dart b/test/helpers/test_data.dart index 3eb4c36..7c5077a 100644 --- a/test/helpers/test_data.dart +++ b/test/helpers/test_data.dart @@ -41,5 +41,28 @@ class TestData { ], ); + static const speakerData = PaginatedData( + items: [ + SpeakerPreview( + id: '1', + name: 'Speaker 1', + title: 'Title 1', + imageUrl: 'Image 1', + ), + SpeakerPreview( + id: '2', + name: 'Speaker 2', + title: 'Title 2', + imageUrl: 'Image 2', + ), + SpeakerPreview( + id: '3', + name: 'Speaker 3', + title: 'Title 3', + imageUrl: 'Image 3', + ), + ], + ); + static final error = Exception('oops'); } diff --git a/test/speakers/bloc/speakers_bloc_test.dart b/test/speakers/bloc/speakers_bloc_test.dart new file mode 100644 index 0000000..51a7027 --- /dev/null +++ b/test/speakers/bloc/speakers_bloc_test.dart @@ -0,0 +1,63 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttercon_api/fluttercon_api.dart'; +import 'package:fluttercon_usa_2024/speakers/speakers.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../helpers/test_data.dart'; + +class _MockFlutterconApi extends Mock implements FlutterconApi {} + +void main() { + group('SpeakersBloc', () { + late FlutterconApi api; + late SpeakersBloc speakersBloc; + + setUp(() { + api = _MockFlutterconApi(); + speakersBloc = SpeakersBloc(api: api); + }); + + test('initial state is SpeakersInitial', () { + expect(speakersBloc.state, equals(const SpeakersInitial())); + }); + + group('SpeakersRequested', () { + blocTest( + 'emits [SpeakersLoading, SpeakersLoaded] when successful', + build: () => speakersBloc, + act: (bloc) { + when(() => api.getSpeakers()).thenAnswer( + (_) async => TestData.speakerData, + ); + bloc.add(const SpeakersRequested()); + }, + expect: () => [ + isA(), + isA().having( + (state) => state.speakers, + 'Speakers', + equals(TestData.speakerData.items), + ), + ], + ); + + blocTest( + 'emits [SpeakersLoading, SpeakersError] when unsuccessful', + build: () => speakersBloc, + act: (bloc) { + when(() => api.getSpeakers()).thenThrow(TestData.error); + bloc.add(const SpeakersRequested()); + }, + expect: () => [ + isA(), + isA().having( + (state) => state.error, + 'Error', + equals(TestData.error), + ), + ], + ); + }); + }); +} diff --git a/test/speakers/bloc/speakers_event_test.dart b/test/speakers/bloc/speakers_event_test.dart new file mode 100644 index 0000000..90e2689 --- /dev/null +++ b/test/speakers/bloc/speakers_event_test.dart @@ -0,0 +1,13 @@ +// ignore_for_file: prefer_const_constructors +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttercon_usa_2024/speakers/speakers.dart'; + +void main() { + group('SpeakersEvent', () { + group('SpeakersRequested', () { + test('supports value equality', () { + expect(SpeakersRequested(), equals(SpeakersRequested())); + }); + }); + }); +} diff --git a/test/speakers/bloc/speakers_state_test.dart b/test/speakers/bloc/speakers_state_test.dart new file mode 100644 index 0000000..ee9c342 --- /dev/null +++ b/test/speakers/bloc/speakers_state_test.dart @@ -0,0 +1,40 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttercon_usa_2024/speakers/speakers.dart'; + +import '../../helpers/test_data.dart'; + +void main() { + group('SpeakersState', () { + group('SpeakersInitial', () { + test('supports value equality', () { + expect(SpeakersInitial(), equals(SpeakersInitial())); + }); + }); + + group('SpeakersLoading', () { + test('supports value equality', () { + expect(SpeakersLoading(), equals(SpeakersLoading())); + }); + }); + + group('SpeakersLoaded', () { + test('supports value equality', () { + expect( + SpeakersLoaded(speakers: TestData.speakerData.items), + equals(SpeakersLoaded(speakers: TestData.speakerData.items)), + ); + }); + }); + + group('SpeakersError', () { + test('supports value equality', () { + expect( + SpeakersError(error: TestData.error), + equals(SpeakersError(error: TestData.error)), + ); + }); + }); + }); +} diff --git a/test/speakers/view/speakers_page_test.dart b/test/speakers/view/speakers_page_test.dart new file mode 100644 index 0000000..7ac0195 --- /dev/null +++ b/test/speakers/view/speakers_page_test.dart @@ -0,0 +1,108 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttercon_usa_2024/speakers/speakers.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../helpers/helpers.dart'; +import '../../helpers/test_data.dart'; + +class _MockSpeakersBloc extends MockBloc + implements SpeakersBloc {} + +void main() { + group('SpeakersPage', () { + testWidgets('renders SpeakersView', (tester) async { + await tester.pumpApp(const SpeakersPage()); + + expect(find.byType(SpeakersView), findsOneWidget); + }); + + group('SpeakersView', () { + late SpeakersBloc speakersBloc; + + setUp(() { + speakersBloc = _MockSpeakersBloc(); + }); + + testWidgets('renders CircularProgressIndicator when state is initial', + (tester) async { + when(() => speakersBloc.state).thenReturn(const SpeakersInitial()); + + await tester.pumpApp( + BlocProvider.value( + value: speakersBloc, + child: const SpeakersView(), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('renders CircularProgressIndicator when state is loading', + (tester) async { + when(() => speakersBloc.state).thenReturn(const SpeakersLoading()); + + await tester.pumpApp( + BlocProvider.value( + value: speakersBloc, + child: const SpeakersView(), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('renders Text with error message when state is error', + (tester) async { + when(() => speakersBloc.state).thenReturn( + SpeakersError(error: TestData.error), + ); + + await tester.pumpApp( + BlocProvider.value( + value: speakersBloc, + child: const SpeakersView(), + ), + ); + + expect(find.text(TestData.error.toString()), findsOneWidget); + }); + + testWidgets('renders SpeakersList when state is loaded', (tester) async { + when(() => speakersBloc.state).thenReturn( + SpeakersLoaded(speakers: TestData.speakerData.items), + ); + + await tester.pumpApp( + BlocProvider.value( + value: speakersBloc, + child: const SpeakersView(), + ), + ); + + expect(find.byType(SpeakersList), findsOneWidget); + }); + + group('SpeakersList', () { + testWidgets('can tap speaker list tile', (tester) async { + when(() => speakersBloc.state).thenReturn( + SpeakersLoaded(speakers: TestData.speakerData.items), + ); + + await tester.pumpApp( + BlocProvider.value( + value: speakersBloc, + child: const SpeakersView(), + ), + ); + + await tester.tap(find.byType(ListTile).first); + + expect(find.byType(SpeakersList), findsOneWidget); + }); + }); + }); + }); +}