Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add get hint api call #342

Merged
merged 2 commits into from
Apr 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading