diff --git a/.vscode/launch.json b/.vscode/launch.json index cc09a28..4937a71 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -51,6 +51,13 @@ "--target", "lib/main_production.dart" ] + }, + { + "name": "API: attach to process", + "type": "dart", + "request": "attach", + "vmServiceUri": "${command:dart.promptForVmService}", + "program": "${workspaceFolder}/api/main.dart" } ] } diff --git a/api/amplify/data/resource.ts b/api/amplify/data/resource.ts index a052d2c..2c2130c 100644 --- a/api/amplify/data/resource.ts +++ b/api/amplify/data/resource.ts @@ -17,6 +17,7 @@ const schema = a.schema({ endTime: a.datetime(), isFavorite: a.boolean(), speakers: a.hasMany('SpeakerTalk', 'talkId'), + favorites: a.hasMany('FavoritesTalk', 'talkId'), }), Speaker: a .model({ @@ -34,6 +35,18 @@ const schema = a.schema({ speakerId: a.id(), speaker: a.belongsTo('Speaker', 'speakerId'), }), + FavoritesTalk: a + .model({ + favoritesId: a.id().required(), + talkId: a.id().required(), + favorites: a.belongsTo('Favorites', 'favoritesId'), + talk: a.belongsTo('Talk', 'talkId'), + }), + Favorites: a + .model({ + userId: a.string(), + talks: a.hasMany('FavoritesTalk', 'favoritesId'), + }), }).authorization((allow) => [allow.guest()]); export type Schema = ClientSchema; diff --git a/api/devtools_options.yaml b/api/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/api/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/api/lib/helpers/request_body_decoder.dart b/api/lib/helpers/request_body_decoder.dart new file mode 100644 index 0000000..fe439fb --- /dev/null +++ b/api/lib/helpers/request_body_decoder.dart @@ -0,0 +1,10 @@ +import 'dart:convert'; + +import 'package:dart_frog/dart_frog.dart'; + +/// Helper to decode dart frog request bodies +extension RequestBodyDecoder on Request { + /// Returns the request body as a `Map` + Future> decodeRequestBody() async => + Map.from(jsonDecode(await body()) as Map); +} diff --git a/api/packages/fluttercon_data_source/lib/src/data_source/amplify_api_client.dart b/api/packages/fluttercon_data_source/lib/src/data_source/amplify_api_client.dart index 854856c..07dbbef 100644 --- a/api/packages/fluttercon_data_source/lib/src/data_source/amplify_api_client.dart +++ b/api/packages/fluttercon_data_source/lib/src/data_source/amplify_api_client.dart @@ -15,6 +15,23 @@ class AmplifyAPIClient { final APICategory _api; final GraphQLRequestWrapper _requestWrapper; + /// Create a GraphQL [get] request. + GraphQLRequest get( + ModelType modelType, + ModelIdentifier modelIdentifier, { + String? apiName, + APIAuthorizationType? authorizationMode, + Map? headers, + }) { + return _requestWrapper.get( + modelType, + modelIdentifier, + apiName: apiName, + authorizationMode: authorizationMode, + headers: headers, + ); + } + /// Create a GraphQL [list] request. GraphQLRequest> list( ModelType modelType, { @@ -34,7 +51,43 @@ class AmplifyAPIClient { ); } + /// Create a GraphQL [create] request. + GraphQLRequest create( + T model, { + String? apiName, + APIAuthorizationType? authorizationMode, + Map? headers, + }) { + return _requestWrapper.create( + model, + apiName: apiName, + authorizationMode: authorizationMode, + headers: headers, + ); + } + + /// Create a GraphQL [deleteById] request. + GraphQLRequest deleteById( + ModelType modelType, + ModelIdentifier modelIdentifier, { + String? apiName, + APIAuthorizationType? authorizationMode, + Map? headers, + }) { + return _requestWrapper.deleteById( + modelType, + modelIdentifier, + apiName: apiName, + authorizationMode: authorizationMode, + headers: headers, + ); + } + /// Send a GraphQL [query] with a given [request]. GraphQLOperation query({required GraphQLRequest request}) => _api.query(request: request); + + /// Send a GraphQL [mutate] with a given [request]. + GraphQLOperation mutate({required GraphQLRequest request}) => + _api.mutate(request: request); } diff --git a/api/packages/fluttercon_data_source/lib/src/data_source/fluttercon_data_source.dart b/api/packages/fluttercon_data_source/lib/src/data_source/fluttercon_data_source.dart index 8ab08dc..d16739b 100644 --- a/api/packages/fluttercon_data_source/lib/src/data_source/fluttercon_data_source.dart +++ b/api/packages/fluttercon_data_source/lib/src/data_source/fluttercon_data_source.dart @@ -1,4 +1,4 @@ -import 'package:amplify_api_dart/amplify_api_dart.dart'; +import 'package:amplify_core/amplify_core.dart'; import 'package:fluttercon_data_source/src/data_source/amplify_api_client.dart'; import 'package:fluttercon_data_source/src/exceptions/exceptions.dart'; @@ -19,6 +19,92 @@ class FlutterconDataSource { final AmplifyAPIClient _apiClient; + /// Creates a new [Favorites] entity. + Future createFavorites({required String userId}) async { + try { + final request = _apiClient.create(Favorites(userId: userId)); + return await _sendGraphQLRequest( + request: request, + operation: (request) => _apiClient.mutate(request: request), + ); + } on Exception catch (e) { + throw AmplifyApiException(exception: e); + } + } + + /// Creates a new [FavoritesTalk] entity. + Future createFavoritesTalk({ + required String favoritesId, + required String talkId, + }) async { + try { + final request = _apiClient.create( + FavoritesTalk( + favorites: Favorites(id: favoritesId), + talk: Talk(id: talkId), + ), + ); + return await _sendGraphQLRequest( + request: request, + operation: (request) => _apiClient.mutate(request: request), + ); + } on Exception catch (e) { + throw AmplifyApiException(exception: e); + } + } + + /// Deletes a [FavoritesTalk] entity. + Future deleteFavoritesTalk({required String id}) async { + try { + final request = _apiClient.deleteById( + FavoritesTalk.classType, + FavoritesTalkModelIdentifier(id: id), + ); + return await _sendGraphQLRequest( + request: request, + operation: (request) => _apiClient.mutate(request: request), + ); + } on Exception catch (e) { + throw AmplifyApiException(exception: e); + } + } + + /// Gets a [Favorites] entity by [id]. + Future getFavoritesTalk({required String id}) async { + try { + final request = _apiClient.get( + FavoritesTalk.classType, + FavoritesTalkModelIdentifier( + id: id, + ), + ); + return await _sendGraphQLRequest( + request: request, + operation: (request) => _apiClient.query(request: request), + ); + } on Exception catch (e) { + throw AmplifyApiException(exception: e); + } + } + + /// Fetches a paginated list of [Favorites] entities. + /// Can optionally provide a [userId] to filter. + Future> getFavorites({String? userId}) async { + try { + final request = _apiClient.list( + Favorites.classType, + where: userId != null ? Favorites.USERID.eq(userId) : null, + ); + + return await _sendGraphQLRequest( + request: request, + operation: (request) => _apiClient.query(request: request), + ); + } on Exception catch (e) { + throw AmplifyApiException(exception: e); + } + } + /// Fetches a paginated list of speakers. Future> getSpeakers() async { try { @@ -33,11 +119,49 @@ class FlutterconDataSource { } /// Fetches a paginated list of talks. - Future> getTalks({bool favorites = false}) async { + Future> getTalks() async { try { final request = _apiClient.list( Talk.classType, - where: favorites ? Talk.ISFAVORITE.eq(true) : null, + ); + return await _sendGraphQLRequest( + request: request, + operation: (request) => _apiClient.query(request: request), + ); + } on Exception catch (e) { + throw AmplifyApiException(exception: e); + } + } + + /// Fetches a [Talk] entity by [id]. + Future getTalk({required String id}) async { + try { + final request = + _apiClient.get(Talk.classType, TalkModelIdentifier(id: id)); + return await _sendGraphQLRequest( + request: request, + operation: (request) => _apiClient.query(request: request), + ); + } on Exception catch (e) { + throw AmplifyApiException(exception: e); + } + } + + /// Fetches a paginated list of [FavoritesTalk] entities + /// for a [favoritesId]. + /// A [FavoritesTalk] contains an ID for a favorites entity and + /// an ID for a corresponding talk. + Future> getFavoritesTalks({ + required String favoritesId, + String? talkId, + }) async { + try { + final request = _apiClient.list( + FavoritesTalk.classType, + where: QueryPredicateGroup(QueryPredicateGroupType.and, [ + FavoritesTalk.FAVORITES.eq(favoritesId), + if (talkId != null) FavoritesTalk.TALK.eq(talkId), + ]), ); return await _sendGraphQLRequest( request: request, diff --git a/api/packages/fluttercon_data_source/lib/src/helpers/graphql_request_wrapper.dart b/api/packages/fluttercon_data_source/lib/src/helpers/graphql_request_wrapper.dart index 1015d95..2b6ff6f 100644 --- a/api/packages/fluttercon_data_source/lib/src/helpers/graphql_request_wrapper.dart +++ b/api/packages/fluttercon_data_source/lib/src/helpers/graphql_request_wrapper.dart @@ -14,4 +14,30 @@ class GraphQLRequestWrapper { APIAuthorizationType? authorizationMode, Map? headers, }) list = ModelQueries.list; + + /// Wrapper around the static [ModelQueries.get] helper. + GraphQLRequest Function( + ModelType modelType, + ModelIdentifier modelIdentifier, { + String? apiName, + APIAuthorizationType? authorizationMode, + Map? headers, + }) get = ModelQueries.get; + + /// Wrapper around the static [ModelMutations.create] helper. + GraphQLRequest Function( + T model, { + String? apiName, + APIAuthorizationType? authorizationMode, + Map? headers, + }) create = ModelMutations.create; + + /// Wrapper around the static [ModelMutations.deleteById] helper. + GraphQLRequest Function( + ModelType modelType, + ModelIdentifier modelIdentifier, { + String? apiName, + APIAuthorizationType? authorizationMode, + Map? headers, + }) deleteById = ModelMutations.deleteById; } diff --git a/api/packages/fluttercon_data_source/lib/src/models/gen/Favorites.dart b/api/packages/fluttercon_data_source/lib/src/models/gen/Favorites.dart new file mode 100644 index 0000000..88f274a --- /dev/null +++ b/api/packages/fluttercon_data_source/lib/src/models/gen/Favorites.dart @@ -0,0 +1,261 @@ +/* +* Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +* +* Licensed under the Apache License, Version 2.0 (the "License"). +* You may not use this file except in compliance with the License. +* A copy of the License is located at +* +* http://aws.amazon.com/apache2.0 +* +* or in the "license" file accompanying this file. This file is distributed +* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +* express or implied. See the License for the specific language governing +* permissions and limitations under the License. +*/ + +// NOTE: This file is generated and may not follow lint rules defined in your app +// Generated files can be excluded from analysis in analysis_options.yaml +// For more info, see: https://dart.dev/guides/language/analysis-options#excluding-code-from-analysis + +// ignore_for_file: public_member_api_docs, annotate_overrides, dead_code, dead_codepublic_member_api_docs, depend_on_referenced_packages, file_names, library_private_types_in_public_api, no_leading_underscores_for_library_prefixes, no_leading_underscores_for_local_identifiers, non_constant_identifier_names, null_check_on_nullable_type_parameter, override_on_non_overriding_member, prefer_adjacent_string_concatenation, prefer_const_constructors, prefer_if_null_operators, prefer_interpolation_to_compose_strings, slash_for_doc_comments, sort_child_properties_last, unnecessary_const, unnecessary_constructor_name, unnecessary_late, unnecessary_new, unnecessary_null_aware_assignments, unnecessary_nullable_for_final_variable_declarations, unnecessary_string_interpolations, use_build_context_synchronously + +import 'ModelProvider.dart'; +import 'package:amplify_core/amplify_core.dart' as amplify_core; +import 'package:collection/collection.dart'; + + +/** This is an auto generated class representing the Favorites type in your schema. */ +class Favorites extends amplify_core.Model { + static const classType = const _FavoritesModelType(); + final String id; + final String? _userId; + final List? _talks; + final amplify_core.TemporalDateTime? _createdAt; + final amplify_core.TemporalDateTime? _updatedAt; + + @override + getInstanceType() => classType; + + @Deprecated('[getId] is being deprecated in favor of custom primary key feature. Use getter [modelIdentifier] to get model identifier.') + @override + String getId() => id; + + FavoritesModelIdentifier get modelIdentifier { + return FavoritesModelIdentifier( + id: id + ); + } + + String? get userId { + return _userId; + } + + List? get talks { + return _talks; + } + + amplify_core.TemporalDateTime? get createdAt { + return _createdAt; + } + + amplify_core.TemporalDateTime? get updatedAt { + return _updatedAt; + } + + const Favorites._internal({required this.id, userId, talks, createdAt, updatedAt}): _userId = userId, _talks = talks, _createdAt = createdAt, _updatedAt = updatedAt; + + factory Favorites({String? id, String? userId, List? talks}) { + return Favorites._internal( + id: id == null ? amplify_core.UUID.getUUID() : id, + userId: userId, + talks: talks != null ? List.unmodifiable(talks) : talks); + } + + bool equals(Object other) { + return this == other; + } + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is Favorites && + id == other.id && + _userId == other._userId && + DeepCollectionEquality().equals(_talks, other._talks); + } + + @override + int get hashCode => toString().hashCode; + + @override + String toString() { + var buffer = new StringBuffer(); + + buffer.write("Favorites {"); + buffer.write("id=" + "$id" + ", "); + buffer.write("userId=" + "$_userId" + ", "); + buffer.write("createdAt=" + (_createdAt != null ? _createdAt!.format() : "null") + ", "); + buffer.write("updatedAt=" + (_updatedAt != null ? _updatedAt!.format() : "null")); + buffer.write("}"); + + return buffer.toString(); + } + + Favorites copyWith({String? userId, List? talks}) { + return Favorites._internal( + id: id, + userId: userId ?? this.userId, + talks: talks ?? this.talks); + } + + Favorites copyWithModelFieldValues({ + ModelFieldValue? userId, + ModelFieldValue?>? talks + }) { + return Favorites._internal( + id: id, + userId: userId == null ? this.userId : userId.value, + talks: talks == null ? this.talks : talks.value + ); + } + + Favorites.fromJson(Map json) + : id = json['id'], + _userId = json['userId'], + _talks = json['talks'] is Map + ? (json['talks']['items'] is List + ? (json['talks']['items'] as List) + .where((e) => e != null) + .map((e) => FavoritesTalk.fromJson(new Map.from(e))) + .toList() + : null) + : (json['talks'] is List + ? (json['talks'] as List) + .where((e) => e?['serializedData'] != null) + .map((e) => FavoritesTalk.fromJson(new Map.from(e?['serializedData']))) + .toList() + : null), + _createdAt = json['createdAt'] != null ? amplify_core.TemporalDateTime.fromString(json['createdAt']) : null, + _updatedAt = json['updatedAt'] != null ? amplify_core.TemporalDateTime.fromString(json['updatedAt']) : null; + + Map toJson() => { + 'id': id, 'userId': _userId, 'talks': _talks?.map((FavoritesTalk? e) => e?.toJson()).toList(), 'createdAt': _createdAt?.format(), 'updatedAt': _updatedAt?.format() + }; + + Map toMap() => { + 'id': id, + 'userId': _userId, + 'talks': _talks, + 'createdAt': _createdAt, + 'updatedAt': _updatedAt + }; + + static final amplify_core.QueryModelIdentifier MODEL_IDENTIFIER = amplify_core.QueryModelIdentifier(); + static final ID = amplify_core.QueryField(fieldName: "id"); + static final USERID = amplify_core.QueryField(fieldName: "userId"); + static final TALKS = amplify_core.QueryField( + fieldName: "talks", + fieldType: amplify_core.ModelFieldType(amplify_core.ModelFieldTypeEnum.model, ofModelName: 'FavoritesTalk')); + static var schema = amplify_core.Model.defineSchema(define: (amplify_core.ModelSchemaDefinition modelSchemaDefinition) { + modelSchemaDefinition.name = "Favorites"; + modelSchemaDefinition.pluralName = "Favorites"; + + modelSchemaDefinition.authRules = [ + amplify_core.AuthRule( + authStrategy: amplify_core.AuthStrategy.PUBLIC, + provider: amplify_core.AuthRuleProvider.IAM, + operations: const [ + amplify_core.ModelOperation.CREATE, + amplify_core.ModelOperation.UPDATE, + amplify_core.ModelOperation.DELETE, + amplify_core.ModelOperation.READ + ]) + ]; + + modelSchemaDefinition.addField(amplify_core.ModelFieldDefinition.id()); + + modelSchemaDefinition.addField(amplify_core.ModelFieldDefinition.field( + key: Favorites.USERID, + isRequired: false, + ofType: amplify_core.ModelFieldType(amplify_core.ModelFieldTypeEnum.string) + )); + + modelSchemaDefinition.addField(amplify_core.ModelFieldDefinition.hasMany( + key: Favorites.TALKS, + isRequired: false, + ofModelName: 'FavoritesTalk', + associatedKey: FavoritesTalk.FAVORITES + )); + + modelSchemaDefinition.addField(amplify_core.ModelFieldDefinition.nonQueryField( + fieldName: 'createdAt', + isRequired: false, + isReadOnly: true, + ofType: amplify_core.ModelFieldType(amplify_core.ModelFieldTypeEnum.dateTime) + )); + + modelSchemaDefinition.addField(amplify_core.ModelFieldDefinition.nonQueryField( + fieldName: 'updatedAt', + isRequired: false, + isReadOnly: true, + ofType: amplify_core.ModelFieldType(amplify_core.ModelFieldTypeEnum.dateTime) + )); + }); +} + +class _FavoritesModelType extends amplify_core.ModelType { + const _FavoritesModelType(); + + @override + Favorites fromJson(Map jsonData) { + return Favorites.fromJson(jsonData); + } + + @override + String modelName() { + return 'Favorites'; + } +} + +/** + * This is an auto generated class representing the model identifier + * of [Favorites] in your schema. + */ +class FavoritesModelIdentifier implements amplify_core.ModelIdentifier { + final String id; + + /** Create an instance of FavoritesModelIdentifier using [id] the primary key. */ + const FavoritesModelIdentifier({ + required this.id}); + + @override + Map serializeAsMap() => ({ + 'id': id + }); + + @override + List> serializeAsList() => serializeAsMap() + .entries + .map((entry) => ({ entry.key: entry.value })) + .toList(); + + @override + String serializeAsString() => serializeAsMap().values.join('#'); + + @override + String toString() => 'FavoritesModelIdentifier(id: $id)'; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + return other is FavoritesModelIdentifier && + id == other.id; + } + + @override + int get hashCode => + id.hashCode; +} \ No newline at end of file diff --git a/api/packages/fluttercon_data_source/lib/src/models/gen/FavoritesTalk.dart b/api/packages/fluttercon_data_source/lib/src/models/gen/FavoritesTalk.dart new file mode 100644 index 0000000..cbd5121 --- /dev/null +++ b/api/packages/fluttercon_data_source/lib/src/models/gen/FavoritesTalk.dart @@ -0,0 +1,260 @@ +/* +* Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +* +* Licensed under the Apache License, Version 2.0 (the "License"). +* You may not use this file except in compliance with the License. +* A copy of the License is located at +* +* http://aws.amazon.com/apache2.0 +* +* or in the "license" file accompanying this file. This file is distributed +* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +* express or implied. See the License for the specific language governing +* permissions and limitations under the License. +*/ + +// NOTE: This file is generated and may not follow lint rules defined in your app +// Generated files can be excluded from analysis in analysis_options.yaml +// For more info, see: https://dart.dev/guides/language/analysis-options#excluding-code-from-analysis + +// ignore_for_file: public_member_api_docs, annotate_overrides, dead_code, dead_codepublic_member_api_docs, depend_on_referenced_packages, file_names, library_private_types_in_public_api, no_leading_underscores_for_library_prefixes, no_leading_underscores_for_local_identifiers, non_constant_identifier_names, null_check_on_nullable_type_parameter, override_on_non_overriding_member, prefer_adjacent_string_concatenation, prefer_const_constructors, prefer_if_null_operators, prefer_interpolation_to_compose_strings, slash_for_doc_comments, sort_child_properties_last, unnecessary_const, unnecessary_constructor_name, unnecessary_late, unnecessary_new, unnecessary_null_aware_assignments, unnecessary_nullable_for_final_variable_declarations, unnecessary_string_interpolations, use_build_context_synchronously + +import 'ModelProvider.dart'; +import 'package:amplify_core/amplify_core.dart' as amplify_core; + + +/** This is an auto generated class representing the FavoritesTalk type in your schema. */ +class FavoritesTalk extends amplify_core.Model { + static const classType = const _FavoritesTalkModelType(); + final String id; + final Favorites? _favorites; + final Talk? _talk; + final amplify_core.TemporalDateTime? _createdAt; + final amplify_core.TemporalDateTime? _updatedAt; + + @override + getInstanceType() => classType; + + @Deprecated('[getId] is being deprecated in favor of custom primary key feature. Use getter [modelIdentifier] to get model identifier.') + @override + String getId() => id; + + FavoritesTalkModelIdentifier get modelIdentifier { + return FavoritesTalkModelIdentifier( + id: id + ); + } + + Favorites? get favorites { + return _favorites; + } + + Talk? get talk { + return _talk; + } + + amplify_core.TemporalDateTime? get createdAt { + return _createdAt; + } + + amplify_core.TemporalDateTime? get updatedAt { + return _updatedAt; + } + + const FavoritesTalk._internal({required this.id, favorites, talk, createdAt, updatedAt}): _favorites = favorites, _talk = talk, _createdAt = createdAt, _updatedAt = updatedAt; + + factory FavoritesTalk({String? id, Favorites? favorites, Talk? talk}) { + return FavoritesTalk._internal( + id: id == null ? amplify_core.UUID.getUUID() : id, + favorites: favorites, + talk: talk); + } + + bool equals(Object other) { + return this == other; + } + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is FavoritesTalk && + id == other.id && + _favorites == other._favorites && + _talk == other._talk; + } + + @override + int get hashCode => toString().hashCode; + + @override + String toString() { + var buffer = new StringBuffer(); + + buffer.write("FavoritesTalk {"); + buffer.write("id=" + "$id" + ", "); + buffer.write("favorites=" + (_favorites != null ? _favorites!.toString() : "null") + ", "); + buffer.write("talk=" + (_talk != null ? _talk!.toString() : "null") + ", "); + buffer.write("createdAt=" + (_createdAt != null ? _createdAt!.format() : "null") + ", "); + buffer.write("updatedAt=" + (_updatedAt != null ? _updatedAt!.format() : "null")); + buffer.write("}"); + + return buffer.toString(); + } + + FavoritesTalk copyWith({Favorites? favorites, Talk? talk}) { + return FavoritesTalk._internal( + id: id, + favorites: favorites ?? this.favorites, + talk: talk ?? this.talk); + } + + FavoritesTalk copyWithModelFieldValues({ + ModelFieldValue? favorites, + ModelFieldValue? talk + }) { + return FavoritesTalk._internal( + id: id, + favorites: favorites == null ? this.favorites : favorites.value, + talk: talk == null ? this.talk : talk.value + ); + } + + FavoritesTalk.fromJson(Map json) + : id = json['id'], + _favorites = json['favorites'] != null + ? json['favorites']['serializedData'] != null + ? Favorites.fromJson(new Map.from(json['favorites']['serializedData'])) + : Favorites.fromJson(new Map.from(json['favorites'])) + : null, + _talk = json['talk'] != null + ? json['talk']['serializedData'] != null + ? Talk.fromJson(new Map.from(json['talk']['serializedData'])) + : Talk.fromJson(new Map.from(json['talk'])) + : null, + _createdAt = json['createdAt'] != null ? amplify_core.TemporalDateTime.fromString(json['createdAt']) : null, + _updatedAt = json['updatedAt'] != null ? amplify_core.TemporalDateTime.fromString(json['updatedAt']) : null; + + Map toJson() => { + 'id': id, 'favorites': _favorites?.toJson(), 'talk': _talk?.toJson(), 'createdAt': _createdAt?.format(), 'updatedAt': _updatedAt?.format() + }; + + Map toMap() => { + 'id': id, + 'favorites': _favorites, + 'talk': _talk, + 'createdAt': _createdAt, + 'updatedAt': _updatedAt + }; + + static final amplify_core.QueryModelIdentifier MODEL_IDENTIFIER = amplify_core.QueryModelIdentifier(); + static final ID = amplify_core.QueryField(fieldName: "id"); + static final FAVORITES = amplify_core.QueryField( + fieldName: "favorites", + fieldType: amplify_core.ModelFieldType(amplify_core.ModelFieldTypeEnum.model, ofModelName: 'Favorites')); + static final TALK = amplify_core.QueryField( + fieldName: "talk", + fieldType: amplify_core.ModelFieldType(amplify_core.ModelFieldTypeEnum.model, ofModelName: 'Talk')); + static var schema = amplify_core.Model.defineSchema(define: (amplify_core.ModelSchemaDefinition modelSchemaDefinition) { + modelSchemaDefinition.name = "FavoritesTalk"; + modelSchemaDefinition.pluralName = "FavoritesTalks"; + + modelSchemaDefinition.authRules = [ + amplify_core.AuthRule( + authStrategy: amplify_core.AuthStrategy.PUBLIC, + provider: amplify_core.AuthRuleProvider.IAM, + operations: const [ + amplify_core.ModelOperation.CREATE, + amplify_core.ModelOperation.UPDATE, + amplify_core.ModelOperation.DELETE, + amplify_core.ModelOperation.READ + ]) + ]; + + modelSchemaDefinition.addField(amplify_core.ModelFieldDefinition.id()); + + modelSchemaDefinition.addField(amplify_core.ModelFieldDefinition.belongsTo( + key: FavoritesTalk.FAVORITES, + isRequired: false, + targetNames: ['favoritesId'], + ofModelName: 'Favorites' + )); + + modelSchemaDefinition.addField(amplify_core.ModelFieldDefinition.belongsTo( + key: FavoritesTalk.TALK, + isRequired: false, + targetNames: ['talkId'], + ofModelName: 'Talk' + )); + + modelSchemaDefinition.addField(amplify_core.ModelFieldDefinition.nonQueryField( + fieldName: 'createdAt', + isRequired: false, + isReadOnly: true, + ofType: amplify_core.ModelFieldType(amplify_core.ModelFieldTypeEnum.dateTime) + )); + + modelSchemaDefinition.addField(amplify_core.ModelFieldDefinition.nonQueryField( + fieldName: 'updatedAt', + isRequired: false, + isReadOnly: true, + ofType: amplify_core.ModelFieldType(amplify_core.ModelFieldTypeEnum.dateTime) + )); + }); +} + +class _FavoritesTalkModelType extends amplify_core.ModelType { + const _FavoritesTalkModelType(); + + @override + FavoritesTalk fromJson(Map jsonData) { + return FavoritesTalk.fromJson(jsonData); + } + + @override + String modelName() { + return 'FavoritesTalk'; + } +} + +/** + * This is an auto generated class representing the model identifier + * of [FavoritesTalk] in your schema. + */ +class FavoritesTalkModelIdentifier implements amplify_core.ModelIdentifier { + final String id; + + /** Create an instance of FavoritesTalkModelIdentifier using [id] the primary key. */ + const FavoritesTalkModelIdentifier({ + required this.id}); + + @override + Map serializeAsMap() => ({ + 'id': id + }); + + @override + List> serializeAsList() => serializeAsMap() + .entries + .map((entry) => ({ entry.key: entry.value })) + .toList(); + + @override + String serializeAsString() => serializeAsMap().values.join('#'); + + @override + String toString() => 'FavoritesTalkModelIdentifier(id: $id)'; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + return other is FavoritesTalkModelIdentifier && + id == other.id; + } + + @override + int get hashCode => + id.hashCode; +} \ No newline at end of file diff --git a/api/packages/fluttercon_data_source/lib/src/models/gen/ModelProvider.dart b/api/packages/fluttercon_data_source/lib/src/models/gen/ModelProvider.dart index dda7025..a4963a5 100644 --- a/api/packages/fluttercon_data_source/lib/src/models/gen/ModelProvider.dart +++ b/api/packages/fluttercon_data_source/lib/src/models/gen/ModelProvider.dart @@ -20,11 +20,15 @@ // ignore_for_file: public_member_api_docs, annotate_overrides, dead_code, dead_codepublic_member_api_docs, depend_on_referenced_packages, file_names, library_private_types_in_public_api, no_leading_underscores_for_library_prefixes, no_leading_underscores_for_local_identifiers, non_constant_identifier_names, null_check_on_nullable_type_parameter, override_on_non_overriding_member, prefer_adjacent_string_concatenation, prefer_const_constructors, prefer_if_null_operators, prefer_interpolation_to_compose_strings, slash_for_doc_comments, sort_child_properties_last, unnecessary_const, unnecessary_constructor_name, unnecessary_late, unnecessary_new, unnecessary_null_aware_assignments, unnecessary_nullable_for_final_variable_declarations, unnecessary_string_interpolations, use_build_context_synchronously import 'package:amplify_core/amplify_core.dart' as amplify_core; +import 'Favorites.dart'; +import 'FavoritesTalk.dart'; import 'Link.dart'; import 'Speaker.dart'; import 'SpeakerTalk.dart'; import 'Talk.dart'; +export 'Favorites.dart'; +export 'FavoritesTalk.dart'; export 'Link.dart'; export 'LinkType.dart'; export 'Speaker.dart'; @@ -33,9 +37,9 @@ export 'Talk.dart'; class ModelProvider implements amplify_core.ModelProviderInterface { @override - String version = "3eaa20d361813cb6b50c795fafdbe99c"; + String version = "0fa6de5368d8544b6272f24d68148164"; @override - List modelSchemas = [Link.schema, Speaker.schema, SpeakerTalk.schema, Talk.schema]; + List modelSchemas = [Favorites.schema, FavoritesTalk.schema, Link.schema, Speaker.schema, SpeakerTalk.schema, Talk.schema]; @override List customTypeSchemas = []; static final ModelProvider _instance = ModelProvider(); @@ -44,6 +48,10 @@ class ModelProvider implements amplify_core.ModelProviderInterface { amplify_core.ModelType getModelTypeByModelName(String modelName) { switch(modelName) { + case "Favorites": + return Favorites.classType; + case "FavoritesTalk": + return FavoritesTalk.classType; case "Link": return Link.classType; case "Speaker": diff --git a/api/packages/fluttercon_data_source/lib/src/models/gen/Talk.dart b/api/packages/fluttercon_data_source/lib/src/models/gen/Talk.dart index e05e2b4..0719a65 100644 --- a/api/packages/fluttercon_data_source/lib/src/models/gen/Talk.dart +++ b/api/packages/fluttercon_data_source/lib/src/models/gen/Talk.dart @@ -35,6 +35,7 @@ class Talk extends amplify_core.Model { final amplify_core.TemporalDateTime? _endTime; final bool? _isFavorite; final List? _speakers; + final List? _favorites; final amplify_core.TemporalDateTime? _createdAt; final amplify_core.TemporalDateTime? _updatedAt; @@ -79,6 +80,10 @@ class Talk extends amplify_core.Model { return _speakers; } + List? get favorites { + return _favorites; + } + amplify_core.TemporalDateTime? get createdAt { return _createdAt; } @@ -87,9 +92,9 @@ class Talk extends amplify_core.Model { return _updatedAt; } - const Talk._internal({required this.id, title, description, room, startTime, endTime, isFavorite, speakers, createdAt, updatedAt}): _title = title, _description = description, _room = room, _startTime = startTime, _endTime = endTime, _isFavorite = isFavorite, _speakers = speakers, _createdAt = createdAt, _updatedAt = updatedAt; + const Talk._internal({required this.id, title, description, room, startTime, endTime, isFavorite, speakers, favorites, createdAt, updatedAt}): _title = title, _description = description, _room = room, _startTime = startTime, _endTime = endTime, _isFavorite = isFavorite, _speakers = speakers, _favorites = favorites, _createdAt = createdAt, _updatedAt = updatedAt; - factory Talk({String? id, String? title, String? description, String? room, amplify_core.TemporalDateTime? startTime, amplify_core.TemporalDateTime? endTime, bool? isFavorite, List? speakers}) { + factory Talk({String? id, String? title, String? description, String? room, amplify_core.TemporalDateTime? startTime, amplify_core.TemporalDateTime? endTime, bool? isFavorite, List? speakers, List? favorites}) { return Talk._internal( id: id == null ? amplify_core.UUID.getUUID() : id, title: title, @@ -98,7 +103,8 @@ class Talk extends amplify_core.Model { startTime: startTime, endTime: endTime, isFavorite: isFavorite, - speakers: speakers != null ? List.unmodifiable(speakers) : speakers); + speakers: speakers != null ? List.unmodifiable(speakers) : speakers, + favorites: favorites != null ? List.unmodifiable(favorites) : favorites); } bool equals(Object other) { @@ -116,7 +122,8 @@ class Talk extends amplify_core.Model { _startTime == other._startTime && _endTime == other._endTime && _isFavorite == other._isFavorite && - DeepCollectionEquality().equals(_speakers, other._speakers); + DeepCollectionEquality().equals(_speakers, other._speakers) && + DeepCollectionEquality().equals(_favorites, other._favorites); } @override @@ -141,7 +148,7 @@ class Talk extends amplify_core.Model { return buffer.toString(); } - Talk copyWith({String? title, String? description, String? room, amplify_core.TemporalDateTime? startTime, amplify_core.TemporalDateTime? endTime, bool? isFavorite, List? speakers}) { + Talk copyWith({String? title, String? description, String? room, amplify_core.TemporalDateTime? startTime, amplify_core.TemporalDateTime? endTime, bool? isFavorite, List? speakers, List? favorites}) { return Talk._internal( id: id, title: title ?? this.title, @@ -150,7 +157,8 @@ class Talk extends amplify_core.Model { startTime: startTime ?? this.startTime, endTime: endTime ?? this.endTime, isFavorite: isFavorite ?? this.isFavorite, - speakers: speakers ?? this.speakers); + speakers: speakers ?? this.speakers, + favorites: favorites ?? this.favorites); } Talk copyWithModelFieldValues({ @@ -160,7 +168,8 @@ class Talk extends amplify_core.Model { ModelFieldValue? startTime, ModelFieldValue? endTime, ModelFieldValue? isFavorite, - ModelFieldValue?>? speakers + ModelFieldValue?>? speakers, + ModelFieldValue?>? favorites }) { return Talk._internal( id: id, @@ -170,7 +179,8 @@ class Talk extends amplify_core.Model { startTime: startTime == null ? this.startTime : startTime.value, endTime: endTime == null ? this.endTime : endTime.value, isFavorite: isFavorite == null ? this.isFavorite : isFavorite.value, - speakers: speakers == null ? this.speakers : speakers.value + speakers: speakers == null ? this.speakers : speakers.value, + favorites: favorites == null ? this.favorites : favorites.value ); } @@ -195,11 +205,24 @@ class Talk extends amplify_core.Model { .map((e) => SpeakerTalk.fromJson(new Map.from(e?['serializedData']))) .toList() : null), + _favorites = json['favorites'] is Map + ? (json['favorites']['items'] is List + ? (json['favorites']['items'] as List) + .where((e) => e != null) + .map((e) => FavoritesTalk.fromJson(new Map.from(e))) + .toList() + : null) + : (json['favorites'] is List + ? (json['favorites'] as List) + .where((e) => e?['serializedData'] != null) + .map((e) => FavoritesTalk.fromJson(new Map.from(e?['serializedData']))) + .toList() + : null), _createdAt = json['createdAt'] != null ? amplify_core.TemporalDateTime.fromString(json['createdAt']) : null, _updatedAt = json['updatedAt'] != null ? amplify_core.TemporalDateTime.fromString(json['updatedAt']) : null; Map toJson() => { - 'id': id, 'title': _title, 'description': _description, 'room': _room, 'startTime': _startTime?.format(), 'endTime': _endTime?.format(), 'isFavorite': _isFavorite, 'speakers': _speakers?.map((SpeakerTalk? e) => e?.toJson()).toList(), 'createdAt': _createdAt?.format(), 'updatedAt': _updatedAt?.format() + 'id': id, 'title': _title, 'description': _description, 'room': _room, 'startTime': _startTime?.format(), 'endTime': _endTime?.format(), 'isFavorite': _isFavorite, 'speakers': _speakers?.map((SpeakerTalk? e) => e?.toJson()).toList(), 'favorites': _favorites?.map((FavoritesTalk? e) => e?.toJson()).toList(), 'createdAt': _createdAt?.format(), 'updatedAt': _updatedAt?.format() }; Map toMap() => { @@ -211,6 +234,7 @@ class Talk extends amplify_core.Model { 'endTime': _endTime, 'isFavorite': _isFavorite, 'speakers': _speakers, + 'favorites': _favorites, 'createdAt': _createdAt, 'updatedAt': _updatedAt }; @@ -226,6 +250,9 @@ class Talk extends amplify_core.Model { static final SPEAKERS = amplify_core.QueryField( fieldName: "speakers", fieldType: amplify_core.ModelFieldType(amplify_core.ModelFieldTypeEnum.model, ofModelName: 'SpeakerTalk')); + static final FAVORITES = amplify_core.QueryField( + fieldName: "favorites", + fieldType: amplify_core.ModelFieldType(amplify_core.ModelFieldTypeEnum.model, ofModelName: 'FavoritesTalk')); static var schema = amplify_core.Model.defineSchema(define: (amplify_core.ModelSchemaDefinition modelSchemaDefinition) { modelSchemaDefinition.name = "Talk"; modelSchemaDefinition.pluralName = "Talks"; @@ -287,6 +314,13 @@ class Talk extends amplify_core.Model { associatedKey: SpeakerTalk.TALK )); + modelSchemaDefinition.addField(amplify_core.ModelFieldDefinition.hasMany( + key: Talk.FAVORITES, + isRequired: false, + ofModelName: 'FavoritesTalk', + associatedKey: FavoritesTalk.TALK + )); + modelSchemaDefinition.addField(amplify_core.ModelFieldDefinition.nonQueryField( fieldName: 'createdAt', isRequired: false, diff --git a/api/packages/fluttercon_data_source/test/helpers/test_helpers.dart b/api/packages/fluttercon_data_source/test/helpers/test_helpers.dart index 295e966..b3f2dc8 100644 --- a/api/packages/fluttercon_data_source/test/helpers/test_helpers.dart +++ b/api/packages/fluttercon_data_source/test/helpers/test_helpers.dart @@ -15,11 +15,9 @@ class TestHelpers { isFavorite: false, ); - static final favoriteTalk = Talk( - id: '2', - title: 'Talk title', - description: 'Talk description', - isFavorite: true, + static final favorites = Favorites( + id: '1', + userId: '1', ); static final speakerTalk = SpeakerTalk( @@ -28,6 +26,12 @@ class TestHelpers { talk: talk, ); + static final favoritesTalk = FavoritesTalk( + id: '1', + favorites: favorites, + talk: talk, + ); + static PaginatedResult paginatedResult( T item, ModelType modelType, diff --git a/api/packages/fluttercon_data_source/test/src/amplify_api_client_test.dart b/api/packages/fluttercon_data_source/test/src/amplify_api_client_test.dart index 7d5156e..f451400 100644 --- a/api/packages/fluttercon_data_source/test/src/amplify_api_client_test.dart +++ b/api/packages/fluttercon_data_source/test/src/amplify_api_client_test.dart @@ -30,6 +30,26 @@ void main() { expect(client, isNotNull); }); + group('get', () { + test('returns a GraphQLRequest with the given type', () { + GraphQLRequest getFunc( + ModelType modelType, + ModelIdentifier modelIdentifier, { + String? apiName, + APIAuthorizationType? authorizationMode, + Map? headers, + }) { + return GraphQLRequest(document: ''); + } + + when(() => requestWrapper.get).thenReturn(getFunc); + expect( + client.get(Speaker.classType, const SpeakerModelIdentifier(id: '1')), + isA>(), + ); + }); + }); + group('list', () { test('returns a GraphQLRequest with the given type', () { GraphQLRequest> listFunc( @@ -51,6 +71,48 @@ void main() { }); }); + group('create', () { + test('returns a GraphQLRequest with the given type', () { + GraphQLRequest createFunc( + T model, { + String? apiName, + APIAuthorizationType? authorizationMode, + Map? headers, + }) { + return GraphQLRequest(document: ''); + } + + when(() => requestWrapper.create).thenReturn(createFunc); + expect( + client.create(Speaker(id: '1')), + isA>(), + ); + }); + }); + + group('deleteById', () { + test('returns a GraphQLRequest with a given type', () { + GraphQLRequest deleteByIdFunc( + ModelType modelType, + ModelIdentifier modelIdentifier, { + String? apiName, + APIAuthorizationType? authorizationMode, + Map? headers, + }) { + return GraphQLRequest(document: ''); + } + + when(() => requestWrapper.deleteById).thenReturn(deleteByIdFunc); + expect( + client.deleteById( + Speaker.classType, + const SpeakerModelIdentifier(id: '1'), + ), + isA>(), + ); + }); + }); + group('query', () { test('calls api query', () { final request = GraphQLRequest>(document: ''); @@ -64,5 +126,17 @@ void main() { verify(() => api.query(request: request)).called(1); }); }); + + group('mutate', () { + test('calls api mutate', () { + final request = GraphQLRequest(document: ''); + when(() => api.mutate(request: request)).thenReturn( + TestHelpers.graphQLOperation(TestHelpers.speaker), + ); + + client.mutate(request: request); + verify(() => api.mutate(request: request)).called(1); + }); + }); }); } diff --git a/api/packages/fluttercon_data_source/test/src/fluttercon_data_source_test.dart b/api/packages/fluttercon_data_source/test/src/fluttercon_data_source_test.dart index efcbace..fe1dac4 100644 --- a/api/packages/fluttercon_data_source/test/src/fluttercon_data_source_test.dart +++ b/api/packages/fluttercon_data_source/test/src/fluttercon_data_source_test.dart @@ -14,24 +14,465 @@ void main() { late FlutterconDataSource dataSource; setUpAll(() { + registerFallbackValue(Favorites(userId: 'userId')); + registerFallbackValue( + FavoritesTalk( + favorites: Favorites(userId: 'userId'), + talk: Talk(id: 'talkId'), + ), + ); + registerFallbackValue(FavoritesTalk.classType); + registerFallbackValue(FavoritesTalkModelIdentifier(id: 'id')); + registerFallbackValue( + GraphQLRequest(document: ''), + ); + registerFallbackValue( + GraphQLRequest(document: ''), + ); + registerFallbackValue(GraphQLRequest(document: '')); + + registerFallbackValue( + GraphQLRequest>(document: ''), + ); + registerFallbackValue( + GraphQLRequest>(document: ''), + ); registerFallbackValue( GraphQLRequest>(document: ''), ); + registerFallbackValue( + GraphQLRequest>(document: ''), + ); registerFallbackValue( GraphQLRequest>(document: ''), ); registerFallbackValue( - GraphQLRequest>(document: ''), + TalkModelIdentifier(id: 'id'), + ); + }); + + setUp(() { + apiClient = _MockAmplifyApiClient(); + dataSource = FlutterconDataSource(apiClient: apiClient); + }); + + test('can be instantiated', () async { + expect(dataSource, isNotNull); + }); + + group('createFavorites', () { + setUp(() { + when( + () => apiClient.create(any()), + ).thenAnswer( + (_) => GraphQLRequest( + document: '', + ), + ); + }); + + test('returns $Favorites when successful', () async { + when( + () => apiClient.mutate( + request: any( + named: 'request', + that: isA>(), + ), + ), + ).thenReturn( + TestHelpers.graphQLOperation(TestHelpers.favorites), + ); + + final result = await dataSource.createFavorites(userId: 'userId'); + expect(result, isA()); + }); + + test('throws $AmplifyApiException when response has errors', () async { + when( + () => apiClient.mutate( + request: any( + named: 'request', + that: isA>(), + ), + ), + ).thenReturn( + TestHelpers.graphQLOperation( + TestHelpers.favorites, + errors: [GraphQLResponseError(message: 'Error')], + ), + ); + + expect( + () => dataSource.createFavorites(userId: 'userId'), + throwsA(isA()), + ); + }); + + test( + 'throws $AmplifyApiException when response data is null', + () async { + when( + () => apiClient.mutate( + request: any( + named: 'request', + that: isA>(), + ), + ), + ).thenReturn( + TestHelpers.graphQLOperation(null), + ); + + expect( + () => dataSource.createFavorites(userId: 'userId'), + throwsA(isA()), + ); + }, + ); + + test( + 'throws $AmplifyApiException when an exception is thrown', + () async { + when( + () => apiClient.mutate( + request: any( + named: 'request', + that: isA>(), + ), + ), + ).thenThrow(Exception('Error')); + + expect( + () => dataSource.createFavorites(userId: 'userId'), + throwsA(isA()), + ); + }, + ); + }); + + group('createFavoritesTalk', () { + setUp(() { + when( + () => apiClient.create(any()), + ).thenAnswer( + (_) => GraphQLRequest( + document: '', + ), + ); + }); + test('returns $FavoritesTalk when successful', () async { + when( + () => apiClient.mutate( + request: any( + named: 'request', + that: isA>(), + ), + ), + ).thenReturn( + TestHelpers.graphQLOperation(TestHelpers.favoritesTalk), + ); + + final result = await dataSource.createFavoritesTalk( + favoritesId: 'userId', + talkId: 'talkId', + ); + expect(result, isA()); + }); + + test('throws $AmplifyApiException when response has errors', () async { + when( + () => apiClient.mutate( + request: any( + named: 'request', + that: isA>(), + ), + ), + ).thenReturn( + TestHelpers.graphQLOperation( + TestHelpers.favoritesTalk, + errors: [GraphQLResponseError(message: 'Error')], + ), + ); + + expect( + () => dataSource.createFavoritesTalk( + favoritesId: 'userId', + talkId: 'talkId', + ), + throwsA(isA()), + ); + }); + + test( + 'throws $AmplifyApiException when response data is null', + () async { + when( + () => apiClient.mutate( + request: any( + named: 'request', + that: isA>(), + ), + ), + ).thenReturn( + TestHelpers.graphQLOperation(null), + ); + + expect( + () => dataSource.createFavoritesTalk( + favoritesId: 'userId', + talkId: 'talkId', + ), + throwsA(isA()), + ); + }, + ); + + test( + 'throws $AmplifyApiException when an exception is thrown', + () async { + when( + () => apiClient.mutate( + request: any( + named: 'request', + that: isA>(), + ), + ), + ).thenThrow(Exception('Error')); + + expect( + () => dataSource.createFavoritesTalk( + favoritesId: 'userId', + talkId: 'talkId', + ), + throwsA(isA()), + ); + }, + ); + }); + + group('deleteFavoritesTalk', () { + setUp(() { + when( + () => apiClient.deleteById( + FavoritesTalk.classType, + any(), + ), + ).thenAnswer( + (_) => GraphQLRequest( + document: '', + ), + ); + }); + + test('returns $FavoritesTalk when successful', () async { + when( + () => apiClient.mutate( + request: any( + named: 'request', + that: isA>(), + ), + ), + ).thenReturn( + TestHelpers.graphQLOperation(TestHelpers.favoritesTalk), + ); + + final result = await dataSource.deleteFavoritesTalk(id: 'id'); + expect(result, isA()); + }); + + test('throws $AmplifyApiException when response has errors', () async { + when( + () => apiClient.mutate( + request: any( + named: 'request', + that: isA>(), + ), + ), + ).thenReturn( + TestHelpers.graphQLOperation( + TestHelpers.favoritesTalk, + errors: [GraphQLResponseError(message: 'Error')], + ), + ); + + expect( + () => dataSource.deleteFavoritesTalk(id: 'id'), + throwsA(isA()), + ); + }); + + test( + 'throws $AmplifyApiException when response data is null', + () async { + when( + () => apiClient.mutate( + request: any( + named: 'request', + that: isA>(), + ), + ), + ).thenReturn( + TestHelpers.graphQLOperation(null), + ); + + expect( + () => dataSource.deleteFavoritesTalk(id: 'id'), + throwsA(isA()), + ); + }, + ); + + test( + 'throws $AmplifyApiException when an exception is thrown', + () async { + when( + () => apiClient.deleteById( + FavoritesTalk.classType, + any(), + ), + ).thenThrow(Exception('Error')); + + expect( + () => dataSource.deleteFavoritesTalk(id: 'id'), + throwsA(isA()), + ); + }, + ); + }); + + group('getFavorites', () { + setUp(() { + when(() => apiClient.list(Favorites.classType)).thenAnswer( + (_) => GraphQLRequest>( + document: '', + ), + ); + }); + + test('returns ${PaginatedResult} when successful', () async { + when( + () => apiClient.query>( + request: any( + named: 'request', + that: isA>>(), + ), + ), + ).thenReturn( + TestHelpers.graphQLOperation( + TestHelpers.paginatedResult( + TestHelpers.favorites, + Favorites.classType, + ), + ), + ); + + final result = await dataSource.getFavorites(); + expect(result, isA>()); + }); + + test( + 'returns filtered ${PaginatedResult} when successful ' + 'and [userId] is not null', + () async { + when( + () => apiClient.list( + Favorites.classType, + where: any( + named: 'where', + that: isA() + .having((qpo) => qpo.field, 'Field', equals('userId')), + ), + ), + ).thenAnswer( + (_) => GraphQLRequest>( + document: '', + ), + ); + when( + () => apiClient.query>( + request: any( + named: 'request', + that: isA>>(), + ), + ), + ).thenReturn( + TestHelpers.graphQLOperation( + TestHelpers.paginatedResult( + TestHelpers.favorites, + Favorites.classType, + ), + ), + ); + + final result = await dataSource.getFavorites(userId: 'userId'); + expect( + result, + isA>().having( + (result) => result.items, + 'favorites', + contains(TestHelpers.favorites), + ), + ); + }, + ); + + test('throws $AmplifyApiException when response has errors', () async { + when( + () => apiClient.query>( + request: any( + named: 'request', + that: isA>>(), + ), + ), + ).thenReturn( + TestHelpers.graphQLOperation( + TestHelpers.paginatedResult( + TestHelpers.favorites, + Favorites.classType, + ), + errors: [GraphQLResponseError(message: 'Error')], + ), + ); + + expect( + () => dataSource.getFavorites(), + throwsA(isA()), + ); + }); + + test( + 'throws $AmplifyApiException when response data is null', + () async { + when( + () => apiClient.query>( + request: any( + named: 'request', + that: isA>>(), + ), + ), + ).thenReturn( + TestHelpers.graphQLOperation(null), + ); + + expect( + () => dataSource.getFavorites(), + throwsA(isA()), + ); + }, ); - }); - setUp(() { - apiClient = _MockAmplifyApiClient(); - dataSource = FlutterconDataSource(apiClient: apiClient); - }); + test( + 'throws $AmplifyApiException when an exception is thrown', + () async { + when(() => apiClient.list(Favorites.classType)).thenThrow( + Exception('Error'), + ); - test('can be instantiated', () async { - expect(dataSource, isNotNull); + expect( + () => dataSource.getFavorites(), + throwsA(isA()), + ); + }, + ); }); group('getSpeakers', () { @@ -143,63 +584,258 @@ void main() { expect(result, isA>()); }); + test('throws $AmplifyApiException when response has errors', () async { + when( + () => apiClient.query>( + request: any( + named: 'request', + that: isA>>(), + ), + ), + ).thenReturn( + TestHelpers.graphQLOperation( + TestHelpers.paginatedResult(TestHelpers.talk, Talk.classType), + errors: [GraphQLResponseError(message: 'Error')], + ), + ); + + expect( + () => dataSource.getTalks(), + throwsA(isA()), + ); + }); + + test( + 'throws $AmplifyApiException when response data is null', + () async { + when( + () => apiClient.query>( + request: any( + named: 'request', + that: isA>>(), + ), + ), + ).thenReturn( + TestHelpers.graphQLOperation(null), + ); + + expect( + () => dataSource.getTalks(), + throwsA(isA()), + ); + }, + ); + + test( + 'throws $AmplifyApiException when an exception is thrown', + () async { + when(() => apiClient.list(Talk.classType)).thenThrow( + Exception('Error'), + ); + + expect( + () => dataSource.getTalks(), + throwsA(isA()), + ); + }, + ); + }); + + group('getTalk', () { + setUp(() { + when(() => apiClient.get(Talk.classType, any())).thenAnswer( + (_) => GraphQLRequest( + document: '', + ), + ); + }); + + test('returns $Talk when successful', () async { + when( + () => apiClient.query( + request: any( + named: 'request', + that: isA>(), + ), + ), + ).thenReturn( + TestHelpers.graphQLOperation(TestHelpers.talk), + ); + + final result = await dataSource.getTalk(id: 'id'); + expect(result, isA()); + }); + + test('throws $AmplifyApiException when response has errors', () async { + when( + () => apiClient.query( + request: any( + named: 'request', + that: isA>(), + ), + ), + ).thenReturn( + TestHelpers.graphQLOperation( + TestHelpers.talk, + errors: [GraphQLResponseError(message: 'Error')], + ), + ); + + expect( + () => dataSource.getTalk(id: 'id'), + throwsA(isA()), + ); + }); + + test( + 'throws $AmplifyApiException when response data is null', + () async { + when( + () => apiClient.query( + request: any( + named: 'request', + that: isA>(), + ), + ), + ).thenReturn( + TestHelpers.graphQLOperation(null), + ); + + expect( + () => dataSource.getTalk(id: 'id'), + throwsA(isA()), + ); + }, + ); + test( - 'returns filtered ${PaginatedResult} when successful ' - 'and [favorites] is true', () async { + 'throws $AmplifyApiException when an exception is thrown', + () async { + when(() => apiClient.get(Talk.classType, any())).thenThrow( + Exception('Error'), + ); + + expect( + () => dataSource.getTalk(id: 'id'), + throwsA(isA()), + ); + }, + ); + }); + + group('getFavoritesTalks', () { + Matcher? isAFavoritesTalkQuery({bool hasTalk = false}) { + final havingFavoritesAndTalks = isA() + .having((qpo) => qpo.field, 'Field', equals('favorites')) + .having((qpo) => qpo.field, 'Field', equals('talk')); + + final havingFavorites = isA() + .having((qpo) => qpo.field, 'Field', equals('favorites')); + + return isA().having( + (qpg) => qpg.predicates, + 'Predicates', + contains( + hasTalk ? havingFavoritesAndTalks : havingFavorites, + ), + ); + } + + setUp(() { when( () => apiClient.list( - Talk.classType, - where: any(named: 'where'), + FavoritesTalk.classType, + where: any( + named: 'where', + that: isAFavoritesTalkQuery(), + ), ), ).thenAnswer( - (_) => GraphQLRequest>( + (_) => GraphQLRequest>( document: '', ), ); + }); + + test('returns ${PaginatedResult} when successful', + () async { when( - () => apiClient.query>( + () => apiClient.query>( request: any( named: 'request', - that: isA>>(), + that: isA>>(), ), ), ).thenReturn( TestHelpers.graphQLOperation( TestHelpers.paginatedResult( - TestHelpers.favoriteTalk, - Talk.classType, + TestHelpers.favoritesTalk, + FavoritesTalk.classType, ), ), ); - final result = await dataSource.getTalks(favorites: true); - expect( - result, - isA>().having( - (result) => result.items, - 'talks', - contains(TestHelpers.favoriteTalk), + final result = await dataSource.getFavoritesTalks(favoritesId: 'id'); + expect(result, isA>()); + }); + + test( + 'returns ${PaginatedResult} filtered by talk ' + 'when successful', () async { + when( + () => apiClient.list( + FavoritesTalk.classType, + where: any( + named: 'where', + that: isAFavoritesTalkQuery(hasTalk: true), + ), + ), + ).thenAnswer( + (_) => GraphQLRequest>(document: ''), + ); + when( + () => apiClient.query>( + request: any( + named: 'request', + that: isA>>(), + ), + ), + ).thenReturn( + TestHelpers.graphQLOperation( + TestHelpers.paginatedResult( + TestHelpers.favoritesTalk, + FavoritesTalk.classType, + ), ), ); + + final result = await dataSource.getFavoritesTalks( + favoritesId: 'id', + talkId: 'talkId', + ); + expect(result, isA>()); }); test('throws $AmplifyApiException when response has errors', () async { when( - () => apiClient.query>( + () => apiClient.query>( request: any( named: 'request', - that: isA>>(), + that: isA>>(), ), ), ).thenReturn( TestHelpers.graphQLOperation( - TestHelpers.paginatedResult(TestHelpers.talk, Talk.classType), + TestHelpers.paginatedResult( + TestHelpers.favoritesTalk, + FavoritesTalk.classType, + ), errors: [GraphQLResponseError(message: 'Error')], ), ); expect( - () => dataSource.getTalks(), + () => dataSource.getFavoritesTalks(favoritesId: 'id'), throwsA(isA()), ); }); @@ -208,10 +844,10 @@ void main() { 'throws $AmplifyApiException when response data is null', () async { when( - () => apiClient.query>( + () => apiClient.query>( request: any( named: 'request', - that: isA>>(), + that: isA>>(), ), ), ).thenReturn( @@ -219,7 +855,7 @@ void main() { ); expect( - () => dataSource.getTalks(), + () => dataSource.getFavoritesTalks(favoritesId: 'id'), throwsA(isA()), ); }, @@ -228,12 +864,103 @@ void main() { test( 'throws $AmplifyApiException when an exception is thrown', () async { - when(() => apiClient.list(Talk.classType)).thenThrow( + when( + () => apiClient.list( + FavoritesTalk.classType, + where: any( + named: 'where', + that: isAFavoritesTalkQuery(), + ), + ), + ).thenThrow( Exception('Error'), ); expect( - () => dataSource.getTalks(), + () => dataSource.getFavoritesTalks(favoritesId: 'id'), + throwsA(isA()), + ); + }, + ); + }); + + group('getFavoritesTalk', () { + setUp(() { + when(() => apiClient.get(FavoritesTalk.classType, any())) + .thenAnswer( + (_) => GraphQLRequest( + document: '', + ), + ); + }); + + test('returns $FavoritesTalk when successful', () async { + when( + () => apiClient.query( + request: any( + named: 'request', + that: isA>(), + ), + ), + ).thenReturn( + TestHelpers.graphQLOperation(TestHelpers.favoritesTalk), + ); + + final result = await dataSource.getFavoritesTalk(id: 'id'); + expect(result, isA()); + }); + + test('throws $AmplifyApiException when response has errors', () async { + when( + () => apiClient.query( + request: any( + named: 'request', + that: isA>(), + ), + ), + ).thenReturn( + TestHelpers.graphQLOperation( + TestHelpers.favoritesTalk, + errors: [GraphQLResponseError(message: 'Error')], + ), + ); + + expect( + () => dataSource.getFavoritesTalk(id: 'id'), + throwsA(isA()), + ); + }); + + test( + 'throws $AmplifyApiException when response data is null', + () async { + when( + () => apiClient.query( + request: any( + named: 'request', + that: isA>(), + ), + ), + ).thenReturn( + TestHelpers.graphQLOperation(null), + ); + + expect( + () => dataSource.getFavoritesTalk(id: 'id'), + throwsA(isA()), + ); + }, + ); + + test( + 'throws $AmplifyApiException when an exception is thrown', + () async { + when( + () => apiClient.get(FavoritesTalk.classType, any()), + ).thenThrow(Exception('Error')); + + expect( + () => dataSource.getFavoritesTalk(id: 'id'), throwsA(isA()), ); }, diff --git a/api/packages/fluttercon_shared_models/lib/src/models/create_favorite_request.dart b/api/packages/fluttercon_shared_models/lib/src/models/create_favorite_request.dart new file mode 100644 index 0000000..3d57feb --- /dev/null +++ b/api/packages/fluttercon_shared_models/lib/src/models/create_favorite_request.dart @@ -0,0 +1,32 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'create_favorite_request.g.dart'; + +/// {@template create_favorite_request} +/// A data model representing a request to create a favorite talk. +/// {@endtemplate} +@JsonSerializable() +class CreateFavoriteRequest extends Equatable { + /// {@macro create_favorite_request} + const CreateFavoriteRequest({ + required this.userId, + required this.talkId, + }); + + /// Converts a JSON object into a [CreateFavoriteRequest] instance. + factory CreateFavoriteRequest.fromJson(Map json) => + _$CreateFavoriteRequestFromJson(json); + + /// Converts this [CreateFavoriteRequest] instance into a JSON object. + Map toJson() => _$CreateFavoriteRequestToJson(this); + + /// The unique ID corresponding to the user who is adding the favorite talk. + final String userId; + + /// The unique ID corresponding to the talk being added to favorites. + final String talkId; + + @override + List get props => [userId, talkId]; +} diff --git a/api/packages/fluttercon_shared_models/lib/src/models/create_favorite_request.g.dart b/api/packages/fluttercon_shared_models/lib/src/models/create_favorite_request.g.dart new file mode 100644 index 0000000..bf541ae --- /dev/null +++ b/api/packages/fluttercon_shared_models/lib/src/models/create_favorite_request.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'create_favorite_request.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CreateFavoriteRequest _$CreateFavoriteRequestFromJson( + Map json) => + CreateFavoriteRequest( + userId: json['userId'] as String, + talkId: json['talkId'] as String, + ); + +Map _$CreateFavoriteRequestToJson( + CreateFavoriteRequest instance) => + { + 'userId': instance.userId, + 'talkId': instance.talkId, + }; diff --git a/api/packages/fluttercon_shared_models/lib/src/models/create_favorite_response.dart b/api/packages/fluttercon_shared_models/lib/src/models/create_favorite_response.dart new file mode 100644 index 0000000..962446e --- /dev/null +++ b/api/packages/fluttercon_shared_models/lib/src/models/create_favorite_response.dart @@ -0,0 +1,32 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'create_favorite_response.g.dart'; + +/// {@template create_favorite_response} +/// A data model representing a response to create a favorite talk. +/// {@endtemplate} +@JsonSerializable() +class CreateFavoriteResponse extends Equatable { + /// {@macro create_favorite_response} + const CreateFavoriteResponse({ + required this.userId, + required this.talkId, + }); + + /// Converts a JSON object into a [CreateFavoriteResponse] instance. + factory CreateFavoriteResponse.fromJson(Map json) => + _$CreateFavoriteResponseFromJson(json); + + /// Converts this [CreateFavoriteResponse] instance into a JSON object. + Map toJson() => _$CreateFavoriteResponseToJson(this); + + /// The unique ID corresponding to the user who is adding the favorite talk. + final String userId; + + /// The unique ID corresponding to the talk being added to favorites. + final String talkId; + + @override + List get props => [userId, talkId]; +} diff --git a/api/packages/fluttercon_shared_models/lib/src/models/create_favorite_response.g.dart b/api/packages/fluttercon_shared_models/lib/src/models/create_favorite_response.g.dart new file mode 100644 index 0000000..c9870c2 --- /dev/null +++ b/api/packages/fluttercon_shared_models/lib/src/models/create_favorite_response.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'create_favorite_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CreateFavoriteResponse _$CreateFavoriteResponseFromJson( + Map json) => + CreateFavoriteResponse( + userId: json['userId'] as String, + talkId: json['talkId'] as String, + ); + +Map _$CreateFavoriteResponseToJson( + CreateFavoriteResponse instance) => + { + 'userId': instance.userId, + 'talkId': instance.talkId, + }; diff --git a/api/packages/fluttercon_shared_models/lib/src/models/delete_favorite_request.dart b/api/packages/fluttercon_shared_models/lib/src/models/delete_favorite_request.dart new file mode 100644 index 0000000..211d9f5 --- /dev/null +++ b/api/packages/fluttercon_shared_models/lib/src/models/delete_favorite_request.dart @@ -0,0 +1,32 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'delete_favorite_request.g.dart'; + +/// {@template delete_favorite_request} +/// A data model representing a request to delete a favorite talk. +/// {@endtemplate} +@JsonSerializable() +class DeleteFavoriteRequest extends Equatable { + /// {@macro delete_favorite_request} + const DeleteFavoriteRequest({ + required this.userId, + required this.talkId, + }); + + /// Converts a JSON object into a [DeleteFavoriteRequest] instance. + factory DeleteFavoriteRequest.fromJson(Map json) => + _$DeleteFavoriteRequestFromJson(json); + + /// Converts this [DeleteFavoriteRequest] instance into a JSON object. + Map toJson() => _$DeleteFavoriteRequestToJson(this); + + /// The unique ID corresponding to the user who is adding the favorite talk. + final String userId; + + /// The unique ID corresponding to the talk being added to favorites. + final String talkId; + + @override + List get props => [userId, talkId]; +} diff --git a/api/packages/fluttercon_shared_models/lib/src/models/delete_favorite_request.g.dart b/api/packages/fluttercon_shared_models/lib/src/models/delete_favorite_request.g.dart new file mode 100644 index 0000000..ce1134d --- /dev/null +++ b/api/packages/fluttercon_shared_models/lib/src/models/delete_favorite_request.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'delete_favorite_request.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +DeleteFavoriteRequest _$DeleteFavoriteRequestFromJson( + Map json) => + DeleteFavoriteRequest( + userId: json['userId'] as String, + talkId: json['talkId'] as String, + ); + +Map _$DeleteFavoriteRequestToJson( + DeleteFavoriteRequest instance) => + { + 'userId': instance.userId, + 'talkId': instance.talkId, + }; diff --git a/api/packages/fluttercon_shared_models/lib/src/models/delete_favorite_response.dart b/api/packages/fluttercon_shared_models/lib/src/models/delete_favorite_response.dart new file mode 100644 index 0000000..d06dd0c --- /dev/null +++ b/api/packages/fluttercon_shared_models/lib/src/models/delete_favorite_response.dart @@ -0,0 +1,32 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'delete_favorite_response.g.dart'; + +/// {@template delete_favorite_response} +/// A data model representing a response to delete a favorite talk. +/// {@endtemplate} +@JsonSerializable() +class DeleteFavoriteResponse extends Equatable { + /// {@macro delete_favorite_response} + const DeleteFavoriteResponse({ + required this.userId, + required this.talkId, + }); + + /// Converts a JSON object into a [DeleteFavoriteResponse] instance. + factory DeleteFavoriteResponse.fromJson(Map json) => + _$DeleteFavoriteResponseFromJson(json); + + /// Converts this [DeleteFavoriteResponse] instance into a JSON object. + Map toJson() => _$DeleteFavoriteResponseToJson(this); + + /// The unique ID corresponding to the user who is adding the favorite talk. + final String userId; + + /// The unique ID corresponding to the talk being added to favorites. + final String talkId; + + @override + List get props => [userId, talkId]; +} diff --git a/api/packages/fluttercon_shared_models/lib/src/models/delete_favorite_response.g.dart b/api/packages/fluttercon_shared_models/lib/src/models/delete_favorite_response.g.dart new file mode 100644 index 0000000..d27fadc --- /dev/null +++ b/api/packages/fluttercon_shared_models/lib/src/models/delete_favorite_response.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'delete_favorite_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +DeleteFavoriteResponse _$DeleteFavoriteResponseFromJson( + Map json) => + DeleteFavoriteResponse( + userId: json['userId'] as String, + talkId: json['talkId'] as String, + ); + +Map _$DeleteFavoriteResponseToJson( + DeleteFavoriteResponse instance) => + { + 'userId': instance.userId, + 'talkId': instance.talkId, + }; diff --git a/api/packages/fluttercon_shared_models/lib/src/models/models.dart b/api/packages/fluttercon_shared_models/lib/src/models/models.dart index 8beb90b..c9bbcd7 100644 --- a/api/packages/fluttercon_shared_models/lib/src/models/models.dart +++ b/api/packages/fluttercon_shared_models/lib/src/models/models.dart @@ -1,3 +1,7 @@ +export 'create_favorite_request.dart'; +export 'create_favorite_response.dart'; +export 'delete_favorite_request.dart'; +export 'delete_favorite_response.dart'; export 'paginated_data.dart'; export 'speaker_detail.dart'; export 'speaker_link.dart'; diff --git a/api/packages/fluttercon_shared_models/test/src/helpers/test_helpers.dart b/api/packages/fluttercon_shared_models/test/src/helpers/test_helpers.dart index b014d5a..e0c65a2 100644 --- a/api/packages/fluttercon_shared_models/test/src/helpers/test_helpers.dart +++ b/api/packages/fluttercon_shared_models/test/src/helpers/test_helpers.dart @@ -103,4 +103,36 @@ class TestHelpers { 'startTime': '2024-01-01T00:00:00.000', 'talks': [talkPreviewJson], }; + + static const createFavoriteRequest = + CreateFavoriteRequest(userId: 'userId', talkId: '1'); + + static const createFavoriteRequestJson = { + 'userId': 'userId', + 'talkId': '1', + }; + + static const createFavoriteResponse = + CreateFavoriteResponse(userId: 'userId', talkId: '1'); + + static const createFavoriteResponseJson = { + 'userId': 'userId', + 'talkId': '1', + }; + + static const deleteFavoriteRequest = + DeleteFavoriteRequest(userId: 'userId', talkId: '1'); + + static const deleteFavoriteRequestJson = { + 'userId': 'userId', + 'talkId': '1', + }; + + static const deleteFavoriteResponse = + DeleteFavoriteResponse(userId: 'userId', talkId: '1'); + + static const deleteFavoriteResponseJson = { + 'userId': 'userId', + 'talkId': '1', + }; } diff --git a/api/packages/fluttercon_shared_models/test/src/models/create_favorite_request_test.dart b/api/packages/fluttercon_shared_models/test/src/models/create_favorite_request_test.dart new file mode 100644 index 0000000..5b0582c --- /dev/null +++ b/api/packages/fluttercon_shared_models/test/src/models/create_favorite_request_test.dart @@ -0,0 +1,46 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:fluttercon_shared_models/src/models/models.dart'; +import 'package:test/test.dart'; + +import '../helpers/test_helpers.dart'; + +void main() { + group('CreateFavoriteRequest', () { + test('supports value equality', () { + expect( + CreateFavoriteRequest( + userId: '1', + talkId: '1', + ), + equals( + CreateFavoriteRequest( + userId: '1', + talkId: '1', + ), + ), + ); + }); + + group('fromJson', () { + test('can be created from valid JSON', () { + final request = CreateFavoriteRequest.fromJson( + TestHelpers.createFavoriteRequestJson, + ); + expect( + request, + equals( + TestHelpers.createFavoriteRequest, + ), + ); + }); + }); + + group('toJson', () { + test('can be serialized to JSON', () { + final json = TestHelpers.createFavoriteRequest.toJson(); + expect(json, equals(TestHelpers.createFavoriteRequestJson)); + }); + }); + }); +} diff --git a/api/packages/fluttercon_shared_models/test/src/models/create_favorite_response_test.dart b/api/packages/fluttercon_shared_models/test/src/models/create_favorite_response_test.dart new file mode 100644 index 0000000..d49b11c --- /dev/null +++ b/api/packages/fluttercon_shared_models/test/src/models/create_favorite_response_test.dart @@ -0,0 +1,46 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:fluttercon_shared_models/src/models/models.dart'; +import 'package:test/test.dart'; + +import '../helpers/test_helpers.dart'; + +void main() { + group('CreateFavoriteResponse', () { + test('supports value equality', () { + expect( + CreateFavoriteResponse( + userId: '1', + talkId: '1', + ), + equals( + CreateFavoriteResponse( + userId: '1', + talkId: '1', + ), + ), + ); + }); + + group('fromJson', () { + test('can be created from valid JSON', () { + final response = CreateFavoriteResponse.fromJson( + TestHelpers.createFavoriteResponseJson, + ); + expect( + response, + equals( + TestHelpers.createFavoriteResponse, + ), + ); + }); + }); + + group('toJson', () { + test('can be serialized to JSON', () { + final json = TestHelpers.createFavoriteResponse.toJson(); + expect(json, equals(TestHelpers.createFavoriteResponseJson)); + }); + }); + }); +} diff --git a/api/packages/fluttercon_shared_models/test/src/models/delete_favorite_request_test.dart b/api/packages/fluttercon_shared_models/test/src/models/delete_favorite_request_test.dart new file mode 100644 index 0000000..aa9ccf6 --- /dev/null +++ b/api/packages/fluttercon_shared_models/test/src/models/delete_favorite_request_test.dart @@ -0,0 +1,46 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:fluttercon_shared_models/src/models/models.dart'; +import 'package:test/test.dart'; + +import '../helpers/test_helpers.dart'; + +void main() { + group('DeleteFavoriteRequest', () { + test('supports value equality', () { + expect( + DeleteFavoriteRequest( + userId: '1', + talkId: '1', + ), + equals( + DeleteFavoriteRequest( + userId: '1', + talkId: '1', + ), + ), + ); + }); + + group('fromJson', () { + test('can be created from valid JSON', () { + final request = DeleteFavoriteRequest.fromJson( + TestHelpers.deleteFavoriteRequestJson, + ); + expect( + request, + equals( + TestHelpers.deleteFavoriteRequest, + ), + ); + }); + }); + + group('toJson', () { + test('can be serialized to JSON', () { + final json = TestHelpers.deleteFavoriteRequest.toJson(); + expect(json, equals(TestHelpers.deleteFavoriteRequestJson)); + }); + }); + }); +} diff --git a/api/packages/fluttercon_shared_models/test/src/models/delete_favorite_response_test.dart b/api/packages/fluttercon_shared_models/test/src/models/delete_favorite_response_test.dart new file mode 100644 index 0000000..cfffe65 --- /dev/null +++ b/api/packages/fluttercon_shared_models/test/src/models/delete_favorite_response_test.dart @@ -0,0 +1,46 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:fluttercon_shared_models/src/models/models.dart'; +import 'package:test/test.dart'; + +import '../helpers/test_helpers.dart'; + +void main() { + group('DeleteFavoriteResponse', () { + test('supports value equality', () { + expect( + DeleteFavoriteResponse( + userId: '1', + talkId: '1', + ), + equals( + DeleteFavoriteResponse( + userId: '1', + talkId: '1', + ), + ), + ); + }); + + group('fromJson', () { + test('can be created from valid JSON', () { + final response = DeleteFavoriteResponse.fromJson( + TestHelpers.deleteFavoriteResponseJson, + ); + expect( + response, + equals( + TestHelpers.deleteFavoriteResponse, + ), + ); + }); + }); + + group('toJson', () { + test('can be serialized to JSON', () { + final json = TestHelpers.deleteFavoriteResponse.toJson(); + expect(json, equals(TestHelpers.deleteFavoriteResponseJson)); + }); + }); + }); +} diff --git a/api/packages/talks_repository/lib/src/talks_repository.dart b/api/packages/talks_repository/lib/src/talks_repository.dart index 65a4fdd..e2918a6 100644 --- a/api/packages/talks_repository/lib/src/talks_repository.dart +++ b/api/packages/talks_repository/lib/src/talks_repository.dart @@ -7,6 +7,9 @@ import 'package:fluttercon_shared_models/fluttercon_shared_models.dart'; /// The cache key for the talks cache. const talksCacheKey = 'talks'; +/// The cache key for the user Id of a favorites cache. +String favoritesUserCacheKey(String userId) => 'favorites_$userId'; + /// {@template talks_repository} /// A repository to cache and prepare talk data retrieved from the api. /// {@endtemplate} @@ -21,6 +24,54 @@ class TalksRepository { final FlutterconDataSource _dataSource; final FlutterconCache _cache; + /// Create a favorite talk entity + /// from a [CreateFavoriteRequest]. + Future createFavorite({ + required CreateFavoriteRequest request, + }) async { + final favorites = await _getFavoritesByUser(request.userId); + + final createResponse = await _dataSource.createFavoritesTalk( + favoritesId: favorites.id, + talkId: request.talkId, + ); + + return CreateFavoriteResponse( + userId: createResponse.favorites?.userId ?? '', + talkId: createResponse.talk?.id ?? '', + ); + } + + /// Delete a favorite talk entity + /// from a [DeleteFavoriteRequest]. + Future deleteFavorite({ + required DeleteFavoriteRequest request, + }) async { + final favorites = await _getFavoritesByUser(request.userId); + + final favoritesTalkResponse = await _dataSource.getFavoritesTalks( + favoritesId: favorites.id, + talkId: request.talkId, + ); + + if (favoritesTalkResponse.items.isEmpty || + favoritesTalkResponse.items.first == null) { + return DeleteFavoriteResponse( + userId: request.userId, + talkId: request.talkId, + ); + } + + final deleteResponse = await _dataSource.deleteFavoritesTalk( + id: favoritesTalkResponse.items.first!.id, + ); + + return DeleteFavoriteResponse( + userId: deleteResponse.favorites?.userId ?? '', + talkId: deleteResponse.talk?.id ?? '', + ); + } + /// Fetches a paginated list of talks. /// Fetches from cache if available, and from api /// if the cache is empty. @@ -41,12 +92,73 @@ class TalksRepository { return result; } - final timeSlots = []; - final talks = []; - final talksResponse = await _dataSource.getTalks(); - for (final talk in talksResponse.items) { - if (talk == null) continue; + + final talks = talksResponse.items + .where((talk) => talk != null) + .map((talk) => talk!) + .toList(); + + final timeSlots = await _buildTalkTimeSlots(talks); + + final result = PaginatedData( + items: timeSlots, + limit: talksResponse.limit, + nextToken: talksResponse.nextToken, + ); + + await _cache.set( + talksCacheKey, + jsonEncode( + result.toJson( + (val) => val.toJson(), + ), + ), + ); + return result; + } + + /// Fetches a paginated list of talks for a given [userId]. + /// + /// Returns [TalkTimeSlot] objects with speaker information + /// for each one. + Future> getFavorites({ + required String userId, + }) async { + final favoritesResponse = await _dataSource.getFavorites(userId: userId); + + if (favoritesResponse.items.isEmpty || + favoritesResponse.items.first == null) { + return PaginatedData( + items: const [], + limit: favoritesResponse.limit, + nextToken: favoritesResponse.nextToken, + ); + } + + final favorites = favoritesResponse.items.first!; + + final favoritesTalks = + await _dataSource.getFavoritesTalks(favoritesId: favorites.id); + + final talks = favoritesTalks.items + .where((ft) => ft?.talk != null) + .map((ft) => ft!.talk!) + .toList(); + + final timeSlots = await _buildTalkTimeSlots(talks); + + return PaginatedData( + items: timeSlots, + limit: favoritesTalks.limit, + nextToken: favoritesTalks.nextToken, + ); + } + + Future> _buildTalkTimeSlots(List talks) async { + final talkPreviews = []; + final timeSlots = []; + for (final talk in talks) { final speakerTalks = await _dataSource.getSpeakerTalks(talk: talk); final talkPreview = TalkPreview( id: talk.id, @@ -56,12 +168,14 @@ class TalksRepository { speakerNames: speakerTalks.items.map((st) => st?.speaker?.name ?? '').toList(), ); - talks.add(talkPreview); + talkPreviews.add(talkPreview); } - final times = talks.map((t) => t.startTime).toSet(); + + final times = talkPreviews.map((t) => t.startTime).toSet(); for (final time in times) { - final talksForTime = talks.where((t) => t.startTime == time).toList(); + final talksForTime = + talkPreviews.where((t) => t.startTime == time).toList(); final timeSlot = TalkTimeSlot( startTime: time, talks: talksForTime, @@ -72,20 +186,27 @@ class TalksRepository { final sortedTimeSlots = [...timeSlots] ..sort((a, b) => a.startTime.compareTo(b.startTime)); - final result = PaginatedData( - items: sortedTimeSlots, - limit: talksResponse.limit, - nextToken: talksResponse.nextToken, - ); + return sortedTimeSlots; + } - await _cache.set( - talksCacheKey, - jsonEncode( - result.toJson( - (val) => val.toJson(), - ), - ), - ); - return result; + Future _getFavoritesByUser(String userId) async { + late final Favorites favorites; + + final cachedFavorites = await _cache.get(favoritesUserCacheKey(userId)); + + if (cachedFavorites != null) { + final json = jsonDecode(cachedFavorites) as Map; + favorites = Favorites.fromJson(json); + } else { + favorites = await _dataSource.createFavorites( + userId: userId, + ); + await _cache.set( + favoritesUserCacheKey(userId), + jsonEncode(favorites.toJson()), + ); + } + + return favorites; } } diff --git a/api/packages/talks_repository/test/helpers/test_helpers.dart b/api/packages/talks_repository/test/helpers/test_helpers.dart index f19d257..7d9d5c2 100644 --- a/api/packages/talks_repository/test/helpers/test_helpers.dart +++ b/api/packages/talks_repository/test/helpers/test_helpers.dart @@ -10,6 +10,25 @@ class TestHelpers { static final talk2StartTime = talk2StartTimeTemporal.getDateTimeInUtc(); static final talk3StartTime = talk3StartTimeTemporal.getDateTimeInUtc(); + static const favoritesId = 'favoritesId'; + static const userId = 'userId'; + + static final favorites = PaginatedResult( + [Favorites(id: favoritesId, userId: userId)], + null, + null, + null, + Favorites.classType, + null, + ); + + static final favoritesJson = { + 'id': favoritesId, + 'items': [ + {'userId': userId}, + ], + }; + static final talks = PaginatedResult( [ Talk( @@ -38,6 +57,66 @@ class TestHelpers { null, ); + static final createFavoriteRequest = CreateFavoriteRequest( + userId: userId, + talkId: talks.items[0]!.id, + ); + + static final createFavoriteResponse = CreateFavoriteResponse( + userId: userId, + talkId: talks.items[0]!.id, + ); + + static final deleteFavoriteRequest = DeleteFavoriteRequest( + userId: userId, + talkId: talks.items[0]!.id, + ); + + static final deleteFavoriteResponse = DeleteFavoriteResponse( + userId: userId, + talkId: talks.items[0]!.id, + ); + + static final favoritesTalks = PaginatedResult( + [ + FavoritesTalk( + id: '1', + favorites: favorites.items[0], + talk: talks.items[0], + ), + FavoritesTalk( + id: '2', + favorites: favorites.items[0], + talk: talks.items[1], + ), + FavoritesTalk( + id: '3', + favorites: favorites.items[0], + talk: talks.items[2], + ), + ], + null, + null, + null, + FavoritesTalk.classType, + null, + ); + + static final favoritesTalkSingle = PaginatedResult( + [ + FavoritesTalk( + id: '1', + favorites: favorites.items[0], + talk: talks.items[0], + ), + ], + null, + null, + null, + FavoritesTalk.classType, + null, + ); + static PaginatedResult speakerTalks(Talk talk) => PaginatedResult( [ diff --git a/api/packages/talks_repository/test/src/talks_repository_test.dart b/api/packages/talks_repository/test/src/talks_repository_test.dart index a80308f..ebdea1d 100644 --- a/api/packages/talks_repository/test/src/talks_repository_test.dart +++ b/api/packages/talks_repository/test/src/talks_repository_test.dart @@ -19,6 +19,7 @@ void main() { late FlutterconDataSource dataSource; late FlutterconCache cache; late TalksRepository talksRepository; + late String favUserCacheKey; setUp(() { dataSource = _MockFlutterconDataSource(); @@ -27,6 +28,11 @@ void main() { when(() => cache.set(talksCacheKey, any())).thenAnswer( (_) async => {}, ); + when(() => cache.set(favoritesUserCacheKey(TestHelpers.userId), any())) + .thenAnswer( + (_) async => {}, + ); + favUserCacheKey = favoritesUserCacheKey(TestHelpers.userId); }); setUpAll(() { @@ -41,6 +47,287 @@ void main() { expect(talksRepository, isNotNull); }); + group('createFavorite', () { + setUp(() { + when( + () => cache.get( + favUserCacheKey, + ), + ).thenAnswer((_) async => null); + when( + () => dataSource.createFavoritesTalk( + favoritesId: TestHelpers.favoritesId, + talkId: TestHelpers.createFavoriteRequest.talkId, + ), + ).thenAnswer((_) async => TestHelpers.favoritesTalks.items.first!); + when( + () => dataSource.createFavorites( + userId: TestHelpers.userId, + ), + ).thenAnswer((_) async => TestHelpers.favorites.items.first!); + }); + test('fetches $Favorites from cache when present', () async { + when( + () => cache.get( + favUserCacheKey, + ), + ).thenAnswer((_) async => jsonEncode(TestHelpers.favoritesJson)); + + await talksRepository.createFavorite( + request: TestHelpers.createFavoriteRequest, + ); + + verifyNever( + () => dataSource.getFavorites( + userId: TestHelpers.createFavoriteRequest.userId, + ), + ); + }); + + test('adds $Favorites in api when not present in cache', () async { + await talksRepository.createFavorite( + request: TestHelpers.createFavoriteRequest, + ); + + verify( + () => dataSource.createFavorites( + userId: TestHelpers.userId, + ), + ).called(1); + verify( + () => cache.set( + favUserCacheKey, + any(), + ), + ).called(1); + }); + + test('returns $CreateFavoriteResponse when successful', () async { + final result = await talksRepository.createFavorite( + request: TestHelpers.createFavoriteRequest, + ); + expect(result, equals(TestHelpers.createFavoriteResponse)); + }); + }); + + group('deleteFavorite', () { + final favoritesTalk = TestHelpers.favoritesTalkSingle.items.first!; + + setUp(() { + when( + () => cache.get( + favUserCacheKey, + ), + ).thenAnswer((_) async => null); + when( + () => dataSource.createFavorites( + userId: TestHelpers.userId, + ), + ).thenAnswer((_) async => TestHelpers.favorites.items.first!); + when( + () => dataSource.getFavoritesTalks( + favoritesId: TestHelpers.favoritesId, + talkId: TestHelpers.talks.items.first!.id, + ), + ).thenAnswer((_) async => TestHelpers.favoritesTalkSingle); + when( + () => dataSource.deleteFavoritesTalk( + id: favoritesTalk.id, + ), + ).thenAnswer((_) async => favoritesTalk); + }); + + test('fetches $Favorites from cache when present', () async { + when( + () => cache.get( + favUserCacheKey, + ), + ).thenAnswer((_) async => jsonEncode(TestHelpers.favoritesJson)); + + await talksRepository.deleteFavorite( + request: TestHelpers.deleteFavoriteRequest, + ); + + verifyNever( + () => dataSource.getFavorites( + userId: TestHelpers.createFavoriteRequest.userId, + ), + ); + }); + + test('adds $Favorites in api when not present in cache', () async { + await talksRepository.deleteFavorite( + request: TestHelpers.deleteFavoriteRequest, + ); + + verify( + () => dataSource.createFavorites( + userId: TestHelpers.userId, + ), + ).called(1); + verify( + () => cache.set( + favUserCacheKey, + any(), + ), + ).called(1); + }); + + test('returns $DeleteFavoriteResponse when successful', () async { + final result = await talksRepository.deleteFavorite( + request: TestHelpers.deleteFavoriteRequest, + ); + expect(result, equals(TestHelpers.deleteFavoriteResponse)); + }); + + test( + 'returns $DeleteFavoriteRequest without calling api ' + 'when favoritesTalk data is null', () async { + when( + () => dataSource.getFavoritesTalks( + favoritesId: TestHelpers.favoritesId, + talkId: TestHelpers.talks.items.first!.id, + ), + ).thenAnswer( + (_) async => PaginatedResult( + [null], + null, + null, + null, + FavoritesTalk.classType, + null, + ), + ); + + final result = await talksRepository.deleteFavorite( + request: TestHelpers.deleteFavoriteRequest, + ); + expect(result, equals(TestHelpers.deleteFavoriteResponse)); + verifyNever( + () => dataSource.deleteFavoritesTalk( + id: favoritesTalk.id, + ), + ); + }); + + test( + 'returns $DeleteFavoriteRequest without calling api ' + 'when favoritesTalk data is empty', () async { + when( + () => dataSource.getFavoritesTalks( + favoritesId: TestHelpers.favoritesId, + talkId: TestHelpers.talks.items.first!.id, + ), + ).thenAnswer( + (_) async => PaginatedResult( + [], + null, + null, + null, + FavoritesTalk.classType, + null, + ), + ); + + final result = await talksRepository.deleteFavorite( + request: TestHelpers.deleteFavoriteRequest, + ); + expect(result, equals(TestHelpers.deleteFavoriteResponse)); + verifyNever( + () => dataSource.deleteFavoritesTalk( + id: favoritesTalk.id, + ), + ); + }); + }); + + group('getFavorites', () { + const userId = 'userId'; + test('returns ${PaginatedData} when successful', () async { + when(() => dataSource.getFavorites(userId: userId)).thenAnswer( + (_) async => TestHelpers.favorites, + ); + when( + () => dataSource.getFavoritesTalks( + favoritesId: any(named: 'favoritesId'), + ), + ).thenAnswer( + (_) async => TestHelpers.favoritesTalks, + ); + final talks = TestHelpers.talks.items; + when(() => dataSource.getSpeakerTalks(talk: talks[0])).thenAnswer( + (_) async => TestHelpers.speakerTalks(talks[0]!), + ); + when(() => dataSource.getSpeakerTalks(talk: talks[1])).thenAnswer( + (_) async => TestHelpers.speakerTalks(talks[1]!), + ); + when(() => dataSource.getSpeakerTalks(talk: talks[2])).thenAnswer( + (_) async => TestHelpers.speakerTalks(talks[2]!), + ); + + final result = await talksRepository.getFavorites(userId: userId); + expect(result, equals(TestHelpers.talkTimeSlots)); + }); + + test('does not return $TalkTimeSlot when favorites data is null', + () async { + when(() => dataSource.getFavorites(userId: userId)).thenAnswer( + (_) async => PaginatedResult( + [null], + null, + null, + null, + Favorites.classType, + null, + ), + ); + + final result = await talksRepository.getFavorites(userId: userId); + expect(result.items, isEmpty); + }); + + test('does not return $TalkTimeSlot when favorites data is empty', + () async { + when(() => dataSource.getFavorites(userId: userId)).thenAnswer( + (_) async => PaginatedResult( + [], + null, + null, + null, + Favorites.classType, + null, + ), + ); + + final result = await talksRepository.getFavorites(userId: userId); + expect(result.items, isEmpty); + }); + + test('does not return $TalkTimeSlot when talk data is null', () async { + when(() => dataSource.getFavorites(userId: userId)).thenAnswer( + (_) async => TestHelpers.favorites, + ); + + when( + () => dataSource.getFavoritesTalks( + favoritesId: any(named: 'favoritesId'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + [null], + null, + null, + null, + FavoritesTalk.classType, + null, + ), + ); + + final result = await talksRepository.getFavorites(userId: userId); + expect(result.items, isEmpty); + }); + }); + group('getTalks', () { test('returns cached ${PaginatedData} when available', () async { diff --git a/api/packages/user_repository/lib/src/user_repository.dart b/api/packages/user_repository/lib/src/user_repository.dart index d6cdfbd..faf8c7c 100644 --- a/api/packages/user_repository/lib/src/user_repository.dart +++ b/api/packages/user_repository/lib/src/user_repository.dart @@ -20,8 +20,11 @@ class UserRepository { return null; } + final identityPoolId = currentSession.identityIdResult.value; + final userId = identityPoolId.split(':').last; + return User( - id: currentSession.identityIdResult.value, + id: userId, sessionToken: token, ); } on Exception { diff --git a/api/routes/favorites/[userId].dart b/api/routes/favorites/[userId].dart new file mode 100644 index 0000000..8d51824 --- /dev/null +++ b/api/routes/favorites/[userId].dart @@ -0,0 +1,31 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:dart_frog/dart_frog.dart'; +import 'package:fluttercon_data_source/fluttercon_data_source.dart'; +import 'package:talks_repository/talks_repository.dart'; + +Future onRequest(RequestContext context, String userId) async { + return switch (context.request.method) { + HttpMethod.get => await _get(context, userId), + _ => Response(statusCode: HttpStatus.methodNotAllowed), + }; +} + +Future _get(RequestContext context, String userId) async { + final talksRepo = context.read(); + try { + final data = await talksRepo.getFavorites( + userId: userId, + ); + 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.toString()), + ); + } +} diff --git a/api/routes/favorites/index.dart b/api/routes/favorites/index.dart new file mode 100644 index 0000000..b591249 --- /dev/null +++ b/api/routes/favorites/index.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:api/helpers/request_body_decoder.dart'; +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:talks_repository/talks_repository.dart'; + +Future onRequest(RequestContext context) async { + return switch (context.request.method) { + HttpMethod.post => await _post(context), + HttpMethod.delete => await _delete(context), + _ => Response(statusCode: HttpStatus.methodNotAllowed), + }; +} + +Future _post(RequestContext context) async { + final talksRepo = context.read(); + try { + late final CreateFavoriteRequest requestBody; + requestBody = CreateFavoriteRequest.fromJson( + await context.request.decodeRequestBody(), + ); + + final createResponse = await talksRepo.createFavorite( + request: requestBody, + ); + final json = createResponse.toJson(); + return Response(body: jsonEncode(json)); + } on AmplifyApiException catch (e) { + return Response( + statusCode: HttpStatus.internalServerError, + body: jsonEncode(e.exception.toString()), + ); + } +} + +Future _delete(RequestContext context) async { + final talksRepo = context.read(); + try { + late final DeleteFavoriteRequest requestBody; + requestBody = DeleteFavoriteRequest.fromJson( + await context.request.decodeRequestBody(), + ); + + final deleteResponse = await talksRepo.deleteFavorite( + request: requestBody, + ); + final json = deleteResponse.toJson(); + return Response(body: jsonEncode(json)); + } on AmplifyApiException catch (e) { + return Response( + statusCode: HttpStatus.internalServerError, + body: jsonEncode(e.exception.toString()), + ); + } +} diff --git a/api/test/routes/favorites/[userId]_test.dart b/api/test/routes/favorites/[userId]_test.dart new file mode 100644 index 0000000..bf27875 --- /dev/null +++ b/api/test/routes/favorites/[userId]_test.dart @@ -0,0 +1,99 @@ +import 'dart:async'; +import 'dart:convert'; +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:talks_repository/talks_repository.dart'; +import 'package:test/test.dart'; + +import '../../../routes/favorites/[userId].dart' as route; +import '../../helpers/method_not_allowed.dart'; + +class _MockRequestContext extends Mock implements RequestContext {} + +class _MockTalksRepository extends Mock implements TalksRepository {} + +void main() { + late TalksRepository talksRepository; + const userId = 'userId'; + + setUp(() { + talksRepository = _MockTalksRepository(); + }); + + group('GET /favorites/[userId]', () { + final responseData = PaginatedData( + items: [ + TalkTimeSlot( + startTime: DateTime(2024), + talks: [ + TalkPreview( + id: 'id', + title: 'title', + room: 'room', + startTime: DateTime(2024), + speakerNames: const ['speakerName'], + ), + ], + ), + ], + ); + test( + 'responds with a 200 and a list of talks by userId 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(talksRepository); + when(() => talksRepository.getFavorites(userId: userId)) + .thenAnswer((_) async => responseData); + + final response = await route.onRequest(context, userId); + expect(response.statusCode, equals(HttpStatus.ok)); + expect( + await response.body(), + equals(jsonEncode(responseData.toJson((value) => value.toJson()))), + ); + }, + ); + + test( + 'responds with a 500 and an exception when there is a failure', + () async { + final context = _MockRequestContext(); + 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(talksRepository); + when(() => talksRepository.getFavorites(userId: userId)) + .thenThrow(amplifyException); + + final response = await route.onRequest(context, userId); + expect(response.statusCode, equals(HttpStatus.internalServerError)); + expect( + await response.body(), + equals(jsonEncode(amplifyException.exception)), + ); + }, + ); + }); + + group('Unsupported methods', () { + test('respond with 405', () async { + final context = _MockRequestContext(); + when(() => context.read()).thenReturn( + talksRepository, + ); + FutureOr action() => route.onRequest(context, userId); + await testMethodNotAllowed(context, action, 'POST'); + await testMethodNotAllowed(context, action, 'DELETE'); + await testMethodNotAllowed(context, action, 'PUT'); + await testMethodNotAllowed(context, action, 'PATCH'); + await testMethodNotAllowed(context, action, 'HEAD'); + await testMethodNotAllowed(context, action, 'OPTIONS'); + }); + }); +} diff --git a/api/test/routes/favorites/index_test.dart b/api/test/routes/favorites/index_test.dart new file mode 100644 index 0000000..acaf391 --- /dev/null +++ b/api/test/routes/favorites/index_test.dart @@ -0,0 +1,158 @@ +import 'dart:async'; +import 'dart:convert'; +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:talks_repository/talks_repository.dart'; +import 'package:test/test.dart'; + +import '../../../routes/favorites/index.dart' as route; +import '../../helpers/method_not_allowed.dart'; + +class _MockRequestContext extends Mock implements RequestContext {} + +class _MockTalksRepository extends Mock implements TalksRepository {} + +void main() { + late TalksRepository talksRepository; + + setUp(() { + talksRepository = _MockTalksRepository(); + }); + + group('POST /favorites', () { + const requestBody = CreateFavoriteRequest( + talkId: 'talkId', + userId: 'userId', + ); + + const responseData = CreateFavoriteResponse( + talkId: 'talkId', + userId: 'userId', + ); + + test( + 'responds with a 200 and a list of talks by userId when successful', + () async { + final context = _MockRequestContext(); + final request = Request( + 'POST', + Uri.parse('http://127.0.0.1/'), + body: jsonEncode(requestBody.toJson()), + ); + when(() => context.request).thenReturn(request); + when(() => context.read()).thenReturn(talksRepository); + when(() => talksRepository.createFavorite(request: requestBody)) + .thenAnswer((_) async => responseData); + + final response = await route.onRequest(context); + expect(response.statusCode, equals(HttpStatus.ok)); + expect( + await response.body(), + equals(jsonEncode(responseData.toJson())), + ); + }, + ); + + test( + 'responds with a 500 and an exception when there is a failure', + () async { + final context = _MockRequestContext(); + final request = Request( + 'POST', + Uri.parse('http://127.0.0.1/'), + body: jsonEncode(requestBody.toJson()), + ); + const amplifyException = AmplifyApiException(exception: 'oops'); + when(() => context.request).thenReturn(request); + when(() => context.read()).thenReturn(talksRepository); + when(() => talksRepository.createFavorite(request: requestBody)) + .thenThrow(amplifyException); + + final response = await route.onRequest(context); + expect(response.statusCode, equals(HttpStatus.internalServerError)); + expect( + await response.body(), + equals(jsonEncode(amplifyException.exception)), + ); + }, + ); + }); + + group('DELETE /favorites', () { + const requestBody = DeleteFavoriteRequest( + talkId: 'talkId', + userId: 'userId', + ); + + const responseData = DeleteFavoriteResponse( + talkId: 'talkId', + userId: 'userId', + ); + + test( + 'responds with a 200 and a list of talks by userId when successful', + () async { + final context = _MockRequestContext(); + final request = Request( + 'DELETE', + Uri.parse('http://127.0.0.1/'), + body: jsonEncode(requestBody.toJson()), + ); + when(() => context.request).thenReturn(request); + when(() => context.read()).thenReturn(talksRepository); + when(() => talksRepository.deleteFavorite(request: requestBody)) + .thenAnswer((_) async => responseData); + + final response = await route.onRequest(context); + expect(response.statusCode, equals(HttpStatus.ok)); + expect( + await response.body(), + equals(jsonEncode(responseData.toJson())), + ); + }, + ); + + test( + 'responds with a 500 and an exception when there is a failure', + () async { + final context = _MockRequestContext(); + final request = Request( + 'DELETE', + Uri.parse('http://127.0.0.1/'), + body: jsonEncode(requestBody.toJson()), + ); + const amplifyException = AmplifyApiException(exception: 'oops'); + when(() => context.request).thenReturn(request); + when(() => context.read()).thenReturn(talksRepository); + when(() => talksRepository.deleteFavorite(request: requestBody)) + .thenThrow(amplifyException); + + final response = await route.onRequest(context); + expect(response.statusCode, equals(HttpStatus.internalServerError)); + expect( + await response.body(), + equals(jsonEncode(amplifyException.exception)), + ); + }, + ); + }); + + group('Unsupported methods', () { + test('respond with 405', () async { + final context = _MockRequestContext(); + when(() => context.read()).thenReturn( + talksRepository, + ); + FutureOr action() => route.onRequest(context); + await testMethodNotAllowed(context, action, 'GET'); + await testMethodNotAllowed(context, action, 'PUT'); + await testMethodNotAllowed(context, action, 'PATCH'); + await testMethodNotAllowed(context, action, 'HEAD'); + await testMethodNotAllowed(context, action, 'OPTIONS'); + }); + }); +} diff --git a/cspell.json b/cspell.json index 15e50a9..371fb22 100644 --- a/cspell.json +++ b/cspell.json @@ -1,7 +1,10 @@ { "version": "0.2", "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", - "dictionaries": ["vgv_allowed", "vgv_forbidden"], + "dictionaries": [ + "vgv_allowed", + "vgv_forbidden" + ], "dictionaryDefinitions": [ { "name": "vgv_allowed", @@ -58,6 +61,7 @@ "Slava", "Techmakers", "Texto", + "USERID", "vectorizing", "Vijay", "Volkert" diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: