import 'dart:async';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart';
import 'package:http/testing.dart';
import 'package:machweis/core/storage/local_storage.dart';
import 'package:machweis/core/sync/master_data_sync.dart';
import 'package:machweis/core/sync/report_sync.dart';
import 'package:machweis/core/sync/sync_queue_store.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _ReportApi implements ReportSyncApi {
_ReportApi({
this.unauthorizedOnce = false,
this.conflict = false,
this.offline = false,
});
bool unauthorizedOnce;
final bool conflict;
bool offline;
int pushes = 0;
@override
Future<List<Map<String, dynamic>>> push(
String accessToken,
List<SyncOperation> operations,
) async {
pushes++;
if (unauthorizedOnce) {
unauthorizedOnce = false;
throw const SyncClientException('unauthorized', unauthorized: true);
}
if (offline) throw const SyncClientException('offline');
return [
for (final operation in operations)
{
'operation_id': operation.id,
'status': conflict ? 'rejected' : 'applied',
'code': conflict ? 'row_version_conflict' : 'ok',
'row_version': conflict ? null : operation.baseRevision + 1,
if (conflict) 'message': 'Basisrevision ist veraltet.',
},
];
}
}
class _DelayedReportApi implements ReportSyncApi {
final gate = Completer<void>();
int pushes = 0;
@override
Future<List<Map<String, dynamic>>> push(
String accessToken,
List<SyncOperation> operations,
) async {
pushes++;
await gate.future;
return [
for (final operation in operations)
{
'operation_id': operation.id,
'status': 'applied',
'row_version': operation.baseRevision + 1,
},
];
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() async {
SharedPreferences.setMockInitialValues({});
await LocalStorage.instance.initialize(forcePreferences: true);
await SyncQueueStore.instance.initialize();
});
test('HTTP-Vertrag überträgt stabile Idempotenz und Basisrevision', () async {
late Map<String, dynamic> requestBody;
late Map<String, String> requestHeaders;
final api = HttpReportSyncApi(
baseUrl: 'https://example.test',
client: MockClient((request) async {
requestBody = jsonDecode(request.body) as Map<String, dynamic>;
requestHeaders = request.headers;
return Response(
jsonEncode({
'data': {
'results': [
{
'operation_id': 'operation-1',
'status': 'applied',
'row_version': 8,
},
],
},
}),
200,
);
}),
);
final operation = SyncOperation(
id: 'operation-1',
entityType: 'report',
entityId: '10000000-0000-4000-8000-000000000001',
action: SyncAction.finalize,
payload: {'version': 9},
createdAt: DateTime.utc(2026, 8, 31),
tenantId: 'tenant-a',
idempotencyKey: 'stable-key',
baseRevision: 7,
);
final result = await api.push('token', [operation]);
final sent = (requestBody['operations'] as List).single as Map;
expect(sent['idempotency_key'], 'stable-key');
expect(sent['base_revision'], 7);
expect(sent['entity'], 'reports');
expect(sent['action'], 'update');
expect(requestHeaders['idempotency-key'], 'stable-key');
expect(result.single['row_version'], 8);
});
test(
'401 erneuert das Token genau einmal und bestätigt die Operation',
() async {
const tenant = 'tenant-refresh';
await SyncQueueStore.instance.enqueue(
entityType: 'report',
entityId: '20000000-0000-4000-8000-000000000002',
action: SyncAction.create,
payload: const {'version': 1},
tenantId: tenant,
);
final api = _ReportApi(unauthorizedOnce: true);
var refreshes = 0;
final controller = ReportSyncController(
queue: SyncQueueStore.instance,
api: api,
supported: true,
tenantProvider: () => tenant,
tokenProvider: ({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.pendingCount, 0);
expect(controller.status, ReportSyncStatus.success);
expect(
SyncQueueStore.instance.reportRevision(
tenant,
'20000000-0000-4000-8000-000000000002',
),
1,
);
},
);
test(
'veraltete Basisrevision bleibt als sichtbarer Konflikt erhalten',
() async {
const tenant = 'tenant-conflict';
await SyncQueueStore.instance.enqueue(
entityType: 'report',
entityId: '30000000-0000-4000-8000-000000000003',
action: SyncAction.update,
payload: const {'version': 4},
tenantId: tenant,
baseRevision: 3,
);
final controller = ReportSyncController(
queue: SyncQueueStore.instance,
api: _ReportApi(conflict: true),
supported: true,
tenantProvider: () => tenant,
tokenProvider: ({bool forceRefresh = false}) async => 'access',
);
await controller.initialize();
await controller.synchronize();
expect(controller.status, ReportSyncStatus.conflict);
expect(controller.conflictCount, 1);
final conflict = SyncQueueStore.instance.operations.singleWhere(
(item) => item.tenantId == tenant,
);
expect(conflict.baseRevision, 3);
expect(conflict.idempotencyKey, isNotEmpty);
expect(conflict.lastError, 'Basisrevision ist veraltet.');
},
);
test('Offlinefehler behält die Operation mit exponentiellem Retry', () async {
const tenant = 'tenant-offline';
const reportId = '50000000-0000-4000-8000-000000000005';
final startedAt = DateTime(2026, 8, 31, 12);
await SyncQueueStore.instance.enqueue(
entityType: 'report',
entityId: reportId,
action: SyncAction.create,
payload: const {'version': 1},
tenantId: tenant,
);
final api = _ReportApi(offline: true);
final controller = ReportSyncController(
queue: SyncQueueStore.instance,
api: api,
supported: true,
tenantProvider: () => tenant,
tokenProvider: ({bool forceRefresh = false}) async => 'access',
);
await controller.initialize();
await controller.synchronize(now: startedAt);
expect(controller.status, ReportSyncStatus.offline);
final retained = SyncQueueStore.instance.operations.singleWhere(
(item) => item.tenantId == tenant,
);
expect(retained.attempts, 1);
expect(retained.nextAttemptAt, startedAt.add(const Duration(minutes: 2)));
expect(
SyncQueueStore.instance.dueReports(
tenant,
startedAt.add(const Duration(minutes: 1)),
),
isEmpty,
);
expect(
SyncQueueStore.instance.dueReports(
tenant,
startedAt.add(const Duration(minutes: 1)),
force: true,
),
hasLength(1),
);
api.offline = false;
await controller.synchronize(
now: startedAt.add(const Duration(minutes: 1)),
force: true,
);
expect(api.pushes, 2);
expect(controller.pendingCount, 0);
});
test(
'paralleler Berichtssync wartet auf denselben laufenden Versand',
() async {
const tenant = 'tenant-parallel';
await SyncQueueStore.instance.enqueue(
entityType: 'report',
entityId: '60000000-0000-4000-8000-000000000006',
action: SyncAction.create,
payload: const {'version': 1},
tenantId: tenant,
);
final api = _DelayedReportApi();
final controller = ReportSyncController(
queue: SyncQueueStore.instance,
api: api,
supported: true,
tenantProvider: () => tenant,
tokenProvider: ({bool forceRefresh = false}) async => 'access',
);
await controller.initialize();
final first = controller.synchronize();
await Future<void>.delayed(Duration.zero);
final second = controller.synchronize();
expect(api.pushes, 1);
api.gate.complete();
await Future.wait([first, second]);
expect(api.pushes, 1);
expect(controller.pendingCount, 0);
},
);
}
