import 'dart:convert'; import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:synchronize/synchronize.dart'; import 'package:test/test.dart'; abstract class Wrap { Wrap(this.content); final String content; } class WrapA extends Wrap { WrapA(super.content); } class WrapB extends Wrap { WrapB(super.content); } class TestSyncState { TestSyncState( this.stateA, this.stateB, ); final Map stateA; final Map stateB; } class TestSyncSourceA implements SyncSource { TestSyncSourceA(this.state); final Map state; @override Future>> listObjects() async => state.keys.map((final key) => (id: key, data: state[key]!)).toList(); @override Future getObjectETag(final SyncObject object) async => etagA(object.data.content); @override Future> writeObject(final SyncObject object) async { final wrap = WrapA(object.data.content); state[object.id] = wrap; return (id: object.id, data: wrap); } @override Future deleteObject(final SyncObject object) async => state.remove(object.id); } class TestSyncSourceB implements SyncSource { TestSyncSourceB(this.state); final Map state; @override Future>> listObjects() async => state.keys.map((final key) => (id: key, data: state[key]!)).toList(); @override Future getObjectETag(final SyncObject object) async => etagB(object.data.content); @override Future> writeObject(final SyncObject object) async { final wrap = WrapB(object.data.content); state[object.id] = wrap; return (id: object.id, data: wrap); } @override Future deleteObject(final SyncObject object) async => state.remove(object.id); } class TestSyncSources implements SyncSources { TestSyncSources( this.sourceA, this.sourceB, ); factory TestSyncSources.fromState(final TestSyncState state) => TestSyncSources( TestSyncSourceA(state.stateA), TestSyncSourceB(state.stateB), ); @override final SyncSource sourceA; @override final SyncSource sourceB; @override SyncConflictSolution? findSolution(final SyncObject objectA, final SyncObject objectB) => null; } String etagA(final String content) => sha1.convert(utf8.encode('A$content')).toString(); String etagB(final String content) => sha1.convert(utf8.encode('B$content')).toString(); String randomEtag() => sha1.convert(utf8.encode(Random().nextDouble().toString())).toString(); Future main() async { group('sync', () { group('stub', () { test('all empty', () async { final state = TestSyncState({}, {}); final sources = TestSyncSources.fromState(state); final journal = SyncJournal(); final conflicts = await sync(sources, journal); expect(conflicts, isEmpty); expect(state.stateA, isEmpty); expect(state.stateB, isEmpty); expect(journal.entries, isEmpty); }); group('copy', () { group('missing', () { test('to A', () async { const id = '123'; const content = '456'; final state = TestSyncState( {}, { id: WrapB(content), }, ); final sources = TestSyncSources.fromState(state); final journal = SyncJournal(); final conflicts = await sync(sources, journal); expect(conflicts, isEmpty); expect(state.stateA, hasLength(1)); expect(state.stateA[id]!.content, content); expect(state.stateB, hasLength(1)); expect(state.stateB[id]!.content, content); expect(journal.entries, hasLength(1)); expect(journal.entries.tryFind(id)!.etagA, etagA(content)); expect(journal.entries.tryFind(id)!.etagB, etagB(content)); }); test('to B', () async { const id = '123'; const content = '456'; final state = TestSyncState( { id: WrapA(content), }, {}, ); final sources = TestSyncSources.fromState(state); final journal = SyncJournal(); final conflicts = await sync(sources, journal); expect(conflicts, isEmpty); expect(state.stateA, hasLength(1)); expect(state.stateA[id]!.content, content); expect(state.stateB, hasLength(1)); expect(state.stateB[id]!.content, content); expect(journal.entries, hasLength(1)); expect(journal.entries.tryFind(id)!.etagA, etagA(content)); expect(journal.entries.tryFind(id)!.etagB, etagB(content)); }); }); group('changed', () { test('to A', () async { const id = '123'; const contentA = '456'; const contentB = '789'; final state = TestSyncState( { id: WrapA(contentA), }, { id: WrapB(contentB), }, ); final sources = TestSyncSources.fromState(state); final journal = SyncJournal({ SyncJournalEntry(id, etagA(contentA), randomEtag()), }); final conflicts = await sync(sources, journal); expect(conflicts, isEmpty); expect(state.stateA, hasLength(1)); expect(state.stateA[id]!.content, contentB); expect(state.stateB, hasLength(1)); expect(state.stateB[id]!.content, contentB); expect(journal.entries, hasLength(1)); expect(journal.entries.tryFind(id)!.etagA, etagA(contentB)); expect(journal.entries.tryFind(id)!.etagB, etagB(contentB)); }); test('to B', () async { const id = '123'; const contentA = '456'; const contentB = '789'; final state = TestSyncState( { id: WrapA(contentA), }, { id: WrapB(contentB), }, ); final sources = TestSyncSources.fromState(state); final journal = SyncJournal({ SyncJournalEntry(id, randomEtag(), etagB(contentB)), }); final conflicts = await sync(sources, journal); expect(conflicts, isEmpty); expect(state.stateA, hasLength(1)); expect(state.stateA[id]!.content, contentA); expect(state.stateB, hasLength(1)); expect(state.stateB[id]!.content, contentA); expect(journal.entries, hasLength(1)); expect(journal.entries.tryFind(id)!.etagA, etagA(contentA)); expect(journal.entries.tryFind(id)!.etagB, etagB(contentA)); }); }); }); group('delete', () { test('from A', () async { const id = '123'; const content = '456'; final state = TestSyncState( { id: WrapA(content), }, {}, ); final sources = TestSyncSources.fromState(state); final journal = SyncJournal({ SyncJournalEntry(id, etagA(content), etagB(content)), }); final conflicts = await sync(sources, journal); expect(conflicts, isEmpty); expect(state.stateA, isEmpty); expect(state.stateB, isEmpty); expect(journal.entries, isEmpty); }); test('from B', () async { const id = '123'; const content = '456'; final state = TestSyncState( {}, { id: WrapB(content), }, ); final sources = TestSyncSources.fromState(state); final journal = SyncJournal({ SyncJournalEntry(id, etagA(content), etagB(content)), }); final conflicts = await sync(sources, journal); expect(conflicts, isEmpty); expect(state.stateA, isEmpty); expect(state.stateB, isEmpty); expect(journal.entries, isEmpty); }); test('from journal', () async { const id = '123'; const content = '456'; final state = TestSyncState({}, {}); final sources = TestSyncSources.fromState(state); final journal = SyncJournal({ SyncJournalEntry(id, etagA(content), etagB(content)), }); final conflicts = await sync(sources, journal); expect(conflicts, isEmpty); expect(state.stateA, isEmpty); expect(state.stateB, isEmpty); expect(journal.entries, isEmpty); }); }); group('conflict', () { test('journal missing', () async { const id = '123'; const content = '456'; final state = TestSyncState( { id: WrapA(content), }, { id: WrapB(content), }, ); final sources = TestSyncSources.fromState(state); final journal = SyncJournal(); final conflicts = await sync(sources, journal); expect(conflicts, hasLength(1)); expect(conflicts[0].type, SyncConflictType.bothNew); expect(state.stateA, hasLength(1)); expect(state.stateA[id]!.content, content); expect(state.stateB, hasLength(1)); expect(state.stateB[id]!.content, content); expect(journal.entries, isEmpty); }); test('both changed', () async { const id = '123'; const contentA = '456'; const contentB = '789'; final state = TestSyncState( { id: WrapA(contentA), }, { id: WrapB(contentB), }, ); final sources = TestSyncSources.fromState(state); final journal = SyncJournal({ SyncJournalEntry(id, randomEtag(), randomEtag()), }); final conflicts = await sync(sources, journal); expect(conflicts, hasLength(1)); expect(conflicts[0].type, SyncConflictType.bothChanged); expect(state.stateA, hasLength(1)); expect(state.stateA[id]!.content, contentA); expect(state.stateB, hasLength(1)); expect(state.stateB[id]!.content, contentB); expect(journal.entries, hasLength(1)); }); group('solution', () { group('journal missing', () { test('skip', () async { const id = '123'; const contentA = '456'; const contentB = '789'; final state = TestSyncState( { id: WrapA(contentA), }, { id: WrapB(contentB), }, ); final sources = TestSyncSources.fromState(state); final journal = SyncJournal(); final conflicts = await sync( sources, journal, conflictSolutions: { id: SyncConflictSolution.skip, }, ); expect(conflicts, isEmpty); expect(state.stateA, hasLength(1)); expect(state.stateA[id]!.content, contentA); expect(state.stateB, hasLength(1)); expect(state.stateB[id]!.content, contentB); expect(journal.entries, isEmpty); }); test('overwrite A', () async { const id = '123'; const contentA = '456'; const contentB = '789'; final state = TestSyncState( { id: WrapA(contentA), }, { id: WrapB(contentB), }, ); final sources = TestSyncSources.fromState(state); final journal = SyncJournal(); final conflicts = await sync( sources, journal, conflictSolutions: { id: SyncConflictSolution.overwriteA, }, ); expect(conflicts, isEmpty); expect(state.stateA, hasLength(1)); expect(state.stateA[id]!.content, contentB); expect(state.stateB, hasLength(1)); expect(state.stateB[id]!.content, contentB); expect(journal.entries, hasLength(1)); expect(journal.entries.tryFind(id)!.etagA, etagA(contentB)); expect(journal.entries.tryFind(id)!.etagB, etagB(contentB)); }); test('overwrite B', () async { const id = '123'; const contentA = '456'; const contentB = '789'; final state = TestSyncState( { id: WrapA(contentA), }, { id: WrapB(contentB), }, ); final sources = TestSyncSources.fromState(state); final journal = SyncJournal(); final conflicts = await sync( sources, journal, conflictSolutions: { id: SyncConflictSolution.overwriteB, }, ); expect(conflicts, isEmpty); expect(state.stateA, hasLength(1)); expect(state.stateA[id]!.content, contentA); expect(state.stateB, hasLength(1)); expect(state.stateB[id]!.content, contentA); expect(journal.entries, hasLength(1)); expect(journal.entries.tryFind(id)!.etagA, etagA(contentA)); expect(journal.entries.tryFind(id)!.etagB, etagB(contentA)); }); }); group('both changed', () { test('skip', () async { const id = '123'; const contentA = '456'; const contentB = '789'; final state = TestSyncState( { id: WrapA(contentA), }, { id: WrapB(contentB), }, ); final sources = TestSyncSources.fromState(state); final journal = SyncJournal({ SyncJournalEntry(id, randomEtag(), randomEtag()), }); final conflicts = await sync( sources, journal, conflictSolutions: { id: SyncConflictSolution.skip, }, ); expect(conflicts, isEmpty); expect(state.stateA, hasLength(1)); expect(state.stateA[id]!.content, contentA); expect(state.stateB, hasLength(1)); expect(state.stateB[id]!.content, contentB); expect(journal.entries, hasLength(1)); }); test('overwrite A', () async { const id = '123'; const contentA = '456'; const contentB = '789'; final state = TestSyncState( { id: WrapA(contentA), }, { id: WrapB(contentB), }, ); final sources = TestSyncSources.fromState(state); final journal = SyncJournal({ SyncJournalEntry(id, randomEtag(), randomEtag()), }); final conflicts = await sync( sources, journal, conflictSolutions: { id: SyncConflictSolution.overwriteA, }, ); expect(conflicts, isEmpty); expect(state.stateA, hasLength(1)); expect(state.stateA[id]!.content, contentB); expect(state.stateB, hasLength(1)); expect(state.stateB[id]!.content, contentB); expect(journal.entries, hasLength(1)); expect(journal.entries.tryFind(id)!.etagA, etagA(contentB)); expect(journal.entries.tryFind(id)!.etagB, etagB(contentB)); }); test('overwrite B', () async { const id = '123'; const contentA = '456'; const contentB = '789'; final state = TestSyncState( { id: WrapA(contentA), }, { id: WrapB(contentB), }, ); final sources = TestSyncSources.fromState(state); final journal = SyncJournal({ SyncJournalEntry(id, randomEtag(), randomEtag()), }); final conflicts = await sync( sources, journal, conflictSolutions: { id: SyncConflictSolution.overwriteB, }, ); expect(conflicts, isEmpty); expect(state.stateA, hasLength(1)); expect(state.stateA[id]!.content, contentA); expect(state.stateB, hasLength(1)); expect(state.stateB[id]!.content, contentA); expect(journal.entries, hasLength(1)); expect(journal.entries.tryFind(id)!.etagA, etagA(contentA)); expect(journal.entries.tryFind(id)!.etagB, etagB(contentA)); }); }); }); }); }); }); }