import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart';
import 'package:http/testing.dart';
import 'package:machweis/core/sync/master_data_sync.dart';
import 'package:machweis/core/sync/master_data_sync_card.dart';
import 'package:machweis/core/sync/master_data_sync_repository.dart';
const _operation = MasterDataOutboxOperation(
operationId: 'operation-1',
idempotencyKey: 'idempotency-1',
entityType: 'customers',
entityId: '10000000-0000-4000-8000-000000000001',
action: 'create',
baseRevision: 0,
payload: {'name': 'Kunde'},
attempts: 0,
);
class _Repository implements MasterDataSyncRepository {
_Repository({this.localOnly = false, this.lastError});
bool localOnly;
String? lastError;
InitialSyncDecision? decision = InitialSyncDecision.serverOnly;
int cursor = 0;
int pending = 1;
int conflicts = 0;
bool failed = false;
bool pulled = false;
List<MasterDataOutboxOperation> operations = const [_operation];
@override
Future<void> clearLastError(String tenantId) async {
lastError = null;
}
@override
Future<void> applyPull(
String tenantId,
List<Map<String, dynamic>> changes,
int nextCursor,
) async {
cursor = nextCursor;
pulled = true;
}
@override
Future<void> applyPushResults(List<Map<String, dynamic>> results) async {
if (results.any((item) => item['status'] != 'applied')) {
conflicts = 1;
} else {
pending = 0;
}
}
@override
Future<void> completeSync(String tenantId, DateTime now) async {
lastError = null;
}
@override
Future<int> conflictCount(String tenantId) async => conflicts;
@override
Future<List<MasterDataOutboxOperation>> dueOperations(
String tenantId,
DateTime now,
) async => operations;
@override
Future<void> failOperations(
List<MasterDataOutboxOperation> operations,
String message,
DateTime now,
) async => failed = true;
@override
Future<void> failSync(String tenantId, String message) async {
failed = true;
lastError = message;
}
@override
Future<bool> hasLocalOnlyData(String tenantId) async => localOnly;
@override
Future<int> pendingCount(String tenantId) async => pending;
@override
Future<void> retryNow(String tenantId) async {}
@override
Future<void> setInitialDecision(
String tenantId,
InitialSyncDecision value,
) async {
decision = value;
}
@override
Future<MasterDataSyncState> state(String tenantId) async =>
MasterDataSyncState(
cursor: cursor,
decision: decision,
lastError: lastError,
);
}
class _Api implements MasterDataSyncApi {
_Api({
this.conflict = false,
this.offline = false,
this.unauthorizedOnce = false,
});
final bool conflict;
final bool offline;
bool unauthorizedOnce;
int pushes = 0;
int pulls = 0;
@override
Future<SyncPullResponse> pull(
String accessToken, {
required int cursor,
}) async {
pulls++;
if (offline) throw const SyncClientException('offline');
return SyncPullResponse(
changes: [
{
'cursor': cursor + 1,
'entity': 'customers',
'entity_id': _operation.entityId,
'operation': 'upsert',
'row_version': 1,
'data': {'id': _operation.entityId, 'name': 'Serverkunde'},
},
],
cursor: cursor + 1,
hasMore: false,
);
}
@override
Future<SyncPushResponse> push(
String accessToken,
List<MasterDataOutboxOperation> operations,
) async {
pushes++;
if (unauthorizedOnce) {
unauthorizedOnce = false;
throw const SyncClientException('unauthorized', unauthorized: true);
}
if (offline) throw const SyncClientException('offline');
return SyncPushResponse([
{
'operation_id': operations.single.operationId,
'status': conflict ? 'rejected' : 'applied',
'code': conflict ? 'row_version_conflict' : 'ok',
'row_version': conflict ? null : 1,
},
]);
}
}
MasterDataSyncController _controller(
_Repository repository,
_Api api, {
Future<String> Function({bool forceRefresh})? tokens,
}) => MasterDataSyncController(
repository: repository,
api: api,
supported: true,
tenantProvider: () => 'tenant-a',
tokenProvider:
tokens ??
({bool forceRefresh = false}) async =>
forceRefresh ? 'refreshed' : 'access',
);
void main() {
test('Outbox-Operation behält Idempotenzschlüssel und Basisrevision', () {
expect(_operation.toApiJson()['operation_id'], 'operation-1');
expect(_operation.toApiJson()['idempotency_key'], 'idempotency-1');
expect(_operation.toApiJson()['base_revision'], 0);
});
test('Push und Pull aktualisieren Cursor und Sync-Status', () async {
final repository = _Repository();
final api = _Api();
final controller = _controller(repository, api);
await controller.initialize();
await controller.synchronize(now: DateTime(2026, 8, 30));
expect(api.pushes, 1);
expect(api.pulls, 1);
expect(repository.cursor, 1);
expect(repository.pulled, isTrue);
expect(controller.status, MasterDataSyncStatus.success);
});
test(
'Neue Sitzung entfernt alten Fehler ohne Sync-Daten zu ändern',
() async {
final repository = _Repository(lastError: 'alter Fehler')
..cursor = 17
..operations = const [_operation];
String? tenant;
final controller = MasterDataSyncController(
repository: repository,
api: _Api(),
supported: true,
tenantProvider: () => tenant,
tokenProvider: ({bool forceRefresh = false}) async => 'access',
);
await controller.initialize();
expect(controller.status, MasterDataSyncStatus.unavailable);
tenant = 'tenant-a';
await controller.initialize();
expect(controller.message, isNull);
expect(repository.lastError, isNull);
expect(repository.cursor, 17);
expect(repository.operations, const [_operation]);
},
);
test('Neuer erfolgreicher Sync ersetzt einen alten Fehler', () async {
final repository = _Repository(lastError: 'alter Fehler');
final controller = _controller(repository, _Api());
await controller.initialize();
await controller.synchronize();
expect(repository.lastError, isNull);
expect(controller.status, MasterDataSyncStatus.success);
expect(controller.message, 'Stammdaten sind synchronisiert.');
});
test('Neuer echter Sync-Fehler wird sichtbar gespeichert', () async {
final repository = _Repository(lastError: 'alter Fehler');
final controller = _controller(repository, _Api(offline: true));
await controller.initialize();
await controller.synchronize();
expect(repository.lastError, 'offline');
expect(controller.status, MasterDataSyncStatus.offline);
expect(controller.message, 'offline');
});
test('401 erneuert das Access-Token genau einmal', () async {
final repository = _Repository();
final api = _Api(unauthorizedOnce: true);
var refreshes = 0;
final controller = _controller(
repository,
api,
tokens: ({bool forceRefresh = false}) async {
if (forceRefresh) refreshes++;
return forceRefresh ? 'refreshed' : 'access';
},
);
await controller.initialize();
await controller.synchronize();
expect(api.pushes, 2);
expect(refreshes, 1);
expect(controller.status, MasterDataSyncStatus.success);
});
test('Versionskonflikt wird sichtbar und nicht überschrieben', () async {
final repository = _Repository();
final controller = _controller(repository, _Api(conflict: true));
await controller.initialize();
await controller.synchronize();
expect(repository.conflicts, 1);
expect(controller.status, MasterDataSyncStatus.conflict);
expect(controller.message, contains('Konflikt'));
});
test('Offlinefall behält Outbox und markiert Retry', () async {
final repository = _Repository();
final controller = _controller(repository, _Api(offline: true));
await controller.initialize();
await controller.synchronize(now: DateTime(2026, 8, 30));
expect(repository.failed, isTrue);
expect(repository.pending, 1);
expect(controller.status, MasterDataSyncStatus.offline);
});
test('HTTP-Client akzeptiert 409 mit klaren Einzelresultaten', () async {
final client = HttpMasterDataSyncApi(
baseUrl: 'https://api.example.test',
client: MockClient((request) async {
expect(request.url.path, '/api/v1/sync/push');
expect(request.headers['authorization'], 'Bearer access');
return Response(
jsonEncode({
'data': {
'results': [
{
'operation_id': 'operation-1',
'status': 'rejected',
'code': 'row_version_conflict',
},
],
},
}),
409,
);
}),
);
final response = await client.push('access', const [_operation]);
expect(response.results.single['code'], 'row_version_conflict');
});
test(
'Push und Pull wiederholen HTTP 401 mit dem jeweils erneuerten Token',
() async {
var requestIndex = 0;
final requests = <String>[];
final responses = <Response>[
Response(
jsonEncode({
'error': {'code': 'authentication_failed'},
}),
401,
),
Response(
jsonEncode({
'data': {
'results': [
{
'operation_id': _operation.operationId,
'status': 'applied',
'code': 'ok',
'row_version': 1,
},
],
},
}),
200,
),
Response(
jsonEncode({
'error': {'code': 'authentication_failed'},
}),
401,
),
Response(
jsonEncode({
'data': {'changes': [], 'cursor': 0, 'has_more': false},
}),
200,
),
];
final api = HttpMasterDataSyncApi(
baseUrl: 'https://api.example.test',
client: MockClient((request) async {
requests.add(
'${request.method} ${request.url.path} ${request.headers['authorization']}',
);
return responses[requestIndex++];
}),
);
final repository = _Repository();
var refreshes = 0;
final controller = MasterDataSyncController(
repository: repository,
api: api,
supported: true,
tenantProvider: () => 'tenant-a',
tokenProvider: ({bool forceRefresh = false}) async {
if (forceRefresh) refreshes++;
return forceRefresh ? 'refreshed-$refreshes' : 'access';
},
);
await controller.initialize();
await controller.synchronize();
expect(requestIndex, 4);
expect(refreshes, 2);
expect(requests, [
'POST /api/v1/sync/push Bearer access',
'POST /api/v1/sync/push Bearer refreshed-1',
'GET /api/v1/sync/pull Bearer refreshed-1',
'GET /api/v1/sync/pull Bearer refreshed-2',
]);
expect(controller.status, MasterDataSyncStatus.success);
},
);
testWidgets('Erstverbindung verlangt eine sichtbare Importentscheidung', (
tester,
) async {
final repository = _Repository(localOnly: true)..decision = null;
final controller = _controller(repository, _Api());
await controller.initialize();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(body: MasterDataSyncCard(controller: controller)),
),
);
expect(find.text('Alles synchronisieren'), findsOneWidget);
expect(find.textContaining('Entscheidung'), findsOneWidget);
await tester.tap(find.text('Alles synchronisieren'));
await tester.pumpAndSettle();
expect(find.text('Erste Synchronisation'), findsOneWidget);
expect(find.text('Nur Serverdaten ergänzen'), findsOneWidget);
expect(find.text('Lokale Daten importieren'), findsOneWidget);
});
}
