tane/apps/app_seeds/test/state/messages_cubit_test.dart
vjrj 21ec836bd8 fix(chat): dedupe messages on display, not just in the store
Duplicated bubbles came from the cubit emitting every inbox event to the
screen regardless of whether it was already shown. On opening a chat the
saved message loads from history AND the live subscription hands back the
same stored gift wrap the relay re-delivers — two bubbles. (The store was
already idempotent, but the cubit ignored that for display.)

MessagesCubit now keeps a seen-set (sender+timestamp+text): a re-delivered
or already-loaded message is skipped, and pre-existing duplicates in old
saved history collapse on load too. Arrival order preserved (no resort).
Tests: re-delivered wrap shown once; history-raced-by-live shown once.
2026-07-10 17:02:40 +02:00

152 lines
5.2 KiB
Dart

import 'dart:async';
import 'package:commons_core/commons_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/services/message_store.dart';
import 'package:tane/state/messages_cubit.dart';
import '../support/test_support.dart';
/// In-memory [MessageTransport]: records sends, lets a test push inbox messages.
class FakeMessageTransport implements MessageTransport {
final List<({String to, String text})> sent = [];
final StreamController<PrivateMessage> _inbox =
StreamController<PrivateMessage>.broadcast();
void receive(PrivateMessage message) => _inbox.add(message);
@override
Future<void> send({required String toPubkey, required String text}) async =>
sent.add((to: toPubkey, text: text));
@override
Stream<PrivateMessage> inbox() => _inbox.stream;
@override
Future<void> close() async => _inbox.close();
}
void main() {
const peer = 'aa';
const me = 'bb';
PrivateMessage msg(String from, String text) =>
PrivateMessage(fromPubkey: from, text: text, at: DateTime(2026));
test('receives messages from the peer only', () async {
final transport = FakeMessageTransport();
final cubit =
MessagesCubit(transport, peerPubkey: peer, selfPubkey: me)..start();
transport.receive(msg(peer, 'hola'));
transport.receive(msg('cc', 'not for this chat')); // other conversation
await pumpEventQueue();
expect(cubit.state.messages, hasLength(1));
expect(cubit.state.messages.single.text, 'hola');
expect(cubit.isMine(cubit.state.messages.single), isFalse);
await cubit.close();
});
test('send delivers to the peer and appends our own message', () async {
final transport = FakeMessageTransport();
final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me);
await cubit.send(' ¿cambiamos? ');
expect(transport.sent, [(to: peer, text: '¿cambiamos?')]);
expect(cubit.state.messages, hasLength(1));
expect(cubit.isMine(cubit.state.messages.single), isTrue);
expect(cubit.state.sending, isFalse);
await cubit.close();
});
test('a full exchange keeps order and sides', () async {
final transport = FakeMessageTransport();
final cubit =
MessagesCubit(transport, peerPubkey: peer, selfPubkey: me)..start();
await cubit.send('hi');
transport.receive(msg(peer, 'hello'));
await pumpEventQueue();
expect(cubit.state.messages.map((m) => m.text), ['hi', 'hello']);
expect(cubit.state.messages.map(cubit.isMine), [true, false]);
await cubit.close();
});
test('empty/whitespace text is not sent', () async {
final transport = FakeMessageTransport();
final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me);
await cubit.send(' ');
expect(transport.sent, isEmpty);
expect(cubit.state.messages, isEmpty);
await cubit.close();
});
test('loads saved history on start and persists across a new cubit',
() async {
final store = MessageStore(InMemorySecretStore());
await store.append(
peer, msg(peer, 'earlier')); // a message from a previous session
final transport = FakeMessageTransport();
final cubit = MessagesCubit(transport,
peerPubkey: peer, selfPubkey: me, store: store);
await cubit.start();
expect(cubit.state.messages.map((m) => m.text), ['earlier']);
await cubit.send('hi'); // persisted
transport.receive(msg(peer, 'reply')); // persisted
await pumpEventQueue();
expect(cubit.state.messages.map((m) => m.text), ['earlier', 'hi', 'reply']);
await cubit.close();
// A fresh cubit (even offline) sees the saved conversation.
final reopened =
MessagesCubit(null, peerPubkey: peer, selfPubkey: me, store: store);
await reopened.start();
expect(reopened.state.messages.map((m) => m.text),
['earlier', 'hi', 'reply']);
await reopened.close();
});
test('a re-delivered wrap is shown once (relay resends stored events)',
() async {
final transport = FakeMessageTransport();
final cubit =
MessagesCubit(transport, peerPubkey: peer, selfPubkey: me)..start();
final same = msg(peer, 'hola'); // identical sender+timestamp+text
transport.receive(same);
transport.receive(same); // relay re-delivery on the same subscription
await pumpEventQueue();
expect(cubit.state.messages, hasLength(1));
await cubit.close();
});
test('history already surfaced live is not shown twice', () async {
// The stored message is ALSO handed back by the live subscription on open.
final store = MessageStore(InMemorySecretStore());
final m = msg(peer, 'hola');
await store.append(peer, m);
final transport = FakeMessageTransport();
final cubit = MessagesCubit(transport,
peerPubkey: peer, selfPubkey: me, store: store);
unawaited(cubit.start());
transport.receive(m); // live redelivery races the history load
await pumpEventQueue();
expect(cubit.state.messages.map((m) => m.text), ['hola']);
await cubit.close();
});
test('offline (no transport) never throws', () async {
final cubit = MessagesCubit(null, peerPubkey: peer, selfPubkey: me)..start();
expect(cubit.isOnline, isFalse);
await cubit.send('hi');
expect(cubit.state.messages, isEmpty);
await cubit.close();
});
}