adding packages
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pocketbase_sync/pocketbase_sync.dart';
|
||||
import 'package:pocketbase_sync/sync_managers/drift.dart';
|
||||
|
||||
// part 'drift_sync_repository_test.g.dart';
|
||||
|
||||
// Test Model
|
||||
class Note {
|
||||
final String id;
|
||||
final String content;
|
||||
Note({required this.id, required this.content});
|
||||
|
||||
Map<String, dynamic> toJson() => {'id': id, 'content': content};
|
||||
static Note fromJson(Map<String, dynamic> json) =>
|
||||
Note(id: json['id'], content: json['content']);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is Note && other.id == id && other.content == content;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, content);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late PackageDatabase database;
|
||||
late DriftSyncRepository<Note> repository;
|
||||
|
||||
setUp(() {
|
||||
// In-memory database for testing
|
||||
database = PackageDatabase(NativeDatabase.memory());
|
||||
repository = DriftSyncRepository<Note>(
|
||||
dbWrapper: database,
|
||||
collectionName: 'notes',
|
||||
toJson: (n) => n.toJson(),
|
||||
fromJson: (j) => Note.fromJson(j),
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await database.close();
|
||||
});
|
||||
|
||||
test('Save and Get', () async {
|
||||
final note = Note(id: '1', content: 'Test');
|
||||
final record = SyncRecord(id: '1', data: note, isDirty: true);
|
||||
|
||||
await repository.save(record);
|
||||
final fetched = await repository.get('1');
|
||||
|
||||
expect(fetched, isNotNull);
|
||||
expect(fetched!.data, equals(note));
|
||||
expect(fetched.isDirty, isTrue);
|
||||
});
|
||||
|
||||
test('Update updates existing record', () async {
|
||||
final note1 = Note(id: '1', content: 'Test 1');
|
||||
await repository.save(SyncRecord(id: '1', data: note1));
|
||||
|
||||
final note2 = Note(id: '1', content: 'Test 2');
|
||||
await repository.save(SyncRecord(id: '1', data: note2, isDirty: true));
|
||||
|
||||
final fetched = await repository.get('1');
|
||||
expect(fetched!.data.content, equals('Test 2'));
|
||||
expect(fetched.isDirty, isTrue);
|
||||
});
|
||||
|
||||
test('GetAll returns filtered by collection', () async {
|
||||
// Add note to correct collection
|
||||
await repository.save(
|
||||
SyncRecord(
|
||||
id: '1',
|
||||
data: Note(id: '1', content: 'A'),
|
||||
),
|
||||
);
|
||||
|
||||
// Add note to OTHER collection (using a different repo instance)
|
||||
final otherRepo = DriftSyncRepository<Note>(
|
||||
dbWrapper: database,
|
||||
collectionName: 'other_notes',
|
||||
toJson: (n) => n.toJson(),
|
||||
fromJson: (j) => Note.fromJson(j),
|
||||
);
|
||||
await otherRepo.save(
|
||||
SyncRecord(
|
||||
id: '2',
|
||||
data: Note(id: '2', content: 'B'),
|
||||
),
|
||||
);
|
||||
|
||||
final all = await repository.getAll();
|
||||
expect(all.length, equals(1));
|
||||
expect(all.first.id, equals('1'));
|
||||
});
|
||||
|
||||
test('LastSyncTime persistence', () async {
|
||||
final time = DateTime.now();
|
||||
await repository.setLastSyncTime(time);
|
||||
|
||||
final fetched = await repository.getLastSyncTime();
|
||||
// Precision might be lost in DB, check difference
|
||||
expect(
|
||||
fetched.difference(time).inMilliseconds.abs(),
|
||||
lessThan(1000),
|
||||
); // allow small diff
|
||||
});
|
||||
|
||||
test('Delete removes record', () async {
|
||||
await repository.save(
|
||||
SyncRecord(
|
||||
id: '1',
|
||||
data: Note(id: '1', content: 'A'),
|
||||
),
|
||||
);
|
||||
await repository.delete('1');
|
||||
|
||||
expect(await repository.get('1'), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import 'package:diff_algorithims/hybrid_diff.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('HybridDiffer Structural Tests (Myers)', () {
|
||||
test('Identical lists return no changes', () {
|
||||
final list = [
|
||||
{'id': 1, 'val': 'a'},
|
||||
{'id': 2, 'val': 'b'},
|
||||
];
|
||||
final changes = HybridDiffer.diff(list, list, idField: 'id');
|
||||
|
||||
// We expect equal nodes or no nodes depending on filtering.
|
||||
// The implementation returns Equal nodes for matches.
|
||||
expect(changes.every((c) => c.op == ChangeOp.equal), isTrue);
|
||||
expect(changes.length, equals(2));
|
||||
});
|
||||
|
||||
test('Detects simple insertion', () {
|
||||
final oldList = [
|
||||
{'id': 1, 'val': 'a'},
|
||||
];
|
||||
final newList = [
|
||||
{'id': 1, 'val': 'a'},
|
||||
{'id': 2, 'val': 'b'},
|
||||
];
|
||||
|
||||
final changes = HybridDiffer.diff(oldList, newList, idField: 'id');
|
||||
|
||||
expect(changes.length, equals(2));
|
||||
expect(changes[0].op, equals(ChangeOp.equal));
|
||||
expect(changes[1].op, equals(ChangeOp.insert));
|
||||
expect(changes[1].key, equals('2'));
|
||||
});
|
||||
|
||||
test('Detects simple deletion', () {
|
||||
final oldList = [
|
||||
{'id': 1, 'val': 'a'},
|
||||
{'id': 2, 'val': 'b'},
|
||||
];
|
||||
final newList = [
|
||||
{'id': 1, 'val': 'a'},
|
||||
];
|
||||
|
||||
final changes = HybridDiffer.diff(oldList, newList, idField: 'id');
|
||||
|
||||
expect(changes.length, equals(2));
|
||||
expect(changes[0].op, equals(ChangeOp.equal));
|
||||
expect(changes[1].op, equals(ChangeOp.delete));
|
||||
expect(changes[1].key, equals('2'));
|
||||
});
|
||||
|
||||
test('Detects moves (Delete + Insert)', () {
|
||||
// Myers detects moves as a delete of the old position and insert at new
|
||||
final oldList = [
|
||||
{'id': 1},
|
||||
{'id': 2},
|
||||
{'id': 3},
|
||||
];
|
||||
final newList = [
|
||||
{'id': 1},
|
||||
{'id': 3},
|
||||
{'id': 2},
|
||||
]; // 2 moved to end
|
||||
|
||||
final changes = HybridDiffer.diff(oldList, newList, idField: 'id');
|
||||
|
||||
// Expected: Equal(1), Equal(3), Insert(2), Delete(2) OR Equal(1), Delete(2), Equal(3), Insert(2)
|
||||
// Myers usually prefers deletes first if weights are equal, but let's check operations.
|
||||
final ops = changes.map((c) => c.op).toList();
|
||||
|
||||
expect(ops, contains(ChangeOp.insert));
|
||||
expect(ops, contains(ChangeOp.delete));
|
||||
|
||||
final insertNode = changes.firstWhere((c) => c.op == ChangeOp.insert);
|
||||
final deleteNode = changes.firstWhere((c) => c.op == ChangeOp.delete);
|
||||
|
||||
expect(insertNode.key, equals(deleteNode.key));
|
||||
expect(['2', '3'], contains(insertNode.key));
|
||||
});
|
||||
});
|
||||
|
||||
group('HybridDiffer Content Tests (Deep Diff)', () {
|
||||
test('Detects modification in primitive fields', () {
|
||||
final oldList = [
|
||||
{'id': 1, 'name': 'Alice'},
|
||||
];
|
||||
final newList = [
|
||||
{'id': 1, 'name': 'Bob'},
|
||||
];
|
||||
|
||||
final changes = HybridDiffer.diff(oldList, newList, idField: 'id');
|
||||
|
||||
expect(changes.length, equals(1));
|
||||
final mod = changes.first;
|
||||
|
||||
expect(mod.op, equals(ChangeOp.modify));
|
||||
expect(mod.children, isNotNull);
|
||||
expect(mod.children!.length, equals(1));
|
||||
|
||||
final fieldChange = mod.children!.first;
|
||||
expect(fieldChange.key, equals('name'));
|
||||
expect(fieldChange.oldValue, equals('Alice'));
|
||||
expect(fieldChange.newValue, equals('Bob'));
|
||||
});
|
||||
|
||||
test('Detects nested map changes recursively', () {
|
||||
final oldList = [
|
||||
{
|
||||
'id': 1,
|
||||
'meta': {'ver': 1, 'author': 'me'},
|
||||
},
|
||||
];
|
||||
final newList = [
|
||||
{
|
||||
'id': 1,
|
||||
'meta': {'ver': 2, 'author': 'me'},
|
||||
},
|
||||
];
|
||||
|
||||
final changes = HybridDiffer.diff(oldList, newList, idField: 'id');
|
||||
final rootMod = changes.first;
|
||||
|
||||
// Look inside the 'meta' field
|
||||
final metaMod = rootMod.children!.firstWhere((c) => c.key == 'meta');
|
||||
expect(metaMod.op, equals(ChangeOp.modify));
|
||||
|
||||
// Look inside 'ver' field
|
||||
final verMod = metaMod.children!.firstWhere((c) => c.key == 'ver');
|
||||
expect(verMod.oldValue, equals(1));
|
||||
expect(verMod.newValue, equals(2));
|
||||
});
|
||||
|
||||
test('Handles null values correctly', () {
|
||||
final oldList = [
|
||||
{'id': 1, 'val': null},
|
||||
];
|
||||
final newList = [
|
||||
{'id': 1, 'val': 'not null'},
|
||||
];
|
||||
|
||||
final changes = HybridDiffer.diff(oldList, newList, idField: 'id');
|
||||
final fieldChange = changes.first.children!.first;
|
||||
|
||||
expect(fieldChange.key, equals('val'));
|
||||
expect(fieldChange.oldValue, isNull);
|
||||
expect(fieldChange.newValue, equals('not null'));
|
||||
});
|
||||
});
|
||||
|
||||
group('String Splice Optimization', () {
|
||||
test('Generates TextSplice for string changes', () {
|
||||
final oldList = [
|
||||
{'id': 1, 'text': 'Hello World'},
|
||||
];
|
||||
final newList = [
|
||||
{'id': 1, 'text': 'Hello Flutter World'},
|
||||
];
|
||||
|
||||
final changes = HybridDiffer.diff(oldList, newList, idField: 'id');
|
||||
final fieldChange = changes.first.children!.first;
|
||||
|
||||
expect(fieldChange.key, equals('text'));
|
||||
expect(fieldChange.splice, isNotNull);
|
||||
|
||||
// "Hello " (len 6) match. Insert "Flutter ".
|
||||
expect(fieldChange.splice!.index, equals(6));
|
||||
expect(fieldChange.splice!.deleteCount, equals(0));
|
||||
expect(fieldChange.splice!.insertText, equals('Flutter '));
|
||||
});
|
||||
|
||||
test('Handles Unicode Surrogate Pairs (Emoji Safety)', () {
|
||||
// 🤚 is \uD83E\uDD1A (2 code units)
|
||||
final oldList = [
|
||||
{'id': 1, 'text': 'Hi 🤚'},
|
||||
];
|
||||
final newList = [
|
||||
{'id': 1, 'text': 'Hi 🤚 there'},
|
||||
];
|
||||
|
||||
final changes = HybridDiffer.diff(oldList, newList, idField: 'id');
|
||||
final fieldChange = changes.first.children!.first;
|
||||
|
||||
expect(fieldChange.splice, isNotNull);
|
||||
// Ensure we didn't split the emoji. The index should differ by proper length.
|
||||
// 'Hi ' is 3. Emoji is 2. Total 5.
|
||||
expect(fieldChange.splice!.index, greaterThanOrEqualTo(5));
|
||||
expect(fieldChange.splice!.insertText, contains('there'));
|
||||
});
|
||||
});
|
||||
|
||||
group('Performance / Stress Test', () {
|
||||
test('Handles large lists efficiently', () {
|
||||
// Generate 5000 items
|
||||
final oldList = List<Map<String, dynamic>>.generate(
|
||||
5000,
|
||||
(i) => {'id': i, 'val': 'item $i'},
|
||||
);
|
||||
final newList = List<Map<String, dynamic>>.from(oldList);
|
||||
|
||||
// Make 3 changes
|
||||
newList.removeAt(100); // Delete
|
||||
newList.insert(4000, {'id': 9999, 'val': 'new'}); // Insert
|
||||
// Index 2500 shifted to 2499
|
||||
newList[2499] = {'id': 2500, 'val': 'CHANGED'}; // Modify
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final changes = HybridDiffer.diff(oldList, newList, idField: 'id');
|
||||
stopwatch.stop();
|
||||
|
||||
print('Diff 5000 items took: ${stopwatch.elapsedMilliseconds}ms');
|
||||
|
||||
final deletes = changes.where((c) => c.op == ChangeOp.delete);
|
||||
final inserts = changes.where((c) => c.op == ChangeOp.insert);
|
||||
final modifies = changes.where((c) => c.op == ChangeOp.modify);
|
||||
|
||||
expect(deletes.length, equals(1));
|
||||
expect(inserts.length, equals(1));
|
||||
expect(modifies.length, equals(1));
|
||||
|
||||
// Ensure it's reasonably fast
|
||||
expect(stopwatch.elapsedMilliseconds, lessThan(1000));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:pocketbase_sync/pocketbase_sync.dart';
|
||||
|
||||
// Generate Mocks
|
||||
@GenerateNiceMocks([MockSpec<PocketBaseSyncManager>()])
|
||||
import 'multi_sync_test.mocks.dart';
|
||||
|
||||
// Test Models
|
||||
class ModelA {}
|
||||
|
||||
class ModelB {}
|
||||
|
||||
void main() {
|
||||
late PocketBaseMultiSyncManager multiManager;
|
||||
late MockPocketBaseSyncManager<ModelA> mockManagerA;
|
||||
late MockPocketBaseSyncManager<ModelB> mockManagerB;
|
||||
|
||||
setUp(() {
|
||||
multiManager = PocketBaseMultiSyncManager();
|
||||
mockManagerA = MockPocketBaseSyncManager<ModelA>();
|
||||
mockManagerB = MockPocketBaseSyncManager<ModelB>();
|
||||
});
|
||||
|
||||
test('Register and Retrieve Manager', () {
|
||||
multiManager.register<ModelA>(mockManagerA);
|
||||
|
||||
expect(multiManager.managerFor<ModelA>(), equals(mockManagerA));
|
||||
expect(() => multiManager.managerFor<ModelB>(), throwsStateError);
|
||||
});
|
||||
|
||||
test('SyncAll calls sync on all managers', () async {
|
||||
multiManager.register<ModelA>(mockManagerA);
|
||||
multiManager.register<ModelB>(mockManagerB);
|
||||
|
||||
await multiManager.sync();
|
||||
|
||||
verify(mockManagerA.sync()).called(1);
|
||||
verify(mockManagerB.sync()).called(1);
|
||||
});
|
||||
|
||||
test('SyncAll continues even if one manager fails', () async {
|
||||
multiManager.register<ModelA>(mockManagerA);
|
||||
multiManager.register<ModelB>(mockManagerB);
|
||||
|
||||
when(mockManagerA.sync()).thenThrow(Exception('Sync Failed'));
|
||||
|
||||
await multiManager.sync();
|
||||
|
||||
verify(mockManagerA.sync()).called(1);
|
||||
verify(mockManagerB.sync()).called(1); // Should still execute
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// Mocks generated by Mockito 5.4.6 from annotations
|
||||
// in pocketbase_sync/test/multi_sync_test.dart.
|
||||
// Do not manually edit this file.
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'dart:async' as _i7;
|
||||
|
||||
import 'package:flutter/foundation.dart' as _i4;
|
||||
import 'package:mockito/mockito.dart' as _i1;
|
||||
import 'package:mockito/src/dummies.dart' as _i6;
|
||||
import 'package:pocketbase/pocketbase.dart' as _i2;
|
||||
import 'package:pocketbase_sync/repo/sync_repository.dart' as _i3;
|
||||
import 'package:pocketbase_sync/sync/pb_sync_manager.dart' as _i5;
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: avoid_redundant_argument_values
|
||||
// ignore_for_file: avoid_setters_without_getters
|
||||
// ignore_for_file: comment_references
|
||||
// ignore_for_file: deprecated_member_use
|
||||
// ignore_for_file: deprecated_member_use_from_same_package
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: invalid_use_of_visible_for_testing_member
|
||||
// ignore_for_file: must_be_immutable
|
||||
// ignore_for_file: prefer_const_constructors
|
||||
// ignore_for_file: unnecessary_parenthesis
|
||||
// ignore_for_file: camel_case_types
|
||||
// ignore_for_file: subtype_of_sealed_class
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
class _FakePocketBase_0 extends _i1.SmartFake implements _i2.PocketBase {
|
||||
_FakePocketBase_0(
|
||||
Object parent,
|
||||
Invocation parentInvocation,
|
||||
) : super(
|
||||
parent,
|
||||
parentInvocation,
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeSyncRepository_1<T1> extends _i1.SmartFake
|
||||
implements _i3.SyncRepository<T1> {
|
||||
_FakeSyncRepository_1(
|
||||
Object parent,
|
||||
Invocation parentInvocation,
|
||||
) : super(
|
||||
parent,
|
||||
parentInvocation,
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeDuration_2 extends _i1.SmartFake implements Duration {
|
||||
_FakeDuration_2(
|
||||
Object parent,
|
||||
Invocation parentInvocation,
|
||||
) : super(
|
||||
parent,
|
||||
parentInvocation,
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeValueNotifier_3<T1> extends _i1.SmartFake
|
||||
implements _i4.ValueNotifier<T1> {
|
||||
_FakeValueNotifier_3(
|
||||
Object parent,
|
||||
Invocation parentInvocation,
|
||||
) : super(
|
||||
parent,
|
||||
parentInvocation,
|
||||
);
|
||||
}
|
||||
|
||||
/// A class which mocks [PocketBaseSyncManager].
|
||||
///
|
||||
/// See the documentation for Mockito's code generation for more information.
|
||||
class MockPocketBaseSyncManager<T> extends _i1.Mock
|
||||
implements _i5.PocketBaseSyncManager<T> {
|
||||
@override
|
||||
_i2.PocketBase get pb => (super.noSuchMethod(
|
||||
Invocation.getter(#pb),
|
||||
returnValue: _FakePocketBase_0(
|
||||
this,
|
||||
Invocation.getter(#pb),
|
||||
),
|
||||
returnValueForMissingStub: _FakePocketBase_0(
|
||||
this,
|
||||
Invocation.getter(#pb),
|
||||
),
|
||||
) as _i2.PocketBase);
|
||||
|
||||
@override
|
||||
String get collection => (super.noSuchMethod(
|
||||
Invocation.getter(#collection),
|
||||
returnValue: _i6.dummyValue<String>(
|
||||
this,
|
||||
Invocation.getter(#collection),
|
||||
),
|
||||
returnValueForMissingStub: _i6.dummyValue<String>(
|
||||
this,
|
||||
Invocation.getter(#collection),
|
||||
),
|
||||
) as String);
|
||||
|
||||
@override
|
||||
_i3.SyncRepository<T> get repository => (super.noSuchMethod(
|
||||
Invocation.getter(#repository),
|
||||
returnValue: _FakeSyncRepository_1<T>(
|
||||
this,
|
||||
Invocation.getter(#repository),
|
||||
),
|
||||
returnValueForMissingStub: _FakeSyncRepository_1<T>(
|
||||
this,
|
||||
Invocation.getter(#repository),
|
||||
),
|
||||
) as _i3.SyncRepository<T>);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> Function(T) get toJson => (super.noSuchMethod(
|
||||
Invocation.getter(#toJson),
|
||||
returnValue: (T __p0) => <String, dynamic>{},
|
||||
returnValueForMissingStub: (T __p0) => <String, dynamic>{},
|
||||
) as Map<String, dynamic> Function(T));
|
||||
|
||||
@override
|
||||
T Function(Map<String, dynamic>) get fromJson => (super.noSuchMethod(
|
||||
Invocation.getter(#fromJson),
|
||||
returnValue: (Map<String, dynamic> __p0) => _i6.dummyValue<T>(
|
||||
this,
|
||||
Invocation.getter(#fromJson),
|
||||
),
|
||||
returnValueForMissingStub: (Map<String, dynamic> __p0) =>
|
||||
_i6.dummyValue<T>(
|
||||
this,
|
||||
Invocation.getter(#fromJson),
|
||||
),
|
||||
) as T Function(Map<String, dynamic>));
|
||||
|
||||
@override
|
||||
Duration get retentionPeriod => (super.noSuchMethod(
|
||||
Invocation.getter(#retentionPeriod),
|
||||
returnValue: _FakeDuration_2(
|
||||
this,
|
||||
Invocation.getter(#retentionPeriod),
|
||||
),
|
||||
returnValueForMissingStub: _FakeDuration_2(
|
||||
this,
|
||||
Invocation.getter(#retentionPeriod),
|
||||
),
|
||||
) as Duration);
|
||||
|
||||
@override
|
||||
String Function() get idGenerator => (super.noSuchMethod(
|
||||
Invocation.getter(#idGenerator),
|
||||
returnValue: () => _i6.dummyValue<String>(
|
||||
this,
|
||||
Invocation.getter(#idGenerator),
|
||||
),
|
||||
returnValueForMissingStub: () => _i6.dummyValue<String>(
|
||||
this,
|
||||
Invocation.getter(#idGenerator),
|
||||
),
|
||||
) as String Function());
|
||||
|
||||
@override
|
||||
_i4.ValueNotifier<bool> get isConnectedNotifier => (super.noSuchMethod(
|
||||
Invocation.getter(#isConnectedNotifier),
|
||||
returnValue: _FakeValueNotifier_3<bool>(
|
||||
this,
|
||||
Invocation.getter(#isConnectedNotifier),
|
||||
),
|
||||
returnValueForMissingStub: _FakeValueNotifier_3<bool>(
|
||||
this,
|
||||
Invocation.getter(#isConnectedNotifier),
|
||||
),
|
||||
) as _i4.ValueNotifier<bool>);
|
||||
|
||||
@override
|
||||
_i7.Stream<void> get onUpdate => (super.noSuchMethod(
|
||||
Invocation.getter(#onUpdate),
|
||||
returnValue: _i7.Stream<void>.empty(),
|
||||
returnValueForMissingStub: _i7.Stream<void>.empty(),
|
||||
) as _i7.Stream<void>);
|
||||
|
||||
@override
|
||||
set autoSyncInterval(Duration? value) => super.noSuchMethod(
|
||||
Invocation.setter(
|
||||
#autoSyncInterval,
|
||||
value,
|
||||
),
|
||||
returnValueForMissingStub: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String generateId() => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#generateId,
|
||||
[],
|
||||
),
|
||||
returnValue: _i6.dummyValue<String>(
|
||||
this,
|
||||
Invocation.method(
|
||||
#generateId,
|
||||
[],
|
||||
),
|
||||
),
|
||||
returnValueForMissingStub: _i6.dummyValue<String>(
|
||||
this,
|
||||
Invocation.method(
|
||||
#generateId,
|
||||
[],
|
||||
),
|
||||
),
|
||||
) as String);
|
||||
|
||||
@override
|
||||
void startAutoSync(Duration? interval) => super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#startAutoSync,
|
||||
[interval],
|
||||
),
|
||||
returnValueForMissingStub: null,
|
||||
);
|
||||
|
||||
@override
|
||||
void stopAutoSync() => super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#stopAutoSync,
|
||||
[],
|
||||
),
|
||||
returnValueForMissingStub: null,
|
||||
);
|
||||
|
||||
@override
|
||||
_i7.Future<void> create(
|
||||
String? id,
|
||||
T? item,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#create,
|
||||
[
|
||||
id,
|
||||
item,
|
||||
],
|
||||
),
|
||||
returnValue: _i7.Future<void>.value(),
|
||||
returnValueForMissingStub: _i7.Future<void>.value(),
|
||||
) as _i7.Future<void>);
|
||||
|
||||
@override
|
||||
_i7.Future<void> update(
|
||||
String? id,
|
||||
T? item,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#update,
|
||||
[
|
||||
id,
|
||||
item,
|
||||
],
|
||||
),
|
||||
returnValue: _i7.Future<void>.value(),
|
||||
returnValueForMissingStub: _i7.Future<void>.value(),
|
||||
) as _i7.Future<void>);
|
||||
|
||||
@override
|
||||
_i7.Future<void> delete(String? id) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#delete,
|
||||
[id],
|
||||
),
|
||||
returnValue: _i7.Future<void>.value(),
|
||||
returnValueForMissingStub: _i7.Future<void>.value(),
|
||||
) as _i7.Future<void>);
|
||||
|
||||
@override
|
||||
_i7.Future<void> sync() => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#sync,
|
||||
[],
|
||||
),
|
||||
returnValue: _i7.Future<void>.value(),
|
||||
returnValueForMissingStub: _i7.Future<void>.value(),
|
||||
) as _i7.Future<void>);
|
||||
|
||||
@override
|
||||
_i7.Future<void> subscribe() => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#subscribe,
|
||||
[],
|
||||
),
|
||||
returnValue: _i7.Future<void>.value(),
|
||||
returnValueForMissingStub: _i7.Future<void>.value(),
|
||||
) as _i7.Future<void>);
|
||||
|
||||
@override
|
||||
_i7.Future<void> unsubscribe() => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#unsubscribe,
|
||||
[],
|
||||
),
|
||||
returnValue: _i7.Future<void>.value(),
|
||||
returnValueForMissingStub: _i7.Future<void>.value(),
|
||||
) as _i7.Future<void>);
|
||||
|
||||
@override
|
||||
void dispose() => super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#dispose,
|
||||
[],
|
||||
),
|
||||
returnValueForMissingStub: null,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import 'package:pocketbase_sync/pocketbase_sync.dart';
|
||||
import 'package:pocketbase_sync/sync_managers/in_memory.dart';
|
||||
|
||||
// Generate Mocks
|
||||
@GenerateNiceMocks([MockSpec<PocketBase>(), MockSpec<RecordService>()])
|
||||
import 'pb_sync_manager_test.mocks.dart';
|
||||
|
||||
// Test Model
|
||||
class Note {
|
||||
final String id;
|
||||
final String content;
|
||||
final String category;
|
||||
Note({required this.id, required this.content, required this.category});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'content': content,
|
||||
'category': category,
|
||||
};
|
||||
static Note fromJson(Map<String, dynamic> json) => Note(
|
||||
id: json['id'],
|
||||
content: json['content'],
|
||||
category: json['category'] ?? 'General',
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late MockPocketBase mockPb;
|
||||
late MockRecordService mockCollection;
|
||||
late InMemoryRepository<Note> repository;
|
||||
late PocketBaseSyncManager<Note> manager;
|
||||
|
||||
setUp(() {
|
||||
mockPb = MockPocketBase();
|
||||
mockCollection = MockRecordService();
|
||||
repository = InMemoryRepository<Note>();
|
||||
|
||||
when(mockPb.collection('notes')).thenReturn(mockCollection);
|
||||
|
||||
manager = PocketBaseSyncManager<Note>(
|
||||
pb: mockPb,
|
||||
collection: 'notes',
|
||||
toJson: (n) => n.toJson(),
|
||||
fromJson: (j) => Note.fromJson(j),
|
||||
repository: repository,
|
||||
retentionPeriod: const Duration(days: 30),
|
||||
);
|
||||
});
|
||||
|
||||
group('Sync Logic', () {
|
||||
test('Push Update: Sends minimal PATCH using HybridDiffer', () async {
|
||||
// 1. Setup Synced State
|
||||
final base = Note(id: 'n1', content: 'Base Content', category: 'Work');
|
||||
await repository.save(
|
||||
SyncRecord(
|
||||
id: 'n1',
|
||||
data: base,
|
||||
baseData: base,
|
||||
serverUpdatedAt: DateTime.parse('2023-01-01 10:00:00'),
|
||||
isDirty: false,
|
||||
),
|
||||
);
|
||||
|
||||
// 2. Local Update (Only change category)
|
||||
await manager.update(
|
||||
'n1',
|
||||
Note(id: 'n1', content: 'Base Content', category: 'Home'),
|
||||
);
|
||||
|
||||
// 3. Mock Response
|
||||
when(mockCollection.update(any, body: anyNamed('body'))).thenAnswer(
|
||||
(_) async => RecordModel.fromJson(
|
||||
{'id': 'n1', 'updated': '2023-01-01 10:05:00'}),
|
||||
);
|
||||
|
||||
// Mock ID List (Server has n1)
|
||||
when(mockCollection.getFullList(fields: 'id')).thenAnswer(
|
||||
(_) async => [
|
||||
RecordModel.fromJson({'id': 'n1'})
|
||||
],
|
||||
);
|
||||
|
||||
// 4. Sync
|
||||
await manager.sync();
|
||||
|
||||
// 5. Verify Payload
|
||||
final captured = verify(
|
||||
mockCollection.update('n1', body: captureAnyNamed('body')),
|
||||
).captured;
|
||||
final patch = captured.first as Map<String, dynamic>;
|
||||
|
||||
expect(patch.containsKey('category'), isTrue);
|
||||
expect(
|
||||
patch.containsKey('content'),
|
||||
isFalse,
|
||||
reason: "Content didn't change",
|
||||
);
|
||||
});
|
||||
|
||||
test('First Pull: Fetches ALL records (getFullList)', () async {
|
||||
// 1. Mock Server returning list
|
||||
final remoteItems = [
|
||||
RecordModel.fromJson({
|
||||
'id': 'n1',
|
||||
'updated': '2023-01-01 10:00:00',
|
||||
'content': 'A',
|
||||
}),
|
||||
];
|
||||
|
||||
when(
|
||||
mockCollection.getFullList(filter: anyNamed('filter')),
|
||||
).thenAnswer((_) async => remoteItems);
|
||||
|
||||
// Mock ID List (Server has n1)
|
||||
when(mockCollection.getFullList(fields: 'id')).thenAnswer(
|
||||
(_) async => [
|
||||
RecordModel.fromJson({'id': 'n1'})
|
||||
],
|
||||
);
|
||||
|
||||
// 2. Sync
|
||||
await manager.sync();
|
||||
|
||||
// 3. Verify Repository Populated
|
||||
final all = await repository.getAll();
|
||||
expect(all.length, equals(1));
|
||||
expect(all.first.data.content, equals('A'));
|
||||
});
|
||||
});
|
||||
|
||||
group('Retention Policy', () {
|
||||
test('Cleanup: Removes expired tombstones', () async {
|
||||
// Expired Tombstone
|
||||
await repository.save(
|
||||
SyncRecord(
|
||||
id: 'del',
|
||||
data: Note(id: 'del', content: '', category: ''),
|
||||
serverUpdatedAt: DateTime.now(),
|
||||
isDeleted: true,
|
||||
isDirty: false,
|
||||
deletedAt: DateTime.now().subtract(const Duration(days: 31)),
|
||||
),
|
||||
);
|
||||
|
||||
// Active Tombstone
|
||||
await repository.save(
|
||||
SyncRecord(
|
||||
id: 'active',
|
||||
data: Note(id: 'active', content: '', category: ''),
|
||||
serverUpdatedAt: DateTime.now(),
|
||||
isDeleted: true,
|
||||
isDirty: false,
|
||||
deletedAt: DateTime.now().subtract(const Duration(days: 1)),
|
||||
),
|
||||
);
|
||||
|
||||
await manager.sync(); // Triggers cleanup at end
|
||||
|
||||
expect(
|
||||
await repository.get('del'),
|
||||
isNull,
|
||||
reason: "Should be hard deleted",
|
||||
);
|
||||
expect(
|
||||
await repository.get('active'),
|
||||
isNotNull,
|
||||
reason: "Should be kept",
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
import 'dart:async';
|
||||
|
||||
// Mocking the result enum to avoid depending on connectivity_plus directly in the harness
|
||||
// if the package is not yet added to dependencies (it wasn't in the list I saw).
|
||||
// But usually one would import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
// Since I haven't added connectivity_plus to the package dependencies (I only added http, etc),
|
||||
// I will define a compatible enum here. If the user app uses connectivity_plus, they can map it.
|
||||
|
||||
enum ConnectivityResult { wifi, mobile, none, ethernet, bluetooth, other, vpn }
|
||||
|
||||
/// A mock adapter for simulating OS connectivity changes.
|
||||
class MockConnectivity {
|
||||
final _controller = StreamController<ConnectivityResult>.broadcast();
|
||||
|
||||
Stream<ConnectivityResult> get onConnectivityChanged => _controller.stream;
|
||||
|
||||
ConnectivityResult _current = ConnectivityResult.wifi;
|
||||
ConnectivityResult get current => _current;
|
||||
|
||||
/// Simulates going offline (Airplane Mode).
|
||||
void goOffline() {
|
||||
_current = ConnectivityResult.none;
|
||||
_controller.add(_current);
|
||||
}
|
||||
|
||||
/// Simulates connecting to WiFi.
|
||||
void goWifi() {
|
||||
_current = ConnectivityResult.wifi;
|
||||
_controller.add(_current);
|
||||
}
|
||||
|
||||
/// Simulates connecting to Mobile Data.
|
||||
void goMobile() {
|
||||
_current = ConnectivityResult.mobile;
|
||||
_controller.add(_current);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_controller.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// Manages a local PocketBase process for testing.
|
||||
class PocketBaseController {
|
||||
final Logger _logger =
|
||||
Logger('PocketBaseController'); // Added logger instance
|
||||
Process? _process;
|
||||
final int port;
|
||||
final String host;
|
||||
final String executablePath;
|
||||
Directory? _dataDir;
|
||||
|
||||
final bool managed;
|
||||
|
||||
PocketBaseController({
|
||||
this.port = 8090,
|
||||
this.host = '127.0.0.1',
|
||||
this.executablePath = 'pocketbase', // Assumes in PATH
|
||||
this.managed = true,
|
||||
String? dataDir,
|
||||
}) : _fixedDataDir = dataDir;
|
||||
|
||||
final String? _fixedDataDir;
|
||||
|
||||
String get baseUrl => 'http://$host:$port';
|
||||
|
||||
/// Starts the PocketBase server.
|
||||
Future<void> start({bool verbose = false}) async {
|
||||
if (!managed) {
|
||||
_logger
|
||||
.info('PocketBase is unmanaged. Assuming it is running at $baseUrl');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_process != null) {
|
||||
throw StateError('PocketBase is already running');
|
||||
}
|
||||
|
||||
// Determine data directory
|
||||
if (_fixedDataDir != null) {
|
||||
_dataDir = Directory(_fixedDataDir);
|
||||
if (!await _dataDir!.exists()) {
|
||||
await _dataDir!.create(recursive: true);
|
||||
}
|
||||
_logger.info('Using fixed data directory: ${_dataDir!.path}');
|
||||
} else {
|
||||
// Create a temporary directory for data
|
||||
_dataDir = await Directory.systemTemp.createTemp('pb_test_data_');
|
||||
_logger.info('Created temp data directory: ${_dataDir!.path}');
|
||||
}
|
||||
|
||||
final args = [
|
||||
'serve',
|
||||
'--http=$host:$port',
|
||||
'--dir=${_dataDir!.path}',
|
||||
];
|
||||
|
||||
// Using simple process start
|
||||
_process = await Process.start(
|
||||
executablePath,
|
||||
args,
|
||||
mode: verbose ? ProcessStartMode.inheritStdio : ProcessStartMode.normal,
|
||||
);
|
||||
|
||||
if (!verbose) {
|
||||
// Drain stdout/stderr so buffer doesn't fill up
|
||||
_process!.stdout.listen((_) {});
|
||||
_process!.stderr.listen((_) {});
|
||||
}
|
||||
|
||||
// Wait for it to be ready?
|
||||
// A simple poll loop or just wait a second.
|
||||
// PocketBase starts very fast.
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
// Verify it's running by hitting health check?
|
||||
// PocketBase doesn't have a standardized /health endpoint in default setup but the root /api/ works.
|
||||
}
|
||||
|
||||
/// Stops the server and cleans up data.
|
||||
Future<void> stop() async {
|
||||
if (!managed) return;
|
||||
|
||||
_process?.kill(ProcessSignal.sigterm);
|
||||
await _process?.exitCode;
|
||||
_process = null;
|
||||
|
||||
// Only clean up if we created a TEMP directory (i.e. no fixed dir provided)
|
||||
if (_fixedDataDir == null && _dataDir != null && await _dataDir!.exists()) {
|
||||
await _dataDir!.delete(recursive: true);
|
||||
_dataDir = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes the server with an admin account and schema.
|
||||
Future<void> initialize({
|
||||
required String adminEmail,
|
||||
required String adminPass,
|
||||
String? schemaPath,
|
||||
}) async {
|
||||
if (!managed) {
|
||||
_logger.info(
|
||||
'Unmanaged mode: Skipping initialization (superuser/schema). Assuming pre-configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (managed) {
|
||||
// 1. Create Superuser using CLI (Only works if we own the dir)
|
||||
_logger.info('Creating superuser: $adminEmail');
|
||||
final args = [
|
||||
'superuser',
|
||||
'create',
|
||||
adminEmail,
|
||||
adminPass,
|
||||
'--dir=${_dataDir!.path}',
|
||||
];
|
||||
|
||||
final p = await Process.run(executablePath, args);
|
||||
if (p.exitCode != 0) {
|
||||
_logger.warning('Failed to creating superuser via CLI: ${p.stderr}');
|
||||
} else {
|
||||
_logger.info('Superuser created.');
|
||||
}
|
||||
} else {
|
||||
_logger.info(
|
||||
'Skipping CLI superuser creation (unmanaged mode). Assuming user exists.');
|
||||
}
|
||||
|
||||
// 2. Import Collections (Works via API regardless of managing process)
|
||||
if (schemaPath != null) {
|
||||
_logger.info('Importing schema from $schemaPath');
|
||||
final schemaFile = File(schemaPath);
|
||||
if (await schemaFile.exists()) {
|
||||
try {
|
||||
// Auth endpoint for superusers (v0.23+)
|
||||
// Fallback to old admins if needed, but let's try the new one first or assuming v0.23 based on schema.
|
||||
var authUrl =
|
||||
'$baseUrl/api/collections/_superusers/auth-with-password';
|
||||
|
||||
final authBody = jsonEncode({
|
||||
'identity': adminEmail,
|
||||
'password': adminPass,
|
||||
});
|
||||
|
||||
var authReq = await http.post(
|
||||
Uri.parse(authUrl),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: authBody,
|
||||
);
|
||||
|
||||
// Fallback for older PB versions if 404
|
||||
if (authReq.statusCode == 404) {
|
||||
_logger.info('Legacy admin auth endpoint fallback...');
|
||||
authUrl = '$baseUrl/api/admins/auth-with-password';
|
||||
authReq = await http.post(
|
||||
Uri.parse(authUrl),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: authBody,
|
||||
);
|
||||
}
|
||||
|
||||
if (authReq.statusCode == 200) {
|
||||
final token = jsonDecode(authReq.body)['token'];
|
||||
|
||||
// New API does not support bulk import. We must create collections one by one.
|
||||
final schemaJson = await schemaFile.readAsString();
|
||||
final List<dynamic> collections = jsonDecode(schemaJson);
|
||||
|
||||
_logger.info('Found ${collections.length} collections to import.');
|
||||
|
||||
for (final col in collections) {
|
||||
final name = col['name'];
|
||||
final type = col['type'];
|
||||
// Preserve ID if possible? PB auto-gens ID usually but can accept ID.
|
||||
final _ = col['id'];
|
||||
|
||||
// Skip system collections that already exist (usually)
|
||||
// In v0.23+, _superusers, _externalAuths, _mfas, _otps, _authOrigins are system.
|
||||
if (col['system'] == true || name.startsWith('_')) {
|
||||
_logger.info('Skipping system collection: $name');
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.info('Creating collection: $name ($type)');
|
||||
|
||||
final createUrl = '$baseUrl/api/collections';
|
||||
final createBody = jsonEncode(col);
|
||||
|
||||
final createReq = await http.post(
|
||||
Uri.parse(createUrl),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token,
|
||||
},
|
||||
body: createBody,
|
||||
);
|
||||
|
||||
if (createReq.statusCode == 200) {
|
||||
_logger.info('Created collection $name');
|
||||
} else if (createReq.statusCode == 400) {
|
||||
// Check if loop? or already exists?
|
||||
// "Collection name ... already exists"
|
||||
final msg = createReq.body;
|
||||
if (msg.contains('exists')) {
|
||||
_logger.info(
|
||||
'Collection $name already exists. Skipping or Updating?');
|
||||
// Ideally we update: PUT /api/collections/{id_or_name}
|
||||
// But for now, skip.
|
||||
} else {
|
||||
_logger.warning('Failed to create collection $name: $msg');
|
||||
}
|
||||
} else {
|
||||
_logger.warning(
|
||||
'Failed to create collection $name: ${createReq.statusCode} ${createReq.body}');
|
||||
}
|
||||
}
|
||||
_logger.info('Schema import process finished.');
|
||||
} else {
|
||||
_logger.warning('Failed to login as admin: ${authReq.body}');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.warning('Error importing schema: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Restarts the server (simulating a crash/restart).
|
||||
Future<void> restart() async {
|
||||
// Keep data dir!
|
||||
final savedDir = _dataDir;
|
||||
|
||||
_process?.kill(ProcessSignal.sigterm);
|
||||
await _process?.exitCode;
|
||||
_process = null;
|
||||
|
||||
// Restart with SAME data dir
|
||||
if (savedDir == null) throw StateError("Cannot restart, never started");
|
||||
|
||||
final args = [
|
||||
'serve',
|
||||
'--http=$host:$port',
|
||||
'--dir=${savedDir.path}',
|
||||
];
|
||||
|
||||
_process = await Process.start(executablePath, args);
|
||||
_process!.stdout.listen((_) {});
|
||||
_process!.stderr.listen((_) {});
|
||||
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/// Abstract Base Action
|
||||
abstract class SimulationAction {
|
||||
Future<void> execute(dynamic context);
|
||||
String describe();
|
||||
}
|
||||
|
||||
/// Creates a new item in the local sync manager
|
||||
class CreateAction extends SimulationAction {
|
||||
final String id;
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
CreateAction(this.id, this.data);
|
||||
|
||||
@override
|
||||
Future<void> execute(context) async {
|
||||
// context is TestContext { manager, logger }
|
||||
await context.manager.create(id, {'id': id, ...data});
|
||||
}
|
||||
|
||||
@override
|
||||
String describe() => 'Create(id: $id, data: $data)';
|
||||
}
|
||||
|
||||
/// Updates an item
|
||||
class UpdateAction extends SimulationAction {
|
||||
final String id;
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
UpdateAction(this.id, this.data);
|
||||
|
||||
@override
|
||||
Future<void> execute(context) async {
|
||||
// We pass the full record data to update() usually, or partial?
|
||||
// SyncManager.update(id, T item). It expects the full item T.
|
||||
// But our T is Map<String, dynamic>.
|
||||
// So if we pass partial, it might overwrite others with null if not careful.
|
||||
// BUT, our generator should probably provide full data or we merge here?
|
||||
// Let's assume the generator provides the fields intended to be updated.
|
||||
// Wait, if SyncManager replaces the record, we need the OLD data to merge.
|
||||
// The test context doesn't expose read access easily unless we use repository.
|
||||
// Let's assume the generator tracks the "current state" of the item to produce valid full updates?
|
||||
// OR: usage of update() in SyncManager:
|
||||
// "await repository.save(record.copyWith(data: item...))"
|
||||
// It REPLACES data. So we need to provide the merged state.
|
||||
|
||||
// For simulation simplicity, we can fetch current from repo, merge, and save.
|
||||
final current = await context.repository.get(id);
|
||||
if (current != null) {
|
||||
final merged = Map<String, dynamic>.from(current.data);
|
||||
merged.addAll(data);
|
||||
await context.manager.update(id, merged);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String describe() => 'Update(id: $id, changes: $data)';
|
||||
}
|
||||
|
||||
/// Deletes an item
|
||||
class DeleteAction extends SimulationAction {
|
||||
final String id;
|
||||
|
||||
DeleteAction(this.id);
|
||||
|
||||
@override
|
||||
Future<void> execute(context) async {
|
||||
await context.manager.delete(id);
|
||||
}
|
||||
|
||||
@override
|
||||
String describe() => 'Delete(id: $id)';
|
||||
}
|
||||
|
||||
/// Forces a Sync
|
||||
class SyncAction extends SimulationAction {
|
||||
@override
|
||||
Future<void> execute(context) async {
|
||||
try {
|
||||
await context.manager.sync();
|
||||
} catch (e) {
|
||||
// Sync might fail if network is down
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String describe() => 'Sync()';
|
||||
}
|
||||
|
||||
/// Goes Offline (Airplane Mode + Cable Cut)
|
||||
class GoOfflineAction extends SimulationAction {
|
||||
@override
|
||||
Future<void> execute(context) async {
|
||||
await context.harness.goOffline();
|
||||
}
|
||||
|
||||
@override
|
||||
String describe() => 'GoOffline()';
|
||||
}
|
||||
|
||||
/// Goes Online
|
||||
class GoOnlineAction extends SimulationAction {
|
||||
@override
|
||||
Future<void> execute(context) async {
|
||||
await context.harness.goOnline();
|
||||
}
|
||||
|
||||
@override
|
||||
String describe() => 'GoOnline()';
|
||||
}
|
||||
|
||||
/// Adds Latency to the connection
|
||||
class AddLatencyAction extends SimulationAction {
|
||||
@override
|
||||
Future<void> execute(context) async {
|
||||
await context.harness.injectLatency();
|
||||
}
|
||||
|
||||
@override
|
||||
String describe() => 'AddLatency(1000ms)';
|
||||
}
|
||||
|
||||
/// Removes Latency (Clears Faults)
|
||||
class RemoveLatencyAction extends SimulationAction {
|
||||
@override
|
||||
Future<void> execute(context) async {
|
||||
await context.harness.clearNetworkFaults();
|
||||
}
|
||||
|
||||
@override
|
||||
String describe() => 'RemoveLatency()';
|
||||
}
|
||||
|
||||
/// Restarts the PocketBase Server
|
||||
class RestartServerAction extends SimulationAction {
|
||||
@override
|
||||
Future<void> execute(context) async {
|
||||
await context.harness.pocketbase.restart();
|
||||
}
|
||||
|
||||
@override
|
||||
String describe() => 'RestartServer()';
|
||||
}
|
||||
|
||||
/// Simulates a change happening on the server (Concurrent Modification)
|
||||
class RemoteUpdateAction extends SimulationAction {
|
||||
final String id;
|
||||
final Map<String, dynamic> changes;
|
||||
|
||||
RemoteUpdateAction(this.id, this.changes);
|
||||
|
||||
@override
|
||||
Future<void> execute(context) async {
|
||||
// We use the manager's PB instance which is authenticated.
|
||||
// This simulates "User updated record on another device".
|
||||
// Bypassing the sync manager to touch the server directly.
|
||||
try {
|
||||
await context.manager.pb.collection('notes').update(id, body: changes);
|
||||
} catch (e) {
|
||||
// Ignore errors (e.g. record deleted on server already, or network down)
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String describe() => 'RemoteUpdate(id: $id, changes: "$changes")';
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:logging/logging.dart';
|
||||
// Import to use in harness if needed, or generic.
|
||||
import 'pocketbase_controller.dart';
|
||||
import 'toxiproxy_client.dart';
|
||||
import 'mock_connectivity.dart';
|
||||
|
||||
/// The Hypervisor that orchestrates the simulation.
|
||||
class SimulationHarness {
|
||||
final ToxiproxyController toxiproxy;
|
||||
final PocketBaseController pocketbase;
|
||||
final MockConnectivity connectivity;
|
||||
final Logger _logger = Logger('SimulationHarness');
|
||||
|
||||
Process? _toxiProcess;
|
||||
final String? toxiproxyBinary;
|
||||
|
||||
SimulationHarness({
|
||||
ToxiproxyController? toxiproxy,
|
||||
PocketBaseController? pocketbase,
|
||||
MockConnectivity? connectivity,
|
||||
this.toxiproxyBinary,
|
||||
}) : toxiproxy = toxiproxy ?? ToxiproxyController(),
|
||||
pocketbase = pocketbase ?? PocketBaseController(),
|
||||
connectivity = connectivity ?? MockConnectivity();
|
||||
|
||||
String get proxyUrl =>
|
||||
'http://localhost:8080'; // The address the app connects to
|
||||
|
||||
/// Sets up the infrastructure: Starts PB, Creates Proxy.
|
||||
Future<void> setUp() async {
|
||||
_logger.info('Setting up simulation environment...');
|
||||
|
||||
// 0. Start Toxiproxy Server if binary provided
|
||||
if (toxiproxyBinary != null) {
|
||||
_logger.info('Starting Toxiproxy server from $toxiproxyBinary');
|
||||
_toxiProcess = await Process.start(toxiproxyBinary!, []);
|
||||
|
||||
// Pipe output to see if it fails to start (e.g. port binding)
|
||||
_toxiProcess!.stdout.transform(utf8.decoder).listen((data) {
|
||||
// _logger.fine('Toxiproxy(out): $data');
|
||||
print('Toxiproxy: $data');
|
||||
});
|
||||
_toxiProcess!.stderr.transform(utf8.decoder).listen((data) {
|
||||
_logger.warning('Toxiproxy(err): $data');
|
||||
print('Toxiproxy(err): $data');
|
||||
});
|
||||
|
||||
// Wait for it to boot
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
// 1. Start PocketBase
|
||||
await pocketbase.start();
|
||||
|
||||
// Initialize DB (Create Admin + Import Collections)
|
||||
await pocketbase.initialize(
|
||||
adminEmail: 'rody.davis.jr@gmail.com',
|
||||
adminPass: 'razroq-hedne5-cafdaT',
|
||||
schemaPath: './example/pb_collections.json',
|
||||
);
|
||||
|
||||
_logger.info('PocketBase started at ${pocketbase.baseUrl}');
|
||||
|
||||
// 2. Setup Toxiproxy
|
||||
// We want localhost:8080 (Proxy) -> localhost:PbPort (Upstream)
|
||||
// If running binary locally, PB is also local (localhost).
|
||||
// So upstream is 'localhost:${pocketbase.port}'.
|
||||
|
||||
// Docker logic fallback
|
||||
// final upstream = 'host.docker.internal:${pocketbase.port}';
|
||||
|
||||
// Local binary logic
|
||||
final upstream = 'localhost:${pocketbase.port}';
|
||||
|
||||
try {
|
||||
// Clean start
|
||||
_logger.info('Resetting Toxiproxy...');
|
||||
await toxiproxy.reset();
|
||||
_logger.info('Toxiproxy reset. Creating proxy pb_api...');
|
||||
await toxiproxy.createProxy('pb_api', '0.0.0.0:8080', upstream);
|
||||
_logger.info('Proxy setup: localhost:8080 -> $upstream');
|
||||
} catch (e) {
|
||||
_logger.warning(
|
||||
'Failed to setup toxiproxy. make sure it is running at ${toxiproxy.host}:${toxiproxy.port}',
|
||||
e);
|
||||
// We might throw here if strict
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Tears down the infrastructure.
|
||||
Future<void> tearDown() async {
|
||||
_logger.info('Tearing down simulation...');
|
||||
await pocketbase.stop();
|
||||
try {
|
||||
await toxiproxy.deleteProxy('pb_api');
|
||||
await toxiproxy.reset(); // Clean up toxics
|
||||
} catch (_) {}
|
||||
|
||||
if (_toxiProcess != null) {
|
||||
_toxiProcess!.kill();
|
||||
_toxiProcess = null;
|
||||
}
|
||||
|
||||
connectivity.dispose();
|
||||
}
|
||||
|
||||
// --- Chaos Helpers ---
|
||||
|
||||
Future<void> goOffline() async {
|
||||
_logger.info('Simulating OFFLINE');
|
||||
connectivity.goOffline(); // OS says offline
|
||||
await toxiproxy.disable('pb_api'); // Cable cut
|
||||
}
|
||||
|
||||
Future<void> goOnline() async {
|
||||
_logger.info('Simulating ONLINE');
|
||||
await toxiproxy.enable('pb_api');
|
||||
connectivity.goWifi();
|
||||
}
|
||||
|
||||
Future<void> injectLatency({int latencyMs = 1000, int jitterMs = 500}) async {
|
||||
await toxiproxy.addToxic(
|
||||
'pb_api', Toxic.latency(latency: latencyMs, jitter: jitterMs));
|
||||
}
|
||||
|
||||
Future<void> injectSlowNetwork() async {
|
||||
// Edge network: High latency, low bandwidth
|
||||
await toxiproxy.addToxic(
|
||||
'pb_api', Toxic.latency(latency: 2000, jitter: 1000));
|
||||
await toxiproxy.addToxic('pb_api', Toxic.bandwidth(rate: 10)); // 10KB/s
|
||||
}
|
||||
|
||||
Future<void> clearNetworkFaults() async {
|
||||
await toxiproxy.deleteToxics('pb_api');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'dart:convert';
|
||||
import 'package:diff_match_patch/diff_match_patch.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import 'package:pocketbase_sync/pocketbase_sync.dart';
|
||||
|
||||
/// Verifies consistency between Local DB and Remote PocketBase.
|
||||
class StateVerifier {
|
||||
final PocketBase pb;
|
||||
final String collection;
|
||||
final SyncRepository repository; // We use raw repository accessor
|
||||
|
||||
StateVerifier({
|
||||
required this.pb,
|
||||
required this.collection,
|
||||
required this.repository,
|
||||
});
|
||||
|
||||
/// Compares local and remote state and returns a list of differences.
|
||||
/// Returns empty list if states are identical (converged).
|
||||
Future<List<String>> verifyConvergence() async {
|
||||
final diffs = <String>[];
|
||||
|
||||
// 1. Fetch All Remote
|
||||
// 1. Fetch All Remote
|
||||
print('Verifier: Fetching remote records...');
|
||||
final remoteRecords =
|
||||
await pb.collection(collection).getFullList(sort: 'id');
|
||||
print('Verifier: Fetched ${remoteRecords.length} remote records.');
|
||||
final remoteMap = {for (var r in remoteRecords) r.id: r.data};
|
||||
|
||||
// 2. Fetch All Local
|
||||
// 2. Fetch All Local
|
||||
print('Verifier: Fetching local records...');
|
||||
final localRecords = await repository.getAll();
|
||||
print('Verifier: Fetched ${localRecords.length} local records.');
|
||||
// Filter out deleted items that are correctly marked as deleted
|
||||
// (In a converged state, if it's deleted locally and synced, it should be gone from server or match server tombstone if server keeps them?
|
||||
// PB doesn't keep tombstones by default unless we use a "deleted" column.
|
||||
// The sync manager deletes from PB. So remote should NOT have it.
|
||||
// Local might have it as isDeleted=true.
|
||||
// So:
|
||||
// - If Record is in Remote: Local must have it, isDeleted=false, content match.
|
||||
// - If Record is NOT in Remote: Local must NOT have it OR (Local has it AND isDeleted=true).
|
||||
|
||||
final localMap = {for (var r in localRecords) r.id: r};
|
||||
|
||||
// Check Remote against Local
|
||||
for (var id in remoteMap.keys) {
|
||||
final _ = remoteMap[id]!; // remoteData
|
||||
final localRecord = localMap[id];
|
||||
|
||||
if (localRecord == null) {
|
||||
diffs
|
||||
.add('Missing Local: Record $id exists on server but not locally.');
|
||||
} else if (localRecord.isDeleted) {
|
||||
// If it's deleted locally but exists on server, sync failed to push delete?
|
||||
// OR we haven't synced yet.
|
||||
diffs.add(
|
||||
'Zombie: Record $id is marked deleted locally but exists on server.');
|
||||
} else {
|
||||
// Compare Content
|
||||
// We need to compare JSON.
|
||||
// SyncRepository stores T data. We need to convert T to Map?
|
||||
// Wait, SyncRecord<T> stores T. PocketBase returns Map.
|
||||
// We probably need a way to compare T to Map.
|
||||
// The SyncManager has `toJson`. The verifier might need it too.
|
||||
// But `repository` is generic.
|
||||
// This verifier needs to know how to serialize local data.
|
||||
// Ideally we pass `toJson` to Verifier or use `SyncManager` which has it.
|
||||
// For this generic impl, let's assume T is Map<String, dynamic> or we pass a serializer.
|
||||
}
|
||||
}
|
||||
|
||||
// Check Local against Remote
|
||||
for (var id in localMap.keys) {
|
||||
final local = localMap[id]!;
|
||||
if (!remoteMap.containsKey(id)) {
|
||||
if (!local.isDeleted) {
|
||||
// Exists locally (alive) but not on server.
|
||||
// Could be: Not yet pushed.
|
||||
diffs.add(
|
||||
'Missing Remote: Record $id exists locally but not on server.');
|
||||
} else {
|
||||
// Deleted locally and not on server. This is Good.
|
||||
// (Assuming we don't keep tombstones forever)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
/// Performs a deep diff of two JSON objects.
|
||||
List<Diff> diffJson(Map<String, dynamic> local, Map<String, dynamic> remote) {
|
||||
final dmp = DiffMatchPatch();
|
||||
// Sort keys for deterministic stringify
|
||||
final localStr = jsonEncode(local); // jsonEncode doesn't guarantee order?
|
||||
// Actually standard jsonEncode is not canonical.
|
||||
// But for simple verification, maybe enough if keys are standard.
|
||||
|
||||
// Better: Compare keys and values manually.
|
||||
return dmp.diff(localStr, jsonEncode(remote));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Controls the Toxiproxy daemon via its HTTP API.
|
||||
class ToxiproxyController {
|
||||
final String host;
|
||||
final int port;
|
||||
final Duration timeout;
|
||||
|
||||
ToxiproxyController({
|
||||
this.host = 'localhost',
|
||||
this.port = 8474,
|
||||
this.timeout = const Duration(seconds: 5),
|
||||
});
|
||||
|
||||
String get _apiBase => 'http://$host:$port';
|
||||
|
||||
/// Creates a new proxy.
|
||||
Future<void> createProxy(String name, String listen, String upstream) async {
|
||||
try {
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('$_apiBase/proxies'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'name': name,
|
||||
'listen': listen,
|
||||
'upstream': upstream,
|
||||
'enabled': true,
|
||||
}),
|
||||
)
|
||||
.timeout(timeout);
|
||||
|
||||
if (response.statusCode != 201 && response.statusCode != 200) {
|
||||
throw HttpException('Failed to create proxy: ${response.body}');
|
||||
}
|
||||
} on TimeoutException {
|
||||
throw HttpException('Timeout creating proxy at $_apiBase');
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a proxy.
|
||||
Future<void> deleteProxy(String name) async {
|
||||
try {
|
||||
final response = await http
|
||||
.delete(Uri.parse('$_apiBase/proxies/$name'))
|
||||
.timeout(timeout);
|
||||
if (response.statusCode != 204 && response.statusCode != 404) {
|
||||
throw HttpException('Failed to delete proxy: ${response.body}');
|
||||
}
|
||||
} on TimeoutException {
|
||||
throw HttpException('Timeout deleting proxy at $_apiBase');
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears all proxies and toxics.
|
||||
Future<void> reset() async {
|
||||
try {
|
||||
final response =
|
||||
await http.post(Uri.parse('$_apiBase/reset')).timeout(timeout);
|
||||
if (response.statusCode != 204) {
|
||||
throw HttpException('Failed to reset toxiproxy: ${response.body}');
|
||||
}
|
||||
} on TimeoutException {
|
||||
throw HttpException('Timeout resetting toxiproxy at $_apiBase');
|
||||
}
|
||||
}
|
||||
|
||||
/// Disables a proxy (Simulates connection cut).
|
||||
Future<void> disable(String name) async {
|
||||
await _updateState(name, false);
|
||||
}
|
||||
|
||||
/// Enables a proxy (Simulates connection restore).
|
||||
Future<void> enable(String name) async {
|
||||
await _updateState(name, true);
|
||||
}
|
||||
|
||||
Future<void> _updateState(String name, bool enabled) async {
|
||||
try {
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('$_apiBase/proxies/$name'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'enabled': enabled}),
|
||||
)
|
||||
.timeout(timeout);
|
||||
if (response.statusCode != 200) {
|
||||
throw HttpException('Failed to update proxy state: ${response.body}');
|
||||
}
|
||||
} on TimeoutException {
|
||||
throw HttpException('Timeout updating proxy state at $_apiBase');
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a toxic (latency, jitter, etc) to a proxy.
|
||||
Future<void> addToxic(String proxyName, Toxic toxic) async {
|
||||
try {
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('$_apiBase/proxies/$proxyName/toxics'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(toxic.toJson()),
|
||||
)
|
||||
.timeout(timeout);
|
||||
if (response.statusCode != 200 &&
|
||||
response.statusCode != 201 &&
|
||||
response.statusCode != 409) {
|
||||
throw HttpException('Failed to add toxic: ${response.body}');
|
||||
}
|
||||
} on TimeoutException {
|
||||
throw HttpException('Timeout adding toxic at $_apiBase');
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes all toxics from a proxy.
|
||||
Future<void> deleteToxics(String proxyName) async {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
class Toxic {
|
||||
final String type;
|
||||
final Map<String, dynamic> attributes;
|
||||
final String? name; // Optional, Toxiproxy generates one if not provided.
|
||||
final double toxicity;
|
||||
|
||||
Toxic({
|
||||
required this.type,
|
||||
this.attributes = const {},
|
||||
this.name,
|
||||
this.toxicity = 1.0,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final map = {
|
||||
'type': type,
|
||||
'attributes': attributes,
|
||||
'toxicity': toxicity,
|
||||
};
|
||||
if (name != null) map['name'] = name!;
|
||||
return map;
|
||||
}
|
||||
|
||||
static Toxic latency({int latency = 1000, int jitter = 0}) {
|
||||
return Toxic(
|
||||
type: 'latency',
|
||||
attributes: {
|
||||
'latency': latency,
|
||||
'jitter': jitter,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Toxic bandwidth({required int rate}) {
|
||||
return Toxic(
|
||||
type: 'bandwidth',
|
||||
attributes: {
|
||||
'rate': rate, // KBs
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Toxic slowClose({required int delay}) {
|
||||
return Toxic(
|
||||
type: 'slow_close',
|
||||
attributes: {
|
||||
'delay': delay,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Add more as needed: limit_data, slicer, timeout.
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import 'dart:math';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pocketbase_sync/pocketbase_sync.dart';
|
||||
import 'package:pocketbase/pocketbase.dart' as client;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:pocketbase_sync/sync_managers/drift.dart';
|
||||
|
||||
import 'simulation/simulation_harness.dart';
|
||||
import 'simulation/simulation_actions.dart';
|
||||
import 'simulation/pocketbase_controller.dart';
|
||||
import 'simulation/state_verifier.dart';
|
||||
|
||||
// --- Test Context ---
|
||||
class TestContext {
|
||||
final PocketBaseSyncManager<Map<String, dynamic>> manager;
|
||||
final SimulationHarness harness;
|
||||
final DriftSyncRepository<Map<String, dynamic>> repository;
|
||||
|
||||
TestContext({
|
||||
required this.manager,
|
||||
required this.harness,
|
||||
required this.repository,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Generator Logic (Simplified Glados) ---
|
||||
// Since Glados integration might require code generation or specific test runner setup,
|
||||
// we will implement a lightweight generator here for immediate execution.
|
||||
// This allows us to run "flutter test" directly without extra steps.
|
||||
|
||||
// Helper to generate valid 15-char IDs
|
||||
String generateValidId() {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
final rnd = Random();
|
||||
return List.generate(15, (index) => chars[rnd.nextInt(chars.length)]).join();
|
||||
}
|
||||
|
||||
List<SimulationAction> generateScenario(int count) {
|
||||
final actions = <SimulationAction>[];
|
||||
final rnd = Random(42); // Fixed seed for reproducibility
|
||||
final activeIds = <String>[];
|
||||
|
||||
// Helper inside to keep consistent
|
||||
String nextId() {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
return List.generate(15, (index) => chars[rnd.nextInt(chars.length)])
|
||||
.join();
|
||||
}
|
||||
|
||||
for (var i = 0; i < count; i++) {
|
||||
final type = rnd.nextDouble();
|
||||
if (type < 0.05) {
|
||||
// Toggle Network (5%)
|
||||
if (rnd.nextBool()) {
|
||||
actions.add(GoOfflineAction());
|
||||
} else {
|
||||
actions.add(GoOnlineAction());
|
||||
}
|
||||
} else if (type < 0.1) {
|
||||
// Latency (5%)
|
||||
if (rnd.nextBool()) {
|
||||
actions.add(AddLatencyAction());
|
||||
} else {
|
||||
actions.add(RemoveLatencyAction());
|
||||
}
|
||||
} else if (type < 0.15) {
|
||||
// Restart Server (5%)
|
||||
actions.add(RestartServerAction());
|
||||
} else if (type < 0.3) {
|
||||
// Sync (15%)
|
||||
actions.add(SyncAction());
|
||||
} else if (type < 0.5) {
|
||||
// Create (20%)
|
||||
final id = nextId();
|
||||
activeIds.add(id);
|
||||
|
||||
actions.add(CreateAction(id, {
|
||||
'content': 'Content for $id',
|
||||
'priority': rnd.nextInt(100),
|
||||
'active': true,
|
||||
}));
|
||||
} else if (type < 0.7 && activeIds.isNotEmpty) {
|
||||
// Update (20%)
|
||||
final id = activeIds[rnd.nextInt(activeIds.length)];
|
||||
|
||||
// Randomly update one or more fields
|
||||
final changes = <String, dynamic>{};
|
||||
final subType = rnd.nextDouble();
|
||||
|
||||
if (subType < 0.4) {
|
||||
// Text Edit: Append or Replace
|
||||
// Simple append to test diffing
|
||||
changes['content'] =
|
||||
'Updated content for $id at ${i} [${rnd.nextInt(1000)}]';
|
||||
} else if (subType < 0.7) {
|
||||
// Number change
|
||||
changes['priority'] = rnd.nextInt(100);
|
||||
} else {
|
||||
// Bool change
|
||||
changes['active'] = rnd.nextBool();
|
||||
}
|
||||
|
||||
actions.add(UpdateAction(id, changes));
|
||||
} else if (type < 0.9 && activeIds.isNotEmpty) {
|
||||
// Remote Update (Conflict) (20%)
|
||||
final id = activeIds[rnd.nextInt(activeIds.length)];
|
||||
actions.add(RemoteUpdateAction(id, {'content': 'Remote Conflict $i'}));
|
||||
} else if (activeIds.isNotEmpty) {
|
||||
// Delete (10%)
|
||||
final id = activeIds[rnd.nextInt(activeIds.length)];
|
||||
activeIds.remove(id);
|
||||
actions.add(DeleteAction(id));
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we end online, clear faults, and sync to converge
|
||||
actions.add(RemoveLatencyAction());
|
||||
actions.add(GoOnlineAction());
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
void main() {
|
||||
Logger.root.level = Level.ALL;
|
||||
Logger.root.level = Level.ALL;
|
||||
Logger.root.onRecord.listen((record) {
|
||||
print('${record.level.name}: ${record.time}: ${record.message}');
|
||||
if (record.error != null) {
|
||||
print('ERROR: ${record.error}');
|
||||
}
|
||||
if (record.stackTrace != null) {
|
||||
print('STACK: ${record.stackTrace}');
|
||||
}
|
||||
});
|
||||
|
||||
group('Simulation Tests', skip: true, () {
|
||||
late SimulationHarness harness;
|
||||
late PackageDatabase database;
|
||||
late DriftSyncRepository<Map<String, dynamic>> repository;
|
||||
late PocketBaseSyncManager<Map<String, dynamic>> manager;
|
||||
|
||||
setUp(() async {
|
||||
harness = SimulationHarness(
|
||||
pocketbase: PocketBaseController(
|
||||
executablePath: './example/pocketbase',
|
||||
managed: true,
|
||||
dataDir: './example/pb_data',
|
||||
),
|
||||
toxiproxyBinary: './toxiproxy-server',
|
||||
);
|
||||
await harness.setUp();
|
||||
|
||||
// Setup Client
|
||||
final pb = client.PocketBase(harness.proxyUrl);
|
||||
|
||||
// 1. Auth as Admin to create 'notes' collection if needed
|
||||
try {
|
||||
// Try new superuser auth
|
||||
try {
|
||||
await pb.collection('_superusers').authWithPassword(
|
||||
'rody.davis.jr@gmail.com', 'razroq-hedne5-cafdaT');
|
||||
} catch (_) {
|
||||
// Fallback to old admin auth
|
||||
await pb.admins.authWithPassword(
|
||||
'rody.davis.jr@gmail.com', 'razroq-hedne5-cafdaT');
|
||||
}
|
||||
|
||||
// Proactively delete 'notes' to ensure fresh schema/rules
|
||||
try {
|
||||
await pb.collections.delete('notes');
|
||||
} catch (_) {}
|
||||
|
||||
// Create 'notes' collection with public rules
|
||||
await pb.collections.create(body: {
|
||||
'name': 'notes',
|
||||
'type': 'base',
|
||||
'schema': [
|
||||
{
|
||||
'name': 'content',
|
||||
'type': 'text',
|
||||
'required': true, // Make content required as per original plan
|
||||
},
|
||||
{
|
||||
'name': 'priority',
|
||||
'type': 'number',
|
||||
'required': false,
|
||||
},
|
||||
{
|
||||
'name': 'active',
|
||||
'type': 'bool',
|
||||
'required': false,
|
||||
}
|
||||
],
|
||||
'listRule': '@request.auth.id != ""',
|
||||
'viewRule': '@request.auth.id != ""',
|
||||
'createRule': '@request.auth.id != ""',
|
||||
'updateRule': '@request.auth.id != ""',
|
||||
'deleteRule': '@request.auth.id != ""',
|
||||
});
|
||||
print(
|
||||
'Created "notes" collection with expanded schema and public rules');
|
||||
|
||||
// Clear admin auth
|
||||
pb.authStore.clear();
|
||||
} catch (e) {
|
||||
print('Admin setup failed (Notes collection might be missing): $e');
|
||||
}
|
||||
|
||||
// 2. Create a random user for this test run to ensure isolation/valid auth
|
||||
|
||||
final email = 'test_${Random().nextInt(10000)}@example.com';
|
||||
final password = 'password123456';
|
||||
|
||||
try {
|
||||
await pb.collection('users').create(body: {
|
||||
'email': email,
|
||||
'password': password,
|
||||
'passwordConfirm': password,
|
||||
'username': 'user_${Random().nextInt(10000)}',
|
||||
});
|
||||
await pb.collection('users').authWithPassword(email, password);
|
||||
print('Authenticated as $email');
|
||||
} catch (e) {
|
||||
print('Failed to auth: $e');
|
||||
// If fail, we might proceed but sync will likely fail
|
||||
}
|
||||
|
||||
database = PackageDatabase(NativeDatabase.memory());
|
||||
repository = DriftSyncRepository<Map<String, dynamic>>(
|
||||
dbWrapper: database,
|
||||
collectionName: 'notes',
|
||||
toJson: (m) => m,
|
||||
fromJson: (m) => m,
|
||||
);
|
||||
|
||||
manager = PocketBaseSyncManager(
|
||||
pb: pb, // Point to Proxy
|
||||
collection: 'notes',
|
||||
repository: repository,
|
||||
toJson: (m) => m,
|
||||
fromJson: (m) => m,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
// Cleanup: Delete 'notes' collection (requires admin)
|
||||
try {
|
||||
// We need a fresh client or auth the existing one as admin
|
||||
final adminPb =
|
||||
client.PocketBase('http://127.0.0.1:${harness.pocketbase.port}');
|
||||
|
||||
// Try new superuser auth first
|
||||
try {
|
||||
await adminPb.collection('_superusers').authWithPassword(
|
||||
'rody.davis.jr@gmail.com', 'razroq-hedne5-cafdaT');
|
||||
} catch (_) {
|
||||
// Fallback
|
||||
await adminPb.admins.authWithPassword(
|
||||
'rody.davis.jr@gmail.com', 'razroq-hedne5-cafdaT');
|
||||
}
|
||||
|
||||
await adminPb.collections.delete('notes');
|
||||
print('Cleanup: Deleted "notes" collection');
|
||||
} catch (e) {
|
||||
print('Cleanup warning: Failed to delete "notes" collection: $e');
|
||||
}
|
||||
|
||||
manager.dispose();
|
||||
await database.close();
|
||||
await harness.tearDown();
|
||||
});
|
||||
|
||||
test('Chaos Scenario: Random actions lead to eventual consistency',
|
||||
() async {
|
||||
const seed = 12345;
|
||||
final actions = generateScenario(100);
|
||||
|
||||
final context = TestContext(
|
||||
manager: manager,
|
||||
harness: harness,
|
||||
repository: repository,
|
||||
);
|
||||
|
||||
print('Running scenario with seed $seed (${actions.length} actions)');
|
||||
|
||||
for (var action in actions) {
|
||||
print('Executing: ${action.describe()}');
|
||||
await action.execute(context);
|
||||
// Add valid jitter between actions?
|
||||
// await Future.delayed(Duration(milliseconds: 10));
|
||||
}
|
||||
|
||||
// Allow final sync to settle
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
|
||||
// Verify
|
||||
final verifyPb = client.PocketBase(
|
||||
'http://127.0.0.1:${harness.pocketbase.port}'); // Bypass proxy for verification
|
||||
try {
|
||||
await verifyPb.collection('_superusers').authWithPassword(
|
||||
'rody.davis.jr@gmail.com', 'razroq-hedne5-cafdaT');
|
||||
} catch (_) {
|
||||
await verifyPb.admins.authWithPassword(
|
||||
'rody.davis.jr@gmail.com', 'razroq-hedne5-cafdaT');
|
||||
}
|
||||
|
||||
final verifier = StateVerifier(
|
||||
pb: verifyPb,
|
||||
collection: 'notes',
|
||||
repository: repository,
|
||||
);
|
||||
|
||||
final diffs = await verifier.verifyConvergence();
|
||||
if (diffs.isNotEmpty) {
|
||||
fail('State diverged:\n${diffs.join('\n')}');
|
||||
} else {
|
||||
print('✓ State Converged!');
|
||||
}
|
||||
}, timeout: const Timeout(Duration(minutes: 5)));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import 'package:pocketbase_sync/pocketbase_sync.dart';
|
||||
import 'package:pocketbase_sync/repo/in_memory_repository.dart';
|
||||
|
||||
import 'pb_sync_manager_test.mocks.dart';
|
||||
|
||||
// Test Model
|
||||
class Note {
|
||||
final String id;
|
||||
final String content;
|
||||
final String category;
|
||||
Note({required this.id, required this.content, required this.category});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'content': content,
|
||||
'category': category,
|
||||
};
|
||||
static Note fromJson(Map<String, dynamic> json) => Note(
|
||||
id: json['id'],
|
||||
content: json['content'],
|
||||
category: json['category'] ?? 'General',
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late MockPocketBase mockPb;
|
||||
late MockRecordService mockCollection;
|
||||
late InMemoryRepository<Note> repository;
|
||||
late PocketBaseSyncManager<Note> manager;
|
||||
|
||||
setUp(() {
|
||||
mockPb = MockPocketBase();
|
||||
mockCollection = MockRecordService();
|
||||
repository = InMemoryRepository<Note>();
|
||||
|
||||
when(mockPb.collection('notes')).thenReturn(mockCollection);
|
||||
|
||||
manager = PocketBaseSyncManager<Note>(
|
||||
pb: mockPb,
|
||||
collection: 'notes',
|
||||
toJson: (n) => n.toJson(),
|
||||
fromJson: (j) => Note.fromJson(j),
|
||||
repository: repository,
|
||||
retentionPeriod: const Duration(days: 30),
|
||||
);
|
||||
});
|
||||
|
||||
test('Reproduction: Server update should reflect locally after sync',
|
||||
() async {
|
||||
// 1. Setup Initial State (Already Synced)
|
||||
final initialTime = DateTime.parse('2023-01-01 10:00:00Z');
|
||||
final note = Note(id: 'n1', content: 'Initial Content', category: 'Work');
|
||||
|
||||
await repository.save(
|
||||
SyncRecord(
|
||||
id: 'n1',
|
||||
data: note,
|
||||
baseData: note,
|
||||
serverUpdatedAt: initialTime,
|
||||
isDirty: false,
|
||||
),
|
||||
);
|
||||
|
||||
// Simulate we have synced up to this time
|
||||
await repository.setLastSyncTime(initialTime);
|
||||
|
||||
// 2. Mock Server having a newer version
|
||||
final updatedTime = initialTime.add(const Duration(minutes: 5));
|
||||
final updatedNoteJson = {
|
||||
'id': 'n1',
|
||||
'content': 'Updated Content', // Changed content
|
||||
'category': 'Work',
|
||||
'updated': updatedTime.toIso8601String(),
|
||||
};
|
||||
|
||||
// Expect the sync manager to ask for records updated after the last sync time
|
||||
// capture the filter to verify what is being sent
|
||||
when(mockCollection.getFullList(filter: anyNamed('filter')))
|
||||
.thenAnswer((invocation) async {
|
||||
final filter =
|
||||
invocation.namedArguments[const Symbol('filter')] as String?;
|
||||
print('Filter used: $filter'); // Debug print
|
||||
|
||||
// precise verification of the filter format
|
||||
if (filter != null) {
|
||||
if (filter.contains('T')) {
|
||||
throw TestFailure('Filter should not contain T separator: $filter');
|
||||
}
|
||||
if (!filter.contains('>=')) {
|
||||
throw TestFailure('Filter should use >= operator: $filter');
|
||||
}
|
||||
}
|
||||
|
||||
// Return the updated record simulating server response
|
||||
return [RecordModel.fromJson(updatedNoteJson)];
|
||||
});
|
||||
|
||||
// Mock ID List for Reconciliation (Server still has the item)
|
||||
when(mockCollection.getFullList(fields: 'id')).thenAnswer((_) async => [
|
||||
RecordModel.fromJson({'id': 'n1'})
|
||||
]);
|
||||
|
||||
// 3. Run Sync
|
||||
await manager.sync();
|
||||
|
||||
// 4. Verify Local Update
|
||||
final localRecord = await repository.get('n1');
|
||||
expect(localRecord, isNotNull);
|
||||
expect(localRecord!.data.content, equals('Updated Content'),
|
||||
reason: "Local content should match server content after sync");
|
||||
expect(localRecord.serverUpdatedAt, equals(updatedTime));
|
||||
});
|
||||
|
||||
test('Reproduction: Conflict (Server Wins on same field)', () async {
|
||||
// 1. Setup Initial State
|
||||
final initialTime = DateTime.parse('2023-01-01 10:00:00Z');
|
||||
final baseNote = Note(id: 'n1', content: 'Base', category: 'Work');
|
||||
|
||||
// 2. Local Edit (Dirty) - Content: "Local Edit"
|
||||
final localNote = Note(id: 'n1', content: 'Local Edit', category: 'Work');
|
||||
await repository.save(
|
||||
SyncRecord(
|
||||
id: 'n1',
|
||||
data: localNote,
|
||||
baseData: baseNote,
|
||||
serverUpdatedAt: initialTime,
|
||||
isDirty: true,
|
||||
),
|
||||
);
|
||||
await repository.setLastSyncTime(initialTime);
|
||||
|
||||
// 3. Server Edit - Content: "Server Edit"
|
||||
final updatedTime = initialTime.add(const Duration(minutes: 5));
|
||||
final remoteNoteJson = {
|
||||
'id': 'n1',
|
||||
'content': 'Server Edit',
|
||||
'category': 'Work',
|
||||
'updated': updatedTime.toIso8601String(),
|
||||
};
|
||||
|
||||
when(mockCollection.getFullList(filter: anyNamed('filter')))
|
||||
.thenAnswer((_) async => [RecordModel.fromJson(remoteNoteJson)]);
|
||||
|
||||
// Mock ID List (Item exists)
|
||||
when(mockCollection.getFullList(fields: 'id')).thenAnswer((_) async => [
|
||||
RecordModel.fromJson({'id': 'n1'})
|
||||
]);
|
||||
|
||||
// 4. Sync
|
||||
await manager.sync();
|
||||
|
||||
// 5. Verify Conflict Resolution
|
||||
final localRecord = await repository.get('n1');
|
||||
expect(localRecord!.data.content, equals('Server Edit'),
|
||||
reason: "Server edit should win on collision");
|
||||
expect(localRecord.isDirty, isTrue,
|
||||
reason: "Merged record should remain dirty to push back merged state");
|
||||
expect(localRecord.serverUpdatedAt, equals(updatedTime));
|
||||
});
|
||||
|
||||
test(
|
||||
'Reproduction: Remote Deletion (Item missing on server should delete local)',
|
||||
() async {
|
||||
// 1. Setup Initial State (Synced)
|
||||
final initialTime = DateTime.parse('2023-01-01 10:00:00Z');
|
||||
final note = Note(id: 'n1', content: 'To Be Deleted', category: 'Work');
|
||||
|
||||
await repository.save(
|
||||
SyncRecord(
|
||||
id: 'n1',
|
||||
data: note,
|
||||
baseData: note,
|
||||
serverUpdatedAt: initialTime,
|
||||
isDirty: false,
|
||||
),
|
||||
);
|
||||
await repository.setLastSyncTime(initialTime);
|
||||
|
||||
// 2. Mock Server State
|
||||
// Incremental Pull: Returns nothing (no updates)
|
||||
when(mockCollection.getFullList(filter: anyNamed('filter')))
|
||||
.thenAnswer((_) async => []);
|
||||
|
||||
// Full ID List Pull (Reconciliation): Returns empty list (Item n1 is GONE)
|
||||
when(mockCollection.getFullList(fields: 'id')).thenAnswer((_) async => []);
|
||||
|
||||
// 3. Sync
|
||||
await manager.sync();
|
||||
|
||||
// 4. Verify Local Deletion
|
||||
final localRecord = await repository.get('n1');
|
||||
expect(localRecord, isNull,
|
||||
reason:
|
||||
"Record should be deleted locally because it is missing on server");
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user