352 lines
11 KiB
Dart
352 lines
11 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:ikar/backend/modules/messages.dart';
|
|
import 'package:ikar/backend/modules/outbox_queue.dart';
|
|
import 'package:ikar/backend/modules/outgoing_text.dart';
|
|
import 'package:ikar/core/protocol/packet.dart';
|
|
|
|
class _Harness {
|
|
final rows = <String, CachedMessage>{};
|
|
final requests = <(String, int)>[];
|
|
final notifications = <CachedMessage>[];
|
|
int? account = 42;
|
|
int epoch = 1;
|
|
bool online = true;
|
|
bool failSave = false;
|
|
bool failReplace = false;
|
|
Object? sendError;
|
|
Future<String> Function()? onSend;
|
|
Future<void> Function()? onLoad;
|
|
DateTime clock = DateTime(2026);
|
|
|
|
OutboxQueue create({Duration retryBase = const Duration(hours: 1)}) =>
|
|
OutboxQueue(
|
|
activeAccount: () async => account,
|
|
isOnline: () => online,
|
|
sessionEpoch: () => epoch,
|
|
now: () => clock,
|
|
retryBase: retryBase,
|
|
load: (id) async {
|
|
await onLoad?.call();
|
|
return rows.values
|
|
.where((m) => m.accountId == id && m.status == 'pending')
|
|
.toList();
|
|
},
|
|
save: (message) async {
|
|
if (failSave) throw StateError('Disk full');
|
|
rows[message.id] = CachedMessage.fromDbRow(
|
|
Map<String, dynamic>.from(
|
|
jsonDecode(jsonEncode(message.toDbRow())) as Map,
|
|
),
|
|
);
|
|
},
|
|
updatePending: (message) async {
|
|
final current = rows[message.id];
|
|
if (current == null ||
|
|
current.deleted ||
|
|
current.status != 'pending') {
|
|
return false;
|
|
}
|
|
if (failSave) throw StateError('Disk full');
|
|
rows[message.id] = message;
|
|
return true;
|
|
},
|
|
replace: (before, after) async {
|
|
if (rows[before.id] == null ||
|
|
rows[before.id]!.deleted ||
|
|
rows[before.id]!.status != 'pending') {
|
|
throw const OutboxCancelled();
|
|
}
|
|
if (failReplace) throw StateError('Database unavailable');
|
|
rows.remove(before.id);
|
|
rows[after.id] = after;
|
|
},
|
|
send: (message, cid) async {
|
|
requests.add((message.text!, cid));
|
|
if (sendError != null) throw sendError!;
|
|
return await onSend?.call() ?? 'server-${requests.length}';
|
|
},
|
|
onChanged: (_, after) => notifications.add(after),
|
|
onError: (_, _) {},
|
|
);
|
|
}
|
|
|
|
CachedMessage _message({int chatId = 7, bool encrypted = true}) =>
|
|
OutgoingText.prepare(
|
|
accountId: 42,
|
|
chatId: chatId,
|
|
wireText: encrypted
|
|
? 'зашифрованный тестовый пакет'
|
|
: 'synthetic message',
|
|
encrypted: encrypted,
|
|
payload: {
|
|
'link': {
|
|
'type': 'REPLY',
|
|
'chatId': 9,
|
|
'message': {'id': 123},
|
|
},
|
|
'elements': <Map<String, dynamic>>[],
|
|
},
|
|
);
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
for (final failure in [
|
|
null,
|
|
StateError('network failed'),
|
|
const PacketError('rejected'),
|
|
]) {
|
|
test('removal during send wins over ${failure ?? 'receipt'}', () async {
|
|
final h = _Harness();
|
|
final queue = h.create();
|
|
addTearDown(queue.dispose);
|
|
final message = _message();
|
|
await queue.enqueue(message);
|
|
h.onSend = () async {
|
|
h.rows.remove(message.id);
|
|
if (failure != null) throw failure;
|
|
return 'late-receipt';
|
|
};
|
|
await queue.flush();
|
|
expect(h.rows, isEmpty);
|
|
expect(h.notifications, isEmpty);
|
|
});
|
|
}
|
|
|
|
test(
|
|
'account switch during send preserves receipt without notifying new account',
|
|
() async {
|
|
final h = _Harness();
|
|
final queue = h.create();
|
|
addTearDown(queue.dispose);
|
|
await queue.enqueue(_message());
|
|
h.onSend = () async {
|
|
h.account = 84;
|
|
h.epoch++;
|
|
return 'old-account-receipt';
|
|
};
|
|
await queue.flush();
|
|
expect(h.rows['old-account-receipt']?.accountId, 42);
|
|
expect(h.rows['old-account-receipt']?.status, 'sent');
|
|
expect(h.notifications, isEmpty);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'late failure from old session does not modify queued message',
|
|
() async {
|
|
final h = _Harness();
|
|
final queue = h.create();
|
|
addTearDown(queue.dispose);
|
|
await queue.enqueue(_message());
|
|
h.onSend = () async {
|
|
h.account = 84;
|
|
h.epoch++;
|
|
throw StateError('old session');
|
|
};
|
|
await queue.flush();
|
|
expect(h.rows.values.single.status, 'pending');
|
|
expect(h.notifications, isEmpty);
|
|
},
|
|
);
|
|
|
|
test('failed persistence prevents any network request', () async {
|
|
final h = _Harness()..failSave = true;
|
|
final queue = h.create();
|
|
addTearDown(queue.dispose);
|
|
await expectLater(queue.enqueue(_message()), throwsStateError);
|
|
await queue.flush();
|
|
expect(h.requests, isEmpty);
|
|
});
|
|
|
|
test(
|
|
'offline enqueue persists and waits for an authenticated connection',
|
|
() async {
|
|
final h = _Harness()..online = false;
|
|
final queue = h.create();
|
|
addTearDown(queue.dispose);
|
|
await queue.enqueue(_message());
|
|
await queue.flush();
|
|
expect(h.rows.values.single.status, 'pending');
|
|
expect(h.requests, isEmpty);
|
|
h.online = true;
|
|
await queue.flush();
|
|
expect(h.rows.values.single.status, 'sent');
|
|
},
|
|
);
|
|
|
|
test(
|
|
'encrypted wire text and CID survive a timeout and process restart',
|
|
() async {
|
|
final h = _Harness()..sendError = TimeoutException('Lost response');
|
|
final original = _message();
|
|
final first = h.create();
|
|
await first.enqueue(original);
|
|
await first.flush();
|
|
first.dispose();
|
|
expect(h.rows[original.id]!.text, original.text);
|
|
expect(h.rows[original.id]!.status, 'pending');
|
|
h.sendError = null;
|
|
h.clock = h.clock.add(const Duration(minutes: 2));
|
|
final restarted = h.create();
|
|
addTearDown(restarted.dispose);
|
|
await restarted.flush();
|
|
expect(h.requests, hasLength(2));
|
|
expect(h.requests[0], h.requests[1]);
|
|
expect(h.rows.values.single.status, 'sent');
|
|
expect(OutgoingText.replyId(h.rows.values.single), 123);
|
|
expect(OutgoingText.replyChatId(h.rows.values.single), 9);
|
|
},
|
|
);
|
|
|
|
test('retries while the connection remains online', () async {
|
|
final h = _Harness();
|
|
final done = Completer<void>();
|
|
h.onSend = () async {
|
|
if (h.requests.length == 1) {
|
|
h.clock = DateTime.now();
|
|
throw TimeoutException('temporary');
|
|
}
|
|
done.complete();
|
|
return 'server-ack';
|
|
};
|
|
final queue = OutboxQueue(
|
|
activeAccount: () async => 42,
|
|
isOnline: () => true,
|
|
sessionEpoch: () => 1,
|
|
load: (_) async =>
|
|
h.rows.values.where((m) => m.status == 'pending').toList(),
|
|
save: (m) async {
|
|
h.rows[m.id] = m;
|
|
},
|
|
updatePending: (m) async {
|
|
if (!h.rows.containsKey(m.id)) return false;
|
|
h.rows[m.id] = m;
|
|
return true;
|
|
},
|
|
replace: (a, b) async {
|
|
h.rows.remove(a.id);
|
|
h.rows[b.id] = b;
|
|
},
|
|
send: (m, cid) async {
|
|
h.requests.add((m.text!, cid));
|
|
return h.onSend!();
|
|
},
|
|
onChanged: (_, _) {},
|
|
onError: (_, _) {},
|
|
retryBase: const Duration(milliseconds: 10),
|
|
);
|
|
addTearDown(queue.dispose);
|
|
await queue.enqueue(_message());
|
|
await queue.flush();
|
|
await done.future.timeout(const Duration(seconds: 3));
|
|
expect(h.requests, hasLength(2));
|
|
});
|
|
|
|
test('permanent rejection stops automatic retries', () async {
|
|
final h = _Harness()
|
|
..sendError = const PacketError('Rejected', errorKey: 'denied');
|
|
final queue = h.create();
|
|
addTearDown(queue.dispose);
|
|
await queue.enqueue(_message());
|
|
await queue.flush();
|
|
await queue.flush();
|
|
expect(h.rows.values.single.status, 'error');
|
|
final restored = CachedMessage.fromDbRow(h.rows.values.single.toDbRow());
|
|
expect(restored.sendFailure, 'Rejected');
|
|
expect(OutgoingText.metadata(restored)?['cid'], isNegative);
|
|
expect(h.requests, hasLength(1));
|
|
});
|
|
|
|
test(
|
|
'legacy pending text is never resent without preparation metadata',
|
|
() async {
|
|
final h = _Harness();
|
|
h.rows['old'] = const CachedMessage(
|
|
id: 'old',
|
|
accountId: 42,
|
|
chatId: 7,
|
|
senderId: 42,
|
|
time: 1,
|
|
text: 'legacy plaintext',
|
|
status: 'pending',
|
|
);
|
|
final queue = h.create();
|
|
addTearDown(queue.dispose);
|
|
await queue.flush();
|
|
expect(h.requests, isEmpty);
|
|
expect(h.rows['old']!.status, 'error');
|
|
},
|
|
);
|
|
|
|
test('account switch while reading storage prevents sending', () async {
|
|
final h = _Harness();
|
|
final queue = h.create();
|
|
addTearDown(queue.dispose);
|
|
await queue.enqueue(_message());
|
|
h.onLoad = () async {
|
|
h.account = 84;
|
|
};
|
|
await queue.flush();
|
|
expect(h.requests, isEmpty);
|
|
});
|
|
|
|
test('session change during batch stops remaining messages', () async {
|
|
final h = _Harness();
|
|
final queue = h.create();
|
|
addTearDown(queue.dispose);
|
|
await queue.enqueue(_message());
|
|
await queue.enqueue(_message());
|
|
h.onSend = () async {
|
|
h.epoch++;
|
|
return 'server-ack';
|
|
};
|
|
await queue.flush();
|
|
expect(h.requests, hasLength(1));
|
|
});
|
|
|
|
test('failed chat preserves ordering without blocking other chats', () async {
|
|
final h = _Harness()..sendError = TimeoutException('temporary');
|
|
final queue = h.create();
|
|
addTearDown(queue.dispose);
|
|
await queue.enqueue(_message());
|
|
await queue.enqueue(_message());
|
|
await queue.enqueue(_message(chatId: 8));
|
|
await queue.flush();
|
|
expect(h.requests, hasLength(2));
|
|
});
|
|
|
|
test(
|
|
'persisting a received acknowledgment does not send again after a database error',
|
|
() async {
|
|
final h = _Harness()..failReplace = true;
|
|
final queue = h.create();
|
|
addTearDown(queue.dispose);
|
|
await queue.enqueue(_message());
|
|
await queue.flush();
|
|
h.failReplace = false;
|
|
h.clock = h.clock.add(const Duration(minutes: 2));
|
|
await queue.flush();
|
|
expect(h.requests, hasLength(1));
|
|
expect(h.rows.values.single.status, 'sent');
|
|
},
|
|
);
|
|
|
|
test('concurrent flush requests share one network operation', () async {
|
|
final h = _Harness();
|
|
final response = Completer<String>();
|
|
h.onSend = () => response.future;
|
|
final queue = h.create();
|
|
addTearDown(queue.dispose);
|
|
await queue.enqueue(_message());
|
|
final first = queue.flush();
|
|
final second = queue.flush();
|
|
response.complete('server-ack');
|
|
await Future.wait([first, second]);
|
|
expect(h.requests, hasLength(1));
|
|
});
|
|
}
|