From 2cca0ec15789370d494f06192f71400afdb60c98 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 1 Oct 2026 13:48:23 -0400 Subject: [PATCH] test(mobile): cover presence ordering against delayed live events (#7526) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary This PR adds tests only. It locks in how the mobile app shows a member as Online or Offline when a live presence update arrives late. The app learns presence two ways: a periodic snapshot from the relay (the server), and live updates sent by each member's device. These use different clocks. A live update can reach the phone after a newer snapshot already counted it. Without a guard, that late update could flip a member to the wrong state until the next 60-second refresh. The guard itself already shipped in #7382 (see `presence_cache_provider.dart` on `main`). This PR adds the tests that prove it keeps working: - A late `offline` update, signed before the snapshot, does not show the member as Offline. The app asks the relay for one fresh snapshot instead. - A real change from a device whose clock runs behind the relay is not dropped. The fresh snapshot confirms it, and the member shows as Offline. - An update signed in the same second as the snapshot is not trusted directly in either direction (Online→Offline or Offline→Online). The app confirms it with a fresh snapshot. - If the same late update is delivered twice, the app asks for only one confirmation. No production code changes. No UI changes. ### Related issue Follow-up to #7382 (snapshot/live ordering, review item 2). It is the last PR in the #7382 → #7383 → #7526 stack; both parents have merged. None found beyond this stack. ### Testing The new file `mobile/test/features/profile/presence_ordering_test.dart` drives the real presence cache through a fake relay session and fake time. On this head, rebased on current `main`: `dart format` clean, `flutter analyze` clean, and the profile test suite passes (139 tests). Signed-off-by: Logan Johnson --- .../profile/presence_ordering_test.dart | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 mobile/test/features/profile/presence_ordering_test.dart diff --git a/mobile/test/features/profile/presence_ordering_test.dart b/mobile/test/features/profile/presence_ordering_test.dart new file mode 100644 index 0000000000..094b7d35c7 --- /dev/null +++ b/mobile/test/features/profile/presence_ordering_test.dart @@ -0,0 +1,203 @@ +import 'dart:async'; + +import 'package:buzz/features/profile/presence_cache_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +void main() { + void scenario(void Function(FakeAsync, ProviderContainer, _Relay) run) { + fakeAsync((time) { + final relay = _Relay(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relay), + appLifecycleProvider.overrideWith(_Lifecycle.new), + ], + ); + container.read(presenceCacheProvider.notifier).track(['alice']); + time.elapse(Duration.zero); + try { + run(time, container, relay); + } finally { + container.dispose(); + time.flushMicrotasks(); + } + }); + } + + test('delayed pre-snapshot live event cannot regress a settled snapshot', () { + scenario((time, container, relay) { + relay.results.removeAt(0).complete([ + _event('relay', 'online', subject: 'alice', timestamp: 20), + ]); + time.flushMicrotasks(); + expect(container.read(presenceCacheProvider)['alice'], 'online'); + + // Delivered after the snapshot settled but signed ten seconds before + // the relay observed it: the observation already folded it in. + relay.emit(_event('alice', 'offline', timestamp: 10)); + expect(container.read(presenceCacheProvider)['alice'], 'online'); + + time.elapse(Duration.zero); + expect(relay.queries, hasLength(2)); + expect(relay.queries.last.authors, ['alice']); + relay.results.removeAt(0).complete([ + _event('relay', 'online', subject: 'alice', timestamp: 22), + ]); + time.flushMicrotasks(); + expect(container.read(presenceCacheProvider)['alice'], 'online'); + }); + }); + + test('live event from a lagging signer clock is confirmed, not dropped', () { + scenario((time, container, relay) { + relay.results.removeAt(0).complete([ + _event('relay', 'online', subject: 'alice', timestamp: 200), + ]); + time.flushMicrotasks(); + expect(container.read(presenceCacheProvider)['alice'], 'online'); + + // Signed behind the relay clock but ingested before fan-out, so the + // fresh observation must carry its effect instead of trusting either + // clock against the other. + relay.emit(_event('alice', 'offline', timestamp: 190)); + expect(container.read(presenceCacheProvider)['alice'], 'online'); + + time.elapse(Duration.zero); + expect(relay.queries, hasLength(2)); + relay.results.removeAt(0).complete([ + _event('relay', 'offline', subject: 'alice', timestamp: 201), + ]); + time.flushMicrotasks(); + expect(container.read(presenceCacheProvider)['alice'], 'offline'); + }); + }); + + for (final settled in ['online', 'offline']) { + for (final newer in [false, true]) { + test( + 'equal-second $settled / newer=$newer requires fresh confirmation', + () { + scenario((time, container, relay) { + final transition = settled == 'online' ? 'offline' : 'online'; + relay.results.removeAt(0).complete([ + _event('relay', 'online', subject: 'alice', timestamp: 20), + ]); + time.flushMicrotasks(); + if (settled == 'offline') { + time.elapse(const Duration(seconds: 60)); + relay.results.removeAt(0).complete([]); + time.flushMicrotasks(); + } + expect(container.read(presenceCacheProvider)['alice'], settled); + relay.emit(_event('alice', transition, timestamp: 20)); + expect(container.read(presenceCacheProvider)['alice'], settled); + time.elapse(Duration.zero); + expect(relay.queries, hasLength(settled == 'online' ? 2 : 3)); + expect(relay.queries.last.authors, ['alice']); + expect(container.read(presenceCacheProvider)['alice'], settled); + // Same observation second, but this POST follows live delivery. + final confirmed = newer ? transition : settled; + relay.results.removeAt(0).complete([ + if (confirmed == 'online') + _event('relay', 'online', subject: 'alice', timestamp: 20), + ]); + time.flushMicrotasks(); + expect( + container.read(presenceCacheProvider)['alice'], + newer ? transition : settled, + ); + }); + }, + ); + } + } + + test('redelivered ambiguous event arms one confirmation', () { + scenario((time, container, relay) { + relay.results.removeAt(0).complete([ + _event('relay', 'online', subject: 'alice', timestamp: 20), + ]); + time.flushMicrotasks(); + final delayed = _event('alice', 'offline', timestamp: 10); + relay.emit(delayed); + time.elapse(Duration.zero); + relay.results.removeAt(0).complete([ + _event('relay', 'online', subject: 'alice', timestamp: 22), + ]); + time.flushMicrotasks(); + expect(container.read(presenceCacheProvider)['alice'], 'online'); + + // The same frame is redelivered after the confirmation settled; a + // bounded id memory must keep it from re-arming a refresh. + relay.emit(delayed); + time.elapse(Duration.zero); + expect(relay.queries, hasLength(2)); + expect(container.read(presenceCacheProvider)['alice'], 'online'); + }); + }); +} + +NostrEvent _event( + String author, + String status, { + String? subject, + int timestamp = 10, +}) => NostrEvent( + id: '$author-$status-$timestamp', + pubkey: author, + createdAt: timestamp, + kind: EventKind.presenceUpdate, + tags: [ + if (subject != null) ['p', subject], + ], + content: status, + sig: 'sig', +); + +class _Lifecycle extends AppLifecycleNotifier { + @override + AppLifecycleState build() => AppLifecycleState.resumed; +} + +class _Relay extends RelaySessionNotifier { + final queries = []; + final results = >>[]; + late void Function(NostrEvent) emit; + Completer? ready; + bool failSubscribe = false; + int subscriptions = 0; + int unsubscribes = 0; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future subscribeWithStatus( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String)? onClosed, + required void Function(RelaySubscriptionStatus) onStatusChanged, + }) async { + subscriptions++; + if (failSubscribe) throw StateError('unavailable'); + emit = onEvent; + onStatusChanged(RelaySubscriptionStatus.ready); + if (ready != null) await ready!.future; + return () => unsubscribes++; + } + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) { + queries.addAll(filters); + final result = Completer>(); + results.add(result); + return result.future; + } +}