From 4dab43d6dd688b710359745ffb3ee69227e67827 Mon Sep 17 00:00:00 2001 From: Ayad Laaouissi Jones <42848220+AyadLaouissi@users.noreply.github.com> Date: Fri, 8 Mar 2024 10:13:34 +0100 Subject: [PATCH 1/4] feat: adds leaderboard resource (#41) --- packages/api_client/lib/src/api_client.dart | 5 + .../src/resources/leaderboard_resource.dart | 98 +++++++++ packages/api_client/pubspec.yaml | 4 +- .../resources/leaderboard_resource_test.dart | 204 ++++++++++++++++++ 4 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 packages/api_client/lib/src/resources/leaderboard_resource.dart create mode 100644 packages/api_client/test/src/resources/leaderboard_resource_test.dart 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"', + ), + ), + ), + ); + }); + }); + }); +} From 3ef6231757c53cf42e16650c45b648ae591a76fe Mon Sep 17 00:00:00 2001 From: Jaime <52668514+jsgalarraga@users.noreply.github.com> Date: Fri, 8 Mar 2024 10:59:19 +0100 Subject: [PATCH 2/4] chore: fix dockerfile importing missing repository (#47) --- api/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/Dockerfile b/api/Dockerfile index abb081674..659503f55 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -9,12 +9,14 @@ 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 # Install Dependencies 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.* ./ From 8c39d251d640811248028e9f5918bd752137de5c Mon Sep 17 00:00:00 2001 From: Jaime <52668514+jsgalarraga@users.noreply.github.com> Date: Fri, 8 Mar 2024 11:15:26 +0100 Subject: [PATCH 3/4] chore: fix staging deploys (#48) --- .github/workflows/deploy_api_staging.yaml | 2 +- .github/workflows/deploy_app_staging.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 From 3e930198c023aa6fe6fc1f58e5379633b4a420b9 Mon Sep 17 00:00:00 2001 From: Erick Date: Fri, 8 Mar 2024 09:42:51 -0300 Subject: [PATCH 4/4] feat: Changing letters.png to include all 4 colors (#46) --- assets/images/letters.png | Bin 1975 -> 7600 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/images/letters.png b/assets/images/letters.png index 37197c97e0944344bafe4db52205839cff7816e8..bdc23a6a6e188938b5acc58444dee4a6716f3e49 100644 GIT binary patch literal 7600 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 GJpK>jtI>o2 delta 696 zcmV;p0!RI@JGT#z7k?-S1^@s68;ME>00001b5ch_0Itp)=>Px+aY;l$RCt{2-CJ_j zI1q;6pD7kX$QH={7r+`Qmcm?2nGr&VmSwBImfxp}nKnMwc`|Bm60000$PwmBX-Y=X306Z5P8~^|S7}9#H{`=bpn;b@^JpTCk z{rAtwBLIv!y}_aUxtLyTDV2$%74yo8PCa)c@s+HIc_v1+oUUGE^cgXN=s*S6V+Poj_3QuPte;c^t=o?^bSdM9STvw?X7VdHrIY z7d+;Vkup8TztNni&B2!XRcU{%zf~L$6~@9lJpLUV?{6P&6N$Ba&-hxrbB8&R+aBP_ zqhDNA=3TMZ7D=jONN@a$Fu!@lm~MKP>OK9rY7V{Q$~TD8e*mzG_gHwB(WqNmp{78dxtZ*TUpnoc4sMjWiHRexVo-eci-~guViU|XWPJ)S~*i%p!WK& zXWlGz+-s*-tBt`5b$Z1G9>JTm@y&2MQOy?*R*Qo>T+aRP8fd?WLMN8U>1 zzh3q6&wG8^T910IRjun&k2Qidw$krb#F)3KURhpLWAw-xaP_<~vM%w8u@*X7WXgFeU-m|?^mG!0000