mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +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>
521 lines
17 KiB
Dart
521 lines
17 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:buzz/shared/profile/user_cache_provider.dart';
|
|
import 'package:buzz/shared/profile/profile_event_parser.dart';
|
|
import 'package:buzz/shared/relay/relay.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
import 'package:pointycastle/digests/sha256.dart';
|
|
|
|
void main() {
|
|
test('preload reports a profile batch failure', () async {
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relaySessionProvider.overrideWith(_FailingProfileSession.new),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
|
|
final succeeded = await container.read(userCacheProvider.notifier).preload(
|
|
const ['agent'],
|
|
);
|
|
|
|
expect(succeeded, isFalse);
|
|
});
|
|
|
|
test('refresh queries profiles that are already cached', () async {
|
|
final session = _RecordingProfileSession();
|
|
final container = ProviderContainer(
|
|
overrides: [relaySessionProvider.overrideWith(() => session)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
cache.cacheProfileEvent(
|
|
_profileEvent(id: 'cached-profile', createdAt: 1, name: 'Cached Human'),
|
|
);
|
|
|
|
final succeeded = await cache.refresh(const ['AGENT']);
|
|
|
|
expect(succeeded, isTrue);
|
|
expect(session.requestedFilter?.kinds, const [0]);
|
|
expect(session.requestedFilter?.authors, const ['agent']);
|
|
expect(session.requestedFilter?.limit, 1);
|
|
});
|
|
|
|
test('older refresh cannot overwrite a newer live profile', () async {
|
|
final refreshCompleter = Completer<List<NostrEvent>>();
|
|
final session = _RecordingProfileSession(result: refreshCompleter.future);
|
|
final container = ProviderContainer(
|
|
overrides: [relaySessionProvider.overrideWith(() => session)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
final owner = nostr.Keys.generate();
|
|
final agent = nostr.Keys.generate();
|
|
final refresh = cache.refresh([agent.public]);
|
|
|
|
cache.cacheProfileEvent(
|
|
_profileEvent(
|
|
id: 'newer-agent',
|
|
pubkey: agent.public,
|
|
createdAt: 2,
|
|
name: 'Agent',
|
|
tags: [_authTag(owner, agent.public)],
|
|
),
|
|
);
|
|
refreshCompleter.complete([
|
|
_profileEvent(
|
|
id: 'older-human',
|
|
pubkey: agent.public,
|
|
createdAt: 1,
|
|
name: 'Human',
|
|
),
|
|
]);
|
|
|
|
expect(await refresh, isTrue);
|
|
expect(cache.state[agent.public]?.displayName, 'Agent');
|
|
expect(cache.state[agent.public]?.ownerPubkey, owner.public);
|
|
});
|
|
|
|
test('newer refresh can remove obsolete owner attribution', () async {
|
|
final owner = nostr.Keys.generate();
|
|
final agent = nostr.Keys.generate();
|
|
final session = _RecordingProfileSession(
|
|
result: Future.value([
|
|
_profileEvent(
|
|
id: 'newer-human',
|
|
pubkey: agent.public,
|
|
createdAt: 2,
|
|
name: 'Human',
|
|
),
|
|
]),
|
|
);
|
|
final container = ProviderContainer(
|
|
overrides: [relaySessionProvider.overrideWith(() => session)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
cache.cacheProfileEvent(
|
|
_profileEvent(
|
|
id: 'older-agent',
|
|
pubkey: agent.public,
|
|
createdAt: 1,
|
|
name: 'Agent',
|
|
tags: [_authTag(owner, agent.public)],
|
|
),
|
|
);
|
|
|
|
expect(await cache.refresh([agent.public]), isTrue);
|
|
expect(cache.state[agent.public]?.displayName, 'Human');
|
|
expect(cache.state[agent.public]?.ownerPubkey, isNull);
|
|
});
|
|
|
|
test('non-profile history cannot poison profile order', () async {
|
|
final session = _RecordingProfileSession(
|
|
results: [
|
|
Future.value([
|
|
_profileEvent(
|
|
id: 'non-profile-newer',
|
|
createdAt: 3,
|
|
name: 'Ignored',
|
|
kind: 1,
|
|
),
|
|
]),
|
|
Future.value([
|
|
_profileEvent(id: 'valid-older', createdAt: 2, name: 'Valid'),
|
|
]),
|
|
],
|
|
);
|
|
final container = ProviderContainer(
|
|
overrides: [relaySessionProvider.overrideWith(() => session)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
|
|
expect(await cache.refresh(const ['agent']), isTrue);
|
|
expect(cache.state['agent'], isNull);
|
|
expect(await cache.refresh(const ['agent']), isTrue);
|
|
expect(cache.state['agent']?.displayName, 'Valid');
|
|
});
|
|
|
|
for (final preload in [false, true]) {
|
|
test(
|
|
'${preload ? 'preload' : 'refresh'} merges after parsing against newer live state',
|
|
() async {
|
|
final started = Completer<void>();
|
|
final release = Completer<void>();
|
|
final session = _RecordingProfileSession(
|
|
result: Future.value([
|
|
_profileEvent(id: 'old', createdAt: 1, name: 'Old'),
|
|
]),
|
|
);
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relaySessionProvider.overrideWith(() => session),
|
|
profileEventBatchParserProvider.overrideWithValue((events) async {
|
|
final parsed = await parseProfileEventBatch(events);
|
|
started.complete();
|
|
await release.future;
|
|
return parsed;
|
|
}),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
final result = preload
|
|
? cache.preload(['agent'])
|
|
: cache.refresh(['agent']);
|
|
await started.future;
|
|
cache.cacheProfileEvent(
|
|
_profileEvent(id: 'new', createdAt: 2, name: 'Live'),
|
|
);
|
|
cache.cacheProfileEvent(
|
|
_profileEvent(
|
|
id: 'other',
|
|
pubkey: 'other',
|
|
createdAt: 1,
|
|
name: 'Other',
|
|
),
|
|
);
|
|
release.complete();
|
|
expect(await result, isTrue);
|
|
expect(cache.state['agent']?.displayName, 'Live');
|
|
expect(cache.state['other']?.displayName, 'Other');
|
|
},
|
|
);
|
|
|
|
test(
|
|
'${preload ? 'preload' : 'refresh'} rejects a retired parsing completion',
|
|
() async {
|
|
final started = Completer<void>();
|
|
final release = Completer<void>();
|
|
final session = _RecordingProfileSession(
|
|
result: Future.value([
|
|
_profileEvent(id: 'old', createdAt: 1, name: 'Previous community'),
|
|
]),
|
|
);
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relaySessionProvider.overrideWith(() => session),
|
|
profileEventBatchParserProvider.overrideWithValue((events) async {
|
|
final parsed = await parseProfileEventBatch(events);
|
|
started.complete();
|
|
await release.future;
|
|
return parsed;
|
|
}),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
final result = preload
|
|
? cache.preload(['agent'])
|
|
: cache.refresh(['agent']);
|
|
await started.future;
|
|
// This is the same dependency invalidation caused by community config.
|
|
container
|
|
.read(relayConfigProvider.notifier)
|
|
.update(baseUrl: 'https://next-community.invalid');
|
|
container.read(userCacheProvider);
|
|
release.complete();
|
|
expect(await result, isFalse);
|
|
expect(container.read(userCacheProvider), isEmpty);
|
|
},
|
|
);
|
|
}
|
|
|
|
test(
|
|
'community change rejects an old network response before parsing',
|
|
() async {
|
|
final response = Completer<List<NostrEvent>>();
|
|
var parsed = false;
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relaySessionProvider.overrideWith(
|
|
() => _RecordingProfileSession(result: response.future),
|
|
),
|
|
profileEventBatchParserProvider.overrideWithValue((events) async {
|
|
parsed = true;
|
|
return parseProfileEventBatch(events);
|
|
}),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final result = container.read(userCacheProvider.notifier).refresh([
|
|
'agent',
|
|
]);
|
|
container
|
|
.read(relayConfigProvider.notifier)
|
|
.update(baseUrl: 'https://next-community.invalid');
|
|
container.read(userCacheProvider);
|
|
response.complete([_profileEvent(id: 'old', createdAt: 1, name: 'Old')]);
|
|
expect(await result, isFalse);
|
|
expect(parsed, isFalse);
|
|
expect(container.read(userCacheProvider), isEmpty);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'parser failure reports refresh failure without modifying cache',
|
|
() async {
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relaySessionProvider.overrideWith(
|
|
() => _RecordingProfileSession(
|
|
result: Future.value([
|
|
_profileEvent(id: 'next', createdAt: 2, name: 'Next'),
|
|
]),
|
|
),
|
|
),
|
|
profileEventBatchParserProvider.overrideWithValue(
|
|
(_) => Future.error(StateError('parse failed')),
|
|
),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
cache.cacheProfileEvent(
|
|
_profileEvent(id: 'cached', createdAt: 1, name: 'Cached'),
|
|
);
|
|
expect(await cache.refresh(['agent']), isFalse);
|
|
expect(cache.state['agent']?.displayName, 'Cached');
|
|
},
|
|
);
|
|
|
|
test('later refresh completion cannot roll back a newer refresh', () async {
|
|
final oldResponse = Completer<List<NostrEvent>>();
|
|
final session = _RecordingProfileSession(
|
|
results: [
|
|
oldResponse.future,
|
|
Future.value([_profileEvent(id: 'new', createdAt: 2, name: 'New')]),
|
|
],
|
|
);
|
|
final container = ProviderContainer(
|
|
overrides: [relaySessionProvider.overrideWith(() => session)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
final old = cache.refresh(['agent']);
|
|
expect(await cache.refresh(['agent']), isTrue);
|
|
oldResponse.complete([_profileEvent(id: 'old', createdAt: 1, name: 'Old')]);
|
|
expect(await old, isTrue);
|
|
expect(cache.state['agent']?.displayName, 'New');
|
|
});
|
|
|
|
test('stale malformed profile is skipped before parsing', () async {
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relaySessionProvider.overrideWith(
|
|
() => _RecordingProfileSession(
|
|
result: Future.value([
|
|
NostrEvent(
|
|
id: 'old',
|
|
pubkey: 'agent',
|
|
createdAt: 1,
|
|
kind: 0,
|
|
tags: [],
|
|
content: '{"name":42}',
|
|
sig: 'sig',
|
|
),
|
|
]),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
cache.cacheProfileEvent(
|
|
_profileEvent(id: 'new', createdAt: 2, name: 'New'),
|
|
);
|
|
expect(await cache.refresh(['agent']), isTrue);
|
|
expect(cache.state['agent']?.displayName, 'New');
|
|
});
|
|
|
|
test(
|
|
'refresh and preload share one verification worker and coalesce pending lookups',
|
|
() async {
|
|
final started = Completer<void>();
|
|
final release = Completer<void>();
|
|
var running = 0;
|
|
var maxRunning = 0;
|
|
var calls = 0;
|
|
final session = _RecordingProfileSession(
|
|
results: [
|
|
Future.value([
|
|
_profileEvent(id: 'a', pubkey: 'a', createdAt: 1, name: 'A'),
|
|
]),
|
|
Future.value([
|
|
_profileEvent(id: 'b', pubkey: 'b', createdAt: 1, name: 'B'),
|
|
]),
|
|
Future.value([
|
|
_profileEvent(id: 'c', pubkey: 'c', createdAt: 1, name: 'C'),
|
|
]),
|
|
],
|
|
);
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relaySessionProvider.overrideWith(() => session),
|
|
profileEventBatchParserProvider.overrideWithValue((events) async {
|
|
running++;
|
|
if (running > maxRunning) maxRunning = running;
|
|
if (calls++ == 0) {
|
|
started.complete();
|
|
await release.future;
|
|
}
|
|
final parsed = await parseProfileEventBatch(events);
|
|
running--;
|
|
return parsed;
|
|
}),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
final first = cache.preload(['a']);
|
|
await started.future;
|
|
final refresh = cache.refresh(['b']);
|
|
final next = cache.preload(['c']);
|
|
final duplicate = cache.preload(['c']);
|
|
// Let the refresh reach the worker queue before releasing its predecessor.
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(calls, 1);
|
|
release.complete();
|
|
expect(
|
|
await Future.wait([first, refresh, next, duplicate]),
|
|
everyElement(isTrue),
|
|
);
|
|
expect(maxRunning, 1);
|
|
expect(calls, 3);
|
|
expect(session.requestCount, 3);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'refresh verifies real OA batch while allowing main-isolate timers',
|
|
() async {
|
|
final owner = nostr.Keys.generate();
|
|
final agents = List.generate(16, (_) => nostr.Keys.generate());
|
|
final events = [
|
|
for (final agent in agents)
|
|
_profileEvent(
|
|
id: agent.public,
|
|
pubkey: agent.public,
|
|
createdAt: 1,
|
|
name: 'Synthetic',
|
|
tags: [_authTag(owner, agent.public)],
|
|
),
|
|
];
|
|
var timerRanBeforeParsingCompleted = false;
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relaySessionProvider.overrideWith(
|
|
() => _RecordingProfileSession(result: Future.value(events)),
|
|
),
|
|
profileEventBatchParserProvider.overrideWithValue((events) async {
|
|
var timerRan = false;
|
|
final timer = Timer(Duration.zero, () => timerRan = true);
|
|
final parsed = await parseProfileEventBatch(events);
|
|
timerRanBeforeParsingCompleted = timerRan;
|
|
timer.cancel();
|
|
return parsed;
|
|
}),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
expect(await cache.refresh(agents.map((a) => a.public).toList()), isTrue);
|
|
expect(timerRanBeforeParsingCompleted, isTrue);
|
|
for (final agent in agents) {
|
|
expect(cache.state[agent.public]?.ownerPubkey, owner.public);
|
|
}
|
|
},
|
|
);
|
|
|
|
test('same-second profile tie keeps the lowest event id', () {
|
|
final container = ProviderContainer();
|
|
addTearDown(container.dispose);
|
|
final cache = container.read(userCacheProvider.notifier);
|
|
|
|
cache.cacheProfileEvent(
|
|
_profileEvent(id: 'b', createdAt: 1, name: 'Larger ID'),
|
|
);
|
|
cache.cacheProfileEvent(
|
|
_profileEvent(id: 'a', createdAt: 1, name: 'Lower ID'),
|
|
);
|
|
cache.cacheProfileEvent(
|
|
_profileEvent(id: 'c', createdAt: 1, name: 'Later Larger ID'),
|
|
);
|
|
|
|
expect(cache.state['agent']?.displayName, 'Lower ID');
|
|
});
|
|
}
|
|
|
|
NostrEvent _profileEvent({
|
|
required String id,
|
|
required int createdAt,
|
|
required String name,
|
|
String pubkey = 'agent',
|
|
List<List<String>> tags = const [],
|
|
int kind = 0,
|
|
}) => NostrEvent(
|
|
id: id,
|
|
pubkey: pubkey,
|
|
createdAt: createdAt,
|
|
kind: kind,
|
|
tags: tags,
|
|
content: jsonEncode({'name': name}),
|
|
sig: 'sig',
|
|
);
|
|
|
|
List<String> _authTag(nostr.Keys owner, String agentPubkey) {
|
|
final digest = SHA256Digest().process(
|
|
Uint8List.fromList(
|
|
utf8.encode('nostr:agent-auth:${agentPubkey.toLowerCase()}:'),
|
|
),
|
|
);
|
|
final message = digest
|
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
|
.join();
|
|
final signature = nostr.Schnorr.sign(
|
|
secretKey: owner.secret,
|
|
message: message,
|
|
);
|
|
return ['auth', owner.public, '', signature];
|
|
}
|
|
|
|
class _RecordingProfileSession extends RelaySessionNotifier {
|
|
_RecordingProfileSession({
|
|
Future<List<NostrEvent>>? result,
|
|
List<Future<List<NostrEvent>>>? results,
|
|
}) : _results = [...?results, ?result];
|
|
|
|
final List<Future<List<NostrEvent>>> _results;
|
|
NostrFilter? requestedFilter;
|
|
int requestCount = 0;
|
|
|
|
@override
|
|
SessionState build() => const SessionState(status: SessionStatus.connected);
|
|
|
|
@override
|
|
Future<List<NostrEvent>> fetchHistory(
|
|
NostrFilter filter, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async {
|
|
requestCount++;
|
|
requestedFilter = filter;
|
|
return _results.isEmpty ? const [] : _results.removeAt(0);
|
|
}
|
|
}
|
|
|
|
class _FailingProfileSession extends RelaySessionNotifier {
|
|
@override
|
|
SessionState build() => const SessionState(status: SessionStatus.connected);
|
|
|
|
@override
|
|
Future<List<NostrEvent>> fetchHistory(
|
|
NostrFilter filter, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) => Future.error('profile unavailable');
|
|
}
|