Scaffold backend gateway and integration docs

This commit is contained in:
Rijad Zuzo
2026-02-14 20:10:16 +01:00
commit 577c4b33b7
166 changed files with 13382 additions and 0 deletions
@@ -0,0 +1,78 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/core/network/backend_exception.dart';
import 'package:relationship_saver/core/network/dio_error_mapper.dart';
void main() {
group('mapDioException', () {
test('maps key status codes', () {
expect(_mapStatus(401), isA<UnauthorizedException>());
expect(_mapStatus(403), isA<ForbiddenException>());
expect(_mapStatus(404), isA<NotFoundException>());
expect(_mapStatus(422), isA<ValidationException>());
expect(_mapStatus(429), isA<RateLimitedException>());
expect(_mapStatus(500), isA<ServerException>());
});
test('maps timeout and network failures', () {
final RequestOptions options = RequestOptions(path: '/v1/me');
final TimeoutException timeout =
mapDioException(
DioException(
requestOptions: options,
type: DioExceptionType.connectionTimeout,
message: 'timed out',
),
)
as TimeoutException;
expect(timeout.message, contains('timed out'));
expect(
mapDioException(
DioException(
requestOptions: options,
type: DioExceptionType.connectionError,
message: 'no route',
),
),
isA<NetworkException>(),
);
});
test('returns embedded backend exception unchanged', () {
final RequestOptions options = RequestOptions(path: '/v1/me');
final AuthExpiredException embedded = const AuthExpiredException(
'session expired',
);
final BackendException mapped = mapDioException(
DioException(
requestOptions: options,
type: DioExceptionType.badResponse,
error: embedded,
),
);
expect(mapped, same(embedded));
});
});
}
BackendException _mapStatus(int status) {
final RequestOptions options = RequestOptions(path: '/v1/example');
return mapDioException(
DioException(
requestOptions: options,
type: DioExceptionType.badResponse,
response: Response<dynamic>(
requestOptions: options,
statusCode: status,
data: <String, dynamic>{'code': 'ERR', 'message': 'failed'},
headers: Headers.fromMap(<String, List<String>>{
'x-request-id': <String>['req-123'],
}),
),
),
);
}
@@ -0,0 +1,71 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/core/network/interceptors/retry_interceptor.dart';
import 'package:relationship_saver/core/network/network_constants.dart';
import '../../helpers/queue_http_client_adapter.dart';
void main() {
test(
'does not retry non-idempotent request without idempotency key',
() async {
final QueueHttpClientAdapter adapter = QueueHttpClientAdapter(
<AdapterHandler>[
(RequestOptions options, requestStream, cancelFuture) async {
return jsonResponse(500, <String, dynamic>{
'code': 'SERVER_ERROR',
'message': 'boom',
});
},
],
);
final Dio dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
dio.httpClientAdapter = adapter;
dio.interceptors.add(
RetryInterceptor(dio: dio, maxRetries: 2, baseDelay: Duration.zero),
);
await expectLater(
dio.post<dynamic>('/v1/auth/sign-out'),
throwsA(isA<DioException>()),
);
expect(adapter.fetchCount, 1);
},
);
test('retries non-idempotent request when idempotency key is set', () async {
final QueueHttpClientAdapter adapter = QueueHttpClientAdapter(
<AdapterHandler>[
(RequestOptions options, requestStream, cancelFuture) async {
return jsonResponse(500, <String, dynamic>{
'code': 'SERVER_ERROR',
'message': 'boom',
});
},
(RequestOptions options, requestStream, cancelFuture) async {
return jsonResponse(200, <String, dynamic>{'ok': true});
},
],
);
final Dio dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
dio.httpClientAdapter = adapter;
dio.interceptors.add(
RetryInterceptor(dio: dio, maxRetries: 2, baseDelay: Duration.zero),
);
final Response<dynamic> response = await dio.post<dynamic>(
'/v1/sync/push',
options: Options(
headers: <String, dynamic>{
NetworkConstants.idempotencyKeyHeader: 'idem-key-1',
},
),
);
expect(response.statusCode, 200);
expect(adapter.fetchCount, 2);
});
}
@@ -0,0 +1,79 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/core/auth/in_memory_token_store.dart';
import 'package:relationship_saver/core/network/dio_factory.dart';
import 'package:relationship_saver/integrations/backend/backend_gateway_rest.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
import '../../helpers/queue_http_client_adapter.dart';
void main() {
test('refreshes token once on 401 and retries original request', () async {
final InMemoryTokenStore tokenStore = InMemoryTokenStore();
await tokenStore.write(
AuthSession(
accessToken: 'old-access',
refreshToken: 'old-refresh',
expiresAt: DateTime.utc(2026, 1, 1),
user: const UserProfile(id: 'u1', email: 'u1@example.com'),
),
);
final QueueHttpClientAdapter adapter = QueueHttpClientAdapter(
<AdapterHandler>[
(RequestOptions options, requestStream, cancelFuture) async {
expect(options.path, '/v1/me');
expect(options.headers['Authorization'], 'Bearer old-access');
return jsonResponse(
401,
<String, dynamic>{'code': 'AUTH_EXPIRED', 'message': 'expired'},
headers: <String, List<String>>{
'x-request-id': <String>['req-1'],
},
);
},
(RequestOptions options, requestStream, cancelFuture) async {
expect(options.path, '/v1/auth/refresh');
final Map<String, dynamic> body = Map<String, dynamic>.from(
options.data as Map,
);
expect(body['refreshToken'], 'old-refresh');
return jsonResponse(200, <String, dynamic>{
'accessToken': 'new-access',
'refreshToken': 'new-refresh',
'expiresAt': '2026-01-02T00:00:00.000Z',
});
},
(RequestOptions options, requestStream, cancelFuture) async {
expect(options.path, '/v1/me');
expect(options.headers['Authorization'], 'Bearer new-access');
return jsonResponse(200, <String, dynamic>{
'id': 'u1',
'email': 'u1@example.com',
'displayName': 'User One',
});
},
],
);
final Dio dio = DioFactory.create(
baseUrl: 'https://api.example.com',
tokenStore: tokenStore,
httpClientAdapter: adapter,
);
final BackendGatewayRest gateway = BackendGatewayRest(
dio: dio,
tokenStore: tokenStore,
);
final UserProfile me = await gateway.me();
expect(me.id, 'u1');
expect(adapter.fetchCount, 3);
final AuthSession? stored = await tokenStore.read();
expect(stored?.accessToken, 'new-access');
expect(stored?.refreshToken, 'new-refresh');
});
}