mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
The mobile app loads user profiles to show names, avatars, and agent ownership. Some profiles include a signature that proves who owns the agent. Checking these signatures for a batch of profiles can block interaction because the checks run on the same thread that draws the screen and handles input. This change moves profile batch parsing and signature checks to a background worker. It processes one batch at a time and combines queued profile requests. It keeps the same signature checks, preserves newer profiles that arrive while a batch runs, and discards results after a community change. Individual live profile updates still use synchronous verification. Tests cover newer live updates, community changes, overlapping requests, stale malformed events, and UI timer responsiveness through the production parser. The responsiveness test fails if parsing is moved back to the UI isolate. A regression also exercises a confirmed profile save while an older batch is paused, including preservation of verified ownership. All 38 focused tests, all 2,132 mobile tests, and the analyzer pass. With 500 valid owner signatures, an AOT test reduced the main-isolate timer delay from about 3.4 seconds to less than a millisecond. Total verification time stayed similar. This measures verification work, not overall screen speed. GitHub CI passes. The full local test run failed on two timing-sensitive ACP tests in unchanged Rust code; both passed isolated retries. All other local checks passed, including the downstream tests and builds run after that failure. --------- Signed-off-by: Tom Brow <tomb@block.xyz>
802 lines
25 KiB
Dart
802 lines
25 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);
|
|
|
|
await container.read(profileProvider.future);
|
|
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);
|
|
|
|
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 => [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;
|
|
}
|