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,151 @@
import 'package:buzz/shared/read_state/message_read_state.dart';
import 'package:buzz/shared/read_state/read_state_provider.dart';
import 'package:flutter_test/flutter_test.dart';
ReadStateState _state(
Map<String, int> contexts, {
Map<String, String> forced = const {},
}) => ReadStateState(
isReady: true,
pubkey: 'pk',
contexts: contexts,
version: 1,
forcedUnreadContexts: forced,
);
void main() {
const channelId = 'chan-1';
const messageId = 'msg-1';
group('effectiveMessageReadAt', () {
test('is null with no markers', () {
expect(
effectiveMessageReadAt(
_state(const {}),
channelId: channelId,
messageId: messageId,
),
isNull,
);
});
test('takes the newest of channel, message, and thread markers', () {
final readState = _state(const {
channelId: 100,
'msg:$messageId': 300,
'thread:root-1': 200,
});
expect(
effectiveMessageReadAt(
readState,
channelId: channelId,
messageId: messageId,
threadRootId: 'root-1',
),
300,
);
});
test('ignores thread marker when no root is given', () {
final readState = _state(const {'thread:root-1': 500});
expect(
effectiveMessageReadAt(
readState,
channelId: channelId,
messageId: messageId,
),
isNull,
);
});
});
group('isMessageUnread', () {
test('unread when created after all markers', () {
final readState = _state(const {channelId: 100});
expect(
isMessageUnread(
readState,
channelId: channelId,
messageId: messageId,
createdAt: 150,
),
isTrue,
);
});
test('read when covered by the channel marker', () {
final readState = _state(const {channelId: 200});
expect(
isMessageUnread(
readState,
channelId: channelId,
messageId: messageId,
createdAt: 150,
),
isFalse,
);
});
test('read when covered by its own msg marker', () {
final readState = _state(const {'msg:$messageId': 150});
expect(
isMessageUnread(
readState,
channelId: channelId,
messageId: messageId,
createdAt: 150,
),
isFalse,
);
});
test('thread reply covered by thread marker', () {
final readState = _state(const {'thread:root-1': 400});
expect(
isMessageUnread(
readState,
channelId: channelId,
messageId: messageId,
createdAt: 350,
threadRootId: 'root-1',
),
isFalse,
);
});
test('forced-unread message wins over markers', () {
final readState = _state(
const {channelId: 1000},
forced: const {'msg:$messageId': channelId},
);
expect(
isMessageUnread(
readState,
channelId: channelId,
messageId: messageId,
createdAt: 150,
),
isTrue,
);
});
test('channel-level forced unread does not leak into message state', () {
// A channel forced unread from the channel tile is a channel-scoped
// choice — an already-read message inside it still shows as read, so
// its own Mark read/unread toggle round-trips independently.
final readState = _state(
const {channelId: 1000},
forced: const {channelId: channelId},
);
expect(
isMessageUnread(
readState,
channelId: channelId,
messageId: messageId,
createdAt: 150,
),
isFalse,
);
});
});
}
@@ -0,0 +1,164 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/read_state/read_state_format.dart';
import 'package:buzz/shared/relay/nostr_models.dart';
void main() {
group('read state event validation', () {
test('requires exactly one valid d tag and one read-state t tag', () {
final plaintext = jsonEncode({
'v': 1,
'client_id': 'client-a',
'contexts': {'channel-a': 1},
});
expect(
decodeReadStateEvent(
_event(tags: const []),
pubkey: 'user-pubkey',
decrypt: (_) => plaintext,
),
isNull,
);
expect(
decodeReadStateEvent(
_event(
tags: const [
['d', 'read-state:slot-a'],
['d', 'read-state:slot-b'],
['t', 'read-state'],
],
),
pubkey: 'user-pubkey',
decrypt: (_) => plaintext,
),
isNull,
);
expect(
decodeReadStateEvent(
_event(
tags: const [
['d', 'read-state:slót'],
['t', 'read-state'],
],
),
pubkey: 'user-pubkey',
decrypt: (_) => plaintext,
),
isNull,
);
expect(
decodeReadStateEvent(
_event(
tags: const [
['d', 'read-state:slot-a'],
],
),
pubkey: 'user-pubkey',
decrypt: (_) => plaintext,
),
isNull,
);
expect(
decodeReadStateEvent(
_event(
tags: const [
['d', 'read-state:slot-a'],
['t', 'read-state'],
['t', 'read-state'],
],
),
pubkey: 'user-pubkey',
decrypt: (_) => plaintext,
),
isNull,
);
});
test('decrypts and sanitizes a valid read-state blob', () {
final longContextId = 'x' * 257;
final plaintext = jsonEncode({
'v': 1,
'client_id': 'client-a',
'contexts': {
'channel-a': 10,
'string-value': '10',
'double-value': 10.5,
'negative': -1,
'too-large': 4294967296,
longContextId: 20,
},
});
final decoded = decodeReadStateEvent(
_event(),
pubkey: 'user-pubkey',
decrypt: (_) => plaintext,
);
expect(decoded, isNotNull);
expect(decoded!.dTag, 'read-state:slot-a');
expect(decoded.blob.clientId, 'client-a');
expect(decoded.blob.contexts, {'channel-a': 10});
});
test('rejects malformed blobs', () {
expect(decodeReadStateBlob('not json'), isNull);
expect(
decodeReadStateBlob(
jsonEncode({
'v': 2,
'client_id': 'client-a',
'contexts': <String, int>{},
}),
),
isNull,
);
expect(
decodeReadStateBlob(
jsonEncode({'v': 1, 'client_id': '', 'contexts': <String, int>{}}),
),
isNull,
);
expect(
decodeReadStateBlob(
jsonEncode({
'v': 1,
'client_id': 'client-a',
'contexts': List.filled(1, 'not-a-map'),
}),
),
isNull,
);
});
});
test('mergeReadStateContexts keeps the maximum timestamp per context', () {
expect(
mergeReadStateContexts([
{'channel-a': 10, 'channel-b': 5},
{'channel-a': 7, 'channel-c': 1},
{'channel-b': 12},
]),
{'channel-a': 10, 'channel-b': 12, 'channel-c': 1},
);
});
}
NostrEvent _event({List<List<String>>? tags}) {
return NostrEvent(
id: 'event-id',
pubkey: 'user-pubkey',
createdAt: 100,
kind: EventKind.readState,
tags:
tags ??
const [
['d', 'read-state:slot-a'],
['t', 'read-state'],
],
content: 'ciphertext',
sig: 'sig',
);
}
@@ -0,0 +1,304 @@
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/shared/read_state/read_state_format.dart';
import 'package:buzz/shared/read_state/read_state_manager.dart';
import 'package:buzz/shared/relay/relay.dart';
void main() {
test('dispose flushes a pending publish after marking disposed', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keychain = nostr.Keys.generate();
final nsec = keychain.nsec;
final crypto = ReadStateCrypto.tryCreate(
nsec: nsec,
pubkey: keychain.public,
);
final relay = _FakeSignedEventRelay();
final manager = ReadStateManager(
pubkey: keychain.public,
prefs: prefs,
crypto: crypto!,
relaySession: null,
signedEventRelay: relay,
remoteEnabled: true,
onChanged: () {},
);
manager.markContextRead('channel-1', 42);
manager.dispose();
final submitted = await relay.submitted.future.timeout(
const Duration(seconds: 1),
);
expect(submitted.kind, EventKind.readState);
expect(
submitted.tags.any(
(tag) => tag.length == 2 && tag[0] == 't' && tag[1] == 'read-state',
),
isTrue,
);
});
test('disables remote sync after relay rejects read-state kind', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keychain = nostr.Keys.generate();
final nsec = keychain.nsec;
final crypto = ReadStateCrypto.tryCreate(
nsec: nsec,
pubkey: keychain.public,
);
final relay = _UnsupportedKindSignedEventRelay();
final manager = ReadStateManager(
pubkey: keychain.public,
prefs: prefs,
crypto: crypto!,
relaySession: null,
signedEventRelay: relay,
remoteEnabled: true,
onChanged: () {},
);
manager.markContextRead('channel-1', 42);
await manager.flush();
manager.markContextRead('channel-2', 43);
await manager.flush();
expect(relay.submitCount, 1);
expect(manager.getEffectiveTimestamp('channel-2'), 43);
});
test(
'disables remote sync after token permanently lacks write scope',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keychain = nostr.Keys.generate();
final nsec = keychain.nsec;
final crypto = ReadStateCrypto.tryCreate(
nsec: nsec,
pubkey: keychain.public,
);
final relay = _MissingScopeSignedEventRelay();
final manager = ReadStateManager(
pubkey: keychain.public,
prefs: prefs,
crypto: crypto!,
relaySession: null,
signedEventRelay: relay,
remoteEnabled: true,
onChanged: () {},
);
manager.markContextRead('channel-1', 42);
await manager.flush();
manager.markContextRead('channel-2', 43);
await manager.flush();
expect(relay.submitCount, 1);
expect(manager.getEffectiveTimestamp('channel-2'), 43);
},
);
test('disables remote sync after an oversized local blob', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keychain = nostr.Keys.generate();
final crypto = ReadStateCrypto.tryCreate(
nsec: keychain.nsec,
pubkey: keychain.public,
)!;
final relay = _FakeSignedEventRelay();
final manager = ReadStateManager(
pubkey: keychain.public,
prefs: prefs,
crypto: crypto,
relaySession: null,
signedEventRelay: relay,
remoteEnabled: true,
onChanged: () {},
);
for (var index = 0; index < 1400; index++) {
manager.markContextRead(
'channel-${index.toString().padLeft(4, '0')}-${'x' * 48}',
index + 1,
);
}
await manager.flush();
manager.markContextRead('channel-new', 2000);
await manager.flush();
expect(relay.submitCount, 0);
expect(manager.getEffectiveTimestamp('channel-0000-${'x' * 48}'), 1);
expect(manager.getEffectiveTimestamp('channel-new'), 2000);
});
test('remote read-state rollback is ignored', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keychain = nostr.Keys.generate();
final crypto = ReadStateCrypto.tryCreate(
nsec: keychain.nsec,
pubkey: keychain.public,
);
final relay = _FakeRelaySession();
final manager = ReadStateManager(
pubkey: keychain.public,
prefs: prefs,
crypto: crypto!,
relaySession: relay,
signedEventRelay: _FakeSignedEventRelay(),
remoteEnabled: true,
onChanged: () {},
);
relay.historyEvents = [
_readStateEvent(
pubkey: keychain.public,
crypto: crypto,
clientId: 'remote-client',
slotId: 'remote-slot',
contexts: {'channel-1': 100},
createdAt: 100,
),
_readStateEvent(
pubkey: keychain.public,
crypto: crypto,
clientId: 'remote-client',
slotId: 'remote-slot',
contexts: {'channel-1': 50},
createdAt: 110,
),
];
await manager.initialize();
expect(manager.getEffectiveTimestamp('channel-1'), 100);
});
}
class _SubmittedEvent {
final int kind;
final List<List<String>> tags;
const _SubmittedEvent({required this.kind, required this.tags});
}
/// Build a stub NostrEvent for tests that just need a "ack" return value.
NostrEvent _stubAckEvent() => const NostrEvent(
id: 'stub',
pubkey: '',
createdAt: 0,
kind: 0,
tags: [],
content: '',
sig: '',
);
class _FakeSignedEventRelay implements SignedEventRelay {
final Completer<_SubmittedEvent> submitted = Completer<_SubmittedEvent>();
int submitCount = 0;
@override
String? get pubkey => null;
@override
Future<NostrEvent> submit({
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
void Function(NostrEvent event)? onSigned,
}) async {
submitCount++;
submitted.complete(_SubmittedEvent(kind: kind, tags: tags));
return _stubAckEvent();
}
}
class _UnsupportedKindSignedEventRelay implements SignedEventRelay {
int submitCount = 0;
@override
String? get pubkey => null;
@override
Future<NostrEvent> submit({
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
void Function(NostrEvent event)? onSigned,
}) async {
submitCount++;
throw Exception('restricted: unknown event kind');
}
}
class _MissingScopeSignedEventRelay implements SignedEventRelay {
int submitCount = 0;
@override
String? get pubkey => null;
@override
Future<NostrEvent> submit({
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
void Function(NostrEvent event)? onSigned,
}) async {
submitCount++;
throw Exception('missing users:write');
}
}
NostrEvent _readStateEvent({
required String pubkey,
required ReadStateCrypto crypto,
required String clientId,
required String slotId,
required Map<String, int> contexts,
required int createdAt,
}) {
final blob = ReadStateBlob(clientId: clientId, contexts: contexts);
return NostrEvent(
id: 'event-$clientId-$createdAt',
pubkey: pubkey,
createdAt: createdAt,
kind: EventKind.readState,
tags: [
['d', '$readStateDTagPrefix$slotId'],
['t', 'read-state'],
],
content: crypto.encrypt(jsonEncode(blob.toJson())),
sig: 'sig',
);
}
class _FakeRelaySession extends RelaySessionNotifier {
List<NostrEvent> historyEvents = [];
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => historyEvents;
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async => () {};
}
@@ -0,0 +1,159 @@
import 'package:buzz/shared/read_state/read_state_format.dart';
import 'package:buzz/shared/read_state/read_state_provider.dart';
import 'package:buzz/shared/community/community_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme_provider.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
/// Drives the production [ReadStateNotifier] (real bookkeeping, real
/// [ReadStateManager]) against an inert fake relay session, so a defect in
/// the forced-unread map removal or the manager-emission path fails here
/// instead of being masked by a fake notifier.
void main() {
const channelId = 'chan-1';
const otherChannelId = 'chan-2';
final msgKey = msgContextKey('msg-1');
final otherMsgKey = msgContextKey('msg-2');
late ProviderContainer container;
Future<ReadStateNotifier> pumpNotifier() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final nsec = nostr.Keys.generate().nsec;
container = ProviderContainer(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
relayConfigProvider.overrideWith(() => _FakeRelayConfig(nsec)),
relaySessionProvider.overrideWith(_FakeRelaySession.new),
activeCommunityProvider.overrideWith((ref) async => null),
appLifecycleProvider.overrideWith(_FakeAppLifecycle.new),
],
);
addTearDown(container.dispose);
final notifier = container.read(readStateProvider.notifier);
// Let the async activeCommunity resolution, the resulting rebuild, and
// the manager initialize() microtasks run.
await container.read(activeCommunityProvider.future);
for (var i = 0; i < 10 && !container.read(readStateProvider).isReady; i++) {
await Future<void>.delayed(Duration.zero);
}
expect(container.read(readStateProvider).isReady, isTrue);
return notifier;
}
ReadStateState state() => container.read(readStateProvider);
test('message unread → read → unread round-trips through the real '
'notifier', () async {
final notifier = await pumpNotifier();
// Force the message unread.
notifier.markContextUnread(msgKey, channelId: channelId);
expect(state().isForcedUnread(msgKey), isTrue);
expect(state().locallyForcedChannelIds, {channelId});
// Mark read: the force flag must drop out of the production map AND the
// marker must land in the manager's effective contexts.
notifier.markContextRead(msgKey, 1000);
expect(state().isForcedUnread(msgKey), isFalse);
expect(state().forcedUnreadContexts, isEmpty);
expect(state().effectiveTimestamp(msgKey), 1000);
// Force unread again: read markers are monotonic, so the flag is the
// only mechanism — it must win even though the marker persists.
notifier.markContextUnread(msgKey, channelId: channelId);
expect(state().isForcedUnread(msgKey), isTrue);
expect(state().effectiveTimestamp(msgKey), 1000);
});
test('explicit channel-level Mark read clears forced message entries in '
'that channel only', () async {
final notifier = await pumpNotifier();
notifier.markContextUnread(msgKey, channelId: channelId);
notifier.markContextUnread(otherMsgKey, channelId: otherChannelId);
// Channel tile "Mark as read" passes clearForcedMessages: true.
notifier.markContextRead(channelId, 2000, clearForcedMessages: true);
expect(state().isForcedUnread(msgKey), isFalse);
expect(state().isForcedUnread(otherMsgKey), isTrue);
expect(state().locallyForcedChannelIds, {otherChannelId});
expect(state().effectiveTimestamp(channelId), 2000);
});
test(
'automatic channel-open read preserves forced message entries',
() async {
final notifier = await pumpNotifier();
notifier.markContextUnread(msgKey, channelId: channelId);
// Channel open marks the channel read without clearForcedMessages.
notifier.markContextRead(channelId, 3000);
expect(state().isForcedUnread(msgKey), isTrue);
expect(state().locallyForcedChannelIds, {channelId});
expect(state().effectiveTimestamp(channelId), 3000);
},
);
test('automatic channel-open read still clears a channel-level force for '
'the same channel', () async {
final notifier = await pumpNotifier();
// Channel forced unread from the tile, message forced from the sheet.
notifier.markContextUnread(channelId, channelId: channelId);
notifier.markContextUnread(msgKey, channelId: channelId);
notifier.markContextRead(channelId, 4000);
// Opening the channel satisfies the channel-scoped force, but the
// deliberate message-level force survives until acted on.
expect(state().isForcedUnread(channelId), isFalse);
expect(state().isForcedUnread(msgKey), isTrue);
expect(state().locallyForcedChannelIds, {channelId});
});
}
class _FakeRelayConfig extends RelayConfigNotifier {
final String nsec;
_FakeRelayConfig(this.nsec);
@override
RelayConfig build() => RelayConfig(baseUrl: 'http://localhost:1', nsec: nsec);
}
/// Relay session that never connects and returns no history, keeping the
/// manager fully local while exercising its real code paths.
class _FakeRelaySession extends RelaySessionNotifier {
@override
SessionState build() =>
const SessionState(status: SessionStatus.disconnected);
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => [];
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async => () {};
}
class _FakeAppLifecycle extends AppLifecycleNotifier {
@override
AppLifecycleState build() => AppLifecycleState.resumed;
}
@@ -0,0 +1,25 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/read_state/read_state_time.dart';
void main() {
test('dateTimeToUnixSeconds converts DateTime values and nulls', () {
final value = DateTime.utc(2026, 4, 29, 4, 30, 45, 900);
expect(dateTimeToUnixSeconds(value), 1777437045);
expect(dateTimeToUnixSeconds(null), isNull);
});
test('isoToUnixSeconds parses stored ISO timestamps defensively', () {
expect(isoToUnixSeconds('1970-01-01T00:00:42.000Z'), 42);
expect(isoToUnixSeconds(''), isNull);
expect(isoToUnixSeconds('not-a-date'), isNull);
expect(isoToUnixSeconds(42), isNull);
});
test('unixSecondsToDateTime writes UTC ISO-compatible values', () {
expect(
unixSecondsToDateTime(1700000000).toIso8601String(),
'2023-11-14T22:13:20.000Z',
);
});
}