-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* feat: add hint api resource * feat: add hint api call
- Loading branch information
1 parent
49003c4
commit 2255728
Showing
5 changed files
with
484 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Response> onRequest(RequestContext context) async { | ||
if (context.request.method == HttpMethod.post) { | ||
return _onPost(context); | ||
} else { | ||
return Response(statusCode: HttpStatus.methodNotAllowed); | ||
} | ||
} | ||
|
||
Future<Response> _onPost(RequestContext context) async { | ||
final crosswordRepository = context.read<CrosswordRepository>(); | ||
final hintRepository = context.read<HintRepository>(); | ||
final user = context.read<AuthenticatedUser>(); | ||
|
||
final json = await context.request.json() as Map<String, dynamic>; | ||
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, | ||
); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<CrosswordRepository>()) | ||
.thenReturn(crosswordRepository); | ||
when(() => requestContext.read<HintRepository>()) | ||
.thenReturn(hintRepository); | ||
when(() => requestContext.read<AuthenticatedUser>()).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); | ||
}, | ||
); | ||
}); | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Hint> 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<String, dynamic>; | ||
final hint = Hint.fromJson(body); | ||
return hint; | ||
} catch (error, stackTrace) { | ||
throw ApiClientError( | ||
'POST $path returned invalid response: "${response.body}"', | ||
stackTrace, | ||
); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
export 'crossword_resource.dart'; | ||
export 'hint_resource.dart'; | ||
export 'leaderboard_resource.dart'; | ||
export 'share_resource.dart'; |
Oops, something went wrong.