mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
## Summary Returning to a previously visited community shows its saved channel list immediately, including offline, while membership and unread state refresh in the background. Initial revalidation failures keep the list visible and retry every 60 seconds without overlapping background attempts. After successful history revalidation, ordinary reconnects use metadata-only refreshes. A reconnect arriving during a background request queues one follow-up attempt; timer ticks never queue overlapping work. History deadlines retain the full-revalidation retry until history succeeds. Snapshots are disposable, versioned and scoped by canonical relay URL and signing identity, capped at 2 MB / 2,000 member channels. They include hidden-DM IDs and prefer fresher in-memory state. Community removal and identity replacement retire outstanding writes; re-adding unchanged credentials preserves the current cache and writer. Startup sweeps orphan snapshots without waiting for disk acknowledgement; community mutations also sweep orphan snapshots, including data restored without secure community records. Cache-delete failures do not block sign-out or strand push revocation; deletion is attempted before journaling and retried by orphan cleanup. Successfully persisted unchanged snapshots skip disk writes; failed writes remain retryable. The cold path reuses fetched membership rosters, overlaps profile/latest-message requests, shares verified DM profiles with avatars, and fetches the own profile once per authenticated connection. Community connection work overlaps the switching animation; avatar hydration no longer gates landing. Full channel refreshes also revalidate cached DM participant profiles so relay-side name changes reach labels and avatars; metadata-only reconnects and background refreshes reuse admitted profiles. Me-chat and invite starter recovery use an explicit fresh membership fetch that propagates errors while leaving the cached list visible. Cached layout/read preferences permit immediate landing; newer cross-device preferences may update ordering or badges after reveal. Integrated with main’s #8490 idle/resume changes: hidden chats detach live subscriptions, background membership polling stays quiet, and each resume reconciles once. Chat history follows main’s live-admission-first ordering to avoid missing messages committed during relay admission; cached community landing remains immediate. ## Validation - `just mobile-install mobile-check mobile-test`: clean analysis; 3,778 tests passed, 19 skipped; four checks for builds without push configuration passed. File-size and diff checks passed. - Provider/storage journeys cover offline startup, failed initial revalidation recovering live delivery, bounded retries, lightweight reconnects, scope isolation, default-port URLs, padded/mismatched stored credentials, credential replacement, backup-restored orphan data, removal races, failed platform deletes and failed writes. - Follow-up regressions cover interrupted native Me-chat lookup, invite failure propagation, retry after an initial list failure, history deadlines, queued reconnects, escaped-path keys, unchanged credential re-adds and non-blocking startup cleanup. Reverting each of the eight behavior/caller/cleanup-order fixes fails its corresponding regression. - DM-name regression changes only the relay profile, verifies a full refresh updates the channel label and shared profile cache, and confirms ordinary reconnects avoid another profile request. The original cache-only implementation fails this regression. - Original signed-in iPhone 18 Pro debug simulator measurements: Buzz's 413 channels went from 6.26–6.30 s to approximately 1.5 s on returning; all three warmed communities switched in 1.47–1.54 s. Real socket pause restored the cached list offline and retained it on reconnect. These are debug simulator observations, not release-device benchmarks. - Integration with current main also passes the real-session resume/background/AUTH and live-admission/detach journeys. - Follow-up failure paths were exercised with deterministic provider/storage tests and widget journeys; the running simulator was not modified during this follow-up. Human acceptance remains pending; this PR stays draft. Try switching among previously visited communities, opening a channel, restarting and repeating. First visits and oversized snapshots require network loading. Cached metadata can be stale until refresh; it lives in ordinary preferences without additional application-level encryption. The snapshot contains no message bodies or credentials. Persistent disk-deletion failures can leave metadata until a later cleanup succeeds. --------- Signed-off-by: Star Lord <b89298dbe87c6b3fd8a425b535d9d161f23c88d555a47925ae0b04dfe02b201e@buzz.block.builderlab.xyz> Co-authored-by: Star Lord <b89298dbe87c6b3fd8a425b535d9d161f23c88d555a47925ae0b04dfe02b201e@buzz.block.builderlab.xyz> Co-authored-by: Rocket <0c41329ade659e01801116549bc617351689892a382bc864a1186dc9191293a7@buzz.block.builderlab.xyz> Co-authored-by: Groot <f4c08302591916fb4b80926bf7e18d22eef8aa0c877e4e841acc46405a45700b@buzz.block.builderlab.xyz> Co-authored-by: Mantis <530af7635ef7d0e1a5189d234338db88cb4ef1ede7119b4ebb4fb05c3eafe3b1@buzz.block.builderlab.xyz>
830 lines
26 KiB
Dart
830 lines
26 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:buzz/features/profile/profile_provider.dart';
|
|
import 'package:buzz/shared/profile/profile_event_parser.dart';
|
|
import 'package:buzz/shared/profile/user_cache_provider.dart';
|
|
import 'package:buzz/shared/profile/user_profile.dart';
|
|
import 'package:buzz/shared/relay/relay.dart';
|
|
import 'package:buzz/shared/theme/theme.dart';
|
|
import 'package:buzz/shared/utils/string_utils.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
import 'package:pointycastle/digests/sha256.dart';
|
|
|
|
void main() {
|
|
test('profile updates preserve existing kind:0 metadata', () async {
|
|
final keys = nostr.Keys.generate();
|
|
final owner = nostr.Keys.generate();
|
|
final profileTags = [
|
|
_authTag(owner, keys.public),
|
|
const ['custom', 'preserve-tag'],
|
|
];
|
|
final relaySession = _ProfileRelaySession(
|
|
NostrEvent(
|
|
id: 'profile-1',
|
|
pubkey: keys.public,
|
|
createdAt: 1,
|
|
kind: EventKind.profile,
|
|
tags: profileTags,
|
|
content: jsonEncode({
|
|
'name': 'alice',
|
|
'display_name': 'Alice',
|
|
'about': 'Building Buzz',
|
|
'picture': 'https://relay.example/alice.png',
|
|
'nip05': 'alice@example.com',
|
|
'custom': 'preserve-me',
|
|
}),
|
|
sig: 'sig',
|
|
),
|
|
);
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relayConfigProvider.overrideWith(
|
|
() => _FixedRelayConfigNotifier(keys.nsec),
|
|
),
|
|
relaySessionProvider.overrideWith(() => relaySession),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
|
|
// The profile survives reconnect transitions without launching reads on
|
|
// each intermediate socket state, then revalidates once connected.
|
|
container.read(relaySessionProvider);
|
|
relaySession.setStatus(SessionStatus.disconnected);
|
|
final subscription = container.listen(profileProvider, (_, _) {});
|
|
addTearDown(subscription.close);
|
|
await container.pump();
|
|
relaySession.setStatus(SessionStatus.connecting);
|
|
await container.pump();
|
|
expect(relaySession.reads, 0);
|
|
relaySession.setStatus(SessionStatus.connected);
|
|
await container.read(profileProvider.future);
|
|
final initialReads = relaySession.reads;
|
|
relaySession.setStatus(SessionStatus.disconnected);
|
|
await container.pump();
|
|
relaySession.setStatus(SessionStatus.connecting);
|
|
await container.pump();
|
|
expect(container.read(profileProvider).requireValue?.displayName, 'Alice');
|
|
expect(relaySession.reads, initialReads);
|
|
relaySession.setStatus(SessionStatus.connected);
|
|
await container.pump();
|
|
await container.read(profileProvider.future);
|
|
expect(relaySession.reads, initialReads + 1);
|
|
await container.read(profileProvider.notifier).updateDisplayName('Alice L');
|
|
|
|
expect(relaySession.published, hasLength(1));
|
|
final content =
|
|
jsonDecode(relaySession.published.single.content)
|
|
as Map<String, dynamic>;
|
|
expect(content['display_name'], 'Alice L');
|
|
expect(content['about'], 'Building Buzz');
|
|
expect(content['picture'], 'https://relay.example/alice.png');
|
|
expect(content['nip05'], 'alice@example.com');
|
|
expect(content['custom'], 'preserve-me');
|
|
expect(relaySession.published.single.tags, profileTags);
|
|
expect(
|
|
container.read(profileProvider).requireValue?.displayName,
|
|
'Alice L',
|
|
);
|
|
expect(
|
|
container.read(profileProvider).requireValue?.ownerPubkey,
|
|
owner.public.toLowerCase(),
|
|
);
|
|
expect(
|
|
container.read(userCacheProvider)[keys.public]?.ownerPubkey,
|
|
owner.public.toLowerCase(),
|
|
);
|
|
});
|
|
|
|
test('confirmed profile save survives an older in-flight batch', () async {
|
|
final keys = nostr.Keys.generate();
|
|
final owner = nostr.Keys.generate();
|
|
NostrEvent event(String id, int createdAt, {bool owned = false}) =>
|
|
NostrEvent(
|
|
id: id,
|
|
pubkey: keys.public,
|
|
createdAt: createdAt,
|
|
kind: EventKind.profile,
|
|
tags: owned ? [_authTag(owner, keys.public)] : const [],
|
|
content: jsonEncode({
|
|
'display_name': owned ? 'Current' : 'Old relay profile',
|
|
'picture': owned ? 'https://example.com/new.png' : 'old.png',
|
|
'about': owned ? 'Current about' : 'Old about',
|
|
'nip05': owned ? 'agent@example.com' : 'old@example.com',
|
|
}),
|
|
sig: 'sig',
|
|
);
|
|
var history = [event('old', 1)];
|
|
final relaySession = _ControlledProfileRelaySession(
|
|
fetch: () async => history,
|
|
);
|
|
final parserStarted = Completer<void>();
|
|
final releaseParser = Completer<void>();
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relayConfigProvider.overrideWith(
|
|
() => _FixedRelayConfigNotifier(keys.nsec),
|
|
),
|
|
relaySessionProvider.overrideWith(() => relaySession),
|
|
profileEventBatchParserProvider.overrideWithValue((events) async {
|
|
final parsed = await parseProfileEventBatch(events);
|
|
parserStarted.complete();
|
|
await releaseParser.future;
|
|
return parsed;
|
|
}),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(profileProvider.future);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
final refresh = cache.refresh([keys.public]);
|
|
await parserStarted.future;
|
|
history = [event('current', 2, owned: true)];
|
|
await container
|
|
.read(profileProvider.notifier)
|
|
.updateDisplayName('Saved locally');
|
|
final saved = container.read(userCacheProvider)[keys.public]!;
|
|
expect(saved.displayName, 'Saved locally');
|
|
expect(saved.avatarUrl, 'https://example.com/new.png');
|
|
expect(saved.about, 'Current about');
|
|
expect(saved.nip05Handle, 'agent@example.com');
|
|
expect(saved.ownerPubkey, owner.public.toLowerCase());
|
|
expect(relaySession.published, hasLength(1));
|
|
|
|
releaseParser.complete();
|
|
expect(await refresh, isTrue);
|
|
final afterBatch = container.read(userCacheProvider)[keys.public]!;
|
|
expect(afterBatch.displayName, 'Saved locally');
|
|
expect(afterBatch.avatarUrl, saved.avatarUrl);
|
|
expect(afterBatch.about, saved.about);
|
|
expect(afterBatch.nip05Handle, saved.nip05Handle);
|
|
expect(afterBatch.ownerPubkey, owner.public.toLowerCase());
|
|
expect(
|
|
container.read(profileProvider).requireValue?.ownerPubkey,
|
|
owner.public.toLowerCase(),
|
|
);
|
|
});
|
|
|
|
test('clearing a display name restores the pubkey label fallback', () async {
|
|
final keys = nostr.Keys.generate();
|
|
final relaySession = _ProfileRelaySession(
|
|
NostrEvent(
|
|
id: 'profile-1',
|
|
pubkey: keys.public,
|
|
createdAt: 1,
|
|
kind: EventKind.profile,
|
|
tags: const [],
|
|
content: jsonEncode({
|
|
'name': 'legacy-alice',
|
|
'display_name': 'Alice',
|
|
'about': 'Building Buzz',
|
|
}),
|
|
sig: 'sig',
|
|
),
|
|
);
|
|
final container = _profileContainer(keys.nsec, relaySession);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(profileProvider.future);
|
|
await container.read(profileProvider.notifier).updateDisplayName(' ');
|
|
|
|
final content =
|
|
jsonDecode(relaySession.published.single.content)
|
|
as Map<String, dynamic>;
|
|
expect(content, {'about': 'Building Buzz'});
|
|
final profile = container.read(profileProvider).requireValue!;
|
|
expect(profile.displayName, isNull);
|
|
// The exact compact label is pinned; its npub shape is covered by the
|
|
// shared string_utils vectors.
|
|
expect(profile.label, shortPubkey(keys.public));
|
|
});
|
|
|
|
test('malformed profile metadata can be repaired by an edit', () async {
|
|
final keys = nostr.Keys.generate();
|
|
final relaySession = _ProfileRelaySession(
|
|
NostrEvent(
|
|
id: 'profile-malformed',
|
|
pubkey: keys.public,
|
|
createdAt: 1,
|
|
kind: EventKind.profile,
|
|
tags: const [],
|
|
content: 'not-json',
|
|
sig: 'sig',
|
|
),
|
|
);
|
|
final container = _profileContainer(keys.nsec, relaySession);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(await container.read(profileProvider.future), isNotNull);
|
|
await container.read(profileProvider.notifier).updateAbout('Repaired');
|
|
|
|
expect(jsonDecode(relaySession.published.single.content), {
|
|
'about': 'Repaired',
|
|
});
|
|
});
|
|
|
|
test('profile updates fail closed while hydration is pending', () async {
|
|
final keys = nostr.Keys.generate();
|
|
final history = Completer<List<NostrEvent>>();
|
|
final relaySession = _ControlledProfileRelaySession(
|
|
fetch: () => history.future,
|
|
);
|
|
final container = _profileContainer(keys.nsec, relaySession);
|
|
addTearDown(container.dispose);
|
|
|
|
container.read(profileProvider);
|
|
await expectLater(
|
|
container.read(profileProvider.notifier).updateDisplayName('Unsafe'),
|
|
throwsStateError,
|
|
);
|
|
expect(relaySession.published, isEmpty);
|
|
history.complete(const []);
|
|
});
|
|
|
|
test('profile updates fail closed after hydration errors', () async {
|
|
final keys = nostr.Keys.generate();
|
|
final relaySession = _ControlledProfileRelaySession(
|
|
fetch: () async => throw Exception('relay unavailable'),
|
|
);
|
|
final container = _profileContainer(keys.nsec, relaySession);
|
|
addTearDown(container.dispose);
|
|
|
|
container.read(profileProvider);
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(container.read(profileProvider).hasError, isTrue);
|
|
await expectLater(
|
|
container.read(profileProvider.notifier).updateAbout('Unsafe'),
|
|
throwsStateError,
|
|
);
|
|
expect(relaySession.published, isEmpty);
|
|
});
|
|
|
|
test('a confirmed empty history can publish a new profile', () async {
|
|
final keys = nostr.Keys.generate();
|
|
final relaySession = _ControlledProfileRelaySession(fetch: () async => []);
|
|
final container = _profileContainer(keys.nsec, relaySession);
|
|
addTearDown(container.dispose);
|
|
|
|
expect(await container.read(profileProvider.future), isNull);
|
|
await container.read(profileProvider.notifier).updateDisplayName('Alice');
|
|
|
|
expect(relaySession.published, hasLength(1));
|
|
expect(jsonDecode(relaySession.published.single.content), {
|
|
'display_name': 'Alice',
|
|
});
|
|
});
|
|
|
|
test('same-second profile replacements use monotonic timestamps', () async {
|
|
final keys = nostr.Keys.generate();
|
|
final futureTimestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 60;
|
|
final relaySession = _ControlledProfileRelaySession(
|
|
fetch: () async => [
|
|
NostrEvent(
|
|
id: 'profile-future',
|
|
pubkey: keys.public,
|
|
createdAt: futureTimestamp,
|
|
kind: EventKind.profile,
|
|
tags: const [],
|
|
content: '{}',
|
|
sig: 'sig',
|
|
),
|
|
],
|
|
);
|
|
final container = _profileContainer(keys.nsec, relaySession);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(profileProvider.future);
|
|
await container.read(profileProvider.notifier).updateDisplayName('Alice');
|
|
await container.read(profileProvider.notifier).updateAbout('Hello');
|
|
|
|
expect(relaySession.published.map((event) => event.createdAt), [
|
|
futureTimestamp + 1,
|
|
futureTimestamp + 2,
|
|
]);
|
|
});
|
|
|
|
test(
|
|
'profile updates merge the current relay head before publishing',
|
|
() async {
|
|
final keys = nostr.Keys.generate();
|
|
var history = [
|
|
NostrEvent(
|
|
id: 'profile-initial',
|
|
pubkey: keys.public,
|
|
createdAt: 10,
|
|
kind: EventKind.profile,
|
|
tags: const [],
|
|
content: jsonEncode({
|
|
'display_name': 'Initial',
|
|
'about': 'Initial about',
|
|
'custom': 'initial',
|
|
}),
|
|
sig: 'sig',
|
|
),
|
|
];
|
|
final relaySession = _ControlledProfileRelaySession(
|
|
fetch: () async => history,
|
|
);
|
|
final container = _profileContainer(keys.nsec, relaySession);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(profileProvider.future);
|
|
history = [
|
|
NostrEvent(
|
|
id: 'profile-remote',
|
|
pubkey: keys.public,
|
|
createdAt: 20,
|
|
kind: EventKind.profile,
|
|
tags: const [],
|
|
content: jsonEncode({
|
|
'display_name': 'Remote',
|
|
'about': 'Remote about',
|
|
'custom': 'remote',
|
|
}),
|
|
sig: 'sig',
|
|
),
|
|
];
|
|
|
|
await container
|
|
.read(profileProvider.notifier)
|
|
.updateDisplayName('Mobile');
|
|
|
|
final content =
|
|
jsonDecode(relaySession.published.single.content)
|
|
as Map<String, dynamic>;
|
|
expect(content, {
|
|
'display_name': 'Mobile',
|
|
'about': 'Remote about',
|
|
'custom': 'remote',
|
|
});
|
|
expect(relaySession.published.single.createdAt, greaterThan(20));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'a competing profile head does not become optimistic local state',
|
|
() async {
|
|
final keys = nostr.Keys.generate();
|
|
final relaySession = _LosingProfileRelaySession(
|
|
NostrEvent(
|
|
id: 'profile-initial',
|
|
pubkey: keys.public,
|
|
createdAt: 10,
|
|
kind: EventKind.profile,
|
|
tags: const [],
|
|
content: jsonEncode({'display_name': 'Initial'}),
|
|
sig: 'sig',
|
|
),
|
|
);
|
|
final container = _profileContainer(keys.nsec, relaySession);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(profileProvider.future);
|
|
|
|
await expectLater(
|
|
container.read(profileProvider.notifier).updateDisplayName('Mobile'),
|
|
throwsStateError,
|
|
);
|
|
expect(relaySession.published, hasLength(1));
|
|
expect(
|
|
container.read(profileProvider).requireValue?.displayName,
|
|
'Initial',
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'overlapping profile updates serialize their full merge cycles',
|
|
() async {
|
|
final keys = nostr.Keys.generate();
|
|
final relaySession = _ControlledProfileRelaySession(
|
|
fetch: () async => [
|
|
NostrEvent(
|
|
id: 'profile-initial',
|
|
pubkey: keys.public,
|
|
createdAt: 10,
|
|
kind: EventKind.profile,
|
|
tags: const [],
|
|
content: jsonEncode({
|
|
'display_name': 'Initial',
|
|
'about': 'Initial about',
|
|
}),
|
|
sig: 'sig',
|
|
),
|
|
],
|
|
);
|
|
final container = _profileContainer(keys.nsec, relaySession);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(profileProvider.future);
|
|
await Future.wait([
|
|
container.read(profileProvider.notifier).updateDisplayName('Mobile'),
|
|
container.read(profileProvider.notifier).updateAbout('Mobile about'),
|
|
]);
|
|
|
|
expect(relaySession.published, hasLength(2));
|
|
expect(jsonDecode(relaySession.published.last.content), {
|
|
'display_name': 'Mobile',
|
|
'about': 'Mobile about',
|
|
});
|
|
expect(
|
|
relaySession.published.last.createdAt,
|
|
greaterThan(relaySession.published.first.createdAt),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('profile updates abort when the active community changes', () async {
|
|
final keys = nostr.Keys.generate();
|
|
final otherKeys = nostr.Keys.generate();
|
|
final initial = NostrEvent(
|
|
id: 'profile-initial',
|
|
pubkey: keys.public,
|
|
createdAt: 10,
|
|
kind: EventKind.profile,
|
|
tags: const [],
|
|
content: jsonEncode({'display_name': 'Initial'}),
|
|
sig: 'sig',
|
|
);
|
|
final patchFetchStarted = Completer<void>();
|
|
final patchHistory = Completer<List<NostrEvent>>();
|
|
var fetchCount = 0;
|
|
final relaySession = _ControlledProfileRelaySession(
|
|
fetch: () async {
|
|
fetchCount += 1;
|
|
if (fetchCount == 1) return [initial];
|
|
if (!patchFetchStarted.isCompleted) patchFetchStarted.complete();
|
|
return patchHistory.future;
|
|
},
|
|
);
|
|
final config = _MutableRelayConfigNotifier(keys.nsec);
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relayConfigProvider.overrideWith(() => config),
|
|
relaySessionProvider.overrideWith(() => relaySession),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(profileProvider.future);
|
|
final update = container
|
|
.read(profileProvider.notifier)
|
|
.updateDisplayName('Mobile');
|
|
await patchFetchStarted.future;
|
|
config.update(baseUrl: 'https://other-relay.example', nsec: otherKeys.nsec);
|
|
patchHistory.complete([initial]);
|
|
|
|
await expectLater(update, throwsStateError);
|
|
expect(relaySession.published, isEmpty);
|
|
});
|
|
|
|
test(
|
|
'queued profile updates report a community change before rehydration',
|
|
() async {
|
|
final keys = nostr.Keys.generate();
|
|
final otherKeys = nostr.Keys.generate();
|
|
final initial = NostrEvent(
|
|
id: 'profile-initial',
|
|
pubkey: keys.public,
|
|
createdAt: 10,
|
|
kind: EventKind.profile,
|
|
tags: const [],
|
|
content: jsonEncode({'display_name': 'Initial'}),
|
|
sig: 'sig',
|
|
);
|
|
final patchFetchStarted = Completer<void>();
|
|
final patchHistory = Completer<List<NostrEvent>>();
|
|
final rehydration = Completer<List<NostrEvent>>();
|
|
var fetchCount = 0;
|
|
final relaySession = _ControlledProfileRelaySession(
|
|
fetch: () async {
|
|
fetchCount += 1;
|
|
if (fetchCount == 1) return [initial];
|
|
if (fetchCount == 2) {
|
|
patchFetchStarted.complete();
|
|
return patchHistory.future;
|
|
}
|
|
return rehydration.future;
|
|
},
|
|
);
|
|
final config = _MutableRelayConfigNotifier(keys.nsec);
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relayConfigProvider.overrideWith(() => config),
|
|
relaySessionProvider.overrideWith(() => relaySession),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
|
|
await container.read(profileProvider.future);
|
|
final firstUpdate = container
|
|
.read(profileProvider.notifier)
|
|
.updateDisplayName('First');
|
|
await patchFetchStarted.future;
|
|
final queuedUpdate = container
|
|
.read(profileProvider.notifier)
|
|
.updateAbout('Queued');
|
|
config.update(
|
|
baseUrl: 'https://other-relay.example',
|
|
nsec: otherKeys.nsec,
|
|
);
|
|
await Future<void>.delayed(Duration.zero);
|
|
patchHistory.complete([initial]);
|
|
|
|
await expectLater(
|
|
firstUpdate,
|
|
throwsA(isA<ProfileCommunityChangedException>()),
|
|
);
|
|
await expectLater(
|
|
queuedUpdate,
|
|
throwsA(isA<ProfileCommunityChangedException>()),
|
|
);
|
|
expect(relaySession.published, isEmpty);
|
|
rehydration.complete(const []);
|
|
},
|
|
);
|
|
|
|
test('stale hydration cannot overwrite the active community head', () async {
|
|
final keys = nostr.Keys.generate();
|
|
final otherKeys = nostr.Keys.generate();
|
|
final oldFetchStarted = Completer<void>();
|
|
final oldHistory = Completer<List<NostrEvent>>();
|
|
final activeProfile = NostrEvent(
|
|
id: 'profile-active',
|
|
pubkey: otherKeys.public,
|
|
createdAt: 20,
|
|
kind: EventKind.profile,
|
|
tags: const [],
|
|
content: jsonEncode({'display_name': 'Active'}),
|
|
sig: 'sig',
|
|
);
|
|
var fetchCount = 0;
|
|
final relaySession = _ControlledProfileRelaySession(
|
|
fetch: () async {
|
|
fetchCount += 1;
|
|
if (fetchCount == 1) {
|
|
oldFetchStarted.complete();
|
|
return oldHistory.future;
|
|
}
|
|
return [activeProfile];
|
|
},
|
|
);
|
|
final config = _MutableRelayConfigNotifier(keys.nsec);
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relayConfigProvider.overrideWith(() => config),
|
|
relaySessionProvider.overrideWith(() => relaySession),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
|
|
container.read(profileProvider);
|
|
await oldFetchStarted.future;
|
|
config.update(baseUrl: 'https://other-relay.example', nsec: otherKeys.nsec);
|
|
expect(
|
|
(await container.read(profileProvider.future))?.displayName,
|
|
'Active',
|
|
);
|
|
oldHistory.complete([
|
|
NostrEvent(
|
|
id: 'profile-stale',
|
|
pubkey: keys.public,
|
|
createdAt: 100,
|
|
kind: EventKind.profile,
|
|
tags: const [],
|
|
content: jsonEncode({'display_name': 'Stale'}),
|
|
sig: 'sig',
|
|
),
|
|
]);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
await container.read(profileProvider.notifier).updateAbout('Active about');
|
|
|
|
expect(relaySession.published, hasLength(1));
|
|
expect(jsonDecode(relaySession.published.single.content), {
|
|
'display_name': 'Active',
|
|
'about': 'Active about',
|
|
});
|
|
});
|
|
|
|
test(
|
|
'manual presence persists until Online restores automatic mode',
|
|
() async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final prefs = await SharedPreferences.getInstance();
|
|
var container = _buildContainer(prefs);
|
|
|
|
expect(
|
|
await container
|
|
.read(presenceProvider.future)
|
|
.timeout(
|
|
const Duration(seconds: 2),
|
|
onTimeout: () =>
|
|
throw StateError('initial presence did not resolve'),
|
|
),
|
|
'online',
|
|
);
|
|
await container
|
|
.read(presenceProvider.notifier)
|
|
.setPresence('away')
|
|
.timeout(
|
|
const Duration(seconds: 2),
|
|
onTimeout: () => throw StateError('setting Away did not resolve'),
|
|
);
|
|
expect(container.read(presenceProvider).value, 'away');
|
|
expect(prefs.getString('buzz_presence_preference_aabb'), 'away');
|
|
|
|
container.dispose();
|
|
container = _buildContainer(prefs);
|
|
addTearDown(container.dispose);
|
|
expect(
|
|
await container
|
|
.read(presenceProvider.future)
|
|
.timeout(
|
|
const Duration(seconds: 2),
|
|
onTimeout: () =>
|
|
throw StateError('stored presence did not resolve'),
|
|
),
|
|
'away',
|
|
);
|
|
|
|
await container
|
|
.read(presenceProvider.notifier)
|
|
.setPresence('online')
|
|
.timeout(
|
|
const Duration(seconds: 2),
|
|
onTimeout: () => throw StateError('setting Online did not resolve'),
|
|
);
|
|
expect(container.read(presenceProvider).value, 'online');
|
|
expect(prefs.getString('buzz_presence_preference_aabb'), 'auto');
|
|
},
|
|
);
|
|
}
|
|
|
|
List<String> _authTag(nostr.Keys owner, String agentPubkey) {
|
|
final digest = SHA256Digest().process(
|
|
Uint8List.fromList(utf8.encode('nostr:agent-auth:$agentPubkey:')),
|
|
);
|
|
final message = digest.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
|
return [
|
|
'auth',
|
|
owner.public,
|
|
'',
|
|
nostr.Schnorr.sign(secretKey: owner.secret, message: message),
|
|
];
|
|
}
|
|
|
|
class _FixedRelayConfigNotifier extends RelayConfigNotifier {
|
|
_FixedRelayConfigNotifier(this.nsec);
|
|
|
|
final String nsec;
|
|
|
|
@override
|
|
RelayConfig build() =>
|
|
RelayConfig(baseUrl: 'https://relay.example', nsec: nsec);
|
|
}
|
|
|
|
class _MutableRelayConfigNotifier extends RelayConfigNotifier {
|
|
_MutableRelayConfigNotifier(this.initialNsec);
|
|
|
|
final String initialNsec;
|
|
|
|
@override
|
|
RelayConfig build() =>
|
|
RelayConfig(baseUrl: 'https://relay.example', nsec: initialNsec);
|
|
}
|
|
|
|
class _ProfileRelaySession extends RelaySessionNotifier {
|
|
_ProfileRelaySession(this.profile);
|
|
|
|
int reads = 0;
|
|
void setStatus(SessionStatus status) => state = SessionState(status: status);
|
|
|
|
final NostrEvent profile;
|
|
final List<NostrEvent> published = [];
|
|
|
|
@override
|
|
SessionState build() => const SessionState(status: SessionStatus.connected);
|
|
|
|
@override
|
|
Future<List<NostrEvent>> fetchHistory(
|
|
NostrFilter filter, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async {
|
|
reads++;
|
|
return [profile, ...published];
|
|
}
|
|
|
|
@override
|
|
Future<NostrEvent> publish(
|
|
NostrEvent event, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async {
|
|
published.add(event);
|
|
return event;
|
|
}
|
|
}
|
|
|
|
ProviderContainer _profileContainer(
|
|
String nsec,
|
|
RelaySessionNotifier relaySession,
|
|
) => ProviderContainer(
|
|
overrides: [
|
|
relayConfigProvider.overrideWith(() => _FixedRelayConfigNotifier(nsec)),
|
|
relaySessionProvider.overrideWith(() => relaySession),
|
|
],
|
|
);
|
|
|
|
class _ControlledProfileRelaySession extends RelaySessionNotifier {
|
|
_ControlledProfileRelaySession({required this.fetch});
|
|
|
|
final Future<List<NostrEvent>> Function() fetch;
|
|
final List<NostrEvent> published = [];
|
|
|
|
@override
|
|
SessionState build() => const SessionState(status: SessionStatus.connected);
|
|
|
|
@override
|
|
Future<List<NostrEvent>> fetchHistory(
|
|
NostrFilter filter, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async => [...await fetch(), ...published];
|
|
|
|
@override
|
|
Future<NostrEvent> publish(
|
|
NostrEvent event, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async {
|
|
published.add(event);
|
|
return event;
|
|
}
|
|
}
|
|
|
|
class _LosingProfileRelaySession extends RelaySessionNotifier {
|
|
_LosingProfileRelaySession(this.initial);
|
|
|
|
final NostrEvent initial;
|
|
final List<NostrEvent> published = [];
|
|
NostrEvent? competing;
|
|
|
|
@override
|
|
SessionState build() => const SessionState(status: SessionStatus.connected);
|
|
|
|
@override
|
|
Future<List<NostrEvent>> fetchHistory(
|
|
NostrFilter filter, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async => [competing ?? initial];
|
|
|
|
@override
|
|
Future<NostrEvent> publish(
|
|
NostrEvent event, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async {
|
|
published.add(event);
|
|
competing = NostrEvent(
|
|
id: 'profile-competing',
|
|
pubkey: initial.pubkey,
|
|
createdAt: event.createdAt + 1,
|
|
kind: EventKind.profile,
|
|
tags: const [],
|
|
content: jsonEncode({'display_name': 'Remote'}),
|
|
sig: 'sig',
|
|
);
|
|
return event;
|
|
}
|
|
}
|
|
|
|
ProviderContainer _buildContainer(SharedPreferences prefs) => ProviderContainer(
|
|
overrides: [
|
|
savedPrefsProvider.overrideWithValue(prefs),
|
|
myPubkeyProvider.overrideWithValue('aabb'),
|
|
profileProvider.overrideWith(_FakeProfileNotifier.new),
|
|
relaySessionProvider.overrideWith(_DisconnectedRelaySession.new),
|
|
appLifecycleProvider.overrideWith(_ResumedLifecycle.new),
|
|
],
|
|
);
|
|
|
|
class _FakeProfileNotifier extends ProfileNotifier {
|
|
@override
|
|
Future<UserProfile?> build() async =>
|
|
const UserProfile(pubkey: 'aabb', displayName: 'Test');
|
|
}
|
|
|
|
class _DisconnectedRelaySession extends RelaySessionNotifier {
|
|
@override
|
|
SessionState build() =>
|
|
const SessionState(status: SessionStatus.disconnected);
|
|
}
|
|
|
|
class _ResumedLifecycle extends AppLifecycleNotifier {
|
|
@override
|
|
AppLifecycleState build() => AppLifecycleState.resumed;
|
|
}
|