Skip to content

Commit

Permalink
feat: create score card (#103)
Browse files Browse the repository at this point in the history
* feat: add api client to app

* feat: create score card object

* feat: add create score call to leaderboard resource

* feat: add create score endpoint

* feat: create score card entry in database

* fix: endpoint

* fix: remove fake app check token

* chore: use tear offs
  • Loading branch information
jsgalarraga authored Mar 21, 2024
1 parent 20105e1 commit 2c5a9d7
Show file tree
Hide file tree
Showing 21 changed files with 491 additions and 2 deletions.
1 change: 1 addition & 0 deletions api/packages/game_domain/lib/src/models/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export 'board_section.dart';
export 'leaderboard_player.dart';
export 'mascots.dart';
export 'point_converter.dart';
export 'score_card.dart';
export 'word.dart';
56 changes: 56 additions & 0 deletions api/packages/game_domain/lib/src/models/score_card.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import 'package:equatable/equatable.dart';
import 'package:game_domain/game_domain.dart';
import 'package:json_annotation/json_annotation.dart';

part 'score_card.g.dart';

/// {@template score_card}
/// A class representing the user session's score and streak count.
/// {@endtemplate}
@JsonSerializable(ignoreUnannotated: true)
class ScoreCard extends Equatable {
/// {@macro score_card}
const ScoreCard({
required this.id,
this.totalScore = 0,
this.streak = 0,
this.mascot = Mascots.dash,
this.initials = '',
});

/// {@macro score_card}
factory ScoreCard.fromJson(Map<String, dynamic> json) =>
_$ScoreCardFromJson(json);

/// Unique identifier of the score object and session id for the player.
@JsonKey()
final String id;

/// Total score of the player.
@JsonKey()
final int totalScore;

/// Streak count of the player.
@JsonKey()
final int streak;

/// Mascot of the player.
@JsonKey()
final Mascots mascot;

/// Initials of the player.
@JsonKey()
final String initials;

/// Returns a json representation from this instance.
Map<String, dynamic> toJson() => _$ScoreCardToJson(this);

@override
List<Object?> get props => [
id,
totalScore,
streak,
mascot,
initials,
];
}
31 changes: 31 additions & 0 deletions api/packages/game_domain/lib/src/models/score_card.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

70 changes: 70 additions & 0 deletions api/packages/game_domain/test/src/models/score_card_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// ignore_for_file: prefer_const_constructors
// ignore_for_file: prefer_const_literals_to_create_immutables

import 'package:game_domain/game_domain.dart';
import 'package:test/test.dart';

void main() {
group('ScoreCard', () {
test('can be instantiated', () {
final scoreCard = ScoreCard(id: 'id');

expect(scoreCard, isNotNull);
});

test('can be instantiated with default values', () {
final scoreCard = ScoreCard(id: 'id');

expect(scoreCard.totalScore, equals(0));
expect(scoreCard.streak, equals(0));
expect(scoreCard.mascot, equals(Mascots.dash));
expect(scoreCard.initials, equals(''));
});

test('creates correct json object', () {
final scoreCard = ScoreCard(
id: 'id',
totalScore: 10,
streak: 5,
mascot: Mascots.android,
initials: 'ABC',
);

final json = scoreCard.toJson();

expect(
json,
equals({
'id': 'id',
'totalScore': 10,
'streak': 5,
'mascot': 'android',
'initials': 'ABC',
}),
);
});

test('creates correct ScoreCard object from json', () {
final scoreCard = ScoreCard.fromJson({
'id': 'id',
'totalScore': 10,
'streak': 5,
'mascot': 'android',
'initials': 'ABC',
});

expect(
scoreCard,
equals(
ScoreCard(
id: 'id',
totalScore: 10,
streak: 5,
mascot: Mascots.android,
initials: 'ABC',
),
),
);
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,25 @@ class LeaderboardRepository {

return (blacklistData.data['blacklist'] as List).cast<String>();
}

/// Creates a score entry with the provided initials and mascot. The score
/// related fields are initialized to 0.
Future<void> createScore(
String userId,
String initials,
String mascot,
) async {
return _dbClient.set(
'score_cards',
DbEntityRecord(
id: userId,
data: {
'totalScore': 0,
'streak': 0,
'mascot': mascot,
'initials': initials,
},
),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -137,5 +137,29 @@ void main() {
expect(response, isEmpty);
});
});

group('createScore', () {
test('completes when writing in the db is successful', () async {
when(
() => dbClient.set(
'score_cards',
DbEntityRecord(
id: 'userId',
data: {
'totalScore': 0,
'streak': 0,
'mascot': 'dash',
'initials': 'ABC',
},
),
),
).thenAnswer((_) async {});

expect(
leaderboardRepository.createScore('userId', 'ABC', 'dash'),
completes,
);
});
});
});
}
39 changes: 39 additions & 0 deletions api/routes/game/create_score.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import 'dart:async';
import 'dart:io';

import 'package:dart_frog/dart_frog.dart';
import 'package:jwt_middleware/jwt_middleware.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:logging/logging.dart';

FutureOr<Response> onRequest(RequestContext context) async {
if (context.request.method == HttpMethod.post) {
return _onPost(context);
}
return Response(statusCode: HttpStatus.methodNotAllowed);
}

Future<Response> _onPost(RequestContext context) async {
final leaderboardRepository = context.read<LeaderboardRepository>();

// TODO(jaime): get the user from the session once implemented
const user = AuthenticatedUser('id');
// final user = context.read<AuthenticatedUser>();

final json = await context.request.json() as Map<String, dynamic>;
final initials = json['initials'] as String?;
final mascot = json['mascot'] as String?;

if (initials == null || mascot == null) {
return Response(statusCode: HttpStatus.badRequest);
}

try {
await leaderboardRepository.createScore(user.id, initials, mascot);
} catch (e, s) {
context.read<Logger>().severe('Error creating a player score', e, s);
rethrow;
}

return Response(statusCode: HttpStatus.created);
}
92 changes: 92 additions & 0 deletions api/test/routes/game/create_score_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import 'dart:io';

import 'package:dart_frog/dart_frog.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:logging/logging.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';

import '../../../routes/game/create_score.dart' as route;

class _MockLeaderboardRepository extends Mock
implements LeaderboardRepository {}

class _MockRequest extends Mock implements Request {}

class _MockRequestContext extends Mock implements RequestContext {}

class _MockLogger extends Mock implements Logger {}

void main() {
late LeaderboardRepository leaderboardRepository;
late Request request;
late RequestContext context;
late Logger logger;

setUp(() {
leaderboardRepository = _MockLeaderboardRepository();
request = _MockRequest();
logger = _MockLogger();

context = _MockRequestContext();
when(() => context.request).thenReturn(request);
when(() => context.read<LeaderboardRepository>())
.thenReturn(leaderboardRepository);
when(() => context.read<Logger>()).thenReturn(logger);
});

group('POST', () {
test(
'responds with a HttpStatus.created status when creating the '
'score succeeds',
() async {
when(() => request.method).thenReturn(HttpMethod.post);
when(request.json)
.thenAnswer((_) async => {'initials': 'AAA', 'mascot': 'dash'});
when(() => leaderboardRepository.createScore(any(), any(), any()))
.thenAnswer((_) async {});

final response = await route.onRequest(context);
expect(response.statusCode, equals(HttpStatus.created));
},
);

test(
'responds with a HttpStatus.badRequest status when the initials '
'or mascot parameters are missing',
() async {
when(() => request.method).thenReturn(HttpMethod.post);
when(request.json).thenAnswer((_) async => {'key': 'value'});

final response = await route.onRequest(context);
expect(response.statusCode, equals(HttpStatus.badRequest));
},
);

test(
'rethrows exception if creating the score fails',
() async {
when(() => request.method).thenReturn(HttpMethod.post);
when(request.json)
.thenAnswer((_) async => {'initials': 'AAA', 'mascot': 'dash'});
when(() => leaderboardRepository.createScore(any(), any(), any()))
.thenThrow(Exception());

final response = route.onRequest(context);
expect(response, throwsA(isA<Exception>()));
},
);
});

group('Other http methods', () {
for (final httpMethod in HttpMethod.values.toList()
..remove(HttpMethod.post)) {
test('are not allowed: $httpMethod', () async {
when(() => request.method).thenReturn(httpMethod);

final response = await route.onRequest(context);
expect(response.statusCode, equals(HttpStatus.methodNotAllowed));
});
}
});
}
4 changes: 4 additions & 0 deletions lib/app/view/app.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:api_client/api_client.dart';
import 'package:board_info_repository/board_info_repository.dart';
import 'package:crossword_repository/crossword_repository.dart';
import 'package:flutter/material.dart';
Expand All @@ -8,18 +9,21 @@ import 'package:provider/provider.dart';

class App extends StatelessWidget {
const App({
required this.apiClient,
required this.crosswordRepository,
required this.boardInfoRepository,
super.key,
});

final ApiClient apiClient;
final CrosswordRepository crosswordRepository;
final BoardInfoRepository boardInfoRepository;

@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider.value(value: apiClient.leaderboardResource),
Provider.value(value: crosswordRepository),
Provider.value(value: boardInfoRepository),
],
Expand Down
Loading

0 comments on commit 2c5a9d7

Please sign in to comment.