feat: WebSocket temps réel + Finance Workflow + corrections

- Task #6: WebSocket /ws/dashboard + Kafka events (5 topics)
  * Backend: KafkaEventProducer, KafkaEventConsumer
  * Mobile: WebSocketService (reconnection, heartbeat, typed events)
  * DashboardBloc: Auto-refresh depuis WebSocket events

- Finance Workflow: approbations + budgets (backend + mobile)
  * Backend: entities, services, resources, migrations Flyway V6
  * Mobile: features finance_workflow complète avec BLoC

- Corrections DI: interfaces IRepository partout
  * IProfileRepository, IOrganizationRepository, IMembreRepository
  * GetIt configuré avec @injectable

- Spec-Kit: constitution + templates mis à jour
  * .specify/memory/constitution.md enrichie
  * Templates agent, plan, spec, tasks, checklist

- Nettoyage: fichiers temporaires supprimés

Signed-off-by: lions dev Team
This commit is contained in:
dahoud
2026-03-15 02:12:17 +00:00
parent bbc409de9d
commit e8ad874015
635 changed files with 58160 additions and 20674 deletions

View File

@@ -0,0 +1,270 @@
/// Tests unitaires pour OfflineManager
library offline_manager_test;
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:unionflow_mobile_apps/core/network/offline_manager.dart';
import 'package:unionflow_mobile_apps/core/storage/pending_operations_store.dart';
import 'package:unionflow_mobile_apps/core/config/environment.dart';
@GenerateMocks([
Connectivity,
PendingOperationsStore,
])
import 'offline_manager_test.mocks.dart';
void main() {
// Initialize AppConfig for tests
setUpAll(() {
AppConfig.initialize();
});
group('OfflineManager', () {
late OfflineManager offlineManager;
late MockConnectivity mockConnectivity;
late MockPendingOperationsStore mockOperationsStore;
late StreamController<List<ConnectivityResult>> connectivityController;
setUp(() {
mockConnectivity = MockConnectivity();
mockOperationsStore = MockPendingOperationsStore();
connectivityController = StreamController<List<ConnectivityResult>>.broadcast();
// Setup default stubs
when(mockConnectivity.onConnectivityChanged)
.thenAnswer((_) => connectivityController.stream);
when(mockConnectivity.checkConnectivity())
.thenAnswer((_) async => [ConnectivityResult.wifi]);
when(mockOperationsStore.getPendingOperations())
.thenAnswer((_) async => []);
when(mockOperationsStore.addPendingOperation(
operationType: anyNamed('operationType'),
endpoint: anyNamed('endpoint'),
data: anyNamed('data'),
headers: anyNamed('headers'),
)).thenAnswer((_) async => Future.value());
offlineManager = OfflineManager(mockConnectivity, mockOperationsStore);
});
tearDown(() {
connectivityController.close();
offlineManager.dispose();
});
group('Connectivity status', () {
test('should initialize with unknown status', () {
// Assert
expect(offlineManager.currentStatus, equals(ConnectivityStatus.unknown));
});
test('should detect online status when WiFi is connected', () async {
// Arrange
when(mockConnectivity.checkConnectivity())
.thenAnswer((_) async => [ConnectivityResult.wifi]);
// Act
final isOnline = await offlineManager.isOnline;
// Assert
expect(isOnline, isTrue);
});
test('should detect online status when mobile is connected', () async {
// Arrange
when(mockConnectivity.checkConnectivity())
.thenAnswer((_) async => [ConnectivityResult.mobile]);
// Act
final isOnline = await offlineManager.isOnline;
// Assert
expect(isOnline, isTrue);
});
test('should detect offline status when no connectivity', () async {
// Arrange
when(mockConnectivity.checkConnectivity())
.thenAnswer((_) async => [ConnectivityResult.none]);
// Act
final isOnline = await offlineManager.isOnline;
// Assert
expect(isOnline, isFalse);
});
});
group('Status stream', () {
test('should emit online status when WiFi connects', () async {
// Arrange
final statusUpdates = <ConnectivityStatus>[];
offlineManager.statusStream.listen(statusUpdates.add);
// Act
connectivityController.add([ConnectivityResult.wifi]);
await Future.delayed(const Duration(milliseconds: 100));
// Assert
expect(statusUpdates, contains(ConnectivityStatus.online));
});
test('should emit offline status when connection is lost', () async {
// Arrange
final statusUpdates = <ConnectivityStatus>[];
offlineManager.statusStream.listen(statusUpdates.add);
// Act - First connect, then disconnect
connectivityController.add([ConnectivityResult.wifi]);
await Future.delayed(const Duration(milliseconds: 100));
connectivityController.add([ConnectivityResult.none]);
await Future.delayed(const Duration(milliseconds: 100));
// Assert
expect(statusUpdates, contains(ConnectivityStatus.online));
expect(statusUpdates, contains(ConnectivityStatus.offline));
});
test('should not emit duplicate status updates', () async {
// Arrange
final statusUpdates = <ConnectivityStatus>[];
offlineManager.statusStream.listen(statusUpdates.add);
// Act - Send same status multiple times
connectivityController.add([ConnectivityResult.wifi]);
await Future.delayed(const Duration(milliseconds: 100));
connectivityController.add([ConnectivityResult.wifi]);
await Future.delayed(const Duration(milliseconds: 100));
connectivityController.add([ConnectivityResult.wifi]);
await Future.delayed(const Duration(milliseconds: 100));
// Assert - Should only emit once
expect(statusUpdates.where((s) => s == ConnectivityStatus.online).length, equals(1));
});
});
group('Operation queueing', () {
test('should queue operation when offline', () async {
// Arrange
const operationType = 'approveTransaction';
const endpoint = '/api/finance/approvals/123/approve';
final data = {'approvalId': '123', 'comment': 'Approved'};
// Act
await offlineManager.queueOperation(
operationType: operationType,
endpoint: endpoint,
data: data,
);
// Assert
verify(mockOperationsStore.addPendingOperation(
operationType: operationType,
endpoint: endpoint,
data: data,
headers: null,
)).called(1);
});
test('should include headers when queueing operation', () async {
// Arrange
const operationType = 'createBudget';
const endpoint = '/api/finance/budgets';
final data = {'name': 'Test Budget'};
final headers = {'Authorization': 'Bearer token123'};
// Act
await offlineManager.queueOperation(
operationType: operationType,
endpoint: endpoint,
data: data,
headers: headers,
);
// Assert
verify(mockOperationsStore.addPendingOperation(
operationType: operationType,
endpoint: endpoint,
data: data,
headers: headers,
)).called(1);
});
test('should get count of pending operations', () async {
// Arrange
when(mockOperationsStore.getPendingOperations()).thenAnswer(
(_) async => [
{'id': '1', 'operationType': 'approve'},
{'id': '2', 'operationType': 'reject'},
],
);
// Act
final count = await offlineManager.getPendingOperationsCount();
// Assert
expect(count, equals(2));
});
});
group('Clear operations', () {
test('should clear all pending operations', () async {
// Arrange
when(mockOperationsStore.clearAll()).thenAnswer((_) async => Future.value());
// Act
await offlineManager.clearPendingOperations();
// Assert
verify(mockOperationsStore.clearAll()).called(1);
});
});
group('Retry pending operations', () {
test('should not retry when offline', () async {
// Arrange
when(mockConnectivity.checkConnectivity())
.thenAnswer((_) async => [ConnectivityResult.none]);
connectivityController.add([ConnectivityResult.none]);
await Future.delayed(const Duration(milliseconds: 100));
// Act
await offlineManager.retryPendingOperations();
// Assert
verifyNever(mockOperationsStore.getPendingOperations());
});
test('should process pending operations when manually triggered and online', () async {
// Arrange
when(mockConnectivity.checkConnectivity())
.thenAnswer((_) async => [ConnectivityResult.wifi]);
connectivityController.add([ConnectivityResult.wifi]);
await Future.delayed(const Duration(milliseconds: 100));
when(mockOperationsStore.getPendingOperations()).thenAnswer(
(_) async => [
{
'id': '1',
'operationType': 'approve',
'endpoint': '/api/finance/approvals/123/approve',
'data': {},
},
],
);
// Act
await offlineManager.retryPendingOperations();
// Assert
verify(mockOperationsStore.getPendingOperations()).called(1);
});
});
});
}

View File

@@ -0,0 +1,296 @@
/// Tests unitaires pour RetryPolicy
library retry_policy_test;
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:unionflow_mobile_apps/core/network/retry_policy.dart';
void main() {
group('RetryPolicy', () {
late RetryPolicy retryPolicy;
setUp(() {
retryPolicy = RetryPolicy(
config: const RetryConfig(
maxAttempts: 3,
initialDelayMs: 100, // Short delay for tests
maxDelayMs: 1000,
backoffMultiplier: 2.0,
useJitter: false, // Disable jitter for predictable tests
),
);
});
group('execute - Happy path', () {
test('should return result when operation succeeds on first attempt', () async {
// Arrange
const expectedResult = 'success';
int attempts = 0;
Future<String> operation() async {
attempts++;
return expectedResult;
}
// Act
final result = await retryPolicy.execute(operation: operation);
// Assert
expect(result, equals(expectedResult));
expect(attempts, equals(1));
});
test('should retry and succeed on second attempt', () async {
// Arrange
const expectedResult = 'success';
int attempts = 0;
Future<String> operation() async {
attempts++;
if (attempts == 1) {
throw Exception('Temporary failure');
}
return expectedResult;
}
// Act
final result = await retryPolicy.execute(
operation: operation,
shouldRetry: (_) => true,
);
// Assert
expect(result, equals(expectedResult));
expect(attempts, equals(2));
});
test('should retry maximum attempts and succeed on last attempt', () async {
// Arrange
const expectedResult = 'success';
int attempts = 0;
Future<String> operation() async {
attempts++;
if (attempts < 3) {
throw Exception('Temporary failure');
}
return expectedResult;
}
// Act
final result = await retryPolicy.execute(
operation: operation,
shouldRetry: (_) => true,
);
// Assert
expect(result, equals(expectedResult));
expect(attempts, equals(3));
});
});
group('execute - Retry exhaustion', () {
test('should throw error when all retries are exhausted', () async {
// Arrange
int attempts = 0;
Future<String> operation() async {
attempts++;
throw Exception('Persistent failure');
}
// Act & Assert
expect(
() => retryPolicy.execute(
operation: operation,
shouldRetry: (_) => true,
),
throwsA(isA<Exception>()),
);
// Wait for all attempts
await Future.delayed(const Duration(milliseconds: 500));
expect(attempts, equals(3)); // maxAttempts
});
test('should not retry when shouldRetry returns false', () async {
// Arrange
int attempts = 0;
Future<String> operation() async {
attempts++;
throw Exception('Non-retryable error');
}
// Act & Assert
expect(
() => retryPolicy.execute(
operation: operation,
shouldRetry: (_) => false,
),
throwsA(isA<Exception>()),
);
expect(attempts, equals(1)); // No retries
});
});
group('execute - Error classification', () {
test('should retry timeout exceptions by default', () async {
// Arrange
int attempts = 0;
Future<String> operation() async {
attempts++;
if (attempts == 1) {
throw TimeoutException('Request timeout');
}
return 'success';
}
// Act
final result = await retryPolicy.execute(operation: operation);
// Assert
expect(result, equals('success'));
expect(attempts, equals(2));
});
test('should use custom shouldRetry function', () async {
// Arrange
int attempts = 0;
final specificError = Exception('Specific retryable error');
Future<String> operation() async {
attempts++;
if (attempts == 1) {
throw specificError;
}
return 'success';
}
bool customShouldRetry(dynamic error) {
return error == specificError;
}
// Act
final result = await retryPolicy.execute(
operation: operation,
shouldRetry: customShouldRetry,
);
// Assert
expect(result, equals('success'));
expect(attempts, equals(2));
});
});
group('execute - Retry callback', () {
test('should call onRetry callback with attempt details', () async {
// Arrange
int attempts = 0;
final retryCallbacks = <Map<String, dynamic>>[];
Future<String> operation() async {
attempts++;
if (attempts < 3) {
throw Exception('Temporary failure');
}
return 'success';
}
void onRetry(int attempt, dynamic error, Duration delay) {
retryCallbacks.add({
'attempt': attempt,
'error': error,
'delay': delay,
});
}
// Act
final result = await retryPolicy.execute(
operation: operation,
shouldRetry: (_) => true,
onRetry: onRetry,
);
// Assert
expect(result, equals('success'));
expect(retryCallbacks.length, equals(2)); // 2 retries before success
// Verify first retry callback
expect(retryCallbacks[0]['attempt'], equals(1));
expect(retryCallbacks[0]['error'], isA<Exception>());
expect(retryCallbacks[0]['delay'], isA<Duration>());
// Verify second retry callback
expect(retryCallbacks[1]['attempt'], equals(2));
expect(retryCallbacks[1]['error'], isA<Exception>());
expect(retryCallbacks[1]['delay'], isA<Duration>());
});
});
group('RetryConfig', () {
test('should use default configuration values', () {
// Arrange & Act
const config = RetryConfig();
// Assert
expect(config.maxAttempts, equals(3));
expect(config.initialDelayMs, equals(1000));
expect(config.maxDelayMs, equals(30000));
expect(config.backoffMultiplier, equals(2.0));
expect(config.useJitter, equals(true));
});
test('should use critical configuration preset', () {
// Arrange & Act
const config = RetryConfig.critical;
// Assert
expect(config.maxAttempts, equals(5));
expect(config.initialDelayMs, equals(500));
expect(config.maxDelayMs, equals(60000));
});
test('should use background sync configuration preset', () {
// Arrange & Act
const config = RetryConfig.backgroundSync;
// Assert
expect(config.maxAttempts, equals(10));
expect(config.initialDelayMs, equals(2000));
expect(config.maxDelayMs, equals(120000));
});
});
group('RetryExtension', () {
test('should add withRetry method to Future functions', () async {
// Arrange
int attempts = 0;
Future<String> operation() async {
attempts++;
if (attempts == 1) {
throw Exception('First attempt fails');
}
return 'success';
}
// Act
final result = await operation.withRetry(
config: const RetryConfig(
maxAttempts: 3,
initialDelayMs: 100,
useJitter: false,
),
shouldRetry: (_) => true,
);
// Assert
expect(result, equals('success'));
expect(attempts, equals(2));
});
});
});
}