feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
@@ -0,0 +1,407 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:buzz/features/channels/channel_sections/channel_sections_manager.dart';
import 'package:buzz/shared/relay/relay.dart';
void main() {
late SharedPreferences prefs;
late nostr.Keys keychain;
late ChannelSectionsCrypto crypto;
Future<void> setUpEnv() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
keychain = nostr.Keys.generate();
crypto = ChannelSectionsCrypto(keychain.nsec, keychain.public);
}
NostrEvent sectionsEvent({
required List<Map<String, dynamic>> sections,
Map<String, String> assignments = const {},
required int createdAt,
String id = 'remote-event',
}) {
final payload = jsonEncode({
'version': 1,
'sections': sections,
'assignments': assignments,
});
return NostrEvent(
id: id,
pubkey: keychain.public,
createdAt: createdAt,
kind: EventKind.readState,
tags: const [
['d', 'channel-sections'],
['t', 'channel-sections'],
],
content: crypto.encrypt(payload),
sig: 'sig',
);
}
ChannelSectionsManager buildManager({
required RelaySessionNotifier relaySession,
Duration startupRetryBaseDelay = const Duration(milliseconds: 5),
}) {
return ChannelSectionsManager(
pubkey: keychain.public,
prefs: prefs,
crypto: crypto,
relaySession: relaySession,
signedEventRelay: null,
remoteEnabled: true,
onChanged: () {},
startupRetryBaseDelay: startupRetryBaseDelay,
);
}
test('startup fetch rejected by relay rate limit retries until the remote '
'blob is adopted (cold-start regression)', () async {
await setUpEnv();
// Cold start: the relay rejects the first fetch AND the first live
// subscription with `rate-limited: quota exceeded` because the channel
// list fired dozens of REQs first. Pre-fix, both errors were swallowed
// and desktop-created groups never appeared until app data was cleared.
final relay = _RateLimitedRelaySession(
failuresBeforeSuccess: 2,
historyEvents: [
sectionsEvent(
sections: [
{'id': 's1', 'name': 'Desktop Group', 'order': 0},
],
createdAt: 100,
),
],
);
final manager = buildManager(
relaySession: relay,
startupRetryBaseDelay: const Duration(milliseconds: 10),
);
await manager.initialize();
expect(
manager.store.sections,
isEmpty,
reason: 'first fetch lost the rate-limit race',
);
// Wait for the backoff retries (10ms, 20ms, …) to win the race.
await _waitUntil(() => manager.store.sections.isNotEmpty);
expect(manager.store.sections.single.name, 'Desktop Group');
expect(
relay.subscribeCalls,
greaterThan(1),
reason: 'live subscription must be retried too',
);
manager.dispose(flushPending: false);
});
test(
'startup retry stops after fetch and subscription both succeed',
() async {
await setUpEnv();
final relay = _RateLimitedRelaySession(
failuresBeforeSuccess: 1,
historyEvents: [
sectionsEvent(
sections: [
{'id': 's1', 'name': 'Desktop Group', 'order': 0},
],
createdAt: 100,
),
],
);
final manager = buildManager(relaySession: relay);
await manager.initialize();
await _waitUntil(() => manager.store.sections.isNotEmpty);
final fetchCallsAfterSuccess = relay.fetchCalls;
final subscribeCallsAfterSuccess = relay.subscribeCalls;
await Future<void>.delayed(const Duration(milliseconds: 60));
expect(relay.fetchCalls, fetchCallsAfterSuccess);
expect(relay.subscribeCalls, subscribeCallsAfterSuccess);
manager.dispose(flushPending: false);
},
);
test('disposing the manager cancels pending startup retries', () async {
await setUpEnv();
final relay = _RateLimitedRelaySession(failuresBeforeSuccess: 1000);
final manager = buildManager(relaySession: relay);
await manager.initialize();
manager.dispose(flushPending: false);
final fetchCallsAtDispose = relay.fetchCalls;
await Future<void>.delayed(const Duration(milliseconds: 60));
expect(
relay.fetchCalls,
fetchCallsAtDispose,
reason: 'no retries may fire after dispose',
);
});
test('late relay CLOSED after subscribe() reported success retries and '
'eventually adopts remote state', () async {
await setUpEnv();
// Under cold-start load, subscribe() can time out its 500ms readiness
// wait and resolve successfully before the relay's rate-limit CLOSED
// lands. The rejection then arrives only via onClosed. Pre-fix the
// manager passed no onClosed, kept the dead subscription, and never
// retried.
final relay = _RateLimitedRelaySession(failuresBeforeSuccess: 0);
final manager = buildManager(relaySession: relay);
await manager.initialize();
expect(relay.subscribeCalls, 1);
// Late CLOSED lands after initialize() completed successfully.
relay.closeLiveSubscription('rate-limited: quota exceeded');
// The manager must drop the dead subscription and re-subscribe.
await _waitUntil(() => relay.subscribeCalls > 1);
// Remote state arriving on the NEW subscription must be adopted.
relay.emit(
sectionsEvent(
sections: [
{'id': 's1', 'name': 'Desktop Group', 'order': 0},
],
createdAt: 100,
),
);
await _waitUntil(() => manager.store.sections.isNotEmpty);
expect(manager.store.sections.single.name, 'Desktop Group');
// Exactly one replacement subscription — no duplicates.
await Future<void>.delayed(const Duration(milliseconds: 60));
expect(relay.subscribeCalls, 2);
expect(relay.activeListeners, 1);
manager.dispose(flushPending: false);
});
test('late CLOSED after dispose does not schedule retries', () async {
await setUpEnv();
final relay = _RateLimitedRelaySession(failuresBeforeSuccess: 0);
final manager = buildManager(relaySession: relay);
await manager.initialize();
manager.dispose(flushPending: false);
relay.closeLiveSubscription('rate-limited: quota exceeded');
await Future<void>.delayed(const Duration(milliseconds: 60));
expect(relay.subscribeCalls, 1, reason: 'no re-subscribe after dispose');
});
test(
'dispose while subscribe is pending closes the late subscription',
() async {
await setUpEnv();
final relay = _DelayedSubscribeRelaySession();
final manager = buildManager(relaySession: relay);
final initializing = manager.initialize();
await relay.subscribeStarted.future;
manager.dispose(flushPending: false);
relay.completeSubscribe();
await initializing;
expect(relay.activeListeners, 0);
expect(relay.unsubscribeCalls, 1);
},
);
test(
'a retry request during an in-flight sync does not overlap subscribe',
() async {
await setUpEnv();
final relay = _DelayedSubscribeRelaySession();
final manager = buildManager(relaySession: relay);
final initializing = manager.initialize();
await relay.subscribeStarted.future;
relay.closePendingSubscription('rate-limited: quota exceeded');
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(relay.subscribeCalls, 1);
relay.completeSubscribe();
await initializing;
await _waitUntil(() => relay.subscribeCalls == 2);
expect(relay.maxConcurrentSubscribes, 1);
expect(relay.activeListeners, 1);
manager.dispose(flushPending: false);
},
);
test('backoff resets after full recovery so later failures start from the '
'base delay', () async {
await setUpEnv();
// Climb the failure counter: 5 rate-limited attempts before success.
// With a 5ms base, attempt 5 would wait 5<<5 = 160ms if the counter
// were never reset.
final relay = _RateLimitedRelaySession(failuresBeforeSuccess: 5);
final manager = buildManager(relaySession: relay);
await manager.initialize();
await _waitUntil(() => relay.subscribeCalls > 5);
// Fully recovered. A later transient failure (late CLOSED) must retry
// from the base delay, not the climbed ceiling.
final subscribeCallsAfterRecovery = relay.subscribeCalls;
relay.closeLiveSubscription('rate-limited: quota exceeded');
final stopwatch = Stopwatch()..start();
await _waitUntil(() => relay.subscribeCalls > subscribeCallsAfterRecovery);
stopwatch.stop();
expect(
stopwatch.elapsedMilliseconds,
lessThan(100),
reason:
'retry after recovery must wait ~base delay (5ms), '
'not the pre-recovery backoff (160ms)',
);
manager.dispose(flushPending: false);
});
}
Future<void> _waitUntil(
bool Function() condition, {
Duration timeout = const Duration(seconds: 2),
}) async {
final deadline = DateTime.now().add(timeout);
while (!condition()) {
if (DateTime.now().isAfter(deadline)) {
fail('condition not met within $timeout');
}
await Future<void>.delayed(const Duration(milliseconds: 10));
}
}
class _DelayedSubscribeRelaySession extends RelaySessionNotifier {
final subscribeStarted = Completer<void>();
Completer<void Function()>? _pendingSubscribe;
void Function(String)? _pendingOnClosed;
int subscribeCalls = 0;
int concurrentSubscribes = 0;
int maxConcurrentSubscribes = 0;
int activeListeners = 0;
int unsubscribeCalls = 0;
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => const [];
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) {
subscribeCalls++;
concurrentSubscribes++;
if (concurrentSubscribes > maxConcurrentSubscribes) {
maxConcurrentSubscribes = concurrentSubscribes;
}
if (!subscribeStarted.isCompleted) subscribeStarted.complete();
_pendingOnClosed = onClosed;
if (subscribeCalls > 1) {
concurrentSubscribes--;
activeListeners++;
return Future.value(_unsubscribe);
}
_pendingSubscribe = Completer<void Function()>();
return _pendingSubscribe!.future.whenComplete(() {
concurrentSubscribes--;
});
}
void closePendingSubscription(String message) =>
_pendingOnClosed?.call(message);
void completeSubscribe() {
activeListeners++;
_pendingSubscribe!.complete(_unsubscribe);
}
void _unsubscribe() {
activeListeners--;
unsubscribeCalls++;
}
}
/// Rejects the first [failuresBeforeSuccess] fetch and subscribe calls with
/// the relay's rate-limit error, then succeeds — the exact failure mode seen
/// on Android cold start where the channel-list REQ burst exhausts the
/// relay's per-connection quota before the sections manager gets a turn.
class _RateLimitedRelaySession extends RelaySessionNotifier {
_RateLimitedRelaySession({
required this.failuresBeforeSuccess,
this.historyEvents = const [],
});
final int failuresBeforeSuccess;
final List<NostrEvent> historyEvents;
int fetchCalls = 0;
int subscribeCalls = 0;
final List<void Function(NostrEvent)> _listeners = [];
final List<void Function(String)> _closedCallbacks = [];
int get activeListeners => _listeners.length;
void emit(NostrEvent event) {
for (final listener in List.of(_listeners)) {
listener(event);
}
}
/// Simulates a relay CLOSED landing after subscribe() already resolved —
/// the readiness-timeout window on a loaded cold start. Drops the live
/// subscription (as _handleClosed does) and invokes onClosed.
void closeLiveSubscription(String message) {
if (_listeners.isEmpty) return;
_listeners.removeAt(0);
final onClosed = _closedCallbacks.removeAt(0);
onClosed(message);
}
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
fetchCalls++;
if (fetchCalls <= failuresBeforeSuccess) {
throw Exception('rate-limited: quota exceeded; retry in 2s');
}
return historyEvents;
}
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async {
subscribeCalls++;
if (subscribeCalls <= failuresBeforeSuccess) {
throw Exception('rate-limited: quota exceeded; retry in 1s');
}
_listeners.add(onEvent);
_closedCallbacks.add(onClosed ?? (_) {});
return () {
final index = _listeners.indexOf(onEvent);
if (index >= 0) {
_listeners.removeAt(index);
_closedCallbacks.removeAt(index);
}
};
}
}
@@ -0,0 +1,43 @@
import 'package:buzz/features/channels/channel_sections/channel_sections_storage.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('ChannelSection icon', () {
test('round-trips through fromJson and toJson', () {
const original = ChannelSection(
id: 'section-1',
name: 'Friends',
icon: ':party_parrot:',
order: 0,
);
final roundTrip = ChannelSection.fromJson(original.toJson());
expect(roundTrip.icon, ':party_parrot:');
expect(roundTrip.toJson(), original.toJson());
});
test('missing icon remains absent', () {
final section = ChannelSection.fromJson({
'id': 'section-1',
'name': 'Friends',
'order': 0,
});
expect(section.icon, isNull);
expect(section.toJson(), isNot(contains('icon')));
});
test('empty icon round-trips', () {
final section = ChannelSection.fromJson({
'id': 'section-1',
'name': 'Friends',
'icon': '',
'order': 0,
});
expect(section.icon, isEmpty);
expect(section.toJson()['icon'], '');
});
});
}