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');
});
}
@@ -0,0 +1,55 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
/// Sequential fake adapter for deterministic Dio unit tests.
typedef AdapterHandler =
Future<ResponseBody> Function(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
);
class QueueHttpClientAdapter implements HttpClientAdapter {
QueueHttpClientAdapter(List<AdapterHandler> handlers)
: _handlers = List<AdapterHandler>.from(handlers);
final List<AdapterHandler> _handlers;
int fetchCount = 0;
@override
void close({bool force = false}) {}
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
if (fetchCount >= _handlers.length) {
throw StateError(
'No queued response for ${options.method} ${options.path} at call $fetchCount',
);
}
final AdapterHandler handler = _handlers[fetchCount];
fetchCount += 1;
return handler(options, requestStream, cancelFuture);
}
}
ResponseBody jsonResponse(
int statusCode,
Map<String, dynamic> body, {
Map<String, List<String>> headers = const <String, List<String>>{},
}) {
return ResponseBody.fromString(
jsonEncode(body),
statusCode,
headers: <String, List<String>>{
Headers.contentTypeHeader: <String>[Headers.jsonContentType],
...headers,
},
);
}
@@ -0,0 +1,39 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
void main() {
test('ChangeEnvelope roundtrip serialization', () {
final ChangeEnvelope source = ChangeEnvelope(
schemaVersion: 1,
entityType: 'person',
entityId: 'p1',
op: ChangeOperation.upsert,
modifiedAt: DateTime.utc(2026, 2, 1),
clientMutationId: 'cm-1',
payload: const <String, dynamic>{'name': 'Alex'},
);
final ChangeEnvelope decoded = ChangeEnvelope.fromJson(source.toJson());
expect(decoded, source);
});
test('SignalsFeed roundtrip serialization', () {
final SignalsFeed source = SignalsFeed(
cursor: 'c1',
items: <SignalItem>[
SignalItem(
id: 's1',
type: 'deal',
title: 'Coffee discount',
createdAt: DateTime.utc(2026, 2, 2, 10),
metadata: const <String, dynamic>{'merchant': 'Cafe'},
),
],
);
final SignalsFeed decoded = SignalsFeed.fromJson(source.toJson());
expect(decoded, source);
});
}
@@ -0,0 +1,37 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/core/network/backend_exception.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
import 'package:relationship_saver/integrations/backend/sync_envelope_validator.dart';
void main() {
test('rejects delete envelopes with payload', () {
final ChangeEnvelope invalid = ChangeEnvelope(
schemaVersion: 1,
entityType: 'person',
entityId: 'p1',
op: ChangeOperation.delete,
modifiedAt: DateTime.utc(2026, 1, 1),
clientMutationId: 'cm-delete',
payload: const <String, dynamic>{'unexpected': true},
);
expect(
() => SyncEnvelopeValidator.validate(invalid),
throwsA(isA<ValidationException>()),
);
});
test('accepts valid upsert envelope', () {
final ChangeEnvelope valid = ChangeEnvelope(
schemaVersion: 1,
entityType: 'person',
entityId: 'p2',
op: ChangeOperation.upsert,
modifiedAt: DateTime.utc(2026, 1, 1),
clientMutationId: 'cm-upsert',
payload: const <String, dynamic>{'name': 'Jamie'},
);
expect(() => SyncEnvelopeValidator.validate(valid), returnsNormally);
});
}
+14
View File
@@ -0,0 +1,14 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/main.dart';
void main() {
testWidgets('renders app shell', (WidgetTester tester) async {
await tester.pumpWidget(const RelationshipSaverApp());
expect(find.text('Relationship Saver'), findsOneWidget);
expect(
find.textContaining('Backend gateway scaffold is ready.'),
findsOneWidget,
);
});
}