Files
buzz/mobile/test/shared/me/me_sync_test.dart
klopez4212 b1d60f98e2 Bring synced Solo chats and projects to mobile (#8337)
## Summary
Bring desktop Solo chats and project organization to mobile, with synced
collapsible sections, shared unread highlights and tab badges, agent
selection, shared search, native iOS creation sheets, and inline typing
bubbles that transition into replies.

Resolve Solo placement for every chat entry route, retain encrypted
organization snapshots for offline reading, and let users resume or
discard unfinished chat and project setup without deleting existing
messages.

### Related issue
Related: #6565; expandable agent transcripts remain separate work.

### Testing
- Fresh Solo read state seeds each active chat from its captured initial
last-message timestamp before exposing row/section/tab unread state.
Baselines survive intervening channel updates and reset across
identity/community boundaries while surviving read-state readiness
changes. Regressions cover multiple chats, events before read-state
readiness and during/after seeding, and identity changes; removing
either the seed or the display gate fails them.
- Chat completion reconciles a retained Solo-save journal even when
placement is already confirmed. False/throwing cleanup regressions
ensure the outer intent remains until reconciliation and only one
placement is published. Typing terminal-close loops remain bounded; the
retry budget resets after an uninterrupted eight-second health interval
or transport reconnect.
- Typing retry budgets reset after sustained recovery; invalid Solo
updates retain cached rows with a retry warning that survives failed
refreshes. Project names use the editors’ 120-grapheme limit, including
emoji and combining characters. Regression coverage exercises repeated
recovery, invalid live/refresh heads, and character-limit boundaries.
- Rebased onto current main, preserving the newer Search controls,
Huddle timeline filtering, and message-media layout.
- Failed project journal writes reload storage before claiming recovery
and reopen the entered name for retry. Typing subscription rejections
are caught; terminal closures clear stale indicators and retry with a
bounded backoff, canceled on disposal. Regression tests cover both
false/throwing storage writes and initial/late subscription rejection.
- Explicit ordinary destinations skip optional Solo lookup waits.
Generic destinations perform one bounded lookup in the destination
(search no longer duplicates it); an unavailable classification falls
back to ordinary sending per the product owner's explicit choice.
Positively identified Solo destinations use Solo providers.
- Terminal Solo subscription closure preserves cached rows and exposes
live-update Retry. Page coverage verifies warning visibility, usable
rows during recovery, and a subsequent terminal close. Removing the
warning or the immediate ordinary-route guard fails its regression.
- Generic channel entries share an ordinary default when Solo
classification is unavailable, per the requested product behavior. Known
Solo placement takes precedence; callers can explicitly require
unresolved placement to remain blocked. Bounded initial lookup preserves
known Solo deep-link routing.
- Dirty newest snapshots and offline-save warnings survive transport
reconnects; Retry persists that same event even if the relay returns an
older head. Community changes replace the service, and agent-directory
reconnect refresh remains intact.
- Pending-save discard reloads failed removals. Repeated reconnect
testing confirms typing subscriptions are released on provider rebuild
and disposal; removing the existing cleanup fails the regression.
- Ordinary Home/create routes explicitly preserve private-channel
sending during Solo failures; identified Solo sends use the Solo sender
and explicit unresolved routes remain fail-closed. Solo unread replies
beyond the initial 200-event page are loaded through the oldest-unread
boundary.
- Chat/project/agent cleanup reloads failed removals, retains
same-process recovery, and protects replaced records. False/throw
regressions verify retries do not duplicate creation or membership.
Route, unread, and cleanup guard-removal mutations each fail their
regression.
- Ordinary channels, DMs, and forums remain sendable when optional Solo
sync fails; explicit unresolved placement stays blocked. Controlled
cache races retain the newest verified snapshot, keep usable chats
visible, and retry offline persistence without stale writes. Both
protection-removal regressions fail as expected.
- Confirmed journal cleanup checks false/throwing removal and
exact-record identity. Interrupted agent choices are visible
immediately, identify the saved agent, and cover Resume/Later/Discard
with accessible name/key-fallback controls.
- Notification deep links preserve the contiguous history cursor;
same-second messages infer the newest agent consistently. Failed
offline-cache writes surface Retry, with false/throwing storage failures
and offline restart covered.
- Composite history cursors cover 350 same-second events plus older
history; removing the ID cursor makes the regression fail. Agent changes
now journal before granting access, with restart/failure and
Resume/Later/Discard widget coverage. Signed malformed profiles remain
isolated.
- Native iOS avatar tests passed on the simulator: visible-row loading,
three-request concurrency, streamed byte limits, response validation,
downsampling/pixel limits, cancellation, and visible avatar updates.
- Solo and Home share observed-event/read-marker logic. Widget coverage
verifies unread rows, collapsed-section indicators, the tab badge before
first Solo visit, message/thread reads, forced unread, and filtering
archived/nonmember/unplaced chats.
- Chat intent is saved before ownership and organization preflight;
tests cover interrupted ownership lookup and a disconnect while the
native creation form is open.
- Mobile analysis, formatting, file-size checks, and full mobile tests
passed at `1d4c41354`.
- Project creation persists its encrypted name/UUID before connectivity
checks or relay reads. Restart tests cover Resume/Later/Discard, scope
isolation, stale-intent rejection, and lost acknowledgements. Removing
the pre-network save makes all four production-flow regressions fail.
- Regression coverage includes notification/Activity routing, inline
replies, unknown-placement send protection, restart recovery/discard,
and encrypted offline cache isolation/refresh.
- Earlier feature build exercised on a physical iPhone with existing
account/community data; review follow-ups have not been reinstalled on
the device. UIKit agent-row accessibility checks passed on an iOS
simulator.
- Earlier cross-platform `just ci` stopped on the existing `buzz-acp`
test `signals_cancel_startup_turn_and_pending_stdin_without_hanging`
(process exit-code assertion); no Rust production changes are included
in this PR.

### Remaining review limitation
Agent discovery is bounded to 10 pages / 1,000 profiles with automatic
retries disabled. Owner-scoped discovery remains open because the
current relay ignores `#auth` filters; supporting that query requires a
relay change.

### Snapshots
Sample data, shared Flutter/Android surface. Native iOS uses platform
sheets and controls.

| Solo: projects and chats | New chat | Choose an agent |
| --- | --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--01-solo.png"
width="240" alt="Solo with collapsible projects and chats" /> | <img
src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--02-new-chat.png"
width="240" alt="New chat form" /> | <img
src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--03-agent-picker.png"
width="240" alt="Agent selection list" /> |

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-15 17:07:23 -04:00

798 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/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: [
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(service.syncClosed.value, isTrue);
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);
});
}