adding packages

This commit is contained in:
2026-01-15 14:38:46 -08:00
parent ef86e3ab6a
commit 6869cf47e5
5253 changed files with 726695 additions and 34 deletions
@@ -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");
});
}