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
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:
@@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user