Files
buzz/mobile/test/shared/me/me_sync_test.dart
Logan Johnson dd2d3cf4ea fix(mobile): hide archived people and agents from choices; give them their own members section (#9320)
## Summary
Mobile kept offering archived people and agents as choices: Add members
(including adding an agent to a Solo chat), New DM, the `@` mention
chooser, Search › People and the Agents page. An archived agent that
shares a live agent's name also forced a qualifier onto the live one,
because names were told apart across both. The members sheet listed
archived members among live ones with nothing to mark them.

Now, matching desktop:
- Archived people and agents are left out of those choices. History
(messages, activity, profiles) still shows them.
- The members sheet moves archived members out of People and Agents into
their own "Archived · N" section after both: archived people, then
archived agents. Each row reads "Archived · Member" (or Agent) in place
of the role, status or "Working…" line. A screen reader reads it with
the name. The members preview on the channel info page (the first five
members above "See all") follows the same order and label.
- Nothing is hidden or marked while the community's archive list is
unknown or cannot be read, and you are never hidden from yourself.

### Related issue
None found. The desktop members dialog gets the same Archived section in
https://github.com/block/buzz-app/pull/3203.

### Testing
Widget and provider tests cover each surface, the unknown-archive case,
the viewer rule and a community switch. Each test fails without its
change.

Android emulator, local relay, fictional names. Each archived person or
agent has a live namesake. History rows still show archived identities,
as intended.

**`@` mention chooser, typing "@Sc" (Scout `qsfn` is archived)**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/6693729c-66c1-4433-8a7b-4f0e948057ee"
width="320"> | <img
src="https://github.com/user-attachments/assets/eb4f80be-780e-4dbe-8b2b-e6a5b6c3ca97"
width="320"> |

**`@` mention chooser, typing "@Ca" (Casey Lee `9mhy` is archived)**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/b6d409cd-7af1-4da7-b5b9-deece5e21de5"
width="320"> | <img
src="https://github.com/user-attachments/assets/60e21387-791b-4233-b8e6-1a29f94e6810"
width="320"> |

**Add members, searching "Morgan" (Morgan Reyes `ssc2` is archived)**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/1222a923-5e43-4263-843d-57564bdc7af3"
width="320"> | <img
src="https://github.com/user-attachments/assets/dbe58278-788d-4a89-83bf-f0b4bc119c5e"
width="320"> |

**Add members, searching "Atlas" (Atlas `85gl` is archived)**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/55c67a21-4cf5-47c5-b6ac-1ba80d68a3ec"
width="320"> | <img
src="https://github.com/user-attachments/assets/54b19ab3-fa2f-4933-8b67-1056d6e3b3a4"
width="320"> |

**New DM, no query yet**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/6a26fd49-4a06-47bd-ba37-37e5898ab344"
width="320"> | <img
src="https://github.com/user-attachments/assets/3f33eb2b-f27a-4528-91c0-ac76d3fdf44b"
width="320"> |

**New DM, searching "Morgan"**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/22d2d617-3074-4910-8c39-e0778e47a879"
width="320"> | <img
src="https://github.com/user-attachments/assets/fa00b160-e350-49af-94d0-beb15c357bef"
width="320"> |

**Search › People, "Morgan"**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/83a28f2b-9b22-4d0c-8728-8a2dfb10e634"
width="320"> | <img
src="https://github.com/user-attachments/assets/573aed3f-75ab-478f-8c11-bedab5c6ce4f"
width="320"> |

**Agents page**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/d7980f95-825c-4d1a-ad67-95f906a52e9a"
width="320"> | <img
src="https://github.com/user-attachments/assets/6252c38a-b2ec-48dc-ab4e-824ea29bbd85"
width="320"> |

**Channel info, members preview**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/8744cdfd-c073-4ade-bd53-9d5e137cee60"
width="320"> | <img
src="https://github.com/user-attachments/assets/5240b1be-13d5-4f22-85a9-8375f1d8ac18"
width="320"> |

**Members sheet, top**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/c9241478-99b1-452e-ad41-8c38fe080182"
width="320"> | <img
src="https://github.com/user-attachments/assets/3c6f9247-60c6-47df-94b0-9175f25805bb"
width="320"> |

**Members sheet, scrolled to the end**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/7da7fcc8-28ea-4c7a-904e-91ca5a068d23"
width="320"> | <img
src="https://github.com/user-attachments/assets/024f676e-8150-457d-b7d0-67fa1a680f8d"
width="320"> |

## Details
- The archive reader (the community's NIP-IA archive list, signed by the
relay's NIP-11 `self` key) moves from `shared/me` to
`shared/identity_archives`, since it is no longer Solo-only.
`hiddenArchivedIdentitiesProvider` is the single rule the surfaces read.
- Pulling to refresh on the Agents page also rereads the archive list.
Other views reuse the last list, as on desktop; it is also reread on
reconnect and when the agent inventory changes.
- A refresh keeps the last known list. After a community or account
switch, the previous community's list does not apply: nobody is hidden
until the new list arrives.
- The shared chooser drops archived identities before names are
compared, which covers Add members, New DM and mentions.
- Search › People asks the relay for 24 people and shows the first 8
that are not archived, so archived matches do not push live people out.
- The channel info preview shows the label on one line, so with a long
name the role after "Archived" can be cut off. A screen reader still
reads it in full.
- Not changed: typing a full `@Name` by hand (without the chooser) still
resolves against archived channel members too, so an archived namesake
can still make a typed name ambiguous. Picking from the chooser is
unaffected. Possible follow-up.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:10:49 -08:00

799 lines
28 KiB
Dart

import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
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:shared_preferences_platform_interface/shared_preferences_platform_interface.dart';
import 'package:buzz/shared/crypto/nip44.dart';
import 'package:buzz/shared/me/me_groups.dart';
import 'package:buzz/shared/me/me_chat_creation.dart';
import 'package:buzz/shared/me/me_assistants.dart';
import 'package:buzz/shared/identity_archives/identity_archives.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/me/me_recovery.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme_provider.dart';
class _Config extends RelayConfigNotifier {
final RelayConfig config;
_Config(this.config);
@override
RelayConfig build() => config;
void switchTo(RelayConfig next) => state = next;
}
class FakeRelay extends RelaySessionNotifier {
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
void setStatus(SessionStatus status) => state = SessionState(status: status);
int directoryQueries = 0;
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
directoryQueries++;
return [];
}
List<NostrEvent> events = [];
Future<List<NostrEvent>>? pendingRead;
void Function(String)? closeSubscription;
void Function(NostrEvent)? emit;
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async {
closeSubscription = onClosed;
emit = onEvent;
return () {};
}
int writes = 0;
bool loseAcknowledgment = false;
void Function()? beforeRead;
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
beforeRead?.call();
return pendingRead ?? events;
}
@override
Future<NostrEvent> publish(
NostrEvent event, {
Duration timeout = const Duration(seconds: 8),
}) async {
writes++;
events = [event];
if (loseAcknowledgment) throw TimeoutException('no acknowledgment');
return event;
}
}
class _FailingCacheStore extends InMemorySharedPreferencesStore {
String? failure;
String? removeFailure;
Completer<bool>? blockedWrite;
int cacheWrites = 0;
_FailingCacheStore() : super.empty();
@override
Future<bool> setValue(String type, String key, Object value) {
if (key.startsWith('flutter.me-cache:')) {
cacheWrites++;
if (blockedWrite != null) return blockedWrite!.future;
if (failure == 'throw') throw StateError('disk error');
if (failure == 'false') return Future.value(false);
}
return super.setValue(type, key, value);
}
@override
Future<bool> remove(String key) async {
if (key.startsWith('flutter.me-pending:')) {
if (removeFailure == 'false') return false;
if (removeFailure == 'throw') throw StateError('remove failed');
}
return super.remove(key);
}
}
void main() {
const secret =
'0000000000000000000000000000000000000000000000000000000000000001';
final nsec = nostr.Nip19.encode(prefix: nostr.Nip19Prefix.nsec, data: secret);
const origin = 'https://relay.test';
final public = nostr.Keys(secret).public;
NostrEvent signed({
String community = origin,
String signingKey = secret,
int timestamp = 1,
MeGroups groups = const MeGroups(),
}) => NostrEvent.fromJson(
nostr.Event.from(
kind: 30078,
content: nip44Encrypt(
getConversationKey(secret, public),
groups.encode(community),
),
secretKey: signingKey,
createdAt: timestamp,
tags: [
['d', MeGroups.coordinate(community)],
['t', MeGroups.tag],
],
verify: false,
).toMap(),
);
for (final failure in ['false', 'throw']) {
test(
'chat recovery reconciles its confirmed Solo journal after $failure cleanup',
() async {
SharedPreferences.setMockInitialValues({});
final store = _FailingCacheStore()..removeFailure = failure;
SharedPreferencesStorePlatform.instance = store;
final prefs = await SharedPreferences.getInstance();
final relay = FakeRelay();
final sync = MeSync(
relay,
RelayConfig(baseUrl: origin, nsec: nsec),
prefs: prefs,
);
const id = '11111111-1111-4111-8111-111111111111';
const intent = MeChatCreation(id, 'Chat', 'agent');
Future<String> complete() => intent.complete(
sync,
findChannel: () async => id,
createChannel: () async => id,
hasAgent: () async => true,
addAgent: () async {},
isPlaced: () async =>
relay.events.isNotEmpty &&
sync.decode(relay.events.single).channels.contains(id),
place: () => sync.update((current) => current.place(id)),
);
await expectLater(complete(), throwsStateError);
expect(prefs.getString(sync.pendingKey), isNotNull);
expect(MeChatCreation.pending(sync), isNotNull);
store.removeFailure = null;
await expectLater(complete(), throwsStateError);
expect(prefs.getString(sync.pendingKey), isNull);
expect(MeChatCreation.pending(sync), isNotNull);
expect(await complete(), id);
expect(MeChatCreation.pending(sync), isNull);
expect(relay.writes, 1);
},
);
}
for (final failure in ['false', 'throw']) {
test(
'pending-save discard retains recovery after $failure removal',
() async {
SharedPreferences.setMockInitialValues({});
final store = _FailingCacheStore();
SharedPreferencesStorePlatform.instance = store;
final prefs = await SharedPreferences.getInstance();
final service = MeSync(
FakeRelay(),
RelayConfig(baseUrl: origin, nsec: nsec),
prefs: prefs,
);
await prefs.setString(service.pendingKey, 'reviewed');
store.removeFailure = failure;
await expectLater(service.discardPending('reviewed'), throwsStateError);
expect(prefs.getString(service.pendingKey), 'reviewed');
store.removeFailure = null;
await service.discardPending('reviewed');
expect(prefs.getString(service.pendingKey), isNull);
},
);
}
test(
'dirty newest snapshot survives transport reconnect and remains scoped',
() async {
SharedPreferences.setMockInitialValues({});
final store = _FailingCacheStore();
SharedPreferencesStorePlatform.instance = store;
final prefs = await SharedPreferences.getInstance();
final relay = FakeRelay();
final config = _Config(RelayConfig(baseUrl: origin, nsec: nsec));
final container = ProviderContainer(
overrides: [
identityArchivesProvider.overrideWith((ref) async => {}),
relayConfigProvider.overrideWith(() => config),
relaySessionProvider.overrideWith(() => relay),
savedPrefsProvider.overrideWithValue(prefs),
],
);
addTearDown(container.dispose);
final service = container.read(meSyncProvider);
container.listen(meAssistantsProvider, (_, _) {});
await container.read(meAssistantsProvider.future);
expect(relay.directoryQueries, 1);
final a = signed(timestamp: 1);
final b = signed(
timestamp: 2,
groups: const MeGroups(
groups: [
{'id': 'new', 'name': 'Newest', 'defaultTemplateId': ''},
],
),
);
await service.cache(a);
relay.events = [a];
container.listen(meGroupsProvider, (_, _) {});
container.listen(meOfflineSaveWarningProvider, (_, _) {});
await container.read(meGroupsProvider.future);
store.failure = 'false';
relay.emit!(b);
await Future<void>.delayed(Duration.zero);
expect(
container.read(meGroupsProvider).value!.groups.single['name'],
'Newest',
);
expect(service.cacheWriteFailed.value, isTrue);
relay.setStatus(SessionStatus.disconnected);
await container.pump();
expect(container.read(meSyncProvider), same(service));
expect(service.connected, isFalse);
expect(
container.read(meGroupsProvider).value!.groups.single['name'],
'Newest',
);
expect(container.read(meOfflineSaveWarningProvider).value, isTrue);
relay.setStatus(SessionStatus.connected);
await container.pump();
await container.read(meGroupsProvider.future);
await Future<void>.delayed(Duration.zero);
expect(container.read(meSyncProvider), same(service));
expect(service.connected, isTrue);
await container.read(meAssistantsProvider.future);
expect(relay.directoryQueries, 2);
expect(
container.read(meGroupsProvider).value!.groups.single['name'],
'Newest',
);
expect(service.cacheWriteFailed.value, isTrue);
store.failure = null;
container.invalidate(meGroupsProvider);
await container.read(meGroupsProvider.future);
await Future<void>.delayed(Duration.zero);
expect(service.cachedSnapshot()?.event?.id, b.id);
expect(service.cacheWriteFailed.value, isFalse);
config.switchTo(RelayConfig(baseUrl: 'https://other.test', nsec: nsec));
await container.pump();
final other = container.read(meSyncProvider);
expect(service.disposed, isTrue);
expect(other.latestCacheCandidate, isNull);
expect(other.cacheWriteFailed.value, isFalse);
},
);
test(
'newer failed cache fences older arrivals and Retry retains newest',
() async {
SharedPreferences.setMockInitialValues({});
final store = _FailingCacheStore();
SharedPreferencesStorePlatform.instance = store;
final prefs = await SharedPreferences.getInstance();
final relay = FakeRelay();
final service = MeSync(
relay,
RelayConfig(baseUrl: origin, nsec: nsec),
prefs: prefs,
);
final initial = signed(timestamp: 1);
await service.cache(initial);
relay.events = [initial];
final container = ProviderContainer(
overrides: [meSyncProvider.overrideWithValue(service)],
);
addTearDown(container.dispose);
final seen = <String>[];
container.listen(meGroupsProvider, (_, value) {
if (value.hasValue) {
seen.add(
value.value!.groups.firstOrNull?['name'] as String? ?? 'initial',
);
}
});
container.listen(meOfflineSaveWarningProvider, (_, _) {});
await container.read(meGroupsProvider.future);
await Future<void>.delayed(Duration.zero);
final newer = signed(
timestamp: 3,
groups: const MeGroups(
groups: [
{'id': 'new', 'name': 'New', 'defaultTemplateId': ''},
],
),
);
final older = signed(
timestamp: 2,
groups: const MeGroups(
groups: [
{'id': 'old', 'name': 'Old', 'defaultTemplateId': ''},
],
),
);
store.blockedWrite = Completer<bool>();
relay.emit!(newer);
await Future<void>.delayed(Duration.zero);
final writes = store.cacheWrites;
relay.emit!(older);
// Also exercise the persistence fence independently of the reader.
final olderWrite = service.cache(older);
final blockedWrite = store.blockedWrite!;
store.blockedWrite = null;
blockedWrite.complete(false);
await olderWrite;
await Future<void>.delayed(Duration.zero);
expect(store.cacheWrites, writes);
expect(seen, isNot(contains('Old')));
expect(
container.read(meGroupsProvider).value!.groups.single['name'],
'New',
);
expect(service.cacheWriteFailed.value, isTrue);
expect(service.cachedSnapshot()?.event?.id, initial.id);
store.blockedWrite = null;
relay.events = [older];
container.invalidate(meGroupsProvider);
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
expect(service.cacheWriteFailed.value, isFalse);
expect(service.cachedSnapshot()?.event?.id, newer.id);
},
);
for (final absent in [false, true]) {
test(
'confirmed cleanup preserves ${absent ? 'already absent' : 'replaced'} journal',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final relay = FakeRelay();
final service = MeSync(
relay,
RelayConfig(baseUrl: origin, nsec: nsec),
prefs: prefs,
);
relay.beforeRead = () {
if (relay.writes > 0) {
if (absent) {
unawaited(prefs.remove(service.pendingKey));
} else {
unawaited(
prefs.setString(service.pendingKey, 'another pending record'),
);
}
}
};
if (absent) {
await service.update((v) => v);
expect(prefs.getString(service.pendingKey), isNull);
} else {
await expectLater(service.update((v) => v), throwsStateError);
expect(prefs.getString(service.pendingKey), 'another pending record');
}
},
);
}
for (final recovered in [false, true]) {
for (final failure in ['false', 'throw']) {
test(
'confirmed ${recovered ? 'recovery' : 'normal'} cleanup $failure remains retryable',
() async {
SharedPreferences.setMockInitialValues({});
final store = _FailingCacheStore();
SharedPreferencesStorePlatform.instance = store;
final prefs = await SharedPreferences.getInstance();
final relay = FakeRelay();
final config = RelayConfig(baseUrl: origin, nsec: nsec);
var service = MeSync(relay, config, prefs: prefs);
if (recovered) {
relay.loseAcknowledgment = true;
await expectLater(
service.update((v) => v),
throwsA(isA<TimeoutException>()),
);
relay.loseAcknowledgment = false;
service = MeSync(relay, config, prefs: prefs);
}
store.removeFailure = failure;
await expectLater(
service.update((v) => v),
throwsA(
isA<StateError>().having(
(e) => e.message,
'message',
contains('cleanup on this phone failed'),
),
),
);
await prefs.reload();
expect(prefs.getString(service.pendingKey), isNotNull);
expect(relay.writes, 1);
store.removeFailure = null;
await expectLater(
MeSync(relay, config, prefs: prefs).update((v) => v),
throwsA(
isA<StateError>().having(
(e) => e.message,
'message',
contains('previous Solo save is confirmed'),
),
),
);
expect(prefs.getString(service.pendingKey), isNull);
expect(relay.writes, 1);
},
);
}
}
for (final failure in ['false', 'throw']) {
test(
'snapshot write $failure exposes failure and Retry persists the same event',
() async {
SharedPreferences.setMockInitialValues({});
final store = _FailingCacheStore();
SharedPreferencesStorePlatform.instance = store;
final prefs = await SharedPreferences.getInstance();
final relay = FakeRelay();
final config = RelayConfig(baseUrl: origin, nsec: nsec);
final service = MeSync(relay, config, prefs: prefs);
final old = signed(timestamp: 1);
await service.cache(old);
final fresh = signed(
timestamp: 2,
groups: const MeGroups(
channels: ['11111111-1111-4111-8111-111111111111'],
),
);
relay.events = [fresh];
store.failure = failure;
final container = ProviderContainer(
overrides: [meSyncProvider.overrideWithValue(service)],
);
addTearDown(container.dispose);
final failed = Completer<void>();
container.listen(meGroupsProvider, (_, _) {});
container.listen(meOfflineSaveWarningProvider, (_, next) {
if (next.value == true && !failed.isCompleted) failed.complete();
});
await failed.future;
expect(container.read(meGroupsProvider).hasError, isFalse);
expect(container.read(meOfflineSaveWarningProvider).value, isTrue);
expect(service.cachedSnapshot()?.event?.id, old.id);
store.failure = null;
container.invalidate(meGroupsProvider);
final restored = Completer<void>();
final subscription = container.listen(meOfflineSaveWarningProvider, (
_,
next,
) {
if (next.value == false && !restored.isCompleted) restored.complete();
});
await restored.future;
subscription.close();
await prefs.reload();
final restarted = MeSync(
FakeRelay(),
config,
prefs: prefs,
connected: false,
);
expect(restarted.cachedSnapshot()?.event?.id, fresh.id);
},
);
}
test(
'offline relaunch emits encrypted cached organization and isolates accounts',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final config = RelayConfig(baseUrl: origin, nsec: nsec);
final online = MeSync(FakeRelay(), config, prefs: prefs);
const groups = MeGroups(
groups: [
{'id': 'project', 'name': 'Private project', 'defaultTemplateId': ''},
],
);
await online.cache(signed(groups: groups, timestamp: 3));
await online.cache(signed(timestamp: 2));
await online.cache(null);
expect(
prefs.getString(online.cacheKey),
isNot(contains('Private project')),
);
final offline = MeSync(
FakeRelay(),
config,
prefs: prefs,
connected: false,
);
final container = ProviderContainer(
overrides: [meSyncProvider.overrideWithValue(offline)],
retry: (_, _) => null,
);
addTearDown(container.dispose);
container.listen(meGroupsProvider, (_, _) {});
expect(
(await container.read(meGroupsProvider.future)).groups.single['name'],
'Private project',
);
await Future<void>.delayed(Duration.zero);
expect(container.read(meGroupsProvider).hasError, isFalse);
for (final other in [
RelayConfig(baseUrl: 'https://other.test', nsec: nsec),
RelayConfig(
baseUrl: origin,
nsec: nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data: '0' * 63 + '2',
),
),
]) {
expect(
MeSync(
FakeRelay(),
other,
prefs: prefs,
connected: false,
).cachedSnapshot(),
isNull,
);
}
},
);
test(
'corrupt cache refreshes online and persists the verified replacement',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final relay = FakeRelay()..events = [signed(timestamp: 5)];
final service = MeSync(
relay,
RelayConfig(baseUrl: origin, nsec: nsec),
prefs: prefs,
);
await prefs.setString(service.cacheKey, 'corrupt');
final container = ProviderContainer(
overrides: [meSyncProvider.overrideWithValue(service)],
retry: (_, _) => null,
);
addTearDown(container.dispose);
container.listen(meGroupsProvider, (_, _) {});
expect((await container.read(meGroupsProvider.future)).channels, isEmpty);
await Future<void>.delayed(Duration.zero);
expect(service.cachedSnapshot()?.event?.id, relay.events.single.id);
},
);
test(
'empty organization survives relaunch without relay connectivity',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final config = RelayConfig(baseUrl: origin, nsec: nsec);
await MeSync(FakeRelay(), config, prefs: prefs).cache(null);
final offline = MeSync(
FakeRelay(),
config,
prefs: prefs,
connected: false,
);
expect(offline.cachedSnapshot()?.groups.channels, isEmpty);
},
);
test(
'cached organization emits before refresh and survives a closed subscription',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final response = Completer<List<NostrEvent>>();
final relay = FakeRelay()..pendingRead = response.future;
final service = MeSync(
relay,
RelayConfig(baseUrl: origin, nsec: nsec),
prefs: prefs,
);
final old = signed(timestamp: 1);
await service.cache(old);
final container = ProviderContainer(
overrides: [meSyncProvider.overrideWithValue(service)],
retry: (_, _) => null,
);
addTearDown(container.dispose);
container.listen(meGroupsProvider, (_, _) {});
expect((await container.read(meGroupsProvider.future)).groups, isEmpty);
final fresh = signed(
timestamp: 2,
groups: const MeGroups(
groups: [
{'id': 'new', 'name': 'From desktop', 'defaultTemplateId': ''},
],
),
);
response.complete([fresh]);
await Future<void>.delayed(Duration.zero);
expect(
container.read(meGroupsProvider).value?.groups.single['name'],
'From desktop',
);
expect(service.cachedSnapshot()?.event?.id, fresh.id);
relay.closeSubscription!('offline');
await Future<void>.delayed(Duration.zero);
expect(container.read(meGroupsProvider).hasError, isFalse);
expect(
container.read(meGroupsProvider).value?.groups.single['name'],
'From desktop',
);
},
);
test('verifies signature, account and encrypted community envelope', () {
final sync = MeSync(FakeRelay(), RelayConfig(baseUrl: origin, nsec: nsec));
expect(sync.decode(signed()).channels, isEmpty);
expect(
() => sync.decode(signed(community: 'https://other.test')),
throwsFormatException,
);
expect(
() => sync.decode(
signed(
signingKey:
'0000000000000000000000000000000000000000000000000000000000000002',
),
),
throwsFormatException,
);
final tampered = signed().toJson()..['sig'] = '0' * 128;
expect(() => sync.decode(NostrEvent.fromJson(tampered)), throwsA(anything));
});
test('equal timestamp selects the lowest event ID', () {
final a = signed(timestamp: 2);
final b = signed(timestamp: 2);
expect(meEventIsNewer(a, b), a.id.compareTo(b.id) < 0);
expect(meEventIsNewer(signed(timestamp: 3), a), isTrue);
});
test('scope disposal during preparation prevents publication', () async {
final relay = FakeRelay();
final sync = MeSync(relay, RelayConfig(baseUrl: origin, nsec: nsec));
relay.beforeRead = () => sync.disposed = true;
await expectLater(sync.update((v) => v), throwsStateError);
expect(relay.writes, 0);
});
test('concurrent desktop change blocks stale replacement', () async {
final relay = FakeRelay()..events = [signed()];
final sync = MeSync(relay, RelayConfig(baseUrl: origin, nsec: nsec));
var reads = 0;
relay.beforeRead = () {
if (++reads == 2) relay.events = [signed(timestamp: 2)];
};
await expectLater(sync.update((v) => v), throwsStateError);
expect(relay.writes, 0);
});
test(
'unconfirmed save survives service restart and never silently replaces it',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final relay = FakeRelay()..loseAcknowledgment = true;
final config = RelayConfig(baseUrl: origin, nsec: nsec);
final first = MeSync(relay, config, prefs: prefs);
await expectLater(
first.update((v) => v),
throwsA(isA<TimeoutException>()),
);
final pending = prefs.getString(first.pendingKey);
expect(pending, isNotNull);
expect(
jsonDecode(pending!)['event']['content'],
isNot(contains('groups')),
);
final second = MeSync(relay, config, prefs: prefs);
await expectLater(second.update((v) => v), throwsStateError);
expect(relay.writes, 1);
expect(prefs.getString(first.pendingKey), isNull);
},
);
test(
'superseded pending save rebases local additions after restart',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final relay = FakeRelay();
final config = RelayConfig(baseUrl: origin, nsec: nsec);
final sync = MeSync(relay, config, prefs: prefs);
const base = MeGroups(
groups: [
{'id': 'old', 'name': 'Original', 'defaultTemplateId': ''},
],
);
const local = MeGroups(
groups: [
{'id': 'old', 'name': 'Original', 'defaultTemplateId': ''},
{'id': 'local', 'name': 'Phone project', 'defaultTemplateId': ''},
],
);
const remote = MeGroups(
groups: [
{'id': 'old', 'name': 'Desktop rename', 'defaultTemplateId': ''},
{'id': 'remote', 'name': 'Desktop project', 'defaultTemplateId': ''},
],
);
await prefs.setString(
sync.pendingKey,
jsonEncode({
'event': signed(groups: local, timestamp: 2).toJson(),
'base': signed(groups: base, timestamp: 1).toJson(),
}),
);
relay.events = [signed(groups: remote, timestamp: 3)];
final restarted = MeSync(relay, config, prefs: prefs);
var newIntentRan = false;
await expectLater(
restarted.update((v) {
newIntentRan = true;
return v;
}),
throwsStateError,
);
expect(newIntentRan, isFalse);
expect(
restarted.decode(relay.events.single).groups.map((g) => g['name']),
['Desktop rename', 'Desktop project', 'Phone project'],
);
expect(prefs.getString(sync.pendingKey), isNull);
expect(relay.writes, 1);
},
);
test('conflicting edits remain durable until explicit discard', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final relay = FakeRelay();
final sync = MeSync(
relay,
RelayConfig(baseUrl: origin, nsec: nsec),
prefs: prefs,
);
MeGroups group(String name) => MeGroups(
groups: [
{'id': 'project', 'name': name, 'defaultTemplateId': ''},
],
);
final stored = jsonEncode({
'event': signed(groups: group('Phone'), timestamp: 2).toJson(),
'base': signed(groups: group('Original')).toJson(),
});
await prefs.setString(sync.pendingKey, stored);
final remote = signed(groups: group('Desktop'), timestamp: 3);
relay.events = [remote];
await expectLater(
sync.update((v) => v),
throwsA(isA<MeRecoveryConflict>()),
);
expect(prefs.getString(sync.pendingKey), stored);
expect(relay.writes, 0);
await expectLater(sync.discardPending('stale snapshot'), throwsStateError);
expect(prefs.getString(sync.pendingKey), stored);
await sync.discardPending(stored);
await sync.update(
(v) => MeGroups(
groups: [
...v.groups,
{'id': 'new', 'name': 'New', 'defaultTemplateId': ''},
],
),
);
expect(sync.decode(relay.events.single).groups.map((g) => g['name']), [
'Desktop',
'New',
]);
expect(prefs.getString(sync.pendingKey), isNull);
});
}