Files
IKAR/test/outbox_queue_test.dart
T

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));
});
}