Files
buzz/mobile/test/features/profile/presence_cache_provider_test.dart
Logan Johnson 965fe1dffa fix(mobile): hydrate and refresh relay presence snapshots (#7382)
<!-- Draft PR body for #7382 · fix(mobile): hydrate and refresh relay
presence snapshots · https://github.com/block/buzz/pull/7382 -->

🤖

## Summary

Mobile showed every person in a DM as **Offline** until they happened to
publish a presence update while the app was connected. Presence was
never loaded when the app opened, never refreshed, and a missing or
failed read looked identical to actually being offline. This PR makes
presence load and stay current: on connect or foreground, the app
fetches the relay's presence snapshot for everyone visible, keeps the
live subscription open while it does, and re-checks every 60 seconds
while the app is in the foreground. A missing or failed read shows
**Unknown** (no dot), not Offline.

- Snapshot reads are authenticated like the rest of the session and
batched by exact key (at most 100 per batch), instead of relying on live
events only.
- The live subscription opens before the snapshot, so updates published
during the read are not dropped. Per-key revisions fence stale **query
completions**: a snapshot that finishes after newer live evidence for
that key does not overwrite it.
- Refreshing every 60 foreground seconds observes relay lease expiry and
retry failures; missing or failed evidence simply stays absent — it is
never reported as Offline.
- Disconnects, backgrounding, and account/community changes reset the
cache cleanly. The app's existing subscription/recovery machinery is
reused rather than duplicated.

Mirrors Desktop's presence hydration
(`desktop/src-tauri/src/commands/profile.rs::get_presence`,
`desktop/src/features/presence/hooks.ts`).

### Presence contract: best effort between polls

Presence is a best-effort estimate that the next foreground poll
corrects. This PR does not give strict causal order between snapshots
and live events:

- Snapshots carry relay time. Live kind:20001 events carry the signer's
time. These clocks are not comparable.
- A live event at or below a snapshot's relay second is not applied
directly. The client confirms it with a fresh snapshot. This works
because the relay persists presence before fan-out.
- An empty (all-Offline) snapshot carries no relay second. If the relay
stored an Online event, then its fan-out was delayed past lease expiry
and an empty snapshot, the delayed event can show Online/Away until the
next 60-second poll. Base `main` has the same stale-delivery exposure
with no correction at all.

Strict ordering needs relay-issued ordering metadata on both paths, or a
fresh confirmation for every live event. Both are protocol or latency
tradeoffs and are out of scope here.

### Related issue

- Fixes: N/A. Closest open duplicates: #4417 (larger presence bootstrap
+ UI patch), #5617. Related: #6200, #7295.
- First slice of the mobile remote-agent parity port. This PR includes
the Unknown rendering at all three consumers (DM header, channel tile,
profile sheet). #7383 and #7526 stack on it and add test coverage only.

### Testing

- Rebased on `main` `95b018c87` (2026-09-30). At stack tip `7c8a93081`:
`just mobile-check` clean; full `just mobile-test` **2,593 passed**.
Earlier receipts below apply to the pre-rebase head.

- Regressions cover cold-open hydration, live/snapshot races, lease
expiry, failed reads and retries, disconnect/background/scope changes,
and >100-key batching.
- At the branch head: `just mobile-check` and the full mobile test suite
pass independently, and every broader CI gate has successful evidence
after isolated recovery runs (no monolithic local `just ci` run) —
receipts in the [exact-head evidence
comment](https://github.com/block/buzz/pull/7382#issuecomment-5573112882).
- Verification is automated-test level; no native device or simulator
run is claimed. No layout change in this slice.

### Screenshots

No layout change in this slice — the renders below show the same
existing profile sheet, with the presence status now following hydrated
relay data. Flutter production-widget test renders — not native-device
screenshots or acceptance captures.

| Scenario | Before | After |
|---|---|---|
| Existing profile opened cold, with a signed relay snapshot saying
Online | ![Before: the profile stays Offline — the snapshot is never
fetched](https://github.com/user-attachments/assets/4b4899c8-364c-4621-8d59-949189860eb3)
| ![After: the profile shows Online once the snapshot
hydrates](https://github.com/user-attachments/assets/7b1a9ecd-a7a8-4e04-b007-b86565a39cfc)
|

<details>
<summary>Capture provenance</summary>

Rendered by the Flutter widget engine in a `flutter test` run
(production widgets, production theme; no device or simulator). Before:
this PR's declared base `3c7f288c60d67df78577b237e27c3dfc8831aaa1`.
After: its head `a8e8028bfb9bb67de2db03ed448839e2f3ab2b0d`.

</details>

Signed-off-by: Logan Johnson <loganj@squareup.com>
2026-09-30 20:10:24 -04:00

196 lines
6.6 KiB
Dart

import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/features/profile/presence_cache_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
/// Live-stream behavior alongside the authenticated snapshot backstop.
void main() {
test('WS presence event updates cache for tracked pubkey', () async {
final relaySession = _RecordingRelaySessionNotifier();
final container = _buildContainer(relaySession: relaySession);
addTearDown(container.dispose);
// Initialize the notifier (triggers build → subscribes to WS).
container.read(presenceCacheProvider);
await _pumpEventQueue();
// Track alice, then emit her initial 'online' status.
container.read(presenceCacheProvider.notifier).track(['alice']);
relaySession.emit(_presence('alice', 'online'));
expect(container.read(presenceCacheProvider)['alice'], 'online');
// Simulate a WS presence event: alice goes away.
relaySession.emit(_presence('alice', 'away'));
expect(container.read(presenceCacheProvider)['alice'], 'away');
});
test('WS presence event ignores untracked pubkeys', () async {
final relaySession = _RecordingRelaySessionNotifier();
final container = _buildContainer(relaySession: relaySession);
addTearDown(container.dispose);
container.read(presenceCacheProvider);
await _pumpEventQueue();
// Track only alice.
container.read(presenceCacheProvider.notifier).track(['alice']);
// Emit event for bob (untracked).
relaySession.emit(_presence('bob', 'online'));
// Bob should NOT appear in the cache.
expect(container.read(presenceCacheProvider).containsKey('bob'), isFalse);
});
test('WS presence event ignores invalid status values', () async {
final relaySession = _RecordingRelaySessionNotifier();
final container = _buildContainer(relaySession: relaySession);
addTearDown(container.dispose);
container.read(presenceCacheProvider);
await _pumpEventQueue();
container.read(presenceCacheProvider.notifier).track(['alice']);
relaySession.emit(_presence('alice', 'online'));
expect(container.read(presenceCacheProvider)['alice'], 'online');
// Emit event with garbage status — should be rejected.
relaySession.emit(_presence('alice', 'garbage-status'));
// Status should remain 'online'.
expect(container.read(presenceCacheProvider)['alice'], 'online');
});
test('WS presence event skips no-op updates', () async {
final relaySession = _RecordingRelaySessionNotifier();
final container = _buildContainer(relaySession: relaySession);
addTearDown(container.dispose);
container.read(presenceCacheProvider);
await _pumpEventQueue();
container.read(presenceCacheProvider.notifier).track(['alice']);
relaySession.emit(_presence('alice', 'online'));
// Listen for state changes after initial setup.
var stateChangeCount = 0;
container.listen(presenceCacheProvider, (prev, next) => stateChangeCount++);
// Emit event with same status as current.
relaySession.emit(_presence('alice', 'online'));
// No state change should occur — it's a no-op.
expect(stateChangeCount, 0);
});
test('subscribes to kind:20001 with limit 0', () async {
final relaySession = _RecordingRelaySessionNotifier();
final container = _buildContainer(relaySession: relaySession);
addTearDown(container.dispose);
container.read(presenceCacheProvider);
await _pumpEventQueue();
// Should have subscribed with the correct filter.
expect(relaySession.filters, hasLength(1));
expect(relaySession.filters.single.kinds, [EventKind.presenceUpdate]);
expect(relaySession.filters.single.limit, 0);
});
test('WS event uses pubkey variable, not literal string', () async {
// Regression test for the map key bug where `{...state, pubkey: status}`
// used the literal string "pubkey" instead of the variable's value.
final relaySession = _RecordingRelaySessionNotifier();
final container = _buildContainer(relaySession: relaySession);
addTearDown(container.dispose);
container.read(presenceCacheProvider);
await _pumpEventQueue();
container.read(presenceCacheProvider.notifier).track([
'deadbeef',
'cafebabe',
]);
// Seed cafebabe -> offline, then set deadbeef online.
relaySession.emit(_presence('cafebabe', 'offline'));
relaySession.emit(_presence('deadbeef', 'online'));
final cache = container.read(presenceCacheProvider);
// deadbeef should be online (the actual pubkey, not a literal "pubkey" key).
expect(cache['deadbeef'], 'online');
// cafebabe should still be offline (not clobbered).
expect(cache['cafebabe'], 'offline');
// There should be no literal "pubkey" key in the map.
expect(cache.containsKey('pubkey'), isFalse);
});
}
NostrEvent _presence(String pubkey, String status) => NostrEvent(
id: 'evt-$pubkey-$status',
pubkey: pubkey,
createdAt: 1000,
kind: EventKind.presenceUpdate,
tags: const [],
content: status,
sig: 'sig',
);
Future<void> _pumpEventQueue() async {
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
}
ProviderContainer _buildContainer({
required _RecordingRelaySessionNotifier relaySession,
}) {
return ProviderContainer(
overrides: [
appLifecycleProvider.overrideWith(() => _FakeAppLifecycleNotifier()),
relaySessionProvider.overrideWith(() => relaySession),
],
);
}
class _RecordingRelaySessionNotifier extends RelaySessionNotifier {
final List<NostrFilter> filters = [];
final List<void Function(NostrEvent)> _listeners = [];
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<void Function()> subscribeWithStatus(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
required void Function(RelaySubscriptionStatus) onStatusChanged,
}) async {
filters.add(filter);
_listeners.add(onEvent);
return () {
filters.remove(filter);
_listeners.remove(onEvent);
};
}
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async => [];
/// Emit an event synchronously to all live subscribers.
void emit(NostrEvent event) {
for (final listener in List.of(_listeners)) {
listener(event);
}
}
}
class _FakeAppLifecycleNotifier extends AppLifecycleNotifier {
@override
AppLifecycleState build() => AppLifecycleState.resumed;
}