diff --git a/.github/workflows/api_client.yaml b/.github/workflows/api_client.yaml
new file mode 100644
index 000000000..d81c2e032
--- /dev/null
+++ b/.github/workflows/api_client.yaml
@@ -0,0 +1,20 @@
+name: api_client
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+on:
+ pull_request:
+ paths:
+ - "packages/api_client/**"
+ - ".github/workflows/api_client.yaml"
+ branches:
+ - main
+
+jobs:
+ build:
+ uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/dart_package.yml@v1
+ with:
+ dart_sdk: stable
+ working_directory: packages/api_client
diff --git a/packages/api_client/.gitignore b/packages/api_client/.gitignore
new file mode 100644
index 000000000..526da1584
--- /dev/null
+++ b/packages/api_client/.gitignore
@@ -0,0 +1,7 @@
+# See https://www.dartlang.org/guides/libraries/private-files
+
+# Files and directories created by pub
+.dart_tool/
+.packages
+build/
+pubspec.lock
\ No newline at end of file
diff --git a/packages/api_client/README.md b/packages/api_client/README.md
new file mode 100644
index 000000000..a4b36aab5
--- /dev/null
+++ b/packages/api_client/README.md
@@ -0,0 +1,62 @@
+# Api Client
+
+[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link]
+[![Powered by Mason](https://img.shields.io/endpoint?url=https%3A%2F%2Ftinyurl.com%2Fmason-badge)](https://github.com/felangel/mason)
+[![License: MIT][license_badge]][license_link]
+
+Client to access the api.
+
+## Installation ๐ป
+
+**โ In order to start using Api Client you must have the [Dart SDK][dart_install_link] installed on your machine.**
+
+Install via `dart pub add`:
+
+```sh
+dart pub add api_client
+```
+
+---
+
+## Continuous Integration ๐ค
+
+Api Client comes with a built-in [GitHub Actions workflow][github_actions_link] powered by [Very Good Workflows][very_good_workflows_link] but you can also add your preferred CI/CD solution.
+
+Out of the box, on each pull request and push, the CI `formats`, `lints`, and `tests` the code. This ensures the code remains consistent and behaves correctly as you add functionality or make changes. The project uses [Very Good Analysis][very_good_analysis_link] for a strict set of analysis options used by our team. Code coverage is enforced using the [Very Good Workflows][very_good_coverage_link].
+
+---
+
+## Running Tests ๐งช
+
+To run all unit tests:
+
+```sh
+dart pub global activate coverage 1.2.0
+dart test --coverage=coverage
+dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info
+```
+
+To view the generated coverage report you can use [lcov](https://github.com/linux-test-project/lcov).
+
+```sh
+# Generate Coverage Report
+genhtml coverage/lcov.info -o coverage/
+
+# Open Coverage Report
+open coverage/index.html
+```
+
+[dart_install_link]: https://dart.dev/get-dart
+[github_actions_link]: https://docs.github.com/en/actions/learn-github-actions
+[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg
+[license_link]: https://opensource.org/licenses/MIT
+[logo_black]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_black.png#gh-light-mode-only
+[logo_white]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_white.png#gh-dark-mode-only
+[mason_link]: https://github.com/felangel/mason
+[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg
+[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis
+[very_good_coverage_link]: https://github.com/marketplace/actions/very-good-coverage
+[very_good_ventures_link]: https://verygood.ventures
+[very_good_ventures_link_light]: https://verygood.ventures#gh-light-mode-only
+[very_good_ventures_link_dark]: https://verygood.ventures#gh-dark-mode-only
+[very_good_workflows_link]: https://github.com/VeryGoodOpenSource/very_good_workflows
diff --git a/packages/api_client/analysis_options.yaml b/packages/api_client/analysis_options.yaml
new file mode 100644
index 000000000..799268d3e
--- /dev/null
+++ b/packages/api_client/analysis_options.yaml
@@ -0,0 +1 @@
+include: package:very_good_analysis/analysis_options.5.1.0.yaml
diff --git a/packages/api_client/coverage_badge.svg b/packages/api_client/coverage_badge.svg
new file mode 100644
index 000000000..499e98ce2
--- /dev/null
+++ b/packages/api_client/coverage_badge.svg
@@ -0,0 +1,20 @@
+
diff --git a/packages/api_client/lib/api_client.dart b/packages/api_client/lib/api_client.dart
new file mode 100644
index 000000000..fd06ea1f4
--- /dev/null
+++ b/packages/api_client/lib/api_client.dart
@@ -0,0 +1,7 @@
+/// Client to access the api.
+library api_client;
+
+export 'src/api_client.dart';
+export 'src/errors/api_client_error.dart';
+export 'src/extensions/extensions.dart';
+export 'src/http_calls/http_calls.dart';
diff --git a/packages/api_client/lib/src/api_client.dart b/packages/api_client/lib/src/api_client.dart
new file mode 100644
index 000000000..f783cf986
--- /dev/null
+++ b/packages/api_client/lib/src/api_client.dart
@@ -0,0 +1,163 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:api_client/api_client.dart';
+import 'package:http/http.dart' as http;
+
+/// {@template api_client}
+/// Client to access the api.
+/// {@endtemplate}
+class ApiClient {
+ /// {@macro api_client}
+ ApiClient({
+ required String baseUrl,
+ required Stream idTokenStream,
+ required Future Function() refreshIdToken,
+ required Stream appCheckTokenStream,
+ String? appCheckToken,
+ PostCall postCall = http.post,
+ PutCall putCall = http.put,
+ PatchCall patchCall = http.patch,
+ GetCall getCall = http.get,
+ }) : _base = Uri.parse(baseUrl),
+ _post = postCall,
+ _put = putCall,
+ _patch = patchCall,
+ _get = getCall,
+ _appCheckToken = appCheckToken,
+ _refreshIdToken = refreshIdToken {
+ _idTokenSubscription = idTokenStream.listen((idToken) {
+ _idToken = idToken;
+ });
+ _appCheckTokenSubscription = appCheckTokenStream.listen((appCheckToken) {
+ _appCheckToken = appCheckToken;
+ });
+ }
+
+ final Uri _base;
+ final PostCall _post;
+ final PostCall _put;
+ final PatchCall _patch;
+ final GetCall _get;
+ final Future Function() _refreshIdToken;
+
+ late final StreamSubscription _idTokenSubscription;
+ late final StreamSubscription _appCheckTokenSubscription;
+ String? _idToken;
+ String? _appCheckToken;
+
+ Map get _headers => {
+ if (_idToken != null) 'Authorization': 'Bearer $_idToken',
+ if (_appCheckToken != null) 'X-Firebase-AppCheck': _appCheckToken!,
+ };
+
+ Future _handleUnauthorized(
+ Future Function() sendRequest,
+ ) async {
+ final response = await sendRequest();
+
+ if (response.statusCode == HttpStatus.unauthorized) {
+ _idToken = await _refreshIdToken();
+ return sendRequest();
+ }
+ return response;
+ }
+
+ /// Dispose of resources used by this client.
+ Future dispose() async {
+ await _idTokenSubscription.cancel();
+ await _appCheckTokenSubscription.cancel();
+ }
+
+ /// Sends a POST request to the specified [path] with the given [body].
+ Future post(
+ String path, {
+ Object? body,
+ Map? queryParameters,
+ }) async {
+ return _handleUnauthorized(() async {
+ final response = await _post(
+ _base.replace(
+ path: path,
+ queryParameters: queryParameters,
+ ),
+ body: body,
+ headers: _headers..addContentTypeJson(),
+ );
+
+ return response.decrypted;
+ });
+ }
+
+ /// Sends a PATCH request to the specified [path] with the given [body].
+ Future patch(
+ String path, {
+ Object? body,
+ Map? queryParameters,
+ }) async {
+ return _handleUnauthorized(() async {
+ final response = await _patch(
+ _base.replace(
+ path: path,
+ queryParameters: queryParameters,
+ ),
+ body: body,
+ headers: _headers..addContentTypeJson(),
+ );
+
+ return response.decrypted;
+ });
+ }
+
+ /// Sends a PUT request to the specified [path] with the given [body].
+ Future put(
+ String path, {
+ Object? body,
+ }) async {
+ return _handleUnauthorized(() async {
+ final response = await _put(
+ _base.replace(path: path),
+ body: body,
+ headers: _headers..addContentTypeJson(),
+ );
+
+ return response.decrypted;
+ });
+ }
+
+ /// Sends a GET request to the specified [path].
+ Future get(
+ String path, {
+ Map? queryParameters,
+ }) async {
+ return _handleUnauthorized(() async {
+ final response = await _get(
+ _base.replace(
+ path: path,
+ queryParameters: queryParameters,
+ ),
+ headers: _headers,
+ );
+
+ return response.decrypted;
+ });
+ }
+
+ /// Sends a GET request to the specified public [path].
+ Future getPublic(
+ String path, {
+ Map? queryParameters,
+ }) async {
+ return _handleUnauthorized(() async {
+ final response = await _get(
+ _base.replace(
+ path: path,
+ queryParameters: queryParameters,
+ ),
+ headers: _headers,
+ );
+
+ return response;
+ });
+ }
+}
diff --git a/packages/api_client/lib/src/errors/api_client_error.dart b/packages/api_client/lib/src/errors/api_client_error.dart
new file mode 100644
index 000000000..901acc60e
--- /dev/null
+++ b/packages/api_client/lib/src/errors/api_client_error.dart
@@ -0,0 +1,23 @@
+/// {@template api_client_error}
+/// Error throw when accessing api failed.
+///
+/// Check [cause] and [stackTrace] for specific details.
+/// {@endtemplate}
+class ApiClientError implements Exception {
+ /// {@macro api_client_error}
+ ApiClientError(this.cause, this.stackTrace);
+
+ /// Error cause.
+ final dynamic cause;
+
+ /// The stack trace of the error.
+ final StackTrace stackTrace;
+
+ @override
+ String toString() {
+ return '''
+cause: $cause
+stackTrace: $stackTrace
+''';
+ }
+}
diff --git a/packages/api_client/lib/src/extensions/extensions.dart b/packages/api_client/lib/src/extensions/extensions.dart
new file mode 100644
index 000000000..537b1ed03
--- /dev/null
+++ b/packages/api_client/lib/src/extensions/extensions.dart
@@ -0,0 +1,2 @@
+export 'json_content_type.dart';
+export 'response_decryption.dart';
diff --git a/packages/api_client/lib/src/extensions/json_content_type.dart b/packages/api_client/lib/src/extensions/json_content_type.dart
new file mode 100644
index 000000000..103186710
--- /dev/null
+++ b/packages/api_client/lib/src/extensions/json_content_type.dart
@@ -0,0 +1,11 @@
+import 'dart:io';
+
+/// {@template json_content_type}
+/// Extension on [Map] to add content-type value
+/// {@endtemplate}
+extension JsonContentType on Map {
+ /// Adds content-type to map
+ void addContentTypeJson() {
+ addAll({HttpHeaders.contentTypeHeader: ContentType.json.value});
+ }
+}
diff --git a/packages/api_client/lib/src/extensions/response_decryption.dart b/packages/api_client/lib/src/extensions/response_decryption.dart
new file mode 100644
index 000000000..0513e59c5
--- /dev/null
+++ b/packages/api_client/lib/src/extensions/response_decryption.dart
@@ -0,0 +1,51 @@
+import 'dart:convert';
+
+import 'package:encrypt/encrypt.dart';
+import 'package:http/http.dart' as http;
+
+/// {@template response_encryption}
+/// Extension on [http.Response] to decrypt body
+/// {@endtemplate}
+extension ResponseDecryption on http.Response {
+ /// Get [http.Response] with decrypted body
+ http.Response get decrypted {
+ if (body.isEmpty) return this;
+
+ final key = Key.fromUtf8(_encryptionKey);
+ final iv = IV.fromUtf8(_encryptionIV);
+
+ final encrypter = Encrypter(AES(key));
+
+ final decrypted = encrypter.decrypt64(body, iv: iv);
+
+ return http.Response(
+ jsonDecode(decrypted).toString(),
+ statusCode,
+ headers: headers,
+ isRedirect: isRedirect,
+ persistentConnection: persistentConnection,
+ reasonPhrase: reasonPhrase,
+ request: request,
+ );
+ }
+
+ String get _encryptionKey {
+ const value = String.fromEnvironment(
+ 'ENCRYPTION_KEY',
+ // Default value is set at 32 characters to match required length of
+ // AES key. The default value can then be used for testing purposes.
+ defaultValue: 'encryption_key_not_set_123456789',
+ );
+ return value;
+ }
+
+ String get _encryptionIV {
+ const value = String.fromEnvironment(
+ 'ENCRYPTION_IV',
+ // Default value is set at 116 characters to match required length of
+ // IV key. The default value can then be used for testing purposes.
+ defaultValue: 'iv_not_set_12345',
+ );
+ return value;
+ }
+}
diff --git a/packages/api_client/lib/src/http_calls/http_calls.dart b/packages/api_client/lib/src/http_calls/http_calls.dart
new file mode 100644
index 000000000..d7f9423eb
--- /dev/null
+++ b/packages/api_client/lib/src/http_calls/http_calls.dart
@@ -0,0 +1,28 @@
+import 'package:http/http.dart' as http;
+
+/// Definition of a post call used by this client.
+typedef PostCall = Future Function(
+ Uri, {
+ Object? body,
+ Map? headers,
+});
+
+/// Definition of a patch call used by this client.
+typedef PatchCall = Future Function(
+ Uri, {
+ Object? body,
+ Map? headers,
+});
+
+/// Definition of a put call used by this client.
+typedef PutCall = Future Function(
+ Uri, {
+ Object? body,
+ Map? headers,
+});
+
+/// Definition of a get call used by this client.
+typedef GetCall = Future Function(
+ Uri, {
+ Map? headers,
+});
diff --git a/packages/api_client/pubspec.yaml b/packages/api_client/pubspec.yaml
new file mode 100644
index 000000000..fdc809f23
--- /dev/null
+++ b/packages/api_client/pubspec.yaml
@@ -0,0 +1,17 @@
+name: api_client
+description: Client to access the api.
+version: 0.1.0+1
+publish_to: none
+
+environment:
+ sdk: ">=3.0.0 <4.0.0"
+
+dev_dependencies:
+ mocktail: ^1.0.0
+ test: ^1.19.2
+ very_good_analysis: ^5.1.0
+
+dependencies:
+ encrypt: ^5.0.3
+ equatable: ^2.0.5
+ http: ^1.2.1
\ No newline at end of file
diff --git a/packages/api_client/test/src/api_client_test.dart b/packages/api_client/test/src/api_client_test.dart
new file mode 100644
index 000000000..462b117a5
--- /dev/null
+++ b/packages/api_client/test/src/api_client_test.dart
@@ -0,0 +1,539 @@
+// ignore_for_file: prefer_const_constructors
+
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:api_client/api_client.dart';
+import 'package:encrypt/encrypt.dart';
+import 'package:http/http.dart' as http;
+import 'package:mocktail/mocktail.dart';
+import 'package:test/test.dart';
+
+class _MockHttpClient extends Mock {
+ Future get(Uri uri, {Map? headers});
+ Future post(
+ Uri uri, {
+ Object? body,
+ Map? headers,
+ });
+ Future patch(
+ Uri uri, {
+ Object? body,
+ Map? headers,
+ });
+ Future put(
+ Uri uri, {
+ Object? body,
+ Map? headers,
+ });
+}
+
+void main() {
+ setUpAll(() {
+ registerFallbackValue(Uri.parse('http://localhost'));
+ });
+
+ group('ApiClient', () {
+ const baseUrl = 'http://baseurl.com';
+ const mockIdToken = 'mockIdToken';
+ const mockNewIdToken = 'mockNewIdToken';
+ const mockAppCheckToken = 'mockAppCheckToken';
+
+ // Since the key and iv are set from the environment variables, we can
+ // reference the default values here.
+ final key = Key.fromUtf8('encryption_key_not_set_123456789');
+ final iv = IV.fromUtf8('iv_not_set_12345');
+ final encrypter = Encrypter(AES(key));
+
+ final testJson = {'data': 'test'};
+
+ final encrypted = encrypter.encrypt(jsonEncode(testJson), iv: iv).base64;
+
+ final encryptedResponse = http.Response(encrypted, 200);
+ final expectedResponse = http.Response(testJson.toString(), 200);
+
+ late ApiClient subject;
+ late _MockHttpClient httpClient;
+ late StreamController idTokenStreamController;
+ late StreamController appCheckTokenStreamController;
+
+ Future Function() refreshIdToken = () async => null;
+
+ setUp(() {
+ httpClient = _MockHttpClient();
+
+ when(
+ () => httpClient.get(
+ any(),
+ headers: any(named: 'headers'),
+ ),
+ ).thenAnswer((_) async => encryptedResponse);
+
+ when(
+ () => httpClient.post(
+ any(),
+ body: any(named: 'body'),
+ headers: any(named: 'headers'),
+ ),
+ ).thenAnswer((_) async => encryptedResponse);
+
+ when(
+ () => httpClient.patch(
+ any(),
+ body: any(named: 'body'),
+ headers: any(named: 'headers'),
+ ),
+ ).thenAnswer((_) async => encryptedResponse);
+
+ when(
+ () => httpClient.put(
+ any(),
+ body: any(named: 'body'),
+ headers: any(named: 'headers'),
+ ),
+ ).thenAnswer((_) async => encryptedResponse);
+
+ idTokenStreamController = StreamController.broadcast();
+ appCheckTokenStreamController = StreamController.broadcast();
+
+ subject = ApiClient(
+ baseUrl: baseUrl,
+ getCall: httpClient.get,
+ postCall: httpClient.post,
+ patchCall: httpClient.patch,
+ putCall: httpClient.put,
+ idTokenStream: idTokenStreamController.stream,
+ refreshIdToken: () => refreshIdToken(),
+ appCheckTokenStream: appCheckTokenStreamController.stream,
+ );
+ });
+
+ test('can be instantiated', () {
+ expect(
+ ApiClient(
+ baseUrl: 'http://localhost',
+ idTokenStream: Stream.empty(),
+ refreshIdToken: () async => null,
+ appCheckTokenStream: Stream.empty(),
+ ),
+ isNotNull,
+ );
+ });
+
+ group('dispose', () {
+ test('cancels id token stream subscription', () async {
+ expect(idTokenStreamController.hasListener, isTrue);
+ expect(appCheckTokenStreamController.hasListener, isTrue);
+
+ await subject.dispose();
+
+ expect(idTokenStreamController.hasListener, isFalse);
+ expect(appCheckTokenStreamController.hasListener, isFalse);
+ });
+ });
+
+ group('get', () {
+ test('returns the response', () async {
+ final response = await subject.get('/');
+
+ expect(response.statusCode, equals(expectedResponse.statusCode));
+ expect(response.body, equals(expectedResponse.body));
+ });
+
+ test('sends the request correctly', () async {
+ await subject.get(
+ '/path/to/endpoint',
+ queryParameters: {
+ 'param1': 'value1',
+ 'param2': 'value2',
+ },
+ );
+
+ verify(
+ () => httpClient.get(
+ Uri.parse('$baseUrl/path/to/endpoint?param1=value1¶m2=value2'),
+ headers: {},
+ ),
+ ).called(1);
+ });
+
+ test('sends the authentication and app check token', () async {
+ idTokenStreamController.add(mockIdToken);
+ appCheckTokenStreamController.add(mockAppCheckToken);
+ await Future.microtask(() {});
+ await subject.get('/path/to/endpoint');
+
+ verify(
+ () => httpClient.get(
+ Uri.parse('$baseUrl/path/to/endpoint'),
+ headers: {
+ 'Authorization': 'Bearer $mockIdToken',
+ 'X-Firebase-AppCheck': mockAppCheckToken,
+ },
+ ),
+ ).called(1);
+ });
+
+ test('refreshes the authentication token when needed', () async {
+ when(
+ () => httpClient.get(
+ any(),
+ headers: any(named: 'headers'),
+ ),
+ ).thenAnswer((_) async => http.Response('', 401));
+
+ refreshIdToken = () async => mockNewIdToken;
+
+ idTokenStreamController.add(mockIdToken);
+ appCheckTokenStreamController.add(mockAppCheckToken);
+ await Future.microtask(() {});
+ await subject.get('/path/to/endpoint');
+
+ verify(
+ () => httpClient.get(
+ Uri.parse('$baseUrl/path/to/endpoint'),
+ headers: {
+ 'Authorization': 'Bearer $mockIdToken',
+ 'X-Firebase-AppCheck': mockAppCheckToken,
+ },
+ ),
+ ).called(1);
+ verify(
+ () => httpClient.get(
+ Uri.parse('$baseUrl/path/to/endpoint'),
+ headers: {
+ 'Authorization': 'Bearer $mockNewIdToken',
+ 'X-Firebase-AppCheck': mockAppCheckToken,
+ },
+ ),
+ ).called(1);
+ });
+ });
+
+ group('getPublic', () {
+ setUp(() {
+ when(
+ () => httpClient.get(
+ any(),
+ headers: any(named: 'headers'),
+ ),
+ ).thenAnswer((_) async => expectedResponse);
+ });
+ test('returns the response', () async {
+ final response = await subject.getPublic('/');
+
+ expect(response.statusCode, equals(expectedResponse.statusCode));
+ expect(response.body, equals(expectedResponse.body));
+ });
+
+ test('sends the request correctly', () async {
+ await subject.getPublic(
+ '/path/to/endpoint',
+ queryParameters: {
+ 'param1': 'value1',
+ 'param2': 'value2',
+ },
+ );
+
+ verify(
+ () => httpClient.get(
+ Uri.parse('$baseUrl/path/to/endpoint?param1=value1¶m2=value2'),
+ headers: {},
+ ),
+ ).called(1);
+ });
+
+ test('sends the authentication and app check token', () async {
+ idTokenStreamController.add(mockIdToken);
+ appCheckTokenStreamController.add(mockAppCheckToken);
+ await Future.microtask(() {});
+ await subject.getPublic('/path/to/endpoint');
+
+ verify(
+ () => httpClient.get(
+ Uri.parse('$baseUrl/path/to/endpoint'),
+ headers: {
+ 'Authorization': 'Bearer $mockIdToken',
+ 'X-Firebase-AppCheck': mockAppCheckToken,
+ },
+ ),
+ ).called(1);
+ });
+
+ test('refreshes the authentication token when needed', () async {
+ when(
+ () => httpClient.get(
+ any(),
+ headers: any(named: 'headers'),
+ ),
+ ).thenAnswer((_) async => http.Response('', 401));
+
+ refreshIdToken = () async => mockNewIdToken;
+
+ idTokenStreamController.add(mockIdToken);
+ appCheckTokenStreamController.add(mockAppCheckToken);
+ await Future.microtask(() {});
+ await subject.getPublic('/path/to/endpoint');
+
+ verify(
+ () => httpClient.get(
+ Uri.parse('$baseUrl/path/to/endpoint'),
+ headers: {
+ 'Authorization': 'Bearer $mockIdToken',
+ 'X-Firebase-AppCheck': mockAppCheckToken,
+ },
+ ),
+ ).called(1);
+ verify(
+ () => httpClient.get(
+ Uri.parse('$baseUrl/path/to/endpoint'),
+ headers: {
+ 'Authorization': 'Bearer $mockNewIdToken',
+ 'X-Firebase-AppCheck': mockAppCheckToken,
+ },
+ ),
+ ).called(1);
+ });
+ });
+
+ group('post', () {
+ test('returns the response', () async {
+ final response = await subject.post('/');
+
+ expect(response.statusCode, equals(expectedResponse.statusCode));
+ expect(response.body, equals(expectedResponse.body));
+ });
+
+ test('sends the request correctly', () async {
+ await subject.post(
+ '/path/to/endpoint',
+ queryParameters: {'param1': 'value1', 'param2': 'value2'},
+ body: 'BODY_CONTENT',
+ );
+
+ verify(
+ () => httpClient.post(
+ Uri.parse('$baseUrl/path/to/endpoint?param1=value1¶m2=value2'),
+ body: 'BODY_CONTENT',
+ headers: {HttpHeaders.contentTypeHeader: ContentType.json.value},
+ ),
+ ).called(1);
+ });
+
+ test('sends the authentication and app check token', () async {
+ idTokenStreamController.add(mockIdToken);
+ appCheckTokenStreamController.add(mockAppCheckToken);
+ await Future.microtask(() {});
+ await subject.post('/path/to/endpoint');
+
+ verify(
+ () => httpClient.post(
+ Uri.parse('$baseUrl/path/to/endpoint'),
+ headers: {
+ 'Authorization': 'Bearer $mockIdToken',
+ 'X-Firebase-AppCheck': mockAppCheckToken,
+ HttpHeaders.contentTypeHeader: ContentType.json.value,
+ },
+ ),
+ ).called(1);
+ });
+
+ test('refreshes the authentication token when needed', () async {
+ when(
+ () => httpClient.post(
+ any(),
+ headers: any(named: 'headers'),
+ ),
+ ).thenAnswer((_) async => http.Response('', 401));
+
+ refreshIdToken = () async => mockNewIdToken;
+
+ idTokenStreamController.add(mockIdToken);
+ appCheckTokenStreamController.add(mockAppCheckToken);
+ await Future.microtask(() {});
+ await subject.post('/path/to/endpoint');
+
+ verify(
+ () => httpClient.post(
+ Uri.parse('$baseUrl/path/to/endpoint'),
+ headers: {
+ 'Authorization': 'Bearer $mockIdToken',
+ 'X-Firebase-AppCheck': mockAppCheckToken,
+ HttpHeaders.contentTypeHeader: ContentType.json.value,
+ },
+ ),
+ ).called(1);
+ verify(
+ () => httpClient.post(
+ Uri.parse('$baseUrl/path/to/endpoint'),
+ headers: {
+ 'Authorization': 'Bearer $mockNewIdToken',
+ 'X-Firebase-AppCheck': mockAppCheckToken,
+ HttpHeaders.contentTypeHeader: ContentType.json.value,
+ },
+ ),
+ ).called(1);
+ });
+ });
+
+ group('patch', () {
+ test('returns the response', () async {
+ final response = await subject.patch('/');
+
+ expect(response.statusCode, equals(expectedResponse.statusCode));
+ expect(response.body, equals(expectedResponse.body));
+ });
+
+ test('sends the request correctly', () async {
+ await subject.patch(
+ '/path/to/endpoint',
+ queryParameters: {'param1': 'value1', 'param2': 'value2'},
+ body: 'BODY_CONTENT',
+ );
+
+ verify(
+ () => httpClient.patch(
+ Uri.parse('$baseUrl/path/to/endpoint?param1=value1¶m2=value2'),
+ body: 'BODY_CONTENT',
+ headers: {HttpHeaders.contentTypeHeader: ContentType.json.value},
+ ),
+ ).called(1);
+ });
+
+ test('sends the authentication and app check token', () async {
+ idTokenStreamController.add(mockIdToken);
+ appCheckTokenStreamController.add(mockAppCheckToken);
+ await Future.microtask(() {});
+ await subject.patch('/path/to/endpoint');
+
+ verify(
+ () => httpClient.patch(
+ Uri.parse('$baseUrl/path/to/endpoint'),
+ headers: {
+ 'Authorization': 'Bearer $mockIdToken',
+ 'X-Firebase-AppCheck': mockAppCheckToken,
+ HttpHeaders.contentTypeHeader: ContentType.json.value,
+ },
+ ),
+ ).called(1);
+ });
+
+ test('refreshes the authentication token when needed', () async {
+ when(
+ () => httpClient.patch(
+ any(),
+ headers: any(named: 'headers'),
+ ),
+ ).thenAnswer((_) async => http.Response('', 401));
+
+ refreshIdToken = () async => mockNewIdToken;
+
+ idTokenStreamController.add(mockIdToken);
+ appCheckTokenStreamController.add(mockAppCheckToken);
+ await Future.microtask(() {});
+ await subject.patch('/path/to/endpoint');
+
+ verify(
+ () => httpClient.patch(
+ Uri.parse('$baseUrl/path/to/endpoint'),
+ headers: {
+ 'Authorization': 'Bearer $mockIdToken',
+ 'X-Firebase-AppCheck': mockAppCheckToken,
+ HttpHeaders.contentTypeHeader: ContentType.json.value,
+ },
+ ),
+ ).called(1);
+ verify(
+ () => httpClient.patch(
+ Uri.parse('$baseUrl/path/to/endpoint'),
+ headers: {
+ 'Authorization': 'Bearer $mockNewIdToken',
+ 'X-Firebase-AppCheck': mockAppCheckToken,
+ HttpHeaders.contentTypeHeader: ContentType.json.value,
+ },
+ ),
+ ).called(1);
+ });
+ });
+
+ group('put', () {
+ test('returns the response', () async {
+ final response = await subject.put('/');
+
+ expect(response.statusCode, equals(expectedResponse.statusCode));
+ expect(response.body, equals(expectedResponse.body));
+ });
+
+ test('sends the request correctly', () async {
+ await subject.put(
+ '/path/to/endpoint',
+ body: 'BODY_CONTENT',
+ );
+
+ verify(
+ () => httpClient.put(
+ Uri.parse('$baseUrl/path/to/endpoint'),
+ body: 'BODY_CONTENT',
+ headers: {HttpHeaders.contentTypeHeader: ContentType.json.value},
+ ),
+ ).called(1);
+ });
+
+ test('sends the authentication and app check token', () async {
+ idTokenStreamController.add(mockIdToken);
+ appCheckTokenStreamController.add(mockAppCheckToken);
+ await Future.microtask(() {});
+ await subject.put('/path/to/endpoint');
+
+ verify(
+ () => httpClient.put(
+ Uri.parse('$baseUrl/path/to/endpoint'),
+ headers: {
+ 'Authorization': 'Bearer $mockIdToken',
+ 'X-Firebase-AppCheck': mockAppCheckToken,
+ HttpHeaders.contentTypeHeader: ContentType.json.value,
+ },
+ ),
+ ).called(1);
+ });
+
+ test('refreshes the authentication token when needed', () async {
+ when(
+ () => httpClient.put(
+ any(),
+ headers: any(named: 'headers'),
+ ),
+ ).thenAnswer((_) async => http.Response('', 401));
+
+ refreshIdToken = () async => mockNewIdToken;
+
+ idTokenStreamController.add(mockIdToken);
+ appCheckTokenStreamController.add(mockAppCheckToken);
+ await Future.microtask(() {});
+ await subject.put('/path/to/endpoint');
+
+ verify(
+ () => httpClient.put(
+ Uri.parse('$baseUrl/path/to/endpoint'),
+ headers: {
+ 'Authorization': 'Bearer $mockIdToken',
+ 'X-Firebase-AppCheck': mockAppCheckToken,
+ HttpHeaders.contentTypeHeader: ContentType.json.value,
+ },
+ ),
+ ).called(1);
+ verify(
+ () => httpClient.put(
+ Uri.parse('$baseUrl/path/to/endpoint'),
+ headers: {
+ 'Authorization': 'Bearer $mockNewIdToken',
+ 'X-Firebase-AppCheck': mockAppCheckToken,
+ HttpHeaders.contentTypeHeader: ContentType.json.value,
+ },
+ ),
+ ).called(1);
+ });
+ });
+ });
+}
diff --git a/packages/api_client/test/src/errors/api_client_error_test.dart b/packages/api_client/test/src/errors/api_client_error_test.dart
new file mode 100644
index 000000000..a753d2377
--- /dev/null
+++ b/packages/api_client/test/src/errors/api_client_error_test.dart
@@ -0,0 +1,16 @@
+import 'package:api_client/src/errors/api_client_error.dart';
+import 'package:test/test.dart';
+
+void main() {
+ group('ApiClientError', () {
+ test('toString returns the cause', () {
+ expect(
+ ApiClientError('Ops', StackTrace.fromString('stackTrace')).toString(),
+ equals('''
+cause: Ops
+stackTrace: stackTrace
+'''),
+ );
+ });
+ });
+}
diff --git a/packages/api_client/test/src/extensions/response_decryption_test.dart b/packages/api_client/test/src/extensions/response_decryption_test.dart
new file mode 100644
index 000000000..8c2bb0890
--- /dev/null
+++ b/packages/api_client/test/src/extensions/response_decryption_test.dart
@@ -0,0 +1,22 @@
+import 'dart:convert';
+
+import 'package:api_client/src/extensions/response_decryption.dart';
+import 'package:encrypt/encrypt.dart';
+import 'package:http/http.dart' as http;
+import 'package:test/test.dart';
+
+void main() {
+ group('ResponseDecryption', () {
+ test('decrypts correctly', () {
+ final key = Key.fromUtf8('encryption_key_not_set_123456789');
+ final iv = IV.fromUtf8('iv_not_set_12345');
+ final encrypter = Encrypter(AES(key));
+ final testJson = {'data': 'test'};
+ final encrypted = encrypter.encrypt(jsonEncode(testJson), iv: iv).base64;
+ final encryptedResponse = http.Response(encrypted, 200);
+ final expectedResponse = http.Response(testJson.toString(), 200);
+
+ expect(encryptedResponse.decrypted.body, equals(expectedResponse.body));
+ });
+ });
+}