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 = {}; final requests = <(String, int)>[]; final notifications = []; int? account = 42; int epoch = 1; bool online = true; bool failSave = false; bool failReplace = false; Object? sendError; Future Function()? onSend; Future 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.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': >[], }, ); 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(); 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(); 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)); }); }