diff --git a/.github/workflows/deploy_api_staging.yaml b/.github/workflows/deploy_api_staging.yaml index 05caab72c..a512424ac 100644 --- a/.github/workflows/deploy_api_staging.yaml +++ b/.github/workflows/deploy_api_staging.yaml @@ -1,4 +1,4 @@ -name: deploy_api_prod +name: deploy_api_staging on: workflow_dispatch diff --git a/.github/workflows/deploy_app_staging.yaml b/.github/workflows/deploy_app_staging.yaml index f969e683c..cd31932fa 100644 --- a/.github/workflows/deploy_app_staging.yaml +++ b/.github/workflows/deploy_app_staging.yaml @@ -1,4 +1,4 @@ -name: deploy_app_dev +name: deploy_app_staging on: workflow_dispatch diff --git a/api/Dockerfile b/api/Dockerfile index bcc56c693..4748a7092 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -9,6 +9,7 @@ COPY packages/db_client ./packages/db_client COPY packages/encryption_middleware ./packages/encryption_middleware COPY packages/game_domain ./packages/game_domain COPY packages/jwt_middleware ./packages/jwt_middleware +COPY packages/leaderboard_repository ./packages/leaderboard_repository COPY packages/board_renderer ./packages/board_renderer COPY packages/crossword_repository ./packages/crossword_repository @@ -17,6 +18,7 @@ RUN dart pub get -C packages/db_client RUN dart pub get -C packages/encryption_middleware RUN dart pub get -C packages/game_domain RUN dart pub get -C packages/jwt_middleware +RUN dart pub get -C packages/leaderboard_repository # Resolve app dependencies. COPY pubspec.* ./ diff --git a/assets/images/letters.png b/assets/images/letters.png index 37197c97e..bdc23a6a6 100644 Binary files a/assets/images/letters.png and b/assets/images/letters.png differ diff --git a/packages/api_client/lib/src/api_client.dart b/packages/api_client/lib/src/api_client.dart index f783cf986..e986698e1 100644 --- a/packages/api_client/lib/src/api_client.dart +++ b/packages/api_client/lib/src/api_client.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:api_client/api_client.dart'; +import 'package:api_client/src/resources/leaderboard_resource.dart'; import 'package:http/http.dart' as http; /// {@template api_client} @@ -51,6 +52,10 @@ class ApiClient { if (_appCheckToken != null) 'X-Firebase-AppCheck': _appCheckToken!, }; + /// {@macro leaderboard_resource} + late final LeaderboardResource leaderboardResource = + LeaderboardResource(apiClient: this); + Future _handleUnauthorized( Future Function() sendRequest, ) async { diff --git a/packages/api_client/lib/src/resources/leaderboard_resource.dart b/packages/api_client/lib/src/resources/leaderboard_resource.dart new file mode 100644 index 000000000..2d3103904 --- /dev/null +++ b/packages/api_client/lib/src/resources/leaderboard_resource.dart @@ -0,0 +1,98 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:api_client/api_client.dart'; +import 'package:game_domain/game_domain.dart'; + +/// {@template leaderboard_resource} +/// An api resource for interacting with the leaderboard. +/// {@endtemplate} +class LeaderboardResource { + /// {@macro leaderboard_resource} + LeaderboardResource({ + required ApiClient apiClient, + }) : _apiClient = apiClient; + + final ApiClient _apiClient; + + /// Get /game/leaderboard/results + /// + /// Returns a list of [LeaderboardPlayer]. + Future> getLeaderboardResults() async { + final response = await _apiClient.get('/game/leaderboard/results'); + + if (response.statusCode != HttpStatus.ok) { + throw ApiClientError( + 'GET /leaderboard/results returned status ${response.statusCode} ' + 'with the following response: "${response.body}"', + StackTrace.current, + ); + } + + try { + final json = jsonDecode(response.body) as Map; + final leaderboardPlayers = json['leaderboardPlayers'] as List; + + return leaderboardPlayers + .map( + (json) => LeaderboardPlayer.fromJson(json as Map), + ) + .toList(); + } catch (error, stackTrace) { + throw ApiClientError( + 'GET /leaderboard/results returned invalid response "${response.body}"', + stackTrace, + ); + } + } + + /// Get /game/leaderboard/initials_blacklist + /// + /// Returns a [List]. + Future> getInitialsBlacklist() async { + final response = + await _apiClient.get('/game/leaderboard/initials_blacklist'); + + if (response.statusCode == HttpStatus.notFound) { + return []; + } + + if (response.statusCode != HttpStatus.ok) { + throw ApiClientError( + 'GET /leaderboard/initials_blacklist returned status ' + '${response.statusCode} with the following response: ' + '"${response.body}"', + StackTrace.current, + ); + } + + try { + final json = jsonDecode(response.body) as Map; + return (json['list'] as List).cast(); + } catch (error, stackTrace) { + throw ApiClientError( + 'GET /leaderboard/initials_blacklist ' + 'returned invalid response "${response.body}"', + stackTrace, + ); + } + } + + /// Post /game/leaderboard/initials + Future addLeaderboardPlayer({ + required LeaderboardPlayer leaderboardPlayer, + }) async { + final response = await _apiClient.post( + '/game/leaderboard/initials', + body: jsonEncode(leaderboardPlayer.toJson()), + ); + + if (response.statusCode != HttpStatus.noContent) { + throw ApiClientError( + 'POST /leaderboard/initials returned status ${response.statusCode} ' + 'with the following response: "${response.body}"', + StackTrace.current, + ); + } + } +} diff --git a/packages/api_client/pubspec.yaml b/packages/api_client/pubspec.yaml index fdc809f23..ff260f385 100644 --- a/packages/api_client/pubspec.yaml +++ b/packages/api_client/pubspec.yaml @@ -14,4 +14,6 @@ dev_dependencies: dependencies: encrypt: ^5.0.3 equatable: ^2.0.5 - http: ^1.2.1 \ No newline at end of file + game_domain: + path: ../../api/packages/game_domain + http: ^1.2.1 diff --git a/packages/api_client/test/src/resources/leaderboard_resource_test.dart b/packages/api_client/test/src/resources/leaderboard_resource_test.dart new file mode 100644 index 000000000..f76526fbb --- /dev/null +++ b/packages/api_client/test/src/resources/leaderboard_resource_test.dart @@ -0,0 +1,204 @@ +// ignore_for_file: prefer_const_constructors + +import 'dart:convert'; +import 'dart:io'; + +import 'package:api_client/api_client.dart'; +import 'package:api_client/src/resources/leaderboard_resource.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('LeaderboardResource', () { + late ApiClient apiClient; + late http.Response response; + late LeaderboardResource resource; + + setUp(() { + apiClient = _MockApiClient(); + response = _MockResponse(); + + resource = LeaderboardResource(apiClient: apiClient); + }); + + group('getLeaderboardResults', () { + setUp(() { + when(() => apiClient.get(any())).thenAnswer((_) async => response); + }); + + test('makes the correct call ', () async { + final leaderboardPlayer = LeaderboardPlayer( + userId: 'id', + score: 10, + initials: 'TST', + ); + + when(() => response.statusCode).thenReturn(HttpStatus.ok); + when(() => response.body).thenReturn( + jsonEncode( + { + 'leaderboardPlayers': [leaderboardPlayer.toJson()], + }, + ), + ); + + final results = await resource.getLeaderboardResults(); + + expect(results, equals([leaderboardPlayer])); + }); + + test('throws ApiClientError when request fails', () async { + when(() => response.statusCode) + .thenReturn(HttpStatus.internalServerError); + when(() => response.body).thenReturn('Oops'); + + await expectLater( + resource.getLeaderboardResults, + throwsA( + isA().having( + (e) => e.cause, + 'cause', + equals( + 'GET /leaderboard/results returned status 500 with the following response: "Oops"', + ), + ), + ), + ); + }); + + test('throws ApiClientError when request response is invalid', () async { + when(() => response.statusCode).thenReturn(HttpStatus.ok); + when(() => response.body).thenReturn('Oops'); + + await expectLater( + resource.getLeaderboardResults, + throwsA( + isA().having( + (e) => e.cause, + 'cause', + equals( + 'GET /leaderboard/results returned invalid response "Oops"', + ), + ), + ), + ); + }); + }); + + group('getInitialsBlacklist', () { + setUp(() { + when(() => apiClient.get(any())).thenAnswer((_) async => response); + }); + + test('gets initials blacklist', () async { + const blacklist = ['WTF']; + + when(() => response.statusCode).thenReturn(HttpStatus.ok); + when(() => response.body).thenReturn(jsonEncode({'list': blacklist})); + final result = await resource.getInitialsBlacklist(); + + expect(result, equals(blacklist)); + }); + + test('gets empty blacklist if endpoint not found', () async { + const emptyList = []; + + when(() => response.statusCode).thenReturn(HttpStatus.notFound); + final result = await resource.getInitialsBlacklist(); + + expect(result, equals(emptyList)); + }); + + test('throws ApiClientError when request fails', () async { + when(() => response.statusCode) + .thenReturn(HttpStatus.internalServerError); + when(() => response.body).thenReturn('Oops'); + + await expectLater( + resource.getInitialsBlacklist, + throwsA( + isA().having( + (e) => e.cause, + 'cause', + equals( + 'GET /leaderboard/initials_blacklist returned status 500 with the following response: "Oops"', + ), + ), + ), + ); + }); + + test('throws ApiClientError when request response is invalid', () async { + when(() => response.statusCode).thenReturn(HttpStatus.ok); + when(() => response.body).thenReturn('Oops'); + + await expectLater( + resource.getInitialsBlacklist, + throwsA( + isA().having( + (e) => e.cause, + 'cause', + equals( + 'GET /leaderboard/initials_blacklist returned invalid response "Oops"', + ), + ), + ), + ); + }); + }); + + group('addLeaderboardPlayer', () { + final leaderboardPlayer = LeaderboardPlayer( + userId: 'id', + score: 10, + initials: 'TST', + ); + + setUp(() { + when(() => apiClient.post(any(), body: any(named: 'body'))) + .thenAnswer((_) async => response); + }); + + test('makes the correct call', () async { + when(() => response.statusCode).thenReturn(HttpStatus.noContent); + await resource.addLeaderboardPlayer( + leaderboardPlayer: leaderboardPlayer, + ); + + verify( + () => apiClient.post( + '/game/leaderboard/initials', + body: jsonEncode(leaderboardPlayer.toJson()), + ), + ).called(1); + }); + + test('throws ApiClientError when request fails', () async { + when(() => response.statusCode) + .thenReturn(HttpStatus.internalServerError); + when(() => response.body).thenReturn('Oops'); + + await expectLater( + () => resource.addLeaderboardPlayer( + leaderboardPlayer: leaderboardPlayer, + ), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + equals( + 'POST /leaderboard/initials returned status 500 with the following response: "Oops"', + ), + ), + ), + ); + }); + }); + }); +}