Files
buzz/mobile/test/shared/me/me_sync_test.dart
Wes fffd8ca957 fix(mobile): recover Solo sync silently instead of showing a retry banner (#8701)
## Problem

The mobile Solo section could display "Live updates stopped. Your saved
chats are still available." with a **Retry live updates** button. Any
failed Solo head refresh raised this banner. That includes the refresh
that runs on app resume before the relay has reconnected, and an 8s
history timeout. In those cases the live subscription itself was still
healthy. A later successful refresh did not clear the banner, so it
stayed up until someone tapped Retry.

## Change

- Remove the banner along with `MeSync.syncClosed` and
`meSyncWarningProvider`. Saved chats stay visible the whole time.
- `meGroupsProvider`: on app resume, an interrupted reader (terminal
CLOSED or a failed read) resubscribes on its own while the relay is
connected. Before the relay reconnects, the existing
rebuild-on-reconnect still owns recovery. A healthy reader keeps doing a
plain head refresh.
- Home's pull-to-refresh (`ChannelsPage` with the Solo sliver) now also
restarts the Solo reader. Before this change, that path refreshed
channels only, so in the shipped layout the banner was the only manual
recovery.
- The no-snapshot `_MeRetry` state and the offline-save warning are
unchanged.

## Validation

- `flutter analyze` is clean.
- The pre-push `mobile-checks` passed.
- The journey step in `offline_save_page_test.dart` closes the
subscription, cycles background and foreground, asserts a new
subscription, and checks that a newer event delivered on it is saved.
Removing the `subscriptionClosed` recovery branch fails it.
- The new `channels_page_test.dart` case drags to refresh with a Solo
sliver and asserts the Solo reader restarts. Removing the invalidation
fails it.
- Not yet exercised in the running app. That is checklist steps 2 and 3.

Reviewed by the Royal Court agents (provider lifecycle, recovery/UX,
independent challenge); the one blocker (Home pull-to-refresh) is fixed
here.

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

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
2026-10-29 14:33:35 -06: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/me/me_archived_agents.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: [
meArchivedAgentsProvider.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);
});
}