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 = []; final retriedMessages = []; 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 = []; final timedOutMessages = []; 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 = []; 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; 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; 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 = []; final acksSent = []; 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 = {}; final acksSent = []; final pendingAcks = []; 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); }); }); }); }