refactor: Projektstruktur in app/ und backend/ aufgeteilt

This commit is contained in:
2026-03-24 15:06:44 +01:00
parent 5f5d5995c5
commit 2673ef658d
449 changed files with 28551 additions and 167 deletions

View File

@@ -0,0 +1,312 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:votianlt_app/services/ack_tracker.dart';
void main() {
group('AckTracker', () {
late AckTracker tracker;
late List<Map<String, String>> retryCalls;
late List<Map<String, String>> timeoutCalls;
setUp(() {
retryCalls = [];
timeoutCalls = [];
tracker = AckTracker(
maxRetries: 4,
onRetry: (topic, payload) async {
retryCalls.add({'topic': topic, 'payload': payload});
return true;
},
onTimeout: (messageId, topic) {
timeoutCalls.add({'messageId': messageId, 'topic': topic});
},
);
});
group('track', () {
test('adds message to pending', () {
tracker.track('msg-1', '/server/user/message', '{"data": "test"}');
expect(tracker.isPending('msg-1'), true);
expect(tracker.pendingCount, 1);
});
test('can track multiple messages', () {
tracker.track('msg-1', '/topic1', 'payload1');
tracker.track('msg-2', '/topic2', 'payload2');
tracker.track('msg-3', '/topic3', 'payload3');
expect(tracker.pendingCount, 3);
expect(tracker.isPending('msg-1'), true);
expect(tracker.isPending('msg-2'), true);
expect(tracker.isPending('msg-3'), true);
});
test('stores correct topic and payload', () {
tracker.track('msg-1', '/server/user/message', '{"key": "value"}');
final pending = tracker.getPendingMessage('msg-1');
expect(pending, isNotNull);
expect(pending!.topic, '/server/user/message');
expect(pending.jsonPayload, '{"key": "value"}');
expect(pending.retryCount, 0);
});
test('overwrites existing message with same ID', () {
tracker.track('msg-1', '/old/topic', 'old payload');
tracker.track('msg-1', '/new/topic', 'new payload');
final pending = tracker.getPendingMessage('msg-1');
expect(pending!.topic, '/new/topic');
expect(pending.jsonPayload, 'new payload');
expect(tracker.pendingCount, 1);
});
});
group('acknowledge', () {
test('removes message from pending', () {
tracker.track('msg-1', '/topic', 'payload');
expect(tracker.isPending('msg-1'), true);
tracker.acknowledge('msg-1');
expect(tracker.isPending('msg-1'), false);
expect(tracker.pendingCount, 0);
});
test('does nothing for unknown message ID', () {
tracker.track('msg-1', '/topic', 'payload');
tracker.acknowledge('unknown-msg');
expect(tracker.pendingCount, 1);
expect(tracker.isPending('msg-1'), true);
});
test('only removes specified message', () {
tracker.track('msg-1', '/topic1', 'payload1');
tracker.track('msg-2', '/topic2', 'payload2');
tracker.acknowledge('msg-1');
expect(tracker.isPending('msg-1'), false);
expect(tracker.isPending('msg-2'), true);
expect(tracker.pendingCount, 1);
});
});
group('isPending', () {
test('returns true for tracked message', () {
tracker.track('msg-1', '/topic', 'payload');
expect(tracker.isPending('msg-1'), true);
});
test('returns false for untracked message', () {
expect(tracker.isPending('unknown'), false);
});
test('returns false after acknowledge', () {
tracker.track('msg-1', '/topic', 'payload');
tracker.acknowledge('msg-1');
expect(tracker.isPending('msg-1'), false);
});
});
group('pendingMessageIds', () {
test('returns empty list when no messages pending', () {
expect(tracker.pendingMessageIds, isEmpty);
});
test('returns all pending message IDs', () {
tracker.track('msg-1', '/topic1', 'payload1');
tracker.track('msg-2', '/topic2', 'payload2');
final ids = tracker.pendingMessageIds;
expect(ids, containsAll(['msg-1', 'msg-2']));
expect(ids.length, 2);
});
test('returns unmodifiable list', () {
tracker.track('msg-1', '/topic', 'payload');
final ids = tracker.pendingMessageIds;
expect(() => ids.add('new-id'), throwsUnsupportedError);
});
});
group('processRetries', () {
test('increments retryCount on each call', () async {
tracker.track('msg-1', '/topic', 'payload');
await tracker.processRetries();
expect(tracker.getPendingMessage('msg-1')!.retryCount, 1);
await tracker.processRetries();
expect(tracker.getPendingMessage('msg-1')!.retryCount, 2);
await tracker.processRetries();
expect(tracker.getPendingMessage('msg-1')!.retryCount, 3);
});
test('calls onRetry callback with correct parameters', () async {
tracker.track('msg-1', '/server/user/message', '{"data": "test"}');
await tracker.processRetries();
expect(retryCalls.length, 1);
expect(retryCalls[0]['topic'], '/server/user/message');
expect(retryCalls[0]['payload'], '{"data": "test"}');
});
test('calls onRetry for each pending message', () async {
tracker.track('msg-1', '/topic1', 'payload1');
tracker.track('msg-2', '/topic2', 'payload2');
await tracker.processRetries();
expect(retryCalls.length, 2);
});
test('calls onTimeout after maxRetries exceeded', () async {
tracker.track('msg-1', '/timeout/topic', 'payload');
// Process until maxRetries reached
for (var i = 0; i < 4; i++) {
await tracker.processRetries();
}
expect(timeoutCalls, isEmpty);
expect(tracker.isPending('msg-1'), true);
// One more retry should trigger timeout
await tracker.processRetries();
expect(timeoutCalls.length, 1);
expect(timeoutCalls[0]['messageId'], 'msg-1');
expect(timeoutCalls[0]['topic'], '/timeout/topic');
});
test('removes message after timeout', () async {
tracker.track('msg-1', '/topic', 'payload');
// Process until timeout
for (var i = 0; i <= 4; i++) {
await tracker.processRetries();
}
expect(tracker.isPending('msg-1'), false);
expect(tracker.pendingCount, 0);
});
test('does not retry when isConnected is false', () async {
tracker.track('msg-1', '/topic', 'payload');
await tracker.processRetries(isConnected: false);
expect(retryCalls, isEmpty);
expect(tracker.getPendingMessage('msg-1')!.retryCount, 0);
});
test('still times out when disconnected after max retries', () async {
tracker.track('msg-1', '/topic', 'payload');
// Manually set retry count to max (simulating previous retries)
final pending = tracker.getPendingMessage('msg-1')!;
pending.retryCount = 4;
await tracker.processRetries(isConnected: false);
expect(timeoutCalls.length, 1);
expect(tracker.isPending('msg-1'), false);
});
test('does nothing when no messages pending', () async {
await tracker.processRetries();
expect(retryCalls, isEmpty);
expect(timeoutCalls, isEmpty);
});
});
group('clearAll', () {
test('removes all pending messages', () {
tracker.track('msg-1', '/topic1', 'payload1');
tracker.track('msg-2', '/topic2', 'payload2');
tracker.clearAll();
expect(tracker.pendingCount, 0);
expect(tracker.isPending('msg-1'), false);
expect(tracker.isPending('msg-2'), false);
});
});
group('clearForTopic', () {
test('removes only messages for matching topic', () {
tracker.track('msg-1', '/server/login', 'login1');
tracker.track('msg-2', '/server/login', 'login2');
tracker.track('msg-3', '/server/user/message', 'message');
tracker.clearForTopic('/server/login');
expect(tracker.isPending('msg-1'), false);
expect(tracker.isPending('msg-2'), false);
expect(tracker.isPending('msg-3'), true);
expect(tracker.pendingCount, 1);
});
test('does nothing when no matching topic', () {
tracker.track('msg-1', '/server/user/message', 'payload');
tracker.clearForTopic('/server/login');
expect(tracker.pendingCount, 1);
expect(tracker.isPending('msg-1'), true);
});
});
group('without callbacks', () {
test('processRetries works without onRetry callback', () async {
final noCallbackTracker = AckTracker(maxRetries: 2);
noCallbackTracker.track('msg-1', '/topic', 'payload');
// Should not throw
await noCallbackTracker.processRetries();
expect(noCallbackTracker.getPendingMessage('msg-1')!.retryCount, 1);
});
test('processRetries works without onTimeout callback', () async {
final noCallbackTracker = AckTracker(maxRetries: 1);
noCallbackTracker.track('msg-1', '/topic', 'payload');
// Process until timeout
await noCallbackTracker.processRetries();
await noCallbackTracker.processRetries();
// Should be removed even without callback
expect(noCallbackTracker.isPending('msg-1'), false);
});
});
group('PendingMessage', () {
test('stores sentAt timestamp', () {
final before = DateTime.now();
tracker.track('msg-1', '/topic', 'payload');
final after = DateTime.now();
final pending = tracker.getPendingMessage('msg-1')!;
expect(pending.sentAt.isAfter(before) || pending.sentAt == before, true);
expect(pending.sentAt.isBefore(after) || pending.sentAt == after, true);
});
test('initializes retryCount to 0', () {
tracker.track('msg-1', '/topic', 'payload');
expect(tracker.getPendingMessage('msg-1')!.retryCount, 0);
});
});
});
}

View File

@@ -0,0 +1,350 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:votianlt_app/services/message_handler.dart';
void main() {
group('MessageHandler', () {
late MessageHandler handler;
late List<String> ackCallbackCalls;
setUp(() {
ackCallbackCalls = [];
handler = MessageHandler(
maxProcessedIds: 100,
onAckRequired: (messageId) => ackCallbackCalls.add(messageId),
);
});
group('isEnvelopeMessage', () {
test('returns true for valid envelope with all required fields', () {
final data = {
'messageId': 'test-id',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user/message',
'payload': {'key': 'value'},
};
expect(handler.isEnvelopeMessage(data), true);
});
test('returns false when messageId is missing', () {
final data = {
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user/message',
'payload': {'key': 'value'},
};
expect(handler.isEnvelopeMessage(data), false);
});
test('returns false when timestamp is missing', () {
final data = {
'messageId': 'test-id',
'topic': '/server/user/message',
'payload': {'key': 'value'},
};
expect(handler.isEnvelopeMessage(data), false);
});
test('returns false when topic is missing', () {
final data = {
'messageId': 'test-id',
'timestamp': '2024-01-15T10:30:00.000Z',
'payload': {'key': 'value'},
};
expect(handler.isEnvelopeMessage(data), false);
});
test('returns false when payload is missing', () {
final data = {
'messageId': 'test-id',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user/message',
};
expect(handler.isEnvelopeMessage(data), false);
});
test('returns false for non-map data', () {
expect(handler.isEnvelopeMessage('string'), false);
expect(handler.isEnvelopeMessage(123), false);
expect(handler.isEnvelopeMessage(['list']), false);
expect(handler.isEnvelopeMessage(null), false);
});
test('returns true even if payload is null', () {
final data = {
'messageId': 'test-id',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user/message',
'payload': null,
};
expect(handler.isEnvelopeMessage(data), true);
});
});
group('unwrapEnvelope', () {
test('extracts payload from valid envelope', () {
final data = {
'messageId': 'test-id-123',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user/message',
'payload': {'content': 'Hello'},
'requiresAck': true,
};
final result = handler.unwrapEnvelope(data);
expect(result, isNotNull);
expect(result!.payload, {'content': 'Hello'});
expect(result.messageId, 'test-id-123');
expect(result.requiresAck, true);
});
test('returns non-envelope data as-is', () {
final plainData = {'content': 'Hello', 'sender': 'user1'};
final result = handler.unwrapEnvelope(plainData);
expect(result, isNotNull);
expect(result!.payload, plainData);
expect(result.messageId, isNull);
expect(result.requiresAck, false);
});
test('returns null for duplicate messageId', () {
final data = {
'messageId': 'duplicate-id',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user/message',
'payload': {'content': 'First'},
'requiresAck': false,
};
// First call should succeed
final firstResult = handler.unwrapEnvelope(data);
expect(firstResult, isNotNull);
// Second call with same messageId should return null
final secondResult = handler.unwrapEnvelope(data);
expect(secondResult, isNull);
});
test('calls onAckRequired for duplicate when original required ACK', () {
final data = {
'messageId': 'ack-duplicate-id',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user/message',
'payload': {'content': 'Message'},
'requiresAck': true,
};
// First call
handler.unwrapEnvelope(data);
expect(ackCallbackCalls, isEmpty); // No ACK callback on first process
// Second call (duplicate) - should trigger ACK
handler.unwrapEnvelope(data);
expect(ackCallbackCalls, ['ack-duplicate-id']);
});
test('does not call onAckRequired for duplicate when requiresAck is false', () {
final data = {
'messageId': 'no-ack-duplicate',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user/message',
'payload': {'content': 'Message'},
'requiresAck': false,
};
handler.unwrapEnvelope(data);
handler.unwrapEnvelope(data);
expect(ackCallbackCalls, isEmpty);
});
test('defaults requiresAck to true when not specified', () {
final data = {
'messageId': 'default-ack-id',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user/message',
'payload': {'content': 'Message'},
// requiresAck not specified
};
final result = handler.unwrapEnvelope(data);
expect(result!.requiresAck, true);
});
test('handles list payload', () {
final data = {
'messageId': 'list-payload-id',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user/jobs',
'payload': [
{'id': '1'},
{'id': '2'}
],
};
final result = handler.unwrapEnvelope(data);
expect(result!.payload, isList);
expect(result.payload.length, 2);
});
test('handles null payload', () {
final data = {
'messageId': 'null-payload-id',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/server/user/ping',
'payload': null,
};
final result = handler.unwrapEnvelope(data);
expect(result!.payload, isNull);
});
});
group('deduplication memory management', () {
test('respects maxProcessedIds limit', () {
final smallHandler = MessageHandler(maxProcessedIds: 3);
// Add 4 messages
for (var i = 1; i <= 4; i++) {
smallHandler.unwrapEnvelope({
'messageId': 'msg-$i',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/test',
'payload': null,
});
}
// Should only have 3 IDs tracked
expect(smallHandler.processedCount, 3);
// First message should have been evicted (FIFO)
expect(smallHandler.wasProcessed('msg-1'), false);
expect(smallHandler.wasProcessed('msg-2'), true);
expect(smallHandler.wasProcessed('msg-3'), true);
expect(smallHandler.wasProcessed('msg-4'), true);
});
test('allows reprocessing after eviction', () {
final smallHandler = MessageHandler(maxProcessedIds: 2);
// Process msg-1
final first = smallHandler.unwrapEnvelope({
'messageId': 'msg-1',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/test',
'payload': {'first': true},
});
expect(first, isNotNull);
// Process msg-2 and msg-3 to evict msg-1
smallHandler.unwrapEnvelope({
'messageId': 'msg-2',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/test',
'payload': null,
});
smallHandler.unwrapEnvelope({
'messageId': 'msg-3',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/test',
'payload': null,
});
// msg-1 should be processable again
final reprocessed = smallHandler.unwrapEnvelope({
'messageId': 'msg-1',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/test',
'payload': {'reprocessed': true},
});
expect(reprocessed, isNotNull);
expect(reprocessed!.payload, {'reprocessed': true});
});
});
group('wasProcessed', () {
test('returns true for processed message', () {
handler.unwrapEnvelope({
'messageId': 'processed-id',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/test',
'payload': null,
});
expect(handler.wasProcessed('processed-id'), true);
});
test('returns false for unprocessed message', () {
expect(handler.wasProcessed('unknown-id'), false);
});
});
group('clearProcessedIds', () {
test('removes all tracked message IDs', () {
handler.unwrapEnvelope({
'messageId': 'msg-1',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/test',
'payload': null,
});
handler.unwrapEnvelope({
'messageId': 'msg-2',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/test',
'payload': null,
});
expect(handler.processedCount, 2);
handler.clearProcessedIds();
expect(handler.processedCount, 0);
expect(handler.wasProcessed('msg-1'), false);
expect(handler.wasProcessed('msg-2'), false);
});
test('allows reprocessing cleared messages', () {
final data = {
'messageId': 'cleared-msg',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/test',
'payload': {'value': 1},
};
handler.unwrapEnvelope(data);
handler.clearProcessedIds();
final result = handler.unwrapEnvelope(data);
expect(result, isNotNull);
});
});
group('without onAckRequired callback', () {
test('handles duplicate gracefully when no callback set', () {
final noCallbackHandler = MessageHandler();
final data = {
'messageId': 'no-callback-msg',
'timestamp': '2024-01-15T10:30:00.000Z',
'topic': '/test',
'payload': null,
'requiresAck': true,
};
noCallbackHandler.unwrapEnvelope(data);
// Should not throw when processing duplicate
expect(() => noCallbackHandler.unwrapEnvelope(data), returnsNormally);
});
});
});
}

View File

@@ -0,0 +1,401 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:votianlt_app/services/message_handler.dart';
import 'package:votianlt_app/services/ack_tracker.dart';
/// Integration tests simulating the full MQTT message flow
/// based on real app behavior from login through job loading.
void main() {
group('MQTT Integration Scenarios', () {
group('Login Flow', () {
test('complete login flow with message tracking and ACK handling', () async {
// === SETUP ===
final acksSent = <String>[];
final retriedMessages = <String>[];
final messageHandler = MessageHandler(maxProcessedIds: 100, onAckRequired: (messageId) => acksSent.add(messageId));
final ackTracker = AckTracker(
maxRetries: 4,
onRetry: (topic, payload) async {
retriedMessages.add(topic);
return true;
},
onTimeout: (messageId, topic) {},
);
const appId = '410cea21-a3cf-47c3-97ad-b1c01f0bacbb';
const userId = '693fdcc757853e744d2ab0d5';
// === STEP 1: Send Login Request ===
// App sends login message with envelope
const loginMessageId = '5f4907de-684e-4381-a771-bd4d7ecdddbd';
const loginTopic = '/server/login';
final loginPayload = '''{
"messageId": "$loginMessageId",
"timestamp": "2026-01-13T11:13:10.275836",
"topic": "$loginTopic",
"payload": {
"email": "mail@svencarstensen.de",
"password": "secret"
},
"requiresAck": true,
"retryCount": 0
}''';
// Track the sent login message for ACK
ackTracker.track(loginMessageId, loginTopic, loginPayload);
expect(ackTracker.isPending(loginMessageId), true);
expect(ackTracker.pendingCount, 1);
// === STEP 2: Receive Auth Response ===
// Server sends auth response with envelope
const authResponseMessageId = '9d867b3a-fe87-40d7-80bf-56b031beb76d';
final authResponseEnvelope = {
'messageId': authResponseMessageId,
'timestamp': '2026-01-13T11:13:10.607327',
'topic': '/client/$appId/auth',
'payload': {'success': true, 'message': 'Anmeldung erfolgreich', 'token': null, 'userId': null, 'appUserId': userId},
'requiresAck': true,
'retryCount': 0,
};
// Unwrap and process the auth response
final authResult = messageHandler.unwrapEnvelope(authResponseEnvelope);
expect(authResult, isNotNull);
expect(authResult!.messageId, authResponseMessageId);
expect(authResult.requiresAck, true);
expect(authResult.payload['success'], true);
expect(authResult.payload['appUserId'], userId);
// Message was processed, now marked as seen
expect(messageHandler.wasProcessed(authResponseMessageId), true);
// === STEP 3: Auth Response acts as implicit ACK for login ===
// The auth response itself confirms the login was received
ackTracker.clearForTopic('/server/login');
expect(ackTracker.isPending(loginMessageId), false);
expect(ackTracker.pendingCount, 0);
// === STEP 4: Send ACK for auth response ===
// Simulate sending ACK (callback would be called after processing)
acksSent.add(authResponseMessageId);
expect(acksSent, contains(authResponseMessageId));
// === STEP 5: Verify duplicate auth response is ignored ===
final duplicateResult = messageHandler.unwrapEnvelope(authResponseEnvelope);
expect(duplicateResult, isNull); // Duplicate returns null
// But ACK is still triggered for duplicate (via callback)
expect(acksSent.where((id) => id == authResponseMessageId).length, 2);
});
test('login request retry when no ACK received', () async {
final retriedTopics = <String>[];
final timedOutMessages = <String>[];
final ackTracker = AckTracker(
maxRetries: 4,
onRetry: (topic, payload) async {
retriedTopics.add(topic);
return true;
},
onTimeout: (messageId, topic) {
timedOutMessages.add(messageId);
},
);
const loginMessageId = 'login-no-ack-test';
const loginTopic = '/server/login';
// Track login message
ackTracker.track(loginMessageId, loginTopic, '{"test": true}');
// Simulate 4 retry cycles (5 seconds each in real app)
for (var i = 1; i <= 4; i++) {
await ackTracker.processRetries();
expect(retriedTopics.length, i);
expect(ackTracker.isPending(loginMessageId), true);
}
// 5th cycle should timeout
await ackTracker.processRetries();
expect(timedOutMessages, contains(loginMessageId));
expect(ackTracker.isPending(loginMessageId), false);
});
});
group('Job Loading Flow', () {
test('complete job loading flow with request, ACK, and response', () async {
// === SETUP ===
final acksSent = <String>[];
final messageHandler = MessageHandler(maxProcessedIds: 100, onAckRequired: (messageId) => acksSent.add(messageId));
final ackTracker = AckTracker(maxRetries: 4, onRetry: (topic, payload) async => true, onTimeout: (messageId, topic) {});
const userId = '693fdcc757853e744d2ab0d5';
// === STEP 1: Send Jobs Request ===
const jobsRequestMessageId = 'eb678b54-2b5e-47f5-9c3e-823d7092c49a';
const jobsRequestTopic = '/server/$userId/jobs/assigned';
ackTracker.track(jobsRequestMessageId, jobsRequestTopic, '{"messageId": "$jobsRequestMessageId"}');
expect(ackTracker.isPending(jobsRequestMessageId), true);
// === STEP 2: Receive ACK for jobs request ===
// Server acknowledges our request
final jobsRequestAckEnvelope = {
'messageId': 'ack-envelope-id-1',
'timestamp': '2026-01-13T11:13:10.700000',
'topic': '/client/$userId/ack',
'payload': {'messageId': jobsRequestMessageId, 'status': 'RECEIVED', 'timestamp': '2026-01-13T11:13:10.700000', 'clientId': 'server'},
'requiresAck': false,
'retryCount': 0,
};
// Process ACK envelope
final ackResult = messageHandler.unwrapEnvelope(jobsRequestAckEnvelope);
expect(ackResult, isNotNull);
expect(ackResult!.requiresAck, false); // ACKs don't need ACKs
// Remove from pending based on ACK content
final ackPayload = ackResult.payload as Map<String, dynamic>;
final acknowledgedMessageId = ackPayload['messageId'] as String;
ackTracker.acknowledge(acknowledgedMessageId);
expect(ackTracker.isPending(jobsRequestMessageId), false);
// === STEP 3: Receive Jobs Response ===
const jobsResponseMessageId = 'c92ef207-65a5-4204-802c-99e097f833f5';
final jobsResponseEnvelope = {
'messageId': jobsResponseMessageId,
'timestamp': '2026-01-13T11:13:10.800000',
'topic': '/client/$userId/jobs',
'payload': [
{
'job': {'jobNumber': 'JOB20260106001', 'status': 'CREATED', 'id': '695ce8faf3fbbd0c2acfdb17', 'pickupCompany': 'cAPPacity GmbH', 'deliveryCompany': 'cAPPacity GmbH', 'pickupCity': 'Taarstedt', 'deliveryCity': 'Taarstedt'},
'cargoItems': [
{'description': 'Europalette', 'quantity': 1, 'id': '695ce8faf3fbbd0c2acfdb18'},
],
'tasks': [
{'taskType': 'CONFIRMATION', 'taskOrder': 0, 'completed': false, 'buttonText': 'TEST', 'displayName': 'Bestätigung', 'id': '695ce8faf3fbbd0c2acfdb19'},
],
},
],
'requiresAck': true,
'retryCount': 0,
};
// Process jobs response
final jobsResult = messageHandler.unwrapEnvelope(jobsResponseEnvelope);
expect(jobsResult, isNotNull);
expect(jobsResult!.messageId, jobsResponseMessageId);
expect(jobsResult.requiresAck, true);
expect(jobsResult.payload, isList);
expect((jobsResult.payload as List).length, 1);
// Verify job data
final jobData = (jobsResult.payload as List)[0] as Map<String, dynamic>;
expect(jobData['job']['jobNumber'], 'JOB20260106001');
expect(jobData['cargoItems'].length, 1);
expect(jobData['tasks'].length, 1);
// === STEP 4: Send ACK for jobs response ===
acksSent.add(jobsResponseMessageId);
expect(acksSent, contains(jobsResponseMessageId));
expect(messageHandler.wasProcessed(jobsResponseMessageId), true);
// === STEP 5: Verify duplicate jobs response is handled ===
final duplicateJobsResult = messageHandler.unwrapEnvelope(jobsResponseEnvelope);
expect(duplicateJobsResult, isNull);
// Duplicate triggers ACK callback
expect(acksSent.where((id) => id == jobsResponseMessageId).length, 2);
});
test('multiple concurrent job requests with independent ACK tracking', () async {
final ackTracker = AckTracker(maxRetries: 4);
const userId = '693fdcc757853e744d2ab0d5';
// Two JobsView instances send requests (as seen in log)
const request1Id = 'eb678b54-2b5e-47f5-9c3e-823d7092c49a';
const request2Id = '936161ce-6535-4098-9e12-0e7bfb9393ed';
const topic = '/server/$userId/jobs/assigned';
ackTracker.track(request1Id, topic, '{}');
ackTracker.track(request2Id, topic, '{}');
expect(ackTracker.pendingCount, 2);
expect(ackTracker.isPending(request1Id), true);
expect(ackTracker.isPending(request2Id), true);
// Both ACKs arrive
ackTracker.acknowledge(request1Id);
expect(ackTracker.pendingCount, 1);
expect(ackTracker.isPending(request1Id), false);
expect(ackTracker.isPending(request2Id), true);
ackTracker.acknowledge(request2Id);
expect(ackTracker.pendingCount, 0);
});
test('ping messages without envelope are handled correctly', () {
final messageHandler = MessageHandler();
// Ping messages come without envelope wrapper
final pingData = {'type': 'ping', 'timestamp': '2026-01-13T11:13:15.000000'};
final result = messageHandler.unwrapEnvelope(pingData);
expect(result, isNotNull);
expect(result!.messageId, isNull); // No messageId for non-envelope
expect(result.requiresAck, false); // No ACK needed
expect(result.payload, pingData); // Returns data as-is
});
});
group('Message Deduplication Across Sessions', () {
test('prevents reprocessing of already-seen messages', () {
final processedPayloads = <String>[];
final acksSent = <String>[];
final messageHandler = MessageHandler(maxProcessedIds: 100, onAckRequired: (messageId) => acksSent.add(messageId));
const messageId = 'duplicate-test-id';
final envelope = {
'messageId': messageId,
'timestamp': '2026-01-13T11:13:10.000000',
'topic': '/client/user/jobs',
'payload': {'jobNumber': 'JOB001'},
'requiresAck': true,
};
// First processing
final result1 = messageHandler.unwrapEnvelope(envelope);
if (result1 != null) {
processedPayloads.add(result1.payload['jobNumber']);
}
// Simulate server retry (same message arrives again)
final result2 = messageHandler.unwrapEnvelope(envelope);
if (result2 != null) {
processedPayloads.add(result2.payload['jobNumber']);
}
// Third arrival
final result3 = messageHandler.unwrapEnvelope(envelope);
if (result3 != null) {
processedPayloads.add(result3.payload['jobNumber']);
}
// Payload should only be processed once
expect(processedPayloads.length, 1);
expect(processedPayloads.first, 'JOB001');
// But ACK should be sent for each duplicate
expect(acksSent.length, 2); // Only duplicates trigger callback
});
});
group('Full Session Flow', () {
test('simulates complete app session from login to job receipt', () async {
// === SETUP ===
final processedMessages = <String, dynamic>{};
final acksSent = <String>[];
final pendingAcks = <String>[];
final messageHandler = MessageHandler(maxProcessedIds: 100, onAckRequired: (messageId) => acksSent.add(messageId));
final ackTracker = AckTracker(maxRetries: 4, onRetry: (topic, payload) async => true);
const appId = '410cea21-a3cf-47c3-97ad-b1c01f0bacbb';
const userId = '693fdcc757853e744d2ab0d5';
// === PHASE 1: LOGIN ===
const loginMsgId = '5f4907de-684e-4381-a771-bd4d7ecdddbd';
ackTracker.track(loginMsgId, '/server/login', '{}');
pendingAcks.add(loginMsgId);
expect(ackTracker.pendingCount, 1);
// Auth response arrives
const authMsgId = '9d867b3a-fe87-40d7-80bf-56b031beb76d';
final authResult = messageHandler.unwrapEnvelope({
'messageId': authMsgId,
'timestamp': '2026-01-13T11:13:10.607327',
'topic': '/client/$appId/auth',
'payload': {'success': true, 'appUserId': userId},
'requiresAck': true,
});
processedMessages['auth'] = authResult!.payload;
acksSent.add(authMsgId); // Send ACK for auth
// Login implicitly ACKed by auth response
ackTracker.clearForTopic('/server/login');
pendingAcks.remove(loginMsgId);
expect(ackTracker.pendingCount, 0);
expect(processedMessages['auth']['success'], true);
// === PHASE 2: REQUEST JOBS ===
const jobsReqMsgId = 'eb678b54-2b5e-47f5-9c3e-823d7092c49a';
ackTracker.track(jobsReqMsgId, '/server/$userId/jobs/assigned', '{}');
pendingAcks.add(jobsReqMsgId);
expect(ackTracker.pendingCount, 1);
// ACK for jobs request arrives
ackTracker.acknowledge(jobsReqMsgId);
pendingAcks.remove(jobsReqMsgId);
expect(ackTracker.pendingCount, 0);
// === PHASE 3: RECEIVE JOBS ===
const jobsMsgId = 'c92ef207-65a5-4204-802c-99e097f833f5';
final jobsResult = messageHandler.unwrapEnvelope({
'messageId': jobsMsgId,
'timestamp': '2026-01-13T11:13:10.800000',
'topic': '/client/$userId/jobs',
'payload': [
{
'job': {'jobNumber': 'JOB20260106001', 'id': '695ce8faf3fbbd0c2acfdb17'},
'tasks': [
{'taskType': 'CONFIRMATION', 'id': '695ce8faf3fbbd0c2acfdb19'},
],
},
],
'requiresAck': true,
});
processedMessages['jobs'] = jobsResult!.payload;
acksSent.add(jobsMsgId); // Send ACK for jobs
// === VERIFY FINAL STATE ===
expect(processedMessages.length, 2);
expect(acksSent.length, 2);
expect(pendingAcks, isEmpty);
expect(ackTracker.pendingCount, 0);
// Verify processed data
expect(processedMessages['auth']['appUserId'], userId);
expect((processedMessages['jobs'] as List).length, 1);
expect((processedMessages['jobs'] as List)[0]['job']['jobNumber'], 'JOB20260106001');
// Verify deduplication state
expect(messageHandler.wasProcessed(authMsgId), true);
expect(messageHandler.wasProcessed(jobsMsgId), true);
expect(messageHandler.processedCount, 2);
});
});
});
}