Scaffold backend gateway and integration docs
This commit is contained in:
@@ -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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user