tane/apps/app_seeds/test/services/sync_service_test.dart
vjrj 225880fc64 feat(sync): device-to-device inventory sync, end to end
Wires the encrypted sync transport into a working replication between one
identity's own devices, reusing the CRDT-ready model (HLC + tombstones +
LWW) the data model was built for.

- SocialSession gains a 5th interface, sync, over the shared connection,
  built with a per-install deviceId + the app's inventory namespace.
- DeviceIdStore: a stable random per-install id in the keystore — distinct
  from the seed-derived CRDT node id, which is IDENTICAL across a user's
  devices (so it couldn't tell them apart / they'd clobber each other's
  replaceable record).
- InventorySnapshotIO (implemented by ExportImportService): build/apply the
  inventory as the SAME interchange JSON as backups but UNSEALED — the sync
  transport does the encryption, so it must not double-seal. Apply = the
  existing idempotent LWW importInventory.
- SyncService: on (re)connect publishes this device's snapshot + subscribes
  to the others'; a local edit (debounced, off tableUpdates) republishes;
  incoming snapshots merge idempotently; skips its own echo; a byte check
  skips re-publishing an unchanged snapshot so the ping-pong converges.
  Foreground only, like the inbox.
- DI/Bootstrap/switchSocialAccount wire and lifecycle it per identity.

Tests: DeviceIdStore, ExportImportService snapshot round-trip (unsealed +
idempotent), SyncService.handleRemote (applies others', skips own).

Follow-ups: deltas vs full-snapshot pushes; background sync; conflict UI.
2026-07-10 23:45:40 +02:00

59 lines
1.9 KiB
Dart

import 'package:commons_core/commons_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/services/inventory_snapshot_io.dart';
import 'package:tane/services/social_connection.dart';
import 'package:tane/services/social_service.dart';
import 'package:tane/services/social_settings.dart';
import 'package:tane/services/sync_service.dart';
import '../support/test_support.dart';
/// Records what it's asked to build/apply — no DB, no relay.
class _FakeIO implements InventorySnapshotIO {
final applied = <List<int>>[];
List<int> snapshot = const [1, 2, 3];
@override
Future<List<int>> buildSnapshot() async => snapshot;
@override
Future<void> applySnapshot(List<int> bytes) async => applied.add(bytes);
}
void main() {
const seedHex =
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
Future<SyncService> make(_FakeIO io) async => SyncService(
connection: SocialConnection(
social: await SocialService.fromRootSeedHex(seedHex),
settings: SocialSettings(InMemorySecretStore()),
open: (_) async => throw StateError('unused'),
online: const Stream.empty(),
),
io: io,
localChanges: const Stream.empty(),
selfDeviceId: 'me',
);
SyncSnapshot snap(String device, List<int> data) =>
SyncSnapshot(deviceId: device, data: data, at: DateTime(2026));
test('merges another device\'s snapshot into the local inventory', () async {
final io = _FakeIO();
final sync = await make(io);
await sync.handleRemote(snap('other', const [9, 9, 9]));
expect(io.applied, [
[9, 9, 9]
]);
await sync.stop();
});
test('ignores our own device\'s snapshot (no self-echo)', () async {
final io = _FakeIO();
final sync = await make(io);
await sync.handleRemote(snap('me', const [1]));
expect(io.applied, isEmpty);
await sync.stop();
});
}