diff --git a/api/routes/game/hint.dart b/api/routes/game/hint.dart new file mode 100644 index 000000000..15d03f790 --- /dev/null +++ b/api/routes/game/hint.dart @@ -0,0 +1,70 @@ +import 'dart:io'; + +import 'package:crossword_repository/crossword_repository.dart'; +import 'package:dart_frog/dart_frog.dart'; +import 'package:hint_repository/hint_repository.dart'; +import 'package:jwt_middleware/jwt_middleware.dart'; + +const _maxAllowedHints = 10; + +Future onRequest(RequestContext context) async { + if (context.request.method == HttpMethod.post) { + return _onPost(context); + } else { + return Response(statusCode: HttpStatus.methodNotAllowed); + } +} + +Future _onPost(RequestContext context) async { + final crosswordRepository = context.read(); + final hintRepository = context.read(); + final user = context.read(); + + final json = await context.request.json() as Map; + final wordId = json['wordId'] as String?; + final userQuestion = json['question'] as String?; + + if (wordId == null || userQuestion == null) { + return Response(statusCode: HttpStatus.badRequest); + } + + try { + final wordAnswer = await crosswordRepository.findAnswerById(wordId); + if (wordAnswer == null) { + return Response( + body: 'Word not found for id $wordId', + statusCode: HttpStatus.notFound, + ); + } + + final previousHints = await hintRepository.getPreviousHints( + userId: user.id, + wordId: wordId, + ); + if (previousHints.length >= _maxAllowedHints) { + return Response( + body: 'Max hints reached for word $wordId', + statusCode: HttpStatus.forbidden, + ); + } + + final hint = await hintRepository.generateHint( + wordAnswer: wordAnswer.answer, + question: userQuestion, + previousHints: previousHints, + ); + + await hintRepository.saveHints( + userId: user.id, + wordId: wordId, + hints: [...previousHints, hint], + ); + + return Response.json(body: hint.toJson()); + } catch (e) { + return Response( + body: e.toString(), + statusCode: HttpStatus.internalServerError, + ); + } +} diff --git a/api/test/routes/game/hint_test.dart b/api/test/routes/game/hint_test.dart new file mode 100644 index 000000000..1b51f9e85 --- /dev/null +++ b/api/test/routes/game/hint_test.dart @@ -0,0 +1,245 @@ +// ignore_for_file: prefer_const_literals_to_create_immutables +// ignore_for_file: prefer_const_constructors + +import 'dart:io'; + +import 'package:crossword_repository/crossword_repository.dart'; +import 'package:dart_frog/dart_frog.dart'; +import 'package:game_domain/game_domain.dart'; +import 'package:hint_repository/hint_repository.dart'; +import 'package:jwt_middleware/jwt_middleware.dart'; +import 'package:mocktail/mocktail.dart' hide Answer; +import 'package:test/test.dart'; + +import '../../../routes/game/hint.dart' as route; + +class _MockRequestContext extends Mock implements RequestContext {} + +class _MockRequest extends Mock implements Request {} + +class _MockCrosswordRepository extends Mock implements CrosswordRepository {} + +class _MockHintRepository extends Mock implements HintRepository {} + +void main() { + group('/game/hint', () { + late RequestContext requestContext; + late Request request; + late CrosswordRepository crosswordRepository; + late HintRepository hintRepository; + late AuthenticatedUser user; + + setUp(() { + requestContext = _MockRequestContext(); + request = _MockRequest(); + crosswordRepository = _MockCrosswordRepository(); + hintRepository = _MockHintRepository(); + user = AuthenticatedUser('userId'); + + when(() => requestContext.request).thenReturn(request); + when(() => requestContext.read()) + .thenReturn(crosswordRepository); + when(() => requestContext.read()) + .thenReturn(hintRepository); + when(() => requestContext.read()).thenReturn(user); + }); + + group('other http methods', () { + const allowedMethods = [HttpMethod.post]; + final notAllowedMethods = HttpMethod.values.where( + (e) => !allowedMethods.contains(e), + ); + + for (final method in notAllowedMethods) { + test('are not allowed: $method', () async { + when(() => request.method).thenReturn(method); + final response = await route.onRequest(requestContext); + + expect(response.statusCode, HttpStatus.methodNotAllowed); + }); + } + }); + + group('POST', () { + setUp(() { + when(() => request.method).thenReturn(HttpMethod.post); + }); + + test( + 'returns Response with a hint and saves it to the hint repository', + () async { + when(() => request.json()).thenAnswer( + (_) async => {'wordId': 'wordId', 'question': 'question'}, + ); + when( + () => crosswordRepository.findAnswerById('wordId'), + ).thenAnswer( + (_) async { + return Answer( + id: 'wordId', + answer: 'answer', + section: Point(1, 1), + ); + }, + ); + when( + () => hintRepository.getPreviousHints( + userId: 'userId', + wordId: 'wordId', + ), + ).thenAnswer((_) async => []); + when( + () => hintRepository.generateHint( + wordAnswer: 'answer', + question: 'question', + previousHints: [], + ), + ).thenAnswer( + (_) async => Hint(question: 'question', response: HintResponse.no), + ); + when( + () => hintRepository.saveHints( + userId: 'userId', + wordId: 'wordId', + hints: [Hint(question: 'question', response: HintResponse.no)], + ), + ).thenAnswer((_) async {}); + + final response = await route.onRequest(requestContext); + + expect(response.statusCode, HttpStatus.ok); + expect( + await response.json(), + equals({'question': 'question', 'response': 'no'}), + ); + verify( + () => hintRepository.saveHints( + userId: 'userId', + wordId: 'wordId', + hints: [Hint(question: 'question', response: HintResponse.no)], + ), + ).called(1); + }, + ); + + test( + 'returns internal server error response when generating hint fails', + () async { + when(() => request.json()).thenAnswer( + (_) async => {'wordId': 'wordId', 'question': 'question'}, + ); + when( + () => crosswordRepository.findAnswerById('wordId'), + ).thenAnswer( + (_) async { + return Answer( + id: 'wordId', + answer: 'answer', + section: Point(1, 1), + ); + }, + ); + when( + () => hintRepository.getPreviousHints( + userId: 'userId', + wordId: 'wordId', + ), + ).thenAnswer((_) async => []); + when( + () => hintRepository.generateHint( + wordAnswer: 'answer', + question: 'question', + previousHints: [], + ), + ).thenThrow(HintException('Oops', StackTrace.empty)); + + final response = await route.onRequest(requestContext); + + expect(response.statusCode, HttpStatus.internalServerError); + expect(await response.body(), contains('Oops')); + }, + ); + + test( + 'returns forbidden response when max hints reached for word', + () async { + when(() => request.json()).thenAnswer( + (_) async => {'wordId': 'wordId', 'question': 'question'}, + ); + when( + () => crosswordRepository.findAnswerById('wordId'), + ).thenAnswer( + (_) async { + return Answer( + id: 'wordId', + answer: 'answer', + section: Point(1, 1), + ); + }, + ); + final hint = Hint(question: 'question', response: HintResponse.yes); + when( + () => hintRepository.getPreviousHints( + userId: 'userId', + wordId: 'wordId', + ), + ).thenAnswer((_) async => List.filled(10, hint)); + + final response = await route.onRequest(requestContext); + + expect(response.statusCode, HttpStatus.forbidden); + expect( + await response.body(), + equals('Max hints reached for word wordId'), + ); + }, + ); + + test( + 'returns not found response when word not found for id', + () async { + when(() => request.json()).thenAnswer( + (_) async => {'wordId': 'wordId', 'question': 'question'}, + ); + when( + () => crosswordRepository.findAnswerById('wordId'), + ).thenAnswer((_) async => null); + + final response = await route.onRequest(requestContext); + + expect(response.statusCode, HttpStatus.notFound); + expect( + await response.body(), + equals('Word not found for id wordId'), + ); + }, + ); + + test( + 'returns bad request response when word id not provided', + () async { + when(() => request.json()).thenAnswer( + (_) async => {'question': 'question'}, + ); + + final response = await route.onRequest(requestContext); + + expect(response.statusCode, HttpStatus.badRequest); + }, + ); + + test( + 'returns bad request response when question not provided', + () async { + when(() => request.json()).thenAnswer( + (_) async => {'wordId': 'theWordId'}, + ); + + final response = await route.onRequest(requestContext); + + expect(response.statusCode, HttpStatus.badRequest); + }, + ); + }); + }); +} diff --git a/packages/api_client/lib/src/resources/hint_resource.dart b/packages/api_client/lib/src/resources/hint_resource.dart new file mode 100644 index 000000000..8421625fd --- /dev/null +++ b/packages/api_client/lib/src/resources/hint_resource.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:api_client/api_client.dart'; +import 'package:game_domain/game_domain.dart'; + +/// {@template hint_resource} +/// An api resource for interacting with the hints. +/// {@endtemplate} +class HintResource { + /// {@macro hint_resource} + HintResource({ + required ApiClient apiClient, + }) : _apiClient = apiClient; + + final ApiClient _apiClient; + + /// Post /game/hint + /// + /// Returns a [Hint]. + Future getHint({ + required String wordId, + required String question, + }) async { + const path = '/game/hint'; + final response = await _apiClient.post( + path, + body: jsonEncode({ + 'wordId': wordId, + 'question': question, + }), + ); + + if (response.statusCode != HttpStatus.ok) { + throw ApiClientError( + 'POST $path returned status ${response.statusCode} ' + 'with the following response: "${response.body}"', + StackTrace.current, + ); + } + + try { + final body = jsonDecode(response.body) as Map; + final hint = Hint.fromJson(body); + return hint; + } catch (error, stackTrace) { + throw ApiClientError( + 'POST $path returned invalid response: "${response.body}"', + stackTrace, + ); + } + } +} diff --git a/packages/api_client/lib/src/resources/resources.dart b/packages/api_client/lib/src/resources/resources.dart index cedf9d845..869cfdc9d 100644 --- a/packages/api_client/lib/src/resources/resources.dart +++ b/packages/api_client/lib/src/resources/resources.dart @@ -1,3 +1,4 @@ export 'crossword_resource.dart'; +export 'hint_resource.dart'; export 'leaderboard_resource.dart'; export 'share_resource.dart'; diff --git a/packages/api_client/test/src/resources/hint_resource_test.dart b/packages/api_client/test/src/resources/hint_resource_test.dart new file mode 100644 index 000000000..a47aeee4d --- /dev/null +++ b/packages/api_client/test/src/resources/hint_resource_test.dart @@ -0,0 +1,115 @@ +// ignore_for_file: prefer_const_constructors + +import 'dart:convert'; +import 'dart:io'; + +import 'package:api_client/api_client.dart'; +import 'package:game_domain/game_domain.dart'; +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class _MockApiClient extends Mock implements ApiClient {} + +class _MockResponse extends Mock implements http.Response {} + +void main() { + group('HintResource', () { + late ApiClient apiClient; + late http.Response response; + late HintResource resource; + + setUp(() { + apiClient = _MockApiClient(); + response = _MockResponse(); + resource = HintResource(apiClient: apiClient); + }); + + group('getHint', () { + setUp(() { + when( + () => apiClient.post(any(), body: any(named: 'body')), + ).thenAnswer((_) async => response); + }); + + test('calls correct api endpoint', () async { + final hint = Hint( + question: 'question', + response: HintResponse.no, + ); + when(() => response.statusCode).thenReturn(HttpStatus.ok); + when(() => response.body).thenReturn(jsonEncode(hint.toJson())); + + await resource.getHint( + wordId: 'wordId', + question: 'question', + ); + + verify( + () => apiClient.post( + '/game/hint', + body: jsonEncode({ + 'wordId': 'wordId', + 'question': 'question', + }), + ), + ).called(1); + }); + + test('returns the hint when succeeds ', () async { + final hint = Hint( + question: 'is it a question?', + response: HintResponse.yes, + ); + when(() => response.statusCode).thenReturn(HttpStatus.ok); + when(() => response.body).thenReturn(jsonEncode(hint.toJson())); + + final result = await resource.getHint( + wordId: 'wordId', + question: 'is it a question?', + ); + + expect(result, equals(hint)); + }); + + test('throws ApiClientError when request fails', () async { + when(() => response.statusCode) + .thenReturn(HttpStatus.internalServerError); + when(() => response.body).thenReturn('Oops'); + + await expectLater( + resource.getHint(wordId: 'wordId', question: 'question'), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + equals( + 'POST /game/hint returned status 500 with the following response: "Oops"', + ), + ), + ), + ); + }); + + test('throws ApiClientError when response is invalid', () async { + when(() => response.statusCode).thenReturn(HttpStatus.ok); + when(() => response.body) + .thenReturn('This is not a well formatted hint'); + + await expectLater( + resource.getHint(wordId: 'wordId', question: 'question'), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + equals( + 'POST /game/hint returned invalid response: ' + '"This is not a well formatted hint"', + ), + ), + ), + ); + }); + }); + }); +}