402 lines
15 KiB
Dart
402 lines
15 KiB
Dart
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);
|
|
});
|
|
});
|
|
});
|
|
}
|