Skip to content

Commit

Permalink
feat: add get hint api call (#342)
Browse files Browse the repository at this point in the history
* feat: add hint api resource

* feat: add hint api call
  • Loading branch information
jsgalarraga authored Apr 20, 2024
1 parent 49003c4 commit 2255728
Show file tree
Hide file tree
Showing 5 changed files with 484 additions and 0 deletions.
70 changes: 70 additions & 0 deletions api/routes/game/hint.dart
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,
);
}
}
245 changes: 245 additions & 0 deletions api/test/routes/game/hint_test.dart
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);
},
);
});
});
}
53 changes: 53 additions & 0 deletions packages/api_client/lib/src/resources/hint_resource.dart
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,
);
}
}
}
1 change: 1 addition & 0 deletions packages/api_client/lib/src/resources/resources.dart
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';
Loading

0 comments on commit 2255728

Please sign in to comment.