Files
buzz/mobile/test/features/channels/sidebar_sync_fixture.dart
Wes 3ec6327618 fix(mobile): keep cached state with its community (#8349)
## Summary

One identity can be paired with several communities, but mobile keyed
several caches by pubkey only. Mobile keeps a single `ProviderScope` and
doesn't remount on a community switch, so one community's data showed up
in another, and some of it was published to the other community's relay.

- **Channel groups**: the second community showed the first community's
groups.
- **Read marks** (contexts, publishable set, NIP-RS client and slot
IDs): the first community's context IDs were merged into, and published
to, the second community's read-state slot.
- **Inbox done/unread rows and thread follows**: one shared 500-entry
cap, so activity in one community evicted another's entries.
- **Channel stars and mutes**: union-merged across communities and
published to every relay.

Every one of these is now keyed by pubkey + normalized community relay,
the same way channel sort already is.

**Switching communities.** The relay session rebuilds for the new
community before the previous community's managers are retired. Each
manager (groups, read state, stars, mutes) now checks that the session
still serves its own community before it publishes, merges a fetch, or
handles a live event. Previously, a pending publish flushed on dispose,
or an in-flight fetch, could cross into the next community.

**Old pubkey-only entries.** Channel IDs can repeat across communities,
so nothing tells us which community an old channel-keyed entry belongs
to:
- **Groups, stars, mutes and read marks** are dropped on upgrade, and
each community restores its own from its relay. Any change that never
reached a relay is lost, and a read mark the relay doesn't hold shows as
unread once. That's an accepted tradeoff: such changes would be very old
by now.
- **Inbox, thread follows, and read-state slot and client IDs** seed
every community, and the old entry is kept. The inbox and follows
entries are event IDs, which only ever match their own community. The
slot ID keeps each community publishing to the slot it already has on
its relay.

This PR also removes the **Pulse** feed (`features/pulse/` and the Nostr
filters only it used). Nothing in the app navigates to it anymore, and
its providers were among the community-unscoped caches.

### Related issue

None found.

### Testing

- Each affected store has a regression test through the real provider
that switches A → B → A. For groups, read marks, stars and mutes, the
test sets a value in A, switches to B while A's change is still pending,
and checks two cases: B's data arriving live and B answering A's
in-flight fetch. It asserts that nothing sent to B contains A's IDs, B
shows only its own value, and A comes back unchanged. Each guard was
removed in turn to confirm a test fails without it, except two that the
current switch order never reaches: the groups merge guard and the
read-state live-event guard.
- Upgrade tests cover each kind of old entry: dropped for groups, stars,
mutes and read marks, seeded for inbox, follows and slot IDs.
- `flutter analyze` is clean, and the full mobile suite passes in the
pre-push hook at `89ebfcc12`.
- Not yet exercised in the app.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:59:38 -06:00

204 lines
6.2 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:buzz/shared/crypto/nip44.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
/// A scripted relay behind the real [RelaySessionNotifier], so REQ/EOSE,
/// CLOSED classification, event batching and OK all run production code.
/// Retains replaceable heads the way the relay does: `created_at DESC, id ASC`.
class SidebarRelay {
SidebarRelay() {
session.debugAttachSocketForTest(_ScriptedSocket(_onSend));
}
final keys = nostr.Keys.generate();
final session = ConnectedRelaySession();
late final signer = SignedEventRelay(session: session, nsec: keys.nsec);
final _storedByRelay = <String?, List<NostrEvent>>{};
/// Heads retained by the relay [session] currently serves, so a community
/// switch reaches a different relay's store.
List<NostrEvent> get stored =>
_storedByRelay.putIfAbsent(session.relayUrl, () => <NostrEvent>[]);
final published = <NostrEvent>[];
/// The relay [session] served when each [published] event was sent.
final publishedTo = <String?>[];
final reqs = <List<dynamic>>[];
final _liveSubIds = <String?, String>{};
int historyFailures = 0;
Completer<void>? holdHistory;
Completer<void>? holdOk;
String? rejectLive;
bool withholdEose = false;
String get pubkey => keys.public;
NostrEvent event(
String dTag,
Object payload,
int createdAt, {
String? id,
List<List<String>> tags = const [],
}) => NostrEvent(
id: id ?? 'e$createdAt'.padRight(64, 'f'),
pubkey: pubkey,
createdAt: createdAt,
kind: EventKind.readState,
tags: [
['d', dTag],
...tags,
],
content: nip44Encrypt(_key, jsonEncode(payload)),
sig: 'sig',
);
Object? decrypt(NostrEvent event) =>
jsonDecode(nip44Decrypt(_key, event.content));
late final _key = getConversationKey(
nostr.Nip19.decode(payload: keys.nsec).data,
keys.public,
);
List<List<dynamic>> reqsFor(String dTag, String prefix) => [
for (final req in reqs)
if ((req[1] as String).startsWith(prefix) && _dTagOf(req) == dTag) req,
];
/// Delivers [event] on the live subscription for its `d` tag, or else on
/// the one filtered by `#t` alone.
void emit(NostrEvent event) => session.debugHandleMessage([
'EVENT',
_liveSubIds[event.getTagValue('d')] ?? _liveSubIds[null],
event.toJson(),
]);
void closeLive(String dTag, String message) =>
session.debugHandleMessage(['CLOSED', _liveSubIds[dTag], message]);
void _onSend(List<dynamic> message) {
if (message.first == 'EVENT') {
final event = NostrEvent.fromJson(message[1] as Map<String, dynamic>);
published.add(event);
publishedTo.add(session.relayUrl);
stored.add(event);
final ok = ['OK', event.id, true, ''];
final hold = holdOk;
hold == null
? scheduleMicrotask(() => _reply(ok))
: hold.future.then((_) => _reply(ok));
}
if (message.first != 'REQ') return;
reqs.add(message);
final subId = message[1] as String;
if (subId.startsWith('h-')) {
unawaited(_answerHistory(subId, _dTagOf(message)));
return;
}
_liveSubIds[_dTagOf(message)] = subId;
final rejection = rejectLive;
if (rejection == null && withholdEose) return;
scheduleMicrotask(
() => _reply(
rejection == null ? ['EOSE', subId] : ['CLOSED', subId, rejection],
),
);
}
Future<void> _answerHistory(String subId, String? dTag) async {
await holdHistory?.future;
if (historyFailures > 0) {
historyFailures--;
return _reply(['CLOSED', subId, 'error: overloaded']);
}
// A `#t` filter reaches every slot it tags; a `#d` filter, one head.
if (dTag == null) {
for (final e in stored.where((e) => e.getTagValue('t') != null)) {
_reply(['EVENT', subId, e.toJson()]);
}
return _reply(['EOSE', subId]);
}
final heads = stored.where((e) => e.getTagValue('d') == dTag).toList()
..sort(
(a, b) => a.createdAt != b.createdAt
? b.createdAt.compareTo(a.createdAt)
: a.id.compareTo(b.id),
);
if (heads.isNotEmpty) _reply(['EVENT', subId, heads.first.toJson()]);
_reply(['EOSE', subId]);
}
void _reply(List<dynamic> message) => session.debugHandleMessage(message);
/// Null for a filter by `#t` alone, such as read state's slot discovery.
static String? _dTagOf(List<dynamic> req) =>
((req[2] as Map<String, dynamic>)['#d'] as List?)?.single as String?;
}
class ConnectedRelaySession extends RelaySessionNotifier {
@override
SessionState build() {
// Follows the active community, as the production session does.
debugRelayUrl = ref.watch(relayConfigProvider).baseUrl;
return const SessionState(status: SessionStatus.connected);
}
}
class _ScriptedSocket extends RelaySocket {
_ScriptedSocket(this._send)
: super(
wsUrl: 'wss://relay.example',
nsec: null,
onMessage: (_) {},
onConnected: () {},
onDisconnected: (_) {},
);
final void Function(List<dynamic>) _send;
@override
void send(List<dynamic> payload) => _send(payload);
@override
void dispose() {}
}
/// Preferences whose next [failures] writes return false or throw [error].
class FlakyPrefs extends Fake implements SharedPreferences {
FlakyPrefs(this._inner);
final SharedPreferences _inner;
int failures = 0;
Object? error;
@override
String? getString(String key) => _inner.getString(key);
@override
Future<bool> remove(String key) => _inner.remove(key);
@override
Future<bool> setString(String key, String value) async {
if (failures == 0) return _inner.setString(key, value);
failures--;
if (error == null) return false;
throw error!;
}
}
Future<SharedPreferences> freshPrefs() async {
SharedPreferences.setMockInitialValues({});
return SharedPreferences.getInstance();
}
int nowSeconds() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
void fakeAsyncTest(String name, void Function(FakeAsync clock) body) =>
test(name, () => fakeAsync(body));